@coywolf/emdash 0.0.0-stage → 0.35.14
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 +203 -2
- package/docs/README.md +45 -0
- package/docs/ai.md +39 -0
- package/docs/backups.md +88 -0
- package/docs/breadcrumbs.md +59 -0
- package/docs/code-blocks.md +21 -0
- package/docs/content-urls.md +41 -0
- package/docs/custom-blocks.md +27 -0
- package/docs/discovery.md +32 -0
- package/docs/files.md +44 -0
- package/docs/form-uploads.md +59 -0
- package/docs/headings.md +37 -0
- package/docs/images.md +141 -0
- package/docs/internals.md +13 -0
- package/docs/links.md +34 -0
- package/docs/performance.md +72 -0
- package/docs/redirects.md +84 -0
- package/docs/releasing.md +21 -0
- package/docs/reviews.md +89 -0
- package/docs/robots.md +81 -0
- package/docs/schema.md +79 -0
- package/docs/search.md +114 -0
- package/docs/videos.md +48 -0
- package/docs/wordpress-import.md +198 -0
- package/package.json +79 -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.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/routes.ts +359 -0
- package/src/ai/store.ts +301 -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.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -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.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.ts +60 -0
- package/src/files/export.ts +44 -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.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.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/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.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.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.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.ts +362 -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.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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EmDash search with an OR fallback, plus resolved public URLs and type
|
|
3
|
+
* labels for each result. Used by the pack's public search route (and so by
|
|
4
|
+
* the SearchBox typeahead), and exported for site search pages:
|
|
5
|
+
*
|
|
6
|
+
* ```astro
|
|
7
|
+
* ---
|
|
8
|
+
* import { searchWithFallback } from "@coywolf/emdash/astro";
|
|
9
|
+
* const q = Astro.url.searchParams.get("q") ?? "";
|
|
10
|
+
* const { items, fallback } = q ? await searchWithFallback(q) : { items: [], fallback: false };
|
|
11
|
+
* ---
|
|
12
|
+
* ```
|
|
13
|
+
*/
|
|
14
|
+
import { type SearchResult, search } from "emdash";
|
|
15
|
+
|
|
16
|
+
import { COLLECTION_SLUG, type CollectionInfo, createEntryUrlResolver, interpolateUrlPattern, readCollections } from "../core/content-url.js";
|
|
17
|
+
import { workerEnv } from "../shared.js";
|
|
18
|
+
import { buildOrQuery, rankByCoverage } from "./fallback.js";
|
|
19
|
+
import { type LiveSearchResponse, liveSearchD1 } from "./engine.js";
|
|
20
|
+
|
|
21
|
+
export interface PackSearchResult extends SearchResult {
|
|
22
|
+
/** Public path of the entry: the site's `urls` override for the collection, or its URL pattern. */
|
|
23
|
+
url: string;
|
|
24
|
+
/** The collection's (singular) label, e.g. "Post". */
|
|
25
|
+
type: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PackSearchResponse {
|
|
29
|
+
items: PackSearchResult[];
|
|
30
|
+
nextCursor?: string;
|
|
31
|
+
/** True when nothing matched every word, so these results match any word. */
|
|
32
|
+
fallback: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PackSearchOptions {
|
|
36
|
+
collections?: string[];
|
|
37
|
+
locale?: string;
|
|
38
|
+
/** Default 10. */
|
|
39
|
+
limit?: number;
|
|
40
|
+
cursor?: string;
|
|
41
|
+
/** "suggest" matches titles first (typeahead), then full text. Default "search". */
|
|
42
|
+
mode?: "search" | "suggest";
|
|
43
|
+
/** D1 binding of the site database, for URL patterns and terms. Default "DB". */
|
|
44
|
+
database?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How many OR results to rank in one go (the fallback isn't paginated). */
|
|
48
|
+
const FALLBACK_POOL = 50;
|
|
49
|
+
|
|
50
|
+
export async function searchWithFallback(query: string, options: PackSearchOptions = {}): Promise<PackSearchResponse> {
|
|
51
|
+
const limit = Math.min(Math.max(options.limit ?? 10, 1), 50);
|
|
52
|
+
const base = {
|
|
53
|
+
collections: options.collections ? [...new Set(options.collections)].filter((c) => COLLECTION_SLUG.test(c)).slice(0, 10) : undefined,
|
|
54
|
+
locale: options.locale,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// EmDash treats an empty list as "every collection"; a list with nothing valid in it should find nothing.
|
|
58
|
+
if (base.collections && base.collections.length === 0) return { items: [], fallback: false };
|
|
59
|
+
|
|
60
|
+
if (options.mode === "suggest" && !options.cursor) {
|
|
61
|
+
const titles = await search(query, { ...base, limit, scope: "title" });
|
|
62
|
+
if (titles.items.length) return { items: await withUrls(titles.items, options.database), fallback: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const all = await search(query, { ...base, limit, cursor: options.cursor });
|
|
66
|
+
if (all.items.length || options.cursor) {
|
|
67
|
+
return { items: await withUrls(all.items, options.database), nextCursor: all.nextCursor, fallback: false };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const orQuery = buildOrQuery(query);
|
|
71
|
+
if (!orQuery) return { items: [], fallback: false };
|
|
72
|
+
const any = await search(orQuery, { ...base, limit: FALLBACK_POOL });
|
|
73
|
+
const ranked = rankByCoverage(any.items, query).slice(0, limit);
|
|
74
|
+
return { items: await withUrls(ranked, options.database), fallback: ranked.length > 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type { LiveSearchResponse, LiveSearchResult } from "./engine.js";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Live results (as-you-type dropdown): title matches first, then full-text
|
|
81
|
+
* matches (with the OR fallback) to fill the list, each with an excerpt from
|
|
82
|
+
* the entry's indexed text. Same SQL, ranking, fallback and URLs as
|
|
83
|
+
* searchWithFallback, run by the batched D1 engine (src/search/engine.ts).
|
|
84
|
+
* Published entries only.
|
|
85
|
+
*/
|
|
86
|
+
export async function liveSearch(query: string, options: Omit<PackSearchOptions, "cursor" | "mode"> & { version?: string } = {}): Promise<LiveSearchResponse> {
|
|
87
|
+
const database = options.database ?? "DB";
|
|
88
|
+
let db: D1Database | undefined;
|
|
89
|
+
try {
|
|
90
|
+
db = (await workerEnv())[database] as D1Database | undefined;
|
|
91
|
+
} catch {
|
|
92
|
+
db = undefined;
|
|
93
|
+
}
|
|
94
|
+
if (!db) return { items: [], fallback: false };
|
|
95
|
+
return liveSearchD1(db, query, { collections: options.collections, locale: options.locale, limit: options.limit, database, version: options.version });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function withUrls(items: SearchResult[], database = "DB"): Promise<PackSearchResult[]> {
|
|
99
|
+
if (!items.length) return [];
|
|
100
|
+
const slugs = [...new Set(items.map((i) => i.collection))];
|
|
101
|
+
let db: D1Database | undefined;
|
|
102
|
+
try {
|
|
103
|
+
db = (await workerEnv())[database] as D1Database | undefined;
|
|
104
|
+
} catch {
|
|
105
|
+
db = undefined;
|
|
106
|
+
}
|
|
107
|
+
const collections = db ? await readCollections(db, slugs).catch(() => new Map<string, CollectionInfo>()) : new Map<string, CollectionInfo>();
|
|
108
|
+
|
|
109
|
+
// Paths from the pack-wide resolver: the site's `urls` overrides (terms read in one batch per
|
|
110
|
+
// collection) or the collection's url_pattern (publish dates read only when it has date tokens).
|
|
111
|
+
const urls = new Map<string, string | null>();
|
|
112
|
+
if (db) {
|
|
113
|
+
const resolver = createEntryUrlResolver(db);
|
|
114
|
+
for (const slug of slugs) {
|
|
115
|
+
const entries = items.filter((i) => i.collection === slug).map((i) => ({ id: i.id, slug: i.slug ?? i.id, locale: i.locale }));
|
|
116
|
+
try {
|
|
117
|
+
for (const [id, url] of await resolver.urls(slug, entries)) urls.set(`${slug}:${id}`, url);
|
|
118
|
+
} catch {
|
|
119
|
+
// Fall back to the default route below rather than fail the search.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return items.map((item) => {
|
|
125
|
+
const info = collections.get(item.collection);
|
|
126
|
+
return {
|
|
127
|
+
...item,
|
|
128
|
+
url: urls.get(`${item.collection}:${item.id}`) ?? interpolateUrlPattern({ pattern: null, collection: item.collection, slug: item.slug ?? item.id, id: item.id }),
|
|
129
|
+
type: info?.labelSingular || info?.label || item.collection,
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-window rate limiting: the pure parts (no imports, unit tested in Node).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const WINDOW_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
export interface Window {
|
|
8
|
+
/** Start of the current window (ms since epoch). */
|
|
9
|
+
start: number;
|
|
10
|
+
/** When the window ends and the count resets. */
|
|
11
|
+
resetAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function currentWindow(now: number, windowMs = WINDOW_MS): Window {
|
|
15
|
+
const start = Math.floor(now / windowMs) * windowMs;
|
|
16
|
+
return { start, resetAt: start + windowMs };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Decision {
|
|
20
|
+
allowed: boolean;
|
|
21
|
+
/** Requests counted in this window, including this one when allowed. */
|
|
22
|
+
count: number;
|
|
23
|
+
/** Whole seconds until the window resets (for Retry-After), at least 1. */
|
|
24
|
+
retryAfter: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Decide on a request given how many were already counted in its window. A limit of 0 disables limiting. */
|
|
28
|
+
export function decide(previousCount: number, limit: number, now: number, windowMs = WINDOW_MS): Decision {
|
|
29
|
+
const { resetAt } = currentWindow(now, windowMs);
|
|
30
|
+
const retryAfter = Math.max(1, Math.ceil((resetAt - now) / 1000));
|
|
31
|
+
if (limit <= 0) return { allowed: true, count: previousCount + 1, retryAfter };
|
|
32
|
+
if (previousCount >= limit) return { allowed: false, count: previousCount, retryAfter };
|
|
33
|
+
return { allowed: true, count: previousCount + 1, retryAfter };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Per-isolate counter. Best effort on its own (Workers spread traffic across
|
|
38
|
+
* isolates), and the first layer in front of KV, which is eventually
|
|
39
|
+
* consistent and catches bursts late.
|
|
40
|
+
*/
|
|
41
|
+
export class MemoryWindowCounter {
|
|
42
|
+
private readonly counts = new Map<string, { start: number; count: number }>();
|
|
43
|
+
private readonly maxKeys: number;
|
|
44
|
+
private readonly windowMs: number;
|
|
45
|
+
|
|
46
|
+
constructor(maxKeys = 10_000, windowMs = WINDOW_MS) {
|
|
47
|
+
this.maxKeys = maxKeys;
|
|
48
|
+
this.windowMs = windowMs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Count a request for `key` and return the decision. */
|
|
52
|
+
hit(key: string, limit: number, now: number): Decision {
|
|
53
|
+
const { start } = currentWindow(now, this.windowMs);
|
|
54
|
+
const entry = this.counts.get(key);
|
|
55
|
+
const previous = entry && entry.start === start ? entry.count : 0;
|
|
56
|
+
const decision = decide(previous, limit, now, this.windowMs);
|
|
57
|
+
if (decision.allowed) {
|
|
58
|
+
if (!entry && this.counts.size >= this.maxKeys) this.prune(start);
|
|
59
|
+
this.counts.set(key, { start, count: decision.count });
|
|
60
|
+
}
|
|
61
|
+
return decision;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Drop entries from earlier windows; if still full, drop the oldest half. */
|
|
65
|
+
prune(currentStart: number): void {
|
|
66
|
+
for (const [key, entry] of this.counts) if (entry.start !== currentStart) this.counts.delete(key);
|
|
67
|
+
if (this.counts.size >= this.maxKeys) {
|
|
68
|
+
let drop = Math.ceil(this.counts.size / 2);
|
|
69
|
+
for (const key of this.counts.keys()) {
|
|
70
|
+
if (drop-- <= 0) break;
|
|
71
|
+
this.counts.delete(key);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get size(): number {
|
|
77
|
+
return this.counts.size;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The Workers Rate Limiting binding's API (wrangler "ratelimits"). */
|
|
82
|
+
export interface RateLimitBinding {
|
|
83
|
+
limit(options: { key: string }): Promise<{ success: boolean }>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decide on one request for a client. With a Rate Limiting binding the
|
|
88
|
+
* binding decides (its limit and period come from the wrangler config, and it
|
|
89
|
+
* counts per Cloudflare location); without one, the per-isolate counter does.
|
|
90
|
+
* If the binding errors, fall back to the counter rather than fail open.
|
|
91
|
+
*/
|
|
92
|
+
export async function limitClient(
|
|
93
|
+
clientKey: string,
|
|
94
|
+
options: { limit: number; now: number; memory: MemoryWindowCounter; binding?: RateLimitBinding; bindingPeriod?: number },
|
|
95
|
+
): Promise<{ allowed: boolean; retryAfter: number }> {
|
|
96
|
+
if (options.binding) {
|
|
97
|
+
try {
|
|
98
|
+
const { success } = await options.binding.limit({ key: clientKey });
|
|
99
|
+
return { allowed: success, retryAfter: options.bindingPeriod ?? 60 };
|
|
100
|
+
} catch {
|
|
101
|
+
// Use the in-memory counter below.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (options.limit <= 0) return { allowed: true, retryAfter: 0 };
|
|
105
|
+
const decision = options.memory.hit(clientKey, options.limit, options.now);
|
|
106
|
+
return { allowed: decision.allowed, retryAfter: decision.retryAfter };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Search URLs the limiter covers: EmDash's public search and suggest endpoints, and the pack's search and live results routes. Admin endpoints (enable, rebuild, stats) aren't limited. */
|
|
110
|
+
export function isLimitedPath(pathname: string): boolean {
|
|
111
|
+
const path = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
|
112
|
+
return (
|
|
113
|
+
path === "/_emdash/api/search" ||
|
|
114
|
+
path === "/_emdash/api/search/suggest" ||
|
|
115
|
+
path === "/_emdash/api/plugins/coywolf-pack/search/query" ||
|
|
116
|
+
path === "/_emdash/api/plugins/coywolf-pack/search/live"
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate limiting for search (feature "search.rateLimit"), served through the
|
|
3
|
+
* pack middleware. Each client is keyed by a salted SHA-256 of its IP address
|
|
4
|
+
* (Cloudflare's CF-Connecting-IP, which visitors can't spoof), never the
|
|
5
|
+
* address itself. With a Workers Rate Limiting binding (option
|
|
6
|
+
* `rateLimiter`), Cloudflare counts across isolates; without one, a
|
|
7
|
+
* per-isolate in-memory counter does, best effort.
|
|
8
|
+
*/
|
|
9
|
+
import type { PackMiddleware } from "../core/module.js";
|
|
10
|
+
import { MemoryWindowCounter, type RateLimitBinding, isLimitedPath, limitClient } from "./ratelimit-core.js";
|
|
11
|
+
|
|
12
|
+
export interface SearchRateLimitOptions {
|
|
13
|
+
/** Requests per minute per visitor for the in-memory fallback. Default 120; 0 turns the fallback off. With a binding, the binding's own limit applies. */
|
|
14
|
+
requestsPerMinute?: number;
|
|
15
|
+
/** Name of a Workers Rate Limiting binding (wrangler "ratelimits"). Default none (per-isolate memory only). */
|
|
16
|
+
rateLimiter?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_RPM = 120;
|
|
20
|
+
const SALT_SECRET = "SEARCH_RATE_LIMIT_SALT";
|
|
21
|
+
/** Used when no secret is set. Keys must agree across isolates for the binding, so this can't be random per isolate. */
|
|
22
|
+
const DEFAULT_SALT = "coywolf-pack:search-rate-limit";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Options from coywolfPlugin({ search }). EmDash instantiates plugins when the
|
|
26
|
+
* Worker starts (the generated plugins module calls createPlugin at import),
|
|
27
|
+
* so this is set before any request reaches the middleware.
|
|
28
|
+
*/
|
|
29
|
+
let config: SearchRateLimitOptions = {};
|
|
30
|
+
export function configureSearchRateLimit(options: SearchRateLimitOptions): void {
|
|
31
|
+
config = { ...options };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const memory = new MemoryWindowCounter();
|
|
35
|
+
|
|
36
|
+
async function hashClient(ip: string, salt: string): Promise<string> {
|
|
37
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${salt}:${ip}`));
|
|
38
|
+
return [...new Uint8Array(digest).slice(0, 16)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clientIp(request: Request, fallback: () => string | undefined): string | null {
|
|
42
|
+
const header = request.headers.get("cf-connecting-ip");
|
|
43
|
+
if (header) return header.trim();
|
|
44
|
+
try {
|
|
45
|
+
return fallback() ?? null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Every limited path is a JSON API, so answer in EmDash's error shape. */
|
|
52
|
+
function tooMany(retryAfter: number): Response {
|
|
53
|
+
return Response.json(
|
|
54
|
+
{ success: false, error: { code: "RATE_LIMITED", message: "Too many searches. Try again shortly." } },
|
|
55
|
+
{ status: 429, headers: { "Retry-After": String(retryAfter), "Cache-Control": "no-store" } },
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The 429 response when this client is over the limit, else undefined. */
|
|
60
|
+
export async function rateLimitResponse(context: Parameters<PackMiddleware["handle"]>[0], env: Record<string, unknown>): Promise<Response | undefined> {
|
|
61
|
+
const binding = config.rateLimiter ? (env[config.rateLimiter] as RateLimitBinding | undefined) : undefined;
|
|
62
|
+
const limit = config.requestsPerMinute ?? DEFAULT_RPM;
|
|
63
|
+
if (!binding && limit <= 0) return undefined;
|
|
64
|
+
const ip = clientIp(context.request, () => context.clientAddress);
|
|
65
|
+
if (!ip) return undefined;
|
|
66
|
+
const secret = env[SALT_SECRET];
|
|
67
|
+
const client = await hashClient(ip, typeof secret === "string" && secret ? secret : DEFAULT_SALT);
|
|
68
|
+
const result = await limitClient(client, { limit, now: Date.now(), memory, binding });
|
|
69
|
+
return result.allowed ? undefined : tooMany(result.retryAfter);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const searchRateLimitMiddleware: PackMiddleware = {
|
|
73
|
+
module: "search",
|
|
74
|
+
feature: "search.rateLimit",
|
|
75
|
+
handle: async (context, env) => {
|
|
76
|
+
if (!isLimitedPath(context.url.pathname)) return undefined;
|
|
77
|
+
return rateLimitResponse(context, env);
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live results highlighting: the excerpt under each result and the matched
|
|
3
|
+
* words in titles. Ported from the Coywolf Search WordPress plugin (a
|
|
4
|
+
* window of text around the first match, ellipses at cut ends, matches
|
|
5
|
+
* wrapped in <mark>), with two changes: matching is by word prefix, the
|
|
6
|
+
* way EmDash's FTS5 prefix search matched the entry, and the text is
|
|
7
|
+
* escaped piece by piece around the matches rather than highlighted after
|
|
8
|
+
* escaping, so a typed "amp" can never land inside "&".
|
|
9
|
+
*
|
|
10
|
+
* The output is escaped HTML whose only tags are <mark> and </mark>.
|
|
11
|
+
*
|
|
12
|
+
* Pure functions (no imports) so they can be unit tested in Node.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Characters of text in a snippet (as in Coywolf Search). */
|
|
16
|
+
export const SNIPPET_WIDTH = 180;
|
|
17
|
+
/** Characters of context kept before the first match. */
|
|
18
|
+
export const SNIPPET_LEAD = 40;
|
|
19
|
+
/** Most words highlighted from one query. */
|
|
20
|
+
export const MAX_HIGHLIGHT_WORDS = 10;
|
|
21
|
+
|
|
22
|
+
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
23
|
+
|
|
24
|
+
export function escapeHtml(text: string): string {
|
|
25
|
+
return text.replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The words to highlight: what the visitor typed (not index terms, which
|
|
30
|
+
* stemming changes), two characters or longer, longest first so "photograph"
|
|
31
|
+
* wins over "photo" and the shorter word can't split the longer one's mark.
|
|
32
|
+
*/
|
|
33
|
+
export function highlightWords(query: string): string[] {
|
|
34
|
+
const seen = new Set<string>();
|
|
35
|
+
for (const part of query.toLowerCase().split(/[^\p{L}\p{N}]+/u)) {
|
|
36
|
+
if ([...part].length >= 2) seen.add(part);
|
|
37
|
+
}
|
|
38
|
+
return [...seen].sort((a, b) => b.length - a.length).slice(0, MAX_HIGHLIGHT_WORDS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
|
|
43
|
+
/** Matches any of the words at the start of a word (case-insensitive). Null when there are none. */
|
|
44
|
+
export function wordPattern(words: string[]): RegExp | null {
|
|
45
|
+
if (!words.length) return null;
|
|
46
|
+
return new RegExp(`(?<![\\p{L}\\p{N}])(?:${words.map(escapeRegExp).join("|")})`, "giu");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Escape `text` and wrap each match of the words in <mark>. */
|
|
50
|
+
export function highlightHtml(text: string, words: string[]): string {
|
|
51
|
+
const pattern = wordPattern(words);
|
|
52
|
+
if (!pattern) return escapeHtml(text);
|
|
53
|
+
let out = "";
|
|
54
|
+
let last = 0;
|
|
55
|
+
for (const match of text.matchAll(pattern)) {
|
|
56
|
+
const start = match.index ?? 0;
|
|
57
|
+
out += `${escapeHtml(text.slice(last, start))}<mark>${escapeHtml(match[0])}</mark>`;
|
|
58
|
+
last = start + match[0].length;
|
|
59
|
+
}
|
|
60
|
+
return out + escapeHtml(text.slice(last));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Collapse whitespace (indexed Portable Text is spans joined by spaces). */
|
|
64
|
+
export function cleanText(text: string): string {
|
|
65
|
+
return text.replace(/\s+/gu, " ").trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isLowSurrogate = (text: string, i: number) => {
|
|
69
|
+
const c = text.charCodeAt(i);
|
|
70
|
+
return c >= 0xdc00 && c <= 0xdfff;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* A window of about `width` characters around the first match of any of the
|
|
75
|
+
* words (or the start, when none match), cut at word boundaries where the
|
|
76
|
+
* text has them, with "…" where it was cut, highlighted and escaped.
|
|
77
|
+
*/
|
|
78
|
+
export function buildSnippet(text: string, words: string[], width = SNIPPET_WIDTH, lead = SNIPPET_LEAD): string {
|
|
79
|
+
const clean = cleanText(text);
|
|
80
|
+
if (!clean) return "";
|
|
81
|
+
const pattern = wordPattern(words);
|
|
82
|
+
const first = pattern ? pattern.exec(clean) : null;
|
|
83
|
+
|
|
84
|
+
let start = first ? Math.max(0, first.index - lead) : 0;
|
|
85
|
+
let end = Math.min(clean.length, start + width);
|
|
86
|
+
// Pull the window back when the end of the text leaves room, so a match near the end keeps its context.
|
|
87
|
+
if (end === clean.length) start = Math.max(0, end - width);
|
|
88
|
+
|
|
89
|
+
// Snap inward to word boundaries (not past the match), so no word is shown cut in half.
|
|
90
|
+
if (start > 0) {
|
|
91
|
+
const space = clean.indexOf(" ", start);
|
|
92
|
+
if (space !== -1 && space < (first ? first.index : end) && space - start < 20) start = space + 1;
|
|
93
|
+
else if (isLowSurrogate(clean, start)) start++;
|
|
94
|
+
}
|
|
95
|
+
if (end < clean.length) {
|
|
96
|
+
const space = clean.lastIndexOf(" ", end);
|
|
97
|
+
if (space > start && end - space < 20 && (!first || space >= first.index + first[0].length)) end = space;
|
|
98
|
+
else if (isLowSurrogate(clean, end)) end--;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const body = highlightHtml(clean.slice(start, end).trim(), words);
|
|
102
|
+
return `${start > 0 ? "…" : ""}${body}${end < clean.length ? "…" : ""}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The readable text of a Portable Text field (stored as JSON): the spans of
|
|
107
|
+
* text blocks (paragraphs, headings, lists) only. Image alt text, captions
|
|
108
|
+
* and other blocks' data, which EmDash's search index includes, are left out
|
|
109
|
+
* so excerpts read like the post. Returns "" for anything that isn't Portable
|
|
110
|
+
* Text.
|
|
111
|
+
*/
|
|
112
|
+
export function portableTextProse(value: unknown, maxChars = 20_000): string {
|
|
113
|
+
let blocks: unknown = value;
|
|
114
|
+
if (typeof value === "string") {
|
|
115
|
+
try {
|
|
116
|
+
blocks = JSON.parse(value);
|
|
117
|
+
} catch {
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!Array.isArray(blocks)) return "";
|
|
122
|
+
const parts: string[] = [];
|
|
123
|
+
let length = 0;
|
|
124
|
+
for (const block of blocks) {
|
|
125
|
+
if (!block || typeof block !== "object" || (block as { _type?: unknown })._type !== "block") continue;
|
|
126
|
+
const children = (block as { children?: unknown }).children;
|
|
127
|
+
if (!Array.isArray(children)) continue;
|
|
128
|
+
const text = children.map((c) => (c && typeof c === "object" && typeof (c as { text?: unknown }).text === "string" ? (c as { text: string }).text : "")).join("");
|
|
129
|
+
if (!text.trim()) continue;
|
|
130
|
+
parts.push(text);
|
|
131
|
+
length += text.length + 1;
|
|
132
|
+
if (length >= maxChars) break;
|
|
133
|
+
}
|
|
134
|
+
return parts.join(" ").slice(0, maxChars);
|
|
135
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The title index behind instant live results: every published entry of
|
|
3
|
+
* the searchable collections as [title, url, type], newest first, capped.
|
|
4
|
+
* The browser downloads it once per content version (lazily, on first focus
|
|
5
|
+
* of a search field) and matches titles locally while the server's full-text
|
|
6
|
+
* answer is on its way.
|
|
7
|
+
*
|
|
8
|
+
* Compact on purpose: no content, root-relative URLs, type labels listed
|
|
9
|
+
* once and referenced by position, and plain arrays that compress well.
|
|
10
|
+
*/
|
|
11
|
+
import { COLLECTION_SLUG, type PrimaryTermRow, type SqlQuery, collectPrimaryTerms, hostDefaultLocale, patternTaxonomies, primaryTermsQuery, urlOverride } from "../core/content-url.js";
|
|
12
|
+
import { type CollectionMeta, type SearchMeta, entryUrlsFromMeta, runBatch, searchTargets, titleExpression } from "./engine.js";
|
|
13
|
+
|
|
14
|
+
/** Most entries in an index (newest kept). */
|
|
15
|
+
export const TITLE_INDEX_MAX = 5000;
|
|
16
|
+
/** Longest title kept (characters); longer ones are cut. */
|
|
17
|
+
const MAX_TITLE = 200;
|
|
18
|
+
|
|
19
|
+
export interface TitleIndex {
|
|
20
|
+
/** The search content version it was built from. */
|
|
21
|
+
v: string;
|
|
22
|
+
/** Type labels; each entry's third item is a position in this list. */
|
|
23
|
+
types: string[];
|
|
24
|
+
/** [title, root-relative URL, type index], newest first. */
|
|
25
|
+
entries: Array<[string, string, number]>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IndexEntry {
|
|
29
|
+
title: string;
|
|
30
|
+
url: string;
|
|
31
|
+
type: string;
|
|
32
|
+
/** Publish date (ISO or SQLite datetime); newer first. */
|
|
33
|
+
date: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Pack entries into the index: newest first, one per URL, titles trimmed, at most `max`. */
|
|
37
|
+
export function packTitleIndex(entries: IndexEntry[], version: string, max = TITLE_INDEX_MAX): TitleIndex {
|
|
38
|
+
const sorted = entries
|
|
39
|
+
.map((e, i) => ({ e, i }))
|
|
40
|
+
.filter(({ e }) => e.title.trim() && e.url)
|
|
41
|
+
.sort((a, b) => (b.e.date ?? "").localeCompare(a.e.date ?? "") || a.i - b.i);
|
|
42
|
+
const types: string[] = [];
|
|
43
|
+
const typeIndex = new Map<string, number>();
|
|
44
|
+
const seen = new Set<string>();
|
|
45
|
+
const out: Array<[string, string, number]> = [];
|
|
46
|
+
for (const { e } of sorted) {
|
|
47
|
+
if (out.length >= max) break;
|
|
48
|
+
if (seen.has(e.url)) continue;
|
|
49
|
+
seen.add(e.url);
|
|
50
|
+
let t = typeIndex.get(e.type);
|
|
51
|
+
if (t === undefined) {
|
|
52
|
+
t = types.length;
|
|
53
|
+
types.push(e.type);
|
|
54
|
+
typeIndex.set(e.type, t);
|
|
55
|
+
}
|
|
56
|
+
out.push([e.title.replace(/\s+/g, " ").trim().slice(0, MAX_TITLE), e.url, t]);
|
|
57
|
+
}
|
|
58
|
+
return { v: version, types, entries: out };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One collection's published entries for the index, newest first. */
|
|
62
|
+
export function indexEntriesQuery(meta: CollectionMeta, options: { locale?: string; max: number }): SqlQuery | null {
|
|
63
|
+
if (!COLLECTION_SLUG.test(meta.slug)) return null;
|
|
64
|
+
const title = titleExpression(meta);
|
|
65
|
+
if (title === "NULL") return null;
|
|
66
|
+
const binds: unknown[] = [];
|
|
67
|
+
let where = "c.status = 'published' AND c.deleted_at IS NULL AND c.slug IS NOT NULL";
|
|
68
|
+
if (options.locale) {
|
|
69
|
+
where += " AND c.locale = ? COLLATE NOCASE";
|
|
70
|
+
binds.push(options.locale);
|
|
71
|
+
}
|
|
72
|
+
binds.push(options.max);
|
|
73
|
+
return {
|
|
74
|
+
sql: `SELECT c.id AS id, c.slug AS slug, c.locale AS locale, c.published_at AS published_at, ${title} AS title FROM "ec_${meta.slug}" c WHERE ${where} ORDER BY c.published_at DESC LIMIT ?`,
|
|
75
|
+
binds,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface IndexRow {
|
|
80
|
+
id: string;
|
|
81
|
+
slug: string;
|
|
82
|
+
locale: string | null;
|
|
83
|
+
published_at: string | null;
|
|
84
|
+
title: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Build the index from D1: one batch with each collection's entries (and,
|
|
89
|
+
* for collections whose URLs use terms, every published entry's terms in one
|
|
90
|
+
* query), then URLs from the cached metadata.
|
|
91
|
+
*/
|
|
92
|
+
export async function buildTitleIndex(db: D1Database, meta: SearchMeta, options: { collections?: string[]; locale?: string; version: string; max?: number }): Promise<TitleIndex> {
|
|
93
|
+
const max = options.max ?? TITLE_INDEX_MAX;
|
|
94
|
+
const targets = searchTargets(meta, options.collections);
|
|
95
|
+
const defaultLocale = await hostDefaultLocale();
|
|
96
|
+
const plan: Array<{ kind: "entries" | "terms"; slug: string }> = [];
|
|
97
|
+
const queries: SqlQuery[] = [];
|
|
98
|
+
for (const t of targets) {
|
|
99
|
+
const q = indexEntriesQuery(t, { locale: options.locale, max });
|
|
100
|
+
if (!q) continue;
|
|
101
|
+
plan.push({ kind: "entries", slug: t.slug });
|
|
102
|
+
queries.push(q);
|
|
103
|
+
const taxonomies = patternTaxonomies(urlOverride(t.slug));
|
|
104
|
+
if (taxonomies.length) {
|
|
105
|
+
plan.push({ kind: "terms", slug: t.slug });
|
|
106
|
+
queries.push(primaryTermsQuery(t.slug, null, taxonomies, defaultLocale));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const results = await runBatch(db, queries);
|
|
110
|
+
const rows = new Map<string, IndexRow[]>();
|
|
111
|
+
const terms = new Map<string, Map<string, Record<string, string>>>();
|
|
112
|
+
plan.forEach((p, i) => {
|
|
113
|
+
if (p.kind === "entries") rows.set(p.slug, (results[i] ?? []) as unknown as IndexRow[]);
|
|
114
|
+
else terms.set(p.slug, collectPrimaryTerms((results[i] ?? []) as unknown as PrimaryTermRow[]));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const slugs = [...rows.keys()];
|
|
118
|
+
const urls = await entryUrlsFromMeta(db, meta, slugs, (slug) =>
|
|
119
|
+
(rows.get(slug) ?? []).map((r) => ({
|
|
120
|
+
id: String(r.id),
|
|
121
|
+
slug: r.slug,
|
|
122
|
+
locale: r.locale,
|
|
123
|
+
publishedAt: r.published_at,
|
|
124
|
+
terms: terms.has(slug) ? (terms.get(slug)?.get(String(r.id)) ?? {}) : undefined,
|
|
125
|
+
})),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const entries: IndexEntry[] = [];
|
|
129
|
+
for (const slug of slugs) {
|
|
130
|
+
const info = meta.collections.get(slug);
|
|
131
|
+
const type = info?.labelSingular || info?.label || slug;
|
|
132
|
+
for (const r of rows.get(slug) ?? []) {
|
|
133
|
+
const url = urls.get(`${slug}:${String(r.id)}`);
|
|
134
|
+
if (!url || !r.title) continue;
|
|
135
|
+
entries.push({ title: String(r.title), url, type, date: r.published_at });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return packTitleIndex(entries, options.version, max);
|
|
139
|
+
}
|