@coywolf/emdash 0.0.0-stage → 0.35.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/package.json +70 -5
- package/src/admin/admin-css.ts +57 -0
- package/src/admin/ai.tsx +915 -0
- package/src/admin/backups.tsx +613 -0
- package/src/admin/breadcrumbs.tsx +542 -0
- package/src/admin/codeBlocks.tsx +204 -0
- package/src/admin/customBlocks.tsx +321 -0
- package/src/admin/dirty.ts +20 -0
- package/src/admin/discovery.tsx +463 -0
- package/src/admin/download.ts +28 -0
- package/src/admin/feature-switches.tsx +124 -0
- package/src/admin/features.tsx +142 -0
- package/src/admin/files.tsx +791 -0
- package/src/admin/formUploads.tsx +445 -0
- package/src/admin/guides.tsx +18 -0
- package/src/admin/headings.tsx +262 -0
- package/src/admin/images.tsx +825 -0
- package/src/admin/index.tsx +62 -0
- package/src/admin/links.tsx +980 -0
- package/src/admin/performance.tsx +330 -0
- package/src/admin/preview.tsx +63 -0
- package/src/admin/redirects-removed.tsx +266 -0
- package/src/admin/redirects.tsx +470 -0
- package/src/admin/reviews.tsx +232 -0
- package/src/admin/robots-bots.tsx +647 -0
- package/src/admin/robots-shared.tsx +109 -0
- package/src/admin/robots-wizard.tsx +748 -0
- package/src/admin/robots.tsx +911 -0
- package/src/admin/save-bar.tsx +134 -0
- package/src/admin/schema.tsx +1187 -0
- package/src/admin/search.tsx +355 -0
- package/src/admin/settings-ui.tsx +87 -0
- package/src/admin/trash-prompt-core.ts +135 -0
- package/src/admin/trash-prompt.tsx +156 -0
- package/src/admin/usage.tsx +301 -0
- package/src/admin/videos-display.tsx +248 -0
- package/src/admin/videos.tsx +1202 -0
- package/src/admin/wpImport.tsx +1516 -0
- package/src/ai/entities.ts +59 -0
- package/src/ai/export.ts +61 -0
- package/src/ai/logic.test.ts +187 -0
- package/src/ai/logic.ts +606 -0
- package/src/ai/pack.ts +121 -0
- package/src/ai/prompts.ts +87 -0
- package/src/ai/providers.ts +276 -0
- package/src/ai/queue.test.ts +97 -0
- package/src/ai/routes.ts +359 -0
- package/src/ai/store.ts +301 -0
- package/src/ai/wikidata.test.ts +184 -0
- package/src/ai/wikidata.ts +87 -0
- package/src/ai/worker.ts +530 -0
- package/src/astro/astro-modules.d.ts +6 -0
- package/src/astro/breadcrumbs/Breadcrumbs.astro +129 -0
- package/src/astro/breadcrumbs/BreadcrumbsBlock.astro +12 -0
- package/src/astro/codeBlocks/CodeBlock.astro +62 -0
- package/src/astro/codeBlocks/EmDashCode.astro +61 -0
- package/src/astro/customBlocks/CustomBlock.astro +64 -0
- package/src/astro/customBlocks/Details.astro +8 -0
- package/src/astro/customBlocks/Disclosure.astro +8 -0
- package/src/astro/customBlocks/Note.astro +8 -0
- package/src/astro/customBlocks/Podcast.astro +8 -0
- package/src/astro/customBlocks/Quote.astro +8 -0
- package/src/astro/customBlocks/Testimonial.astro +8 -0
- package/src/astro/files/FileCard.astro +103 -0
- package/src/astro/headings/HeadingBlock.astro +129 -0
- package/src/astro/headings/TableOfContents.astro +45 -0
- package/src/astro/headings/request.ts +19 -0
- package/src/astro/images/ResponsiveImage.astro +60 -0
- package/src/astro/index.ts +84 -0
- package/src/astro/reviews/CoywolfReview.astro +27 -0
- package/src/astro/reviews/Review.astro +49 -0
- package/src/astro/search/SearchBox.astro +61 -0
- package/src/astro/search/SearchBoxForm.astro +509 -0
- package/src/astro/tables/CaptionedTable.astro +72 -0
- package/src/astro/videos/CoywolfVideo.astro +448 -0
- package/src/backups/download.ts +112 -0
- package/src/backups/dump.test.mjs +287 -0
- package/src/backups/dump.ts +288 -0
- package/src/backups/module.ts +276 -0
- package/src/backups/pack.ts +33 -0
- package/src/backups/restore.test.mjs +103 -0
- package/src/backups/restore.ts +209 -0
- package/src/backups/store.ts +409 -0
- package/src/breadcrumbs/pack.ts +113 -0
- package/src/breadcrumbs/settings.ts +102 -0
- package/src/breadcrumbs/stamp.ts +40 -0
- package/src/breadcrumbs/trail.ts +123 -0
- package/src/codeBlocks/contrast.ts +179 -0
- package/src/codeBlocks/hljs.d.ts +5 -0
- package/src/codeBlocks/pack.ts +110 -0
- package/src/codeBlocks/render.test.ts +152 -0
- package/src/codeBlocks/render.ts +304 -0
- package/src/codeBlocks/settings.ts +18 -0
- package/src/codeBlocks/themes.generated.ts +304 -0
- package/src/codeBlocks/themes.ts +114 -0
- package/src/core/compose.ts +244 -0
- package/src/core/content-url.ts +888 -0
- package/src/core/csv.ts +119 -0
- package/src/core/d1-batch.ts +65 -0
- package/src/core/entry-docs.ts +175 -0
- package/src/core/features-module.ts +48 -0
- package/src/core/features.ts +392 -0
- package/src/core/generation.ts +88 -0
- package/src/core/guide-content.ts +166 -0
- package/src/core/module.ts +104 -0
- package/src/core/read-failures.ts +57 -0
- package/src/core/request-memo.ts +71 -0
- package/src/core/secrets.ts +83 -0
- package/src/core/settings-enhance.ts +74 -0
- package/src/core/site.ts +36 -0
- package/src/core/storage.ts +27 -0
- package/src/customBlocks/pack.ts +260 -0
- package/src/customBlocks/render.ts +424 -0
- package/src/customBlocks/rich.ts +313 -0
- package/src/customBlocks/settings.ts +46 -0
- package/src/discovery/build.ts +252 -0
- package/src/discovery/feed-build.ts +217 -0
- package/src/discovery/feed.ts +172 -0
- package/src/discovery/indexnow.ts +114 -0
- package/src/discovery/llms.ts +79 -0
- package/src/discovery/markdown.ts +294 -0
- package/src/discovery/memory-cache.ts +97 -0
- package/src/discovery/middleware.ts +186 -0
- package/src/discovery/module.ts +353 -0
- package/src/discovery/news.ts +99 -0
- package/src/discovery/pack.ts +58 -0
- package/src/discovery/settings.ts +91 -0
- package/src/discovery/urls.ts +35 -0
- package/src/env.d.ts +10 -0
- package/src/files/card.ts +231 -0
- package/src/files/counts.test.ts +114 -0
- package/src/files/counts.ts +60 -0
- package/src/files/export.ts +44 -0
- package/src/files/format.test.ts +93 -0
- package/src/files/format.ts +163 -0
- package/src/files/module.ts +596 -0
- package/src/files/pack.ts +92 -0
- package/src/files/r2-rules.test.ts +30 -0
- package/src/files/r2-rules.ts +41 -0
- package/src/files/range.ts +50 -0
- package/src/files/s3.ts +114 -0
- package/src/files/serve.ts +141 -0
- package/src/files/sigv4.test.ts +64 -0
- package/src/files/sigv4.ts +160 -0
- package/src/files/site.ts +137 -0
- package/src/files/walker.ts +58 -0
- package/src/formUploads/lib.ts +266 -0
- package/src/formUploads/pack.ts +55 -0
- package/src/formUploads/wrap.ts +592 -0
- package/src/forms.ts +16 -0
- package/src/headings/headings.test.ts +159 -0
- package/src/headings/pack.ts +154 -0
- package/src/headings/render.ts +139 -0
- package/src/headings/settings.ts +117 -0
- package/src/headings/slug.ts +68 -0
- package/src/headings/stamp.ts +142 -0
- package/src/headings/test-hooks.mjs +14 -0
- package/src/headings/toc.ts +48 -0
- package/src/images/cloudflare.ts +329 -0
- package/src/images/lib.ts +221 -0
- package/src/images/module.ts +372 -0
- package/src/images/pack.ts +804 -0
- package/src/images/pending.ts +37 -0
- package/src/images/settings.ts +48 -0
- package/src/images/setup.ts +192 -0
- package/src/images/variants-estimate.ts +162 -0
- package/src/images/variants-store.ts +475 -0
- package/src/images/variants.ts +311 -0
- package/src/index.ts +143 -0
- package/src/links/check.ts +101 -0
- package/src/links/classify.ts +270 -0
- package/src/links/export.ts +59 -0
- package/src/links/module.ts +652 -0
- package/src/links/pack.ts +43 -0
- package/src/links/pt.ts +325 -0
- package/src/links/store.ts +704 -0
- package/src/middleware.ts +287 -0
- package/src/modules.ts +53 -0
- package/src/options.ts +66 -0
- package/src/pageCache/lib.test.mjs +224 -0
- package/src/pageCache/lib.ts +274 -0
- package/src/pageCache/pack.ts +240 -0
- package/src/pageCache/usage-core.ts +152 -0
- package/src/pageCache/usage.ts +262 -0
- package/src/pageCache/warm.test.mjs +670 -0
- package/src/pageCache/warm.ts +722 -0
- package/src/redirects/middleware.ts +169 -0
- package/src/redirects/module.ts +124 -0
- package/src/redirects/pack.ts +89 -0
- package/src/redirects/removed-core.ts +151 -0
- package/src/redirects/removed.ts +293 -0
- package/src/redirects/rules.ts +355 -0
- package/src/redirects/slug-changes.ts +207 -0
- package/src/redirects/slug-core.test.ts +50 -0
- package/src/redirects/slug-core.ts +49 -0
- package/src/redirects/transfer.ts +92 -0
- package/src/reviews/lib.ts +624 -0
- package/src/reviews/pack.ts +135 -0
- package/src/reviews/schema.ts +128 -0
- package/src/reviews/settings.ts +26 -0
- package/src/robots/bots.ts +321 -0
- package/src/robots/data/bots.json +696 -0
- package/src/robots/data/verified.json +582 -0
- package/src/robots/directory.ts +26 -0
- package/src/robots/explain.ts +191 -0
- package/src/robots/guided.ts +300 -0
- package/src/robots/importer.ts +263 -0
- package/src/robots/middleware.ts +151 -0
- package/src/robots/module.ts +446 -0
- package/src/robots/pack.ts +69 -0
- package/src/robots/radar.ts +129 -0
- package/src/robots/rep.test.ts +335 -0
- package/src/robots/rep.ts +362 -0
- package/src/robots/rules.test.ts +251 -0
- package/src/robots/rules.ts +883 -0
- package/src/robots/transfer.ts +45 -0
- package/src/robots/validate.ts +524 -0
- package/src/schema/catalog.ts +146 -0
- package/src/schema/graph.test.mjs +522 -0
- package/src/schema/graph.ts +791 -0
- package/src/schema/module.ts +1138 -0
- package/src/schema/og-image.ts +100 -0
- package/src/schema/pack.ts +58 -0
- package/src/search/engine.ts +578 -0
- package/src/search/fallback.ts +80 -0
- package/src/search/live-cache.ts +116 -0
- package/src/search/live-client.ts +770 -0
- package/src/search/live-serve.ts +282 -0
- package/src/search/module.ts +261 -0
- package/src/search/pack.ts +57 -0
- package/src/search/query.ts +132 -0
- package/src/search/ratelimit-core.ts +118 -0
- package/src/search/ratelimit.ts +79 -0
- package/src/search/snippet.ts +135 -0
- package/src/search/title-index.ts +139 -0
- package/src/search/title-match.ts +192 -0
- package/src/shared.ts +71 -0
- package/src/version.ts +2 -0
- package/src/videos/date.ts +40 -0
- package/src/videos/engagement.ts +75 -0
- package/src/videos/lib.ts +679 -0
- package/src/videos/module.ts +1160 -0
- package/src/videos/pack.ts +187 -0
- package/src/videos/pause.ts +42 -0
- package/src/videos/poster.ts +324 -0
- package/src/videos/render.ts +336 -0
- package/src/videos/store.ts +551 -0
- package/src/videos/stream.ts +134 -0
- package/src/wpImport/convert.ts +702 -0
- package/src/wpImport/guests.ts +329 -0
- package/src/wpImport/gutenberg.ts +157 -0
- package/src/wpImport/markers.ts +66 -0
- package/src/wpImport/pack.ts +451 -0
- package/src/wpImport/parents.ts +202 -0
- package/src/wpImport/prepare.ts +592 -0
- package/src/wpImport/redirects.ts +358 -0
- package/src/wpImport/sources.ts +67 -0
- package/src/wpImport/stream.ts +160 -0
- package/src/wpImport/urls.ts +354 -0
|
@@ -0,0 +1,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
|
+
}
|