@coywolf/emdash 0.0.0-stage → 0.35.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/package.json +70 -5
  4. package/src/admin/admin-css.ts +57 -0
  5. package/src/admin/ai.tsx +915 -0
  6. package/src/admin/backups.tsx +613 -0
  7. package/src/admin/breadcrumbs.tsx +542 -0
  8. package/src/admin/codeBlocks.tsx +204 -0
  9. package/src/admin/customBlocks.tsx +321 -0
  10. package/src/admin/dirty.ts +20 -0
  11. package/src/admin/discovery.tsx +463 -0
  12. package/src/admin/download.ts +28 -0
  13. package/src/admin/feature-switches.tsx +124 -0
  14. package/src/admin/features.tsx +142 -0
  15. package/src/admin/files.tsx +791 -0
  16. package/src/admin/formUploads.tsx +445 -0
  17. package/src/admin/guides.tsx +18 -0
  18. package/src/admin/headings.tsx +262 -0
  19. package/src/admin/images.tsx +825 -0
  20. package/src/admin/index.tsx +62 -0
  21. package/src/admin/links.tsx +980 -0
  22. package/src/admin/performance.tsx +330 -0
  23. package/src/admin/preview.tsx +63 -0
  24. package/src/admin/redirects-removed.tsx +266 -0
  25. package/src/admin/redirects.tsx +470 -0
  26. package/src/admin/reviews.tsx +232 -0
  27. package/src/admin/robots-bots.tsx +647 -0
  28. package/src/admin/robots-shared.tsx +109 -0
  29. package/src/admin/robots-wizard.tsx +748 -0
  30. package/src/admin/robots.tsx +911 -0
  31. package/src/admin/save-bar.tsx +134 -0
  32. package/src/admin/schema.tsx +1187 -0
  33. package/src/admin/search.tsx +355 -0
  34. package/src/admin/settings-ui.tsx +87 -0
  35. package/src/admin/trash-prompt-core.ts +135 -0
  36. package/src/admin/trash-prompt.tsx +156 -0
  37. package/src/admin/usage.tsx +301 -0
  38. package/src/admin/videos-display.tsx +248 -0
  39. package/src/admin/videos.tsx +1202 -0
  40. package/src/admin/wpImport.tsx +1516 -0
  41. package/src/ai/entities.ts +59 -0
  42. package/src/ai/export.ts +61 -0
  43. package/src/ai/logic.test.ts +187 -0
  44. package/src/ai/logic.ts +606 -0
  45. package/src/ai/pack.ts +121 -0
  46. package/src/ai/prompts.ts +87 -0
  47. package/src/ai/providers.ts +276 -0
  48. package/src/ai/queue.test.ts +97 -0
  49. package/src/ai/routes.ts +359 -0
  50. package/src/ai/store.ts +301 -0
  51. package/src/ai/wikidata.test.ts +184 -0
  52. package/src/ai/wikidata.ts +87 -0
  53. package/src/ai/worker.ts +530 -0
  54. package/src/astro/astro-modules.d.ts +6 -0
  55. package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
  56. package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
  57. package/src/astro/codeBlocks/CodeBlock.astro +62 -0
  58. package/src/astro/codeBlocks/EmDashCode.astro +61 -0
  59. package/src/astro/customBlocks/CustomBlock.astro +64 -0
  60. package/src/astro/customBlocks/Details.astro +8 -0
  61. package/src/astro/customBlocks/Disclosure.astro +8 -0
  62. package/src/astro/customBlocks/Note.astro +8 -0
  63. package/src/astro/customBlocks/Podcast.astro +8 -0
  64. package/src/astro/customBlocks/Quote.astro +8 -0
  65. package/src/astro/customBlocks/Testimonial.astro +8 -0
  66. package/src/astro/files/FileCard.astro +103 -0
  67. package/src/astro/headings/HeadingBlock.astro +129 -0
  68. package/src/astro/headings/TableOfContents.astro +45 -0
  69. package/src/astro/headings/request.ts +19 -0
  70. package/src/astro/images/ResponsiveImage.astro +60 -0
  71. package/src/astro/index.ts +84 -0
  72. package/src/astro/reviews/CoywolfReview.astro +27 -0
  73. package/src/astro/reviews/Review.astro +49 -0
  74. package/src/astro/search/SearchBox.astro +61 -0
  75. package/src/astro/search/SearchBoxForm.astro +509 -0
  76. package/src/astro/tables/CaptionedTable.astro +72 -0
  77. package/src/astro/videos/CoywolfVideo.astro +448 -0
  78. package/src/backups/download.ts +112 -0
  79. package/src/backups/dump.test.mjs +287 -0
  80. package/src/backups/dump.ts +288 -0
  81. package/src/backups/module.ts +276 -0
  82. package/src/backups/pack.ts +33 -0
  83. package/src/backups/restore.test.mjs +103 -0
  84. package/src/backups/restore.ts +209 -0
  85. package/src/backups/store.ts +409 -0
  86. package/src/breadcrumbs/pack.ts +113 -0
  87. package/src/breadcrumbs/settings.ts +102 -0
  88. package/src/breadcrumbs/stamp.ts +40 -0
  89. package/src/breadcrumbs/trail.ts +123 -0
  90. package/src/codeBlocks/contrast.ts +179 -0
  91. package/src/codeBlocks/hljs.d.ts +5 -0
  92. package/src/codeBlocks/pack.ts +110 -0
  93. package/src/codeBlocks/render.test.ts +152 -0
  94. package/src/codeBlocks/render.ts +304 -0
  95. package/src/codeBlocks/settings.ts +18 -0
  96. package/src/codeBlocks/themes.generated.ts +304 -0
  97. package/src/codeBlocks/themes.ts +114 -0
  98. package/src/core/compose.ts +244 -0
  99. package/src/core/content-url.ts +888 -0
  100. package/src/core/csv.ts +119 -0
  101. package/src/core/d1-batch.ts +65 -0
  102. package/src/core/entry-docs.ts +175 -0
  103. package/src/core/features-module.ts +48 -0
  104. package/src/core/features.ts +392 -0
  105. package/src/core/generation.ts +88 -0
  106. package/src/core/guide-content.ts +166 -0
  107. package/src/core/module.ts +104 -0
  108. package/src/core/read-failures.ts +57 -0
  109. package/src/core/request-memo.ts +71 -0
  110. package/src/core/secrets.ts +83 -0
  111. package/src/core/settings-enhance.ts +74 -0
  112. package/src/core/site.ts +36 -0
  113. package/src/core/storage.ts +27 -0
  114. package/src/customBlocks/pack.ts +260 -0
  115. package/src/customBlocks/render.ts +424 -0
  116. package/src/customBlocks/rich.ts +313 -0
  117. package/src/customBlocks/settings.ts +46 -0
  118. package/src/discovery/build.ts +252 -0
  119. package/src/discovery/feed-build.ts +217 -0
  120. package/src/discovery/feed.ts +172 -0
  121. package/src/discovery/indexnow.ts +114 -0
  122. package/src/discovery/llms.ts +79 -0
  123. package/src/discovery/markdown.ts +294 -0
  124. package/src/discovery/memory-cache.ts +97 -0
  125. package/src/discovery/middleware.ts +186 -0
  126. package/src/discovery/module.ts +353 -0
  127. package/src/discovery/news.ts +99 -0
  128. package/src/discovery/pack.ts +58 -0
  129. package/src/discovery/settings.ts +91 -0
  130. package/src/discovery/urls.ts +35 -0
  131. package/src/env.d.ts +10 -0
  132. package/src/files/card.ts +231 -0
  133. package/src/files/counts.test.ts +114 -0
  134. package/src/files/counts.ts +60 -0
  135. package/src/files/export.ts +44 -0
  136. package/src/files/format.test.ts +93 -0
  137. package/src/files/format.ts +163 -0
  138. package/src/files/module.ts +596 -0
  139. package/src/files/pack.ts +92 -0
  140. package/src/files/r2-rules.test.ts +30 -0
  141. package/src/files/r2-rules.ts +41 -0
  142. package/src/files/range.ts +50 -0
  143. package/src/files/s3.ts +114 -0
  144. package/src/files/serve.ts +141 -0
  145. package/src/files/sigv4.test.ts +64 -0
  146. package/src/files/sigv4.ts +160 -0
  147. package/src/files/site.ts +137 -0
  148. package/src/files/walker.ts +58 -0
  149. package/src/formUploads/lib.ts +266 -0
  150. package/src/formUploads/pack.ts +55 -0
  151. package/src/formUploads/wrap.ts +592 -0
  152. package/src/forms.ts +16 -0
  153. package/src/headings/headings.test.ts +159 -0
  154. package/src/headings/pack.ts +154 -0
  155. package/src/headings/render.ts +139 -0
  156. package/src/headings/settings.ts +117 -0
  157. package/src/headings/slug.ts +68 -0
  158. package/src/headings/stamp.ts +142 -0
  159. package/src/headings/test-hooks.mjs +14 -0
  160. package/src/headings/toc.ts +48 -0
  161. package/src/images/cloudflare.ts +329 -0
  162. package/src/images/lib.ts +221 -0
  163. package/src/images/module.ts +372 -0
  164. package/src/images/pack.ts +804 -0
  165. package/src/images/pending.ts +37 -0
  166. package/src/images/settings.ts +48 -0
  167. package/src/images/setup.ts +192 -0
  168. package/src/images/variants-estimate.ts +162 -0
  169. package/src/images/variants-store.ts +475 -0
  170. package/src/images/variants.ts +311 -0
  171. package/src/index.ts +143 -0
  172. package/src/links/check.ts +101 -0
  173. package/src/links/classify.ts +270 -0
  174. package/src/links/export.ts +59 -0
  175. package/src/links/module.ts +652 -0
  176. package/src/links/pack.ts +43 -0
  177. package/src/links/pt.ts +325 -0
  178. package/src/links/store.ts +704 -0
  179. package/src/middleware.ts +287 -0
  180. package/src/modules.ts +53 -0
  181. package/src/options.ts +66 -0
  182. package/src/pageCache/lib.test.mjs +224 -0
  183. package/src/pageCache/lib.ts +274 -0
  184. package/src/pageCache/pack.ts +240 -0
  185. package/src/pageCache/usage-core.ts +152 -0
  186. package/src/pageCache/usage.ts +262 -0
  187. package/src/pageCache/warm.test.mjs +670 -0
  188. package/src/pageCache/warm.ts +722 -0
  189. package/src/redirects/middleware.ts +169 -0
  190. package/src/redirects/module.ts +124 -0
  191. package/src/redirects/pack.ts +89 -0
  192. package/src/redirects/removed-core.ts +151 -0
  193. package/src/redirects/removed.ts +293 -0
  194. package/src/redirects/rules.ts +355 -0
  195. package/src/redirects/slug-changes.ts +207 -0
  196. package/src/redirects/slug-core.test.ts +50 -0
  197. package/src/redirects/slug-core.ts +49 -0
  198. package/src/redirects/transfer.ts +92 -0
  199. package/src/reviews/lib.ts +624 -0
  200. package/src/reviews/pack.ts +135 -0
  201. package/src/reviews/schema.ts +128 -0
  202. package/src/reviews/settings.ts +26 -0
  203. package/src/robots/bots.ts +321 -0
  204. package/src/robots/data/bots.json +696 -0
  205. package/src/robots/data/verified.json +582 -0
  206. package/src/robots/directory.ts +26 -0
  207. package/src/robots/explain.ts +191 -0
  208. package/src/robots/guided.ts +300 -0
  209. package/src/robots/importer.ts +263 -0
  210. package/src/robots/middleware.ts +151 -0
  211. package/src/robots/module.ts +446 -0
  212. package/src/robots/pack.ts +69 -0
  213. package/src/robots/radar.ts +129 -0
  214. package/src/robots/rep.test.ts +335 -0
  215. package/src/robots/rep.ts +362 -0
  216. package/src/robots/rules.test.ts +251 -0
  217. package/src/robots/rules.ts +883 -0
  218. package/src/robots/transfer.ts +45 -0
  219. package/src/robots/validate.ts +524 -0
  220. package/src/schema/catalog.ts +146 -0
  221. package/src/schema/graph.test.mjs +522 -0
  222. package/src/schema/graph.ts +791 -0
  223. package/src/schema/module.ts +1138 -0
  224. package/src/schema/og-image.ts +100 -0
  225. package/src/schema/pack.ts +58 -0
  226. package/src/search/engine.ts +578 -0
  227. package/src/search/fallback.ts +80 -0
  228. package/src/search/live-cache.ts +116 -0
  229. package/src/search/live-client.ts +770 -0
  230. package/src/search/live-serve.ts +282 -0
  231. package/src/search/module.ts +261 -0
  232. package/src/search/pack.ts +57 -0
  233. package/src/search/query.ts +132 -0
  234. package/src/search/ratelimit-core.ts +118 -0
  235. package/src/search/ratelimit.ts +79 -0
  236. package/src/search/snippet.ts +135 -0
  237. package/src/search/title-index.ts +139 -0
  238. package/src/search/title-match.ts +192 -0
  239. package/src/shared.ts +71 -0
  240. package/src/version.ts +2 -0
  241. package/src/videos/date.ts +40 -0
  242. package/src/videos/engagement.ts +75 -0
  243. package/src/videos/lib.ts +679 -0
  244. package/src/videos/module.ts +1160 -0
  245. package/src/videos/pack.ts +187 -0
  246. package/src/videos/pause.ts +42 -0
  247. package/src/videos/poster.ts +324 -0
  248. package/src/videos/render.ts +336 -0
  249. package/src/videos/store.ts +551 -0
  250. package/src/videos/stream.ts +134 -0
  251. package/src/wpImport/convert.ts +702 -0
  252. package/src/wpImport/guests.ts +329 -0
  253. package/src/wpImport/gutenberg.ts +157 -0
  254. package/src/wpImport/markers.ts +66 -0
  255. package/src/wpImport/pack.ts +451 -0
  256. package/src/wpImport/parents.ts +202 -0
  257. package/src/wpImport/prepare.ts +592 -0
  258. package/src/wpImport/redirects.ts +358 -0
  259. package/src/wpImport/sources.ts +67 -0
  260. package/src/wpImport/stream.ts +160 -0
  261. package/src/wpImport/urls.ts +354 -0
@@ -0,0 +1,470 @@
1
+ /**
2
+ * Redirects admin page, styled like the EmDash admin's own Redirects page.
3
+ */
4
+ import { Badge, Banner, Button, Checkbox, Dialog, DropdownMenu, Input, InputArea, Loader, Select } from "@cloudflare/kumo";
5
+ import { ArrowRight, ArrowsSplit, DotsThree, DownloadSimple, FileArrowUp, PencilSimple, Plus, Trash, UploadSimple } from "@phosphor-icons/react";
6
+ import { apiFetch, parseApiResponse } from "emdash/plugin-utils";
7
+ import * as React from "react";
8
+
9
+ import { parseRedirectsImport, redirectsToCsv, redirectsToJson } from "../redirects/transfer.js";
10
+ import type { RedirectRule } from "../redirects/rules.js";
11
+ import { saveFile, siteSlug, today } from "./download.js";
12
+ import { RemovedContentPanel } from "./redirects-removed.js";
13
+ import { PartSwitches } from "./feature-switches.js";
14
+
15
+ const API = "/_emdash/api/plugins/coywolf-pack/redirects";
16
+
17
+ interface Rule {
18
+ id: string;
19
+ source: string;
20
+ target: string;
21
+ type: number;
22
+ isRegex: boolean;
23
+ enabled: boolean;
24
+ hits: number;
25
+ lastHit: string | null;
26
+ note: string | null;
27
+ }
28
+
29
+ type RuleDraft = Partial<Omit<Rule, "hits" | "lastHit">>;
30
+
31
+ const TYPES = [301, 302, 307, 308, 410];
32
+ const TYPE_LABELS: Record<number, string> = {
33
+ 301: "301 Permanent",
34
+ 302: "302 Temporary",
35
+ 307: "307 Temporary (keep method)",
36
+ 308: "308 Permanent (keep method)",
37
+ 410: "410 Gone",
38
+ };
39
+
40
+ const errorText = (cause: unknown, fallback: string) => (cause instanceof Error && cause.message ? cause.message : fallback);
41
+ const dateFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "medium" });
42
+
43
+ async function post<T>(path: string, body: unknown): Promise<T> {
44
+ const response = await apiFetch(`${API}/${path}`, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify(body),
48
+ });
49
+ return parseApiResponse<T>(response, "The request failed");
50
+ }
51
+
52
+ /** Download every rule (not just the filtered ones) in a format Import reads back. */
53
+ async function exportRules(format: "csv" | "json"): Promise<number> {
54
+ const { items } = await parseApiResponse<{ items: RedirectRule[] }>(await apiFetch(`${API}/list`), "Could not load redirects");
55
+ const name = `redirects-${siteSlug() || "site"}-${today()}`;
56
+ if (format === "csv") saveFile(redirectsToCsv(items), `${name}.csv`, "text/csv");
57
+ else saveFile(redirectsToJson(items), `${name}.json`, "application/json");
58
+ return items.length;
59
+ }
60
+
61
+ function RuleDialog(props: { rule: RuleDraft | null; onClose: () => void; onSaved: () => void }) {
62
+ const [draft, setDraft] = React.useState<RuleDraft>({});
63
+ const [pending, setPending] = React.useState(false);
64
+ const [error, setError] = React.useState<string>();
65
+ React.useEffect(() => {
66
+ setDraft(props.rule ?? {});
67
+ setError(undefined);
68
+ }, [props.rule]);
69
+ const set = (patch: RuleDraft) => setDraft((d) => ({ ...d, ...patch }));
70
+ const type = draft.type ?? 301;
71
+
72
+ const save = async () => {
73
+ setPending(true);
74
+ setError(undefined);
75
+ try {
76
+ await post("save", { ...draft, type });
77
+ props.onSaved();
78
+ } catch (cause) {
79
+ setError(errorText(cause, "Could not save the redirect"));
80
+ } finally {
81
+ setPending(false);
82
+ }
83
+ };
84
+
85
+ return (
86
+ <Dialog.Root open={props.rule !== null} onOpenChange={(open) => !open && !pending && props.onClose()}>
87
+ <Dialog className="p-6" size="lg">
88
+ <Dialog.Title className="text-lg font-semibold">{draft.id ? "Edit redirect" : "New redirect"}</Dialog.Title>
89
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
90
+ Paths match with or without a trailing slash. The visitor's query string is kept unless the destination has
91
+ one.
92
+ </Dialog.Description>
93
+ <form
94
+ className="mt-4 space-y-4"
95
+ onSubmit={(e) => {
96
+ e.preventDefault();
97
+ void save();
98
+ }}
99
+ >
100
+ <Input
101
+ label={draft.isRegex ? "Pattern (regular expression)" : "Source path"}
102
+ placeholder={draft.isRegex ? "^/old-section/(.*)$" : "/visit/partner"}
103
+ value={draft.source ?? ""}
104
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ source: e.target.value })}
105
+ required
106
+ />
107
+ <Checkbox
108
+ label="Regular expression (use $1–$9 in the destination for captured groups)"
109
+ checked={draft.isRegex ?? false}
110
+ onCheckedChange={(checked: boolean) => set({ isRegex: checked })}
111
+ />
112
+ {type !== 410 && (
113
+ <Input
114
+ label="Destination"
115
+ placeholder="https://example.com/page or /new-path/"
116
+ value={draft.target ?? ""}
117
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ target: e.target.value })}
118
+ required
119
+ />
120
+ )}
121
+ <Select
122
+ label="Type"
123
+ value={String(type)}
124
+ onValueChange={(value: string | null) => set({ type: Number(value ?? 301) })}
125
+ items={TYPES.map((t) => ({ value: String(t), label: TYPE_LABELS[t] }))}
126
+ />
127
+ <Input
128
+ label="Note (optional)"
129
+ value={draft.note ?? ""}
130
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => set({ note: e.target.value })}
131
+ />
132
+ <Checkbox
133
+ label="Enabled"
134
+ checked={draft.enabled ?? true}
135
+ onCheckedChange={(checked: boolean) => set({ enabled: checked })}
136
+ />
137
+ {error && <Banner variant="error" role="alert" description={error} />}
138
+ <div className="flex justify-end gap-2">
139
+ <Button type="button" variant="secondary" disabled={pending} onClick={props.onClose}>
140
+ Cancel
141
+ </Button>
142
+ <Button type="submit" variant="primary" disabled={pending}>
143
+ {pending ? "Saving…" : "Save redirect"}
144
+ </Button>
145
+ </div>
146
+ </form>
147
+ </Dialog>
148
+ </Dialog.Root>
149
+ );
150
+ }
151
+
152
+ function ImportDialog(props: { open: boolean; onClose: () => void; onImported: (count: number) => void }) {
153
+ const [text, setText] = React.useState("");
154
+ const [pending, setPending] = React.useState(false);
155
+ const [error, setError] = React.useState<string>();
156
+ const fileRef = React.useRef<HTMLInputElement>(null);
157
+ const run = async () => {
158
+ setPending(true);
159
+ setError(undefined);
160
+ try {
161
+ const rules = parseRedirectsImport(text);
162
+ if (!rules.length) throw new Error("Paste or choose at least one rule.");
163
+ const result = await post<{ imported: number }>("import", { rules });
164
+ setText("");
165
+ props.onImported(result.imported);
166
+ } catch (cause) {
167
+ setError(errorText(cause, "Import failed"));
168
+ } finally {
169
+ setPending(false);
170
+ }
171
+ };
172
+ return (
173
+ <Dialog.Root open={props.open} onOpenChange={(open) => !open && !pending && props.onClose()}>
174
+ <Dialog className="p-6" size="lg">
175
+ <Dialog.Title className="text-lg font-semibold">Import redirects</Dialog.Title>
176
+ <Dialog.Description className="mt-1 text-sm text-kumo-subtle">
177
+ Paste or choose a file: an Export from this page (CSV or JSON), a JSON array, or rows of{" "}
178
+ <code>source, target, type, is_regex</code> separated by tabs or commas (a header row is optional). A Coywolf SEO
179
+ export from WordPress works as is. Existing rules with the same source are updated.
180
+ </Dialog.Description>
181
+ <div className="mt-4">
182
+ <InputArea
183
+ label="Rules"
184
+ rows={10}
185
+ value={text}
186
+ onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
187
+ className="font-mono text-xs"
188
+ />
189
+ </div>
190
+ {error && <Banner variant="error" role="alert" className="mt-3 whitespace-pre-wrap" description={error} />}
191
+ <div className="mt-6 flex flex-wrap justify-end gap-2">
192
+ <Button className="me-auto" variant="secondary" icon={<FileArrowUp />} disabled={pending} onClick={() => fileRef.current?.click()}>
193
+ Choose file…
194
+ </Button>
195
+ <input
196
+ ref={fileRef}
197
+ type="file"
198
+ accept=".csv,.tsv,.txt,.json,text/csv,text/plain,application/json"
199
+ className="sr-only"
200
+ tabIndex={-1}
201
+ aria-hidden="true"
202
+ onChange={(e) => {
203
+ const file = e.target.files?.[0];
204
+ e.target.value = "";
205
+ if (file) void file.text().then(setText, () => setError("Could not read that file."));
206
+ }}
207
+ />
208
+ <Button variant="secondary" disabled={pending} onClick={props.onClose}>
209
+ Cancel
210
+ </Button>
211
+ <Button variant="primary" disabled={pending || !text.trim()} onClick={() => void run()}>
212
+ {pending ? "Importing…" : "Import"}
213
+ </Button>
214
+ </div>
215
+ </Dialog>
216
+ </Dialog.Root>
217
+ );
218
+ }
219
+
220
+ export function RedirectsPage() {
221
+ const [rules, setRules] = React.useState<Rule[]>();
222
+ const [error, setError] = React.useState<string>();
223
+ const [query, setQuery] = React.useState("");
224
+ const [editing, setEditing] = React.useState<RuleDraft | null>(null);
225
+ const [importing, setImporting] = React.useState(false);
226
+ const [notice, setNotice] = React.useState<string>();
227
+ const [testUrl, setTestUrl] = React.useState("");
228
+ const [testResult, setTestResult] = React.useState<string>();
229
+
230
+ const load = React.useCallback(async () => {
231
+ setError(undefined);
232
+ try {
233
+ const data = await parseApiResponse<{ items: Rule[] }>(await apiFetch(`${API}/list`), "Could not load redirects");
234
+ setRules(data.items);
235
+ } catch (cause) {
236
+ setError(errorText(cause, "Could not load redirects"));
237
+ }
238
+ }, []);
239
+ React.useEffect(() => {
240
+ void load();
241
+ }, [load]);
242
+
243
+ const remove = async (rule: Rule) => {
244
+ if (!window.confirm(`Delete the redirect from ${rule.source}?`)) return;
245
+ try {
246
+ await post("delete", { id: rule.id });
247
+ setNotice(`Deleted ${rule.source}.`);
248
+ await load();
249
+ } catch (cause) {
250
+ setError(errorText(cause, "Delete failed"));
251
+ }
252
+ };
253
+
254
+ const runExport = async (format: "csv" | "json") => {
255
+ try {
256
+ const count = await exportRules(format);
257
+ setNotice(`Exported ${count} ${count === 1 ? "redirect" : "redirects"}. Import the file on any site to bring them back.`);
258
+ } catch (cause) {
259
+ setError(errorText(cause, "Export failed"));
260
+ }
261
+ };
262
+
263
+ const test = async () => {
264
+ try {
265
+ const result = await post<{ matched: boolean; rule?: Rule; location?: string }>("test", { url: testUrl });
266
+ setTestResult(
267
+ result.matched && result.rule
268
+ ? result.rule.type === 410
269
+ ? `410 Gone (rule ${result.rule.source})`
270
+ : `${result.rule.type} → ${result.location} (rule ${result.rule.source})`
271
+ : "No Coywolf redirect matches. EmDash's built-in Redirects or the page itself will handle it.",
272
+ );
273
+ } catch (cause) {
274
+ setTestResult(errorText(cause, "Test failed"));
275
+ }
276
+ };
277
+
278
+ const q = query.trim().toLowerCase();
279
+ const visible = (rules ?? []).filter(
280
+ (r) => !q || r.source.toLowerCase().includes(q) || r.target.toLowerCase().includes(q) || r.note?.toLowerCase().includes(q),
281
+ );
282
+
283
+ return (
284
+ <div className="space-y-6">
285
+ <header className="grid min-w-0 gap-4 border-b border-kumo-line pb-4">
286
+ <div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-3 gap-y-1">
287
+ <h1 className="flex min-h-9 min-w-0 items-center text-2xl font-semibold leading-tight">Redirects</h1>
288
+ <div className="flex shrink-0 justify-end gap-2">
289
+ <Button variant="secondary" icon={<UploadSimple />} onClick={() => setImporting(true)}>
290
+ Import
291
+ </Button>
292
+ <DropdownMenu>
293
+ <DropdownMenu.Trigger
294
+ render={
295
+ <Button variant="secondary" icon={<DownloadSimple />} disabled={!rules?.length}>
296
+ Export
297
+ </Button>
298
+ }
299
+ />
300
+ <DropdownMenu.Content className="p-1">
301
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" onClick={() => void runExport("csv")}>
302
+ CSV (spreadsheets)
303
+ </DropdownMenu.Item>
304
+ <DropdownMenu.Item className="py-1 data-highlighted:bg-kumo-fill" onClick={() => void runExport("json")}>
305
+ JSON
306
+ </DropdownMenu.Item>
307
+ </DropdownMenu.Content>
308
+ </DropdownMenu>
309
+ <Button variant="primary" icon={<Plus />} onClick={() => setEditing({})}>
310
+ New redirect
311
+ </Button>
312
+ </div>
313
+ <p className="col-span-2 text-sm leading-5 text-pretty text-kumo-subtle">
314
+ For redirects EmDash's built-in Redirects can't handle: external destinations (affiliate links, moved
315
+ articles) and file paths such as old <code>/wp-content/</code> URLs. Changes apply within a minute.
316
+ </p>
317
+ </div>
318
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
319
+ <div className="sm:w-72">
320
+ <Input
321
+ label="Filter"
322
+ placeholder="Search sources, destinations, notes"
323
+ value={query}
324
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
325
+ />
326
+ </div>
327
+ <form
328
+ className="flex flex-1 items-end gap-2"
329
+ onSubmit={(e) => {
330
+ e.preventDefault();
331
+ void test();
332
+ }}
333
+ >
334
+ <div className="flex-1">
335
+ <Input
336
+ label="Test a URL"
337
+ placeholder="/visit/partner"
338
+ value={testUrl}
339
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTestUrl(e.target.value)}
340
+ />
341
+ </div>
342
+ <Button type="submit" variant="secondary" disabled={!testUrl.trim()}>
343
+ Test
344
+ </Button>
345
+ </form>
346
+ </div>
347
+ {testResult && <p className="text-sm text-kumo-subtle">{testResult}</p>}
348
+ </header>
349
+
350
+ <PartSwitches module="redirects" />
351
+
352
+ {notice && <Banner variant="default" role="status" title={notice} />}
353
+ {error && <Banner variant="error" role="alert" title="Something went wrong" description={error} />}
354
+ <RemovedContentPanel onRuleCreated={() => void load()} />
355
+
356
+ {!rules && !error ? (
357
+ <div className="py-12 text-center text-kumo-subtle">
358
+ <Loader />
359
+ </div>
360
+ ) : rules && rules.length === 0 ? (
361
+ <div className="py-10 text-center text-kumo-subtle">
362
+ <ArrowsSplit size={40} className="mx-auto mb-3 opacity-30" aria-hidden="true" />
363
+ <p className="text-base font-medium">No redirects yet</p>
364
+ <p className="mt-1 text-sm">Create one, or import rules from WordPress.</p>
365
+ </div>
366
+ ) : rules ? (
367
+ <div className="rounded-lg border">
368
+ <div className="flex items-center gap-4 border-b bg-kumo-tint/50 px-4 py-2 text-sm font-medium text-kumo-subtle">
369
+ <div className="min-w-0 flex-1">Source</div>
370
+ <div className="w-6" />
371
+ <div className="min-w-0 flex-1">Destination</div>
372
+ <div className="w-14 text-center">Code</div>
373
+ <div className="hidden w-16 text-end md:block" title="Requests the site's Worker answered; repeats served from the edge cache aren't counted">
374
+ Hits
375
+ </div>
376
+ <div className="w-32 cw-lg-block">Last hit</div>
377
+ <div className="w-10" />
378
+ </div>
379
+ {visible.map((rule) => (
380
+ <div
381
+ key={rule.id}
382
+ className={`flex items-center gap-4 border-b px-4 py-2 text-sm last:border-0 ${rule.enabled ? "" : "opacity-50"}`}
383
+ >
384
+ <div className="min-w-0 flex-1">
385
+ <div className="truncate font-mono text-xs" title={rule.source}>
386
+ {rule.source}
387
+ </div>
388
+ {(rule.isRegex || rule.note) && (
389
+ <div className="mt-0.5 flex items-center gap-1 truncate text-xs text-kumo-subtle">
390
+ {rule.isRegex && <Badge variant="outline">regex</Badge>}
391
+ {rule.note}
392
+ </div>
393
+ )}
394
+ </div>
395
+ <ArrowRight className="w-6 shrink-0 text-kumo-subtle" aria-hidden="true" />
396
+ <div className="min-w-0 flex-1 truncate font-mono text-xs" title={rule.target}>
397
+ {rule.type === 410 ? <span className="text-kumo-subtle">Gone</span> : rule.target}
398
+ </div>
399
+ <div className="w-14 text-center">
400
+ <Badge variant="secondary">{rule.type}</Badge>
401
+ </div>
402
+ <div className="hidden w-16 text-end tabular-nums md:block">{rule.hits.toLocaleString()}</div>
403
+ <div className="w-32 text-xs text-kumo-subtle cw-lg-block">
404
+ {rule.lastHit ? dateFormat.format(new Date(rule.lastHit)) : "Never"}
405
+ </div>
406
+ <div className="flex w-10 justify-end">
407
+ <DropdownMenu>
408
+ <DropdownMenu.Trigger
409
+ render={
410
+ <Button
411
+ type="button"
412
+ variant="ghost"
413
+ shape="square"
414
+ icon={<DotsThree aria-hidden="true" />}
415
+ aria-label={`Actions for ${rule.source}`}
416
+ />
417
+ }
418
+ />
419
+ <DropdownMenu.Content className="p-1">
420
+ <DropdownMenu.Item
421
+ className="py-1 data-highlighted:bg-kumo-fill"
422
+ icon={<PencilSimple className="me-1.5 size-3.5" aria-hidden="true" />}
423
+ onClick={() => setEditing(rule)}
424
+ >
425
+ Edit
426
+ </DropdownMenu.Item>
427
+ <DropdownMenu.Separator className="my-0.5" />
428
+ <DropdownMenu.Item
429
+ className="py-1 data-highlighted:bg-kumo-fill"
430
+ icon={<Trash className="me-1.5 size-3.5" aria-hidden="true" />}
431
+ onClick={() => void remove(rule)}
432
+ >
433
+ Delete
434
+ </DropdownMenu.Item>
435
+ </DropdownMenu.Content>
436
+ </DropdownMenu>
437
+ </div>
438
+ </div>
439
+ ))}
440
+ {visible.length === 0 && <p className="px-4 py-6 text-center text-sm text-kumo-subtle">No redirects match.</p>}
441
+ </div>
442
+ ) : null}
443
+ {rules && rules.length > 0 && (
444
+ <p className="text-sm text-kumo-subtle">
445
+ Hits count requests the site's Worker answered. With the page cache on, redirects are cached at the edge (and cleared when you
446
+ change them), so repeats served from the cache aren't counted.
447
+ </p>
448
+ )}
449
+
450
+ <RuleDialog
451
+ rule={editing}
452
+ onClose={() => setEditing(null)}
453
+ onSaved={() => {
454
+ setEditing(null);
455
+ setNotice("Redirect saved.");
456
+ void load();
457
+ }}
458
+ />
459
+ <ImportDialog
460
+ open={importing}
461
+ onClose={() => setImporting(false)}
462
+ onImported={(count) => {
463
+ setImporting(false);
464
+ setNotice(`Imported ${count} ${count === 1 ? "redirect" : "redirects"}.`);
465
+ void load();
466
+ }}
467
+ />
468
+ </div>
469
+ );
470
+ }