@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,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search admin page: which collections are searchable, field weights, the
|
|
3
|
+
* tokenizer, and index rebuilds. A thin UI over EmDash's own search API
|
|
4
|
+
* (/_emdash/api/search/{enable,rebuild,stats}), called from the browser so
|
|
5
|
+
* EmDash's permission checks apply. The pack route only reads the current
|
|
6
|
+
* configuration, which EmDash has no endpoint for.
|
|
7
|
+
*/
|
|
8
|
+
import { Badge, Banner, Button, Input, Loader, Meter, Select, Switch } from "@cloudflare/kumo";
|
|
9
|
+
import { ArrowsClockwise, MagnifyingGlass } from "@phosphor-icons/react";
|
|
10
|
+
import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
import { SaveBar } from "./save-bar.js";
|
|
14
|
+
import { PartSwitches } from "./feature-switches.js";
|
|
15
|
+
|
|
16
|
+
const PACK_API = "/_emdash/api/plugins/coywolf-pack/search";
|
|
17
|
+
const SEARCH_API = "/_emdash/api/search";
|
|
18
|
+
|
|
19
|
+
const TOKENIZERS = [
|
|
20
|
+
{ value: "porter unicode61", label: "English stemming (porter): “running” finds “run”" },
|
|
21
|
+
{ value: "unicode61", label: "Exact words (unicode61): any language, no stemming" },
|
|
22
|
+
{ value: "trigram", label: "Substrings (trigram): matches inside words; larger index" },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
interface Field {
|
|
26
|
+
slug: string;
|
|
27
|
+
label: string;
|
|
28
|
+
type: string;
|
|
29
|
+
searchable: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CollectionConfig {
|
|
33
|
+
slug: string;
|
|
34
|
+
label: string;
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
weights: Record<string, number>;
|
|
37
|
+
tokenize: string;
|
|
38
|
+
titleField: string | null;
|
|
39
|
+
fields: Field[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Stats = Record<string, { indexed: number }>;
|
|
43
|
+
|
|
44
|
+
/** A collection's unsaved weights (as typed) and tokenizer. */
|
|
45
|
+
interface Draft {
|
|
46
|
+
weights: Record<string, string>;
|
|
47
|
+
tokenize: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const searchableFields = (c: CollectionConfig) => c.fields.filter((f) => f.searchable);
|
|
51
|
+
const draftOf = (c: CollectionConfig): Draft => ({
|
|
52
|
+
weights: Object.fromEntries(searchableFields(c).map((f) => [f.slug, String(c.weights[f.slug] ?? 1)])),
|
|
53
|
+
tokenize: c.tokenize,
|
|
54
|
+
});
|
|
55
|
+
const parseWeights = (d: Draft) => Object.fromEntries(Object.entries(d.weights).map(([k, v]) => [k, Number(v)]));
|
|
56
|
+
const invalidDraft = (d: Draft) => Object.values(parseWeights(d)).some((n) => !Number.isFinite(n) || n < 0 || n > 100);
|
|
57
|
+
/** Unsaved changes on an enabled collection (weights compared as numbers, so "1.0" equals 1). */
|
|
58
|
+
function draftDirty(c: CollectionConfig, d: Draft | undefined): boolean {
|
|
59
|
+
if (!d || !c.enabled) return false;
|
|
60
|
+
const parsed = parseWeights(d);
|
|
61
|
+
return d.tokenize !== c.tokenize || searchableFields(c).some((f) => parsed[f.slug] !== (c.weights[f.slug] ?? 1));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
|
|
65
|
+
|
|
66
|
+
async function post<T>(path: string, body: unknown, fallback: string): Promise<T> {
|
|
67
|
+
const response = await apiFetch(`${SEARCH_API}/${path}`, {
|
|
68
|
+
method: "POST",
|
|
69
|
+
headers: { "Content-Type": "application/json" },
|
|
70
|
+
body: JSON.stringify(body),
|
|
71
|
+
});
|
|
72
|
+
const result = await parseApiResponse<T>(response, fallback);
|
|
73
|
+
// Cached live results and title indexes were built with the old settings: start fresh (best effort).
|
|
74
|
+
await apiFetch(`${PACK_API}/touch`, { method: "POST" }).catch(() => undefined);
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function CollectionCard(props: {
|
|
79
|
+
collection: CollectionConfig;
|
|
80
|
+
draft: Draft;
|
|
81
|
+
onDraft: (draft: Draft) => void;
|
|
82
|
+
indexed: number | undefined;
|
|
83
|
+
busy: boolean;
|
|
84
|
+
onEnable: (enabled: boolean) => Promise<void>;
|
|
85
|
+
/** Saves every collection with unsaved changes (the page's save bar). */
|
|
86
|
+
onSubmit: () => void;
|
|
87
|
+
onRebuild: () => Promise<void>;
|
|
88
|
+
}) {
|
|
89
|
+
const { collection, draft } = props;
|
|
90
|
+
const searchable = searchableFields(collection);
|
|
91
|
+
const { weights, tokenize } = draft;
|
|
92
|
+
const invalid = invalidDraft(draft);
|
|
93
|
+
const headingId = `cw-search-${collection.slug}`;
|
|
94
|
+
const weightsErrorId = `cw-search-${collection.slug}-weights-error`;
|
|
95
|
+
const badWeight = (slug: string) => {
|
|
96
|
+
const n = Number(weights[slug] ?? "1");
|
|
97
|
+
return !Number.isFinite(n) || n < 0 || n > 100;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<section aria-labelledby={headingId} className="rounded-lg border border-kumo-line">
|
|
102
|
+
<div className="flex items-start justify-between gap-4 p-4">
|
|
103
|
+
<div className="min-w-0">
|
|
104
|
+
<h2 id={headingId} className="flex items-center gap-2 text-base font-semibold">
|
|
105
|
+
{collection.label}
|
|
106
|
+
<code className="text-xs font-normal text-kumo-subtle">{collection.slug}</code>
|
|
107
|
+
</h2>
|
|
108
|
+
<p className="mt-1 text-sm text-kumo-subtle">
|
|
109
|
+
{searchable.length === 0
|
|
110
|
+
? "No searchable fields. Mark fields as searchable in this collection's schema first."
|
|
111
|
+
: collection.enabled
|
|
112
|
+
? `${props.indexed === undefined ? "—" : props.indexed.toLocaleString()} entries indexed · searching ${searchable.map((f) => f.label).join(", ")}`
|
|
113
|
+
: "Not searchable."}
|
|
114
|
+
</p>
|
|
115
|
+
</div>
|
|
116
|
+
<Switch className="shrink-0"
|
|
117
|
+
aria-label={`Search ${collection.label}: ${collection.enabled ? "on" : "off"}`}
|
|
118
|
+
checked={collection.enabled}
|
|
119
|
+
disabled={props.busy || searchable.length === 0}
|
|
120
|
+
onCheckedChange={(on) => void props.onEnable(on)}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
{collection.enabled && searchable.length > 0 && (
|
|
124
|
+
<form
|
|
125
|
+
className="space-y-4 border-t border-kumo-line p-4"
|
|
126
|
+
onSubmit={(e) => {
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
props.onSubmit();
|
|
129
|
+
}}
|
|
130
|
+
>
|
|
131
|
+
{/* Keeps Enter in a weight field submitting; saving happens from the page's save bar. */}
|
|
132
|
+
<button type="submit" className="sr-only" tabIndex={-1} aria-hidden="true" />
|
|
133
|
+
<fieldset style={{ minWidth: 0 }}>
|
|
134
|
+
<legend className="text-sm font-medium">Field weights</legend>
|
|
135
|
+
<p className="mt-0.5 text-sm text-kumo-subtle">
|
|
136
|
+
How much a match in each field counts toward ranking. 1 is normal; 0 ignores the field for ranking.
|
|
137
|
+
</p>
|
|
138
|
+
<div className="mt-2 grid gap-3 sm:grid-cols-3">
|
|
139
|
+
{searchable.map((f) => (
|
|
140
|
+
<Input
|
|
141
|
+
key={f.slug}
|
|
142
|
+
label={f.label}
|
|
143
|
+
type="number"
|
|
144
|
+
min={0}
|
|
145
|
+
max={100}
|
|
146
|
+
step={0.5}
|
|
147
|
+
inputMode="decimal"
|
|
148
|
+
value={weights[f.slug] ?? "1"}
|
|
149
|
+
aria-invalid={badWeight(f.slug) || undefined}
|
|
150
|
+
aria-describedby={invalid ? weightsErrorId : undefined}
|
|
151
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.onDraft({ ...draft, weights: { ...weights, [f.slug]: e.target.value } })}
|
|
152
|
+
/>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
</fieldset>
|
|
156
|
+
<Select
|
|
157
|
+
label="Tokenizer"
|
|
158
|
+
value={tokenize}
|
|
159
|
+
onValueChange={(value: string | null) => props.onDraft({ ...draft, tokenize: value ?? "porter unicode61" })}
|
|
160
|
+
items={TOKENIZERS}
|
|
161
|
+
/>
|
|
162
|
+
{invalid && (
|
|
163
|
+
<p id={weightsErrorId} className="text-sm text-kumo-danger">
|
|
164
|
+
Weights must be numbers from 0 to 100.
|
|
165
|
+
</p>
|
|
166
|
+
)}
|
|
167
|
+
<div className="flex flex-wrap justify-end gap-2">
|
|
168
|
+
<Button type="button" variant="secondary" icon={<ArrowsClockwise />} disabled={props.busy} onClick={() => void props.onRebuild()}>
|
|
169
|
+
Rebuild index
|
|
170
|
+
</Button>
|
|
171
|
+
</div>
|
|
172
|
+
</form>
|
|
173
|
+
)}
|
|
174
|
+
</section>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function SearchPage() {
|
|
179
|
+
const [collections, setCollections] = React.useState<CollectionConfig[]>();
|
|
180
|
+
const [stats, setStats] = React.useState<Stats>({});
|
|
181
|
+
const [error, setError] = React.useState<string>();
|
|
182
|
+
const [status, setStatus] = React.useState("");
|
|
183
|
+
const [progress, setProgress] = React.useState<{ done: number; total: number; current: string } | null>(null);
|
|
184
|
+
|
|
185
|
+
const loadStats = React.useCallback(async () => {
|
|
186
|
+
try {
|
|
187
|
+
const data = await parseApiResponse<{ collections: Stats }>(await apiFetch(`${SEARCH_API}/stats`), "Couldn't load index stats");
|
|
188
|
+
setStats(data.collections ?? {});
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
setError(errorText(cause, "Couldn't load index stats"));
|
|
191
|
+
}
|
|
192
|
+
}, []);
|
|
193
|
+
|
|
194
|
+
const load = React.useCallback(async () => {
|
|
195
|
+
setError(undefined);
|
|
196
|
+
try {
|
|
197
|
+
const data = await parseApiResponse<{ collections: CollectionConfig[] }>(
|
|
198
|
+
await apiFetch(`${PACK_API}/config`),
|
|
199
|
+
"Couldn't load search settings",
|
|
200
|
+
);
|
|
201
|
+
setCollections(data.collections);
|
|
202
|
+
await loadStats();
|
|
203
|
+
} catch (cause) {
|
|
204
|
+
setError(errorText(cause, "Couldn't load search settings"));
|
|
205
|
+
}
|
|
206
|
+
}, [loadStats]);
|
|
207
|
+
|
|
208
|
+
React.useEffect(() => {
|
|
209
|
+
void load();
|
|
210
|
+
}, [load]);
|
|
211
|
+
|
|
212
|
+
const busy = progress !== null;
|
|
213
|
+
|
|
214
|
+
/** Run one or more index jobs with a progress readout, then refresh. */
|
|
215
|
+
async function run(jobs: Array<{ label: string; fn: () => Promise<unknown> }>, done: string) {
|
|
216
|
+
setError(undefined);
|
|
217
|
+
setProgress({ done: 0, total: jobs.length, current: jobs[0]?.label ?? "" });
|
|
218
|
+
setStatus(`Working on ${jobs[0]?.label ?? ""}…`);
|
|
219
|
+
try {
|
|
220
|
+
for (const [i, job] of jobs.entries()) {
|
|
221
|
+
setProgress({ done: i, total: jobs.length, current: job.label });
|
|
222
|
+
setStatus(`${job.label} (${i + 1} of ${jobs.length})…`);
|
|
223
|
+
await job.fn();
|
|
224
|
+
}
|
|
225
|
+
setStatus(done);
|
|
226
|
+
} catch (cause) {
|
|
227
|
+
setError(errorText(cause, "The search index couldn't be updated"));
|
|
228
|
+
setStatus("");
|
|
229
|
+
} finally {
|
|
230
|
+
setProgress(null);
|
|
231
|
+
await load();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const enabled = (collections ?? []).filter((c) => c.enabled);
|
|
236
|
+
|
|
237
|
+
// Unsaved weights and tokenizers, reset whenever the saved config reloads.
|
|
238
|
+
const [drafts, setDrafts] = React.useState<Record<string, Draft>>({});
|
|
239
|
+
React.useEffect(() => {
|
|
240
|
+
setDrafts(Object.fromEntries((collections ?? []).map((c) => [c.slug, draftOf(c)])));
|
|
241
|
+
}, [collections]);
|
|
242
|
+
const dirtyCollections = (collections ?? []).filter((c) => draftDirty(c, drafts[c.slug]));
|
|
243
|
+
const dirty = dirtyCollections.length > 0;
|
|
244
|
+
const anyInvalid = dirtyCollections.some((c) => invalidDraft(drafts[c.slug]));
|
|
245
|
+
|
|
246
|
+
function saveAll() {
|
|
247
|
+
if (!dirty || anyInvalid || busy) return;
|
|
248
|
+
const labels = dirtyCollections.map((c) => c.label).join(", ");
|
|
249
|
+
void run(
|
|
250
|
+
dirtyCollections.map((c) => ({
|
|
251
|
+
label: `Rebuilding ${c.label}`,
|
|
252
|
+
fn: () => post("enable", { collection: c.slug, enabled: true, weights: parseWeights(drafts[c.slug]), tokenize: drafts[c.slug].tokenize }, "Couldn't save"),
|
|
253
|
+
})),
|
|
254
|
+
dirtyCollections.length === 1 ? `Saved. ${labels}'s index was rebuilt.` : `Saved. Rebuilt the indexes for ${labels}.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="space-y-6">
|
|
260
|
+
<header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
|
|
261
|
+
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
|
|
262
|
+
<h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Search</h1>
|
|
263
|
+
<div className="flex shrink-0 justify-end gap-2">
|
|
264
|
+
<Button
|
|
265
|
+
variant="secondary"
|
|
266
|
+
icon={<ArrowsClockwise />}
|
|
267
|
+
disabled={busy || enabled.length === 0}
|
|
268
|
+
onClick={() =>
|
|
269
|
+
void run(
|
|
270
|
+
enabled.map((c) => ({
|
|
271
|
+
label: `Rebuilding ${c.label}`,
|
|
272
|
+
fn: () => post("rebuild", { collection: c.slug }, `Couldn't rebuild ${c.label}`),
|
|
273
|
+
})),
|
|
274
|
+
"All search indexes rebuilt.",
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
>
|
|
278
|
+
Rebuild all
|
|
279
|
+
</Button>
|
|
280
|
+
</div>
|
|
281
|
+
<p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
|
|
282
|
+
EmDash's full-text search: choose which collections are searchable, how much each field counts, and how words
|
|
283
|
+
are matched. Changing weights or the tokenizer rebuilds that collection's index. Fields are made searchable in
|
|
284
|
+
each collection's schema.
|
|
285
|
+
</p>
|
|
286
|
+
</div>
|
|
287
|
+
</header>
|
|
288
|
+
|
|
289
|
+
<PartSwitches module="search" />
|
|
290
|
+
|
|
291
|
+
<div role="status" aria-live="polite" className="space-y-2">
|
|
292
|
+
{progress && (
|
|
293
|
+
<Meter
|
|
294
|
+
label={progress.current}
|
|
295
|
+
value={Math.round((progress.done / Math.max(progress.total, 1)) * 100)}
|
|
296
|
+
customValue={`${progress.done} of ${progress.total}`}
|
|
297
|
+
/>
|
|
298
|
+
)}
|
|
299
|
+
{!progress && status && <Banner variant="default" title={status} />}
|
|
300
|
+
</div>
|
|
301
|
+
{error && <Banner variant="error" role="alert" title="Something went wrong" description={error} />}
|
|
302
|
+
|
|
303
|
+
{!collections && !error ? (
|
|
304
|
+
<div className="flex justify-center py-12">
|
|
305
|
+
<Loader />
|
|
306
|
+
</div>
|
|
307
|
+
) : collections && collections.length === 0 ? (
|
|
308
|
+
<div className="py-10 text-center text-kumo-subtle">
|
|
309
|
+
<MagnifyingGlass size={40} className="mx-auto mb-3 opacity-30" aria-hidden="true" />
|
|
310
|
+
<p className="text-base font-medium">No collections yet</p>
|
|
311
|
+
</div>
|
|
312
|
+
) : (
|
|
313
|
+
collections?.map((c) => (
|
|
314
|
+
<CollectionCard
|
|
315
|
+
key={c.slug}
|
|
316
|
+
collection={c}
|
|
317
|
+
draft={drafts[c.slug] ?? draftOf(c)}
|
|
318
|
+
onDraft={(d) => setDrafts((all) => ({ ...all, [c.slug]: d }))}
|
|
319
|
+
indexed={stats[c.slug]?.indexed}
|
|
320
|
+
busy={busy}
|
|
321
|
+
onEnable={async (on) => {
|
|
322
|
+
if (!on && !window.confirm(`Turn off search for ${c.label}? Its search index is deleted; your settings are kept.`)) return;
|
|
323
|
+
await run(
|
|
324
|
+
[
|
|
325
|
+
{
|
|
326
|
+
label: on ? `Indexing ${c.label}` : `Turning off search for ${c.label}`,
|
|
327
|
+
fn: () => post("enable", { collection: c.slug, enabled: on }, "Couldn't change search"),
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
on ? `${c.label} is searchable.` : `Search is off for ${c.label}.`,
|
|
331
|
+
);
|
|
332
|
+
}}
|
|
333
|
+
onSubmit={saveAll}
|
|
334
|
+
onRebuild={() =>
|
|
335
|
+
run(
|
|
336
|
+
[{ label: `Rebuilding ${c.label}`, fn: () => post("rebuild", { collection: c.slug }, `Couldn't rebuild ${c.label}`) }],
|
|
337
|
+
`${c.label}'s index was rebuilt.`,
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
/>
|
|
341
|
+
))
|
|
342
|
+
)}
|
|
343
|
+
|
|
344
|
+
<SaveBar
|
|
345
|
+
dirty={dirty}
|
|
346
|
+
saving={busy}
|
|
347
|
+
canSave={!anyInvalid}
|
|
348
|
+
label="Save and rebuild"
|
|
349
|
+
detail={dirtyCollections.map((c) => c.label).join(", ")}
|
|
350
|
+
onSave={saveAll}
|
|
351
|
+
onDiscard={() => setDrafts((all) => ({ ...all, ...Object.fromEntries(dirtyCollections.map((c) => [c.slug, draftOf(c)])) }))}
|
|
352
|
+
/>
|
|
353
|
+
</div>
|
|
354
|
+
);
|
|
355
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small building blocks for the settings sections on module pages, styled
|
|
3
|
+
* like the AI Enrichment and Schema pages: a bordered section with a title
|
|
4
|
+
* and description, a write-only secret field, and a setup card shown in place
|
|
5
|
+
* of features that need a key first.
|
|
6
|
+
*/
|
|
7
|
+
import { Button, Input } from "@cloudflare/kumo";
|
|
8
|
+
import { Key } from "@phosphor-icons/react";
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
export function SettingsSection(props: { id: string; title: string; description?: React.ReactNode; actions?: React.ReactNode; children?: React.ReactNode }) {
|
|
12
|
+
return (
|
|
13
|
+
<section className="rounded-lg border border-kumo-line" aria-labelledby={props.id}>
|
|
14
|
+
<div className={`flex items-start justify-between gap-4 p-4${props.children ? " border-b border-kumo-line" : ""}`}>
|
|
15
|
+
<div className="min-w-0">
|
|
16
|
+
<h2 id={props.id} className="text-base font-semibold">
|
|
17
|
+
{props.title}
|
|
18
|
+
</h2>
|
|
19
|
+
{props.description && <p className="mt-1 text-sm text-kumo-subtle">{props.description}</p>}
|
|
20
|
+
</div>
|
|
21
|
+
{props.actions && <div className="flex shrink-0 gap-2">{props.actions}</div>}
|
|
22
|
+
</div>
|
|
23
|
+
{props.children && <div className="space-y-4 p-4">{props.children}</div>}
|
|
24
|
+
</section>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A write-only secret: shows dots when one is saved, and sends a value only
|
|
30
|
+
* when something new is typed. `onClear` offers removing the saved one.
|
|
31
|
+
*/
|
|
32
|
+
export function SecretField(props: {
|
|
33
|
+
label: string;
|
|
34
|
+
saved: boolean;
|
|
35
|
+
value: string;
|
|
36
|
+
onChange: (value: string) => void;
|
|
37
|
+
description?: string;
|
|
38
|
+
onClear?: () => void;
|
|
39
|
+
clearing?: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
}) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="space-y-2">
|
|
44
|
+
<Input
|
|
45
|
+
type="password"
|
|
46
|
+
autoComplete="off"
|
|
47
|
+
label={props.saved ? `${props.label} (saved)` : props.label}
|
|
48
|
+
placeholder={props.saved ? "••••••••••••••••••••••••" : undefined}
|
|
49
|
+
description={props.saved ? "Stored encrypted. Type a new value to replace it." : props.description}
|
|
50
|
+
value={props.value}
|
|
51
|
+
disabled={props.disabled}
|
|
52
|
+
onChange={(e: React.ChangeEvent<HTMLInputElement>) => props.onChange(e.target.value)}
|
|
53
|
+
/>
|
|
54
|
+
{props.saved && props.onClear && (
|
|
55
|
+
<Button type="button" variant="ghost" size="sm" disabled={props.disabled || props.clearing} onClick={props.onClear}>
|
|
56
|
+
{props.clearing ? "Removing…" : `Remove saved ${props.label.toLowerCase()}`}
|
|
57
|
+
</Button>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Shown instead of features that need a key or binding that isn't there yet. */
|
|
64
|
+
export function SetupCard(props: { title: string; description: React.ReactNode; children?: React.ReactNode }) {
|
|
65
|
+
return (
|
|
66
|
+
<section className="rounded-lg border border-dashed border-kumo-line p-5" aria-label={props.title}>
|
|
67
|
+
<div className="flex items-start gap-3">
|
|
68
|
+
<Key size={22} className="mt-0.5 shrink-0 text-kumo-subtle" aria-hidden="true" />
|
|
69
|
+
<div className="min-w-0 flex-1 space-y-3">
|
|
70
|
+
<div>
|
|
71
|
+
<h2 className="text-base font-semibold">{props.title}</h2>
|
|
72
|
+
<p className="mt-1 text-sm text-pretty text-kumo-subtle">{props.description}</p>
|
|
73
|
+
</div>
|
|
74
|
+
{props.children}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</section>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
|
|
82
|
+
|
|
83
|
+
/** Parse a number input, keeping `fallback` for anything that isn't a whole number. */
|
|
84
|
+
export const wholeNumber = (value: string, fallback: number) => {
|
|
85
|
+
const n = Number.parseInt(value, 10);
|
|
86
|
+
return Number.isFinite(n) ? n : fallback;
|
|
87
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The trash prompt's plumbing (feature "redirects.trashPrompt"), kept free of
|
|
3
|
+
* React so Node can test it. EmDash gives plugins no event for "an editor just
|
|
4
|
+
* trashed or unpublished something", so the admin module watches the admin's
|
|
5
|
+
* own content API calls: a successful DELETE /_emdash/api/content/:collection/:id
|
|
6
|
+
* (move to trash) or POST .../:id/unpublish. After a short pause (a bulk trash
|
|
7
|
+
* is one DELETE per entry) it reads the pending decisions the server recorded
|
|
8
|
+
* for those entries and hands them to the prompt.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** A pending decision, as the `redirects/removed` route lists it. */
|
|
12
|
+
export interface Pending {
|
|
13
|
+
id: string;
|
|
14
|
+
collection: string;
|
|
15
|
+
url: string;
|
|
16
|
+
title: string;
|
|
17
|
+
reason: "deleted" | "unpublished";
|
|
18
|
+
at: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Removal {
|
|
22
|
+
/** Pending decision id: "<collection>:<entry id>". */
|
|
23
|
+
id: string;
|
|
24
|
+
reason: "deleted" | "unpublished";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CONTENT_PATH = /^\/_emdash\/api\/content\/([^/]+)\/([^/]+)(\/unpublish)?\/?$/;
|
|
28
|
+
|
|
29
|
+
/** The removal an admin API request makes, or null. Permanent deletes, restores, and everything else are null. */
|
|
30
|
+
export function removalFromRequest(method: string, url: string): Removal | null {
|
|
31
|
+
let pathname: string;
|
|
32
|
+
try {
|
|
33
|
+
pathname = new URL(url, "http://admin.invalid").pathname;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const match = CONTENT_PATH.exec(pathname);
|
|
38
|
+
if (!match) return null;
|
|
39
|
+
const verb = method.toUpperCase();
|
|
40
|
+
const reason = match[3] ? (verb === "POST" ? "unpublished" : null) : verb === "DELETE" ? "deleted" : null;
|
|
41
|
+
if (!reason) return null;
|
|
42
|
+
try {
|
|
43
|
+
return { id: `${decodeURIComponent(match[1])}:${decodeURIComponent(match[2])}`, reason };
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const WATCHED = Symbol.for("coywolf-pack.trash-prompt.fetch");
|
|
50
|
+
|
|
51
|
+
type FetchHost = { fetch: typeof fetch; [WATCHED]?: true };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wrap `host.fetch` (window) once so every successful removal request calls
|
|
55
|
+
* onRemoved. The wrapper returns the original Response untouched and never
|
|
56
|
+
* reads its body; a failing matcher or callback is swallowed. False when the
|
|
57
|
+
* host was already wrapped.
|
|
58
|
+
*/
|
|
59
|
+
export function watchContentRemovals(onRemoved: (removal: Removal) => void, host: FetchHost = globalThis as unknown as FetchHost): boolean {
|
|
60
|
+
if (host[WATCHED] || typeof host.fetch !== "function") return false;
|
|
61
|
+
const original = host.fetch;
|
|
62
|
+
const wrapped = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
63
|
+
const response = await original.call(host, input, init);
|
|
64
|
+
try {
|
|
65
|
+
if (response.ok) {
|
|
66
|
+
const request = typeof input === "object" && "url" in input ? input : null;
|
|
67
|
+
const method = init?.method ?? request?.method ?? "GET";
|
|
68
|
+
const url = typeof input === "string" ? input : request ? request.url : String(input);
|
|
69
|
+
const removal = removalFromRequest(method, url);
|
|
70
|
+
if (removal) onRemoved(removal);
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
// Never get in the way of the admin's own request.
|
|
74
|
+
}
|
|
75
|
+
return response;
|
|
76
|
+
};
|
|
77
|
+
host.fetch = wrapped as typeof fetch;
|
|
78
|
+
host[WATCHED] = true;
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CollectorOptions {
|
|
83
|
+
/** The pending decisions, or null when there's nothing to show (feature off, no permission, error). */
|
|
84
|
+
fetchPending: () => Promise<Pending[] | null>;
|
|
85
|
+
onReady: (items: Pending[]) => void;
|
|
86
|
+
/** Pause after the last removal before looking (bulk trash sends one request per entry). */
|
|
87
|
+
debounceMs?: number;
|
|
88
|
+
/** Extra looks while some removals aren't recorded yet (unpublish records after its response). */
|
|
89
|
+
retryMs?: number[];
|
|
90
|
+
setTimer?: (fn: () => void, ms: number) => unknown;
|
|
91
|
+
clearTimer?: (timer: unknown) => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Collect removals and report the ones the server recorded a decision for.
|
|
96
|
+
* Removals the server didn't record (drafts, entries without a URL) are
|
|
97
|
+
* dropped. A trash is recorded before its response, so only an unpublish
|
|
98
|
+
* that isn't listed yet is worth looking again for.
|
|
99
|
+
*/
|
|
100
|
+
export function collectRemovals(options: CollectorOptions): (removal: Removal) => void {
|
|
101
|
+
const debounceMs = options.debounceMs ?? 700;
|
|
102
|
+
const retryMs = options.retryMs ?? [600, 1500];
|
|
103
|
+
const setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
104
|
+
const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer as ReturnType<typeof setTimeout>));
|
|
105
|
+
let queued = new Map<string, Removal["reason"]>();
|
|
106
|
+
let timer: unknown = null;
|
|
107
|
+
|
|
108
|
+
const look = async (removals: Map<string, Removal["reason"]>, attempt: number) => {
|
|
109
|
+
let list: Pending[] | null = null;
|
|
110
|
+
try {
|
|
111
|
+
list = await options.fetchPending();
|
|
112
|
+
} catch {
|
|
113
|
+
list = null;
|
|
114
|
+
}
|
|
115
|
+
if (!list) return;
|
|
116
|
+
const found = list.filter((item) => removals.has(item.id));
|
|
117
|
+
const missingUnpublish = [...removals].some(([id, reason]) => reason === "unpublished" && !found.some((item) => item.id === id));
|
|
118
|
+
if (missingUnpublish && attempt < retryMs.length) {
|
|
119
|
+
setTimer(() => void look(removals, attempt + 1), retryMs[attempt]);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (found.length) options.onReady(found);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (removal) => {
|
|
126
|
+
queued.set(removal.id, removal.reason);
|
|
127
|
+
if (timer !== null) clearTimer(timer);
|
|
128
|
+
timer = setTimer(() => {
|
|
129
|
+
timer = null;
|
|
130
|
+
const removals = queued;
|
|
131
|
+
queued = new Map();
|
|
132
|
+
void look(removals, 0);
|
|
133
|
+
}, debounceMs);
|
|
134
|
+
};
|
|
135
|
+
}
|