@dineway-ai/plugin-seo-graph 0.1.7

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/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # Dineway SEO Graph Plugin
2
+
3
+ An SEO Graph plugin for [Dineway Agentic Web](https://github.com/foodism-dev/foodism-dineway) that generates meta tags, Open Graph, Twitter Cards, canonical URLs, robots directives, and JSON-LD schema markup via the `page:metadata` hook.
4
+
5
+ ## Features
6
+
7
+ - **Meta descriptions** with configurable fallback chain
8
+ - **Meta robots** with `max-snippet`, `max-image-preview`, and `max-video-preview` directives; `noindex` for search/utility pages; omitted on 404
9
+ - **Canonical URLs** — absolute, normalized, with trailing slash and pagination support
10
+ - **Open Graph** — `og:title` without site name suffix, `og:type: article` for content pages, full set of OG tags
11
+ - **Twitter Cards** — `summary_large_image` when image present, site handle from settings
12
+ - **JSON-LD schema graph** with linked nodes:
13
+ - `Person` or `Organization` (configurable), with `publishingPrinciples`
14
+ - `WebSite` with `SearchAction` and optional `SiteNavigationElement`
15
+ - `Blog` entity (when blog URL is configured)
16
+ - `WebPage` (`CollectionPage` for archives, `ProfilePage` for `/about`), with `about`, copyright, and license fields
17
+ - `BlogPosting` with author `Person` (for content pages), linked to `Blog` when configured, with taxonomy-derived `keywords` and `articleSection`
18
+ - `ImageObject` for primary page images
19
+ - `BreadcrumbList` with a back-reference from `WebPage`
20
+ - **Breadcrumbs** — derived from the URL path by default, with segment label overrides (`/blog/` → "Blog") and per-`pageType` rule overrides both editable in the admin UI. `@id` scheme matches [joost.blog](https://joost.blog) via `@jdevalk/seo-graph-core`
21
+ - **hreflang alternates** — for multilingual Dineway sites (Astro `i18n` + `translation_group`), one `<link rel="alternate" hreflang="…">` per published sibling plus an automatic `x-default`, with BCP 47 tag normalization (`fr-ca` → `fr-CA`). Zero cost on single-locale sites
22
+ - **llms.txt** — exposes a small-form [llms.txt](https://llmstxt.org) index of published content at the plugin's `llms/txt` route. Enabled by default; flip the setting to disable
23
+ - **Schema map** — exposes every published URL backed by schema markup at the public `schema/map` plugin route for agent/crawler discovery
24
+ - **Fuzzy Redirects** — admin tool that mines the core 404 log, ranks live URLs by path similarity, and lets editors create 301 redirects for moved slugs, typos, and punctuation drift
25
+ - **NLWeb `<link>` tag** — when the **NLWeb endpoint URL** setting is set, every rendered page carries `<link rel="nlweb" href="…">` for conversational agent discovery
26
+ - **IndexNow** — on publish/unpublish transitions, submits the affected URL to [IndexNow](https://www.indexnow.org) so Bing, Yandex, Seznam, Naver, and Yep recrawl immediately. Opt-in via a single toggle in the settings UI; the key is generated and persisted automatically on first use
27
+ - **Admin settings UI** — auto-generated from `settingsSchema` for configuring Person/Organization identity, social profiles, title separator, and default description
28
+
29
+ ## Installation
30
+
31
+ Copy the `src/` directory into your Dineway theme's `plugins/seo-graph/` directory, or install from this repo:
32
+
33
+ ```bash
34
+ # In your dineway theme directory
35
+ cp -r path/to/dineway-plugin-seo-graph/src plugins/seo-graph/src
36
+ cp path/to/dineway-plugin-seo-graph/package.json plugins/seo-graph/package.json
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ Register the plugin in your `astro.config.mjs`:
42
+
43
+ ```typescript
44
+ import { seoGraphPlugin } from "./plugins/seo-graph/src/index.ts";
45
+
46
+ export default defineConfig({
47
+ integrations: [
48
+ dineway({
49
+ plugins: [seoGraphPlugin()],
50
+ }),
51
+ ],
52
+ });
53
+ ```
54
+
55
+ Then configure your site identity and social profiles in the Dineway admin under **Plugins > SEO Graph > Settings**.
56
+
57
+ ## Settings
58
+
59
+ | Setting | Description |
60
+ | ------------------------------------------- | --------------------------------------------------------------------------------------------------- |
61
+ | Site represents | Person or Organization |
62
+ | Title separator | Character between page title and site name (em dash, pipe, hyphen, dot) |
63
+ | Default meta description | Fallback for pages without their own |
64
+ | Person name / bio / image / job title / URL | Person schema fields |
65
+ | Organization name / logo URL | Organization schema fields |
66
+ | Social URLs | Twitter/X, Facebook, LinkedIn, Instagram, YouTube, GitHub, Bluesky, Mastodon, Wikipedia |
67
+ | Publishing principles URL | Link to editorial policy page |
68
+ | Copyright year | Year copyright was first asserted |
69
+ | License URL | Content license (e.g. Creative Commons) |
70
+ | Blog URL / name | Enables `Blog` schema entity linked to `BlogPosting` nodes |
71
+ | Navigation items | JSON array of `{name, url}` for `SiteNavigationElement` schema |
72
+ | Breadcrumb segment labels | `segment → display label` overrides (e.g. `blog → Blog`) |
73
+ | Breadcrumb page type rules | Per-`pageType` ordered crumb lists, JSON-edited, for themes that need full control over trail shape |
74
+ | IndexNow submission | Submit published/unpublished URLs to IndexNow. Disabled by default |
75
+ | llms.txt | Expose an `llms.txt` index of published content. Enabled by default |
76
+ | llms.txt site description | Optional blockquote text at the top of `llms.txt`. Falls back to the default meta description |
77
+ | NLWeb endpoint URL | Absolute URL of the site's conversational endpoint, emitted as `<link rel="nlweb">` |
78
+
79
+ ## Multilingual sites (hreflang)
80
+
81
+ When your site has more than one locale configured in Astro's `i18n` block and content entries are linked via `translation_group`, the plugin automatically emits hreflang annotations for each content page. No configuration required — it activates as soon as `isI18nEnabled()` returns true.
82
+
83
+ ```js
84
+ // astro.config.mjs
85
+ export default defineConfig({
86
+ i18n: {
87
+ defaultLocale: "en",
88
+ locales: ["en", "fr", "nl"],
89
+ routing: { prefixDefaultLocale: false },
90
+ },
91
+ integrations: [dineway({ plugins: [seoGraphPlugin()] })],
92
+ });
93
+ ```
94
+
95
+ A 3-locale post at `/hello/`, with published French (`/fr/bonjour/`) and Dutch (`/nl/hallo/`) translations in the same `translation_group`, renders:
96
+
97
+ ```html
98
+ <link rel="alternate" hreflang="en" href="https://example.com/hello/" />
99
+ <link rel="alternate" hreflang="fr" href="https://example.com/fr/bonjour/" />
100
+ <link rel="alternate" hreflang="nl" href="https://example.com/nl/hallo/" />
101
+ <link rel="alternate" hreflang="x-default" href="https://example.com/hello/" />
102
+ ```
103
+
104
+ Only published siblings are included. Drafts, scheduled entries, and siblings whose locale is no longer in your Astro config are dropped. If the page has fewer than two published locales, no hreflang tags are emitted (a single-locale page has no meaningful alternates).
105
+
106
+ ### Region-specific locales (`fr-CA` vs `fr-FR`)
107
+
108
+ If you need region-specific hreflang, use the BCP 47 code as the locale path directly:
109
+
110
+ ```js
111
+ i18n: {
112
+ defaultLocale: "en",
113
+ locales: ["en", "fr-ca", "fr-fr"],
114
+ }
115
+ ```
116
+
117
+ URLs become `/fr-ca/…` and `/fr-fr/…`, and the emitted `hreflang` attributes are normalized to conventional casing (`fr-CA`, `fr-FR`). Dineway core currently drops Astro's object-form `{ path, codes }` shape at the integration boundary, so the code-as-path workaround is the supported path for region tags in this plugin version.
118
+
119
+ ## IndexNow
120
+
121
+ When enabled via the **IndexNow submission** setting, the plugin submits
122
+ the canonical URL of any content item that transitions to or from
123
+ published. A 32-character hex key is minted on first use and persisted in
124
+ plugin KV.
125
+
126
+ The front-end Astro site must serve the key-verification file at
127
+ `/<key>.txt`. Fetch the key from the plugin's `indexnow/key` route and
128
+ wire a route on the Astro side using
129
+ [`createIndexNowKeyRoute`](https://www.npmjs.com/package/@jdevalk/astro-seo-graph):
130
+
131
+ ```ts
132
+ // src/pages/[your-key-here].txt.ts
133
+ import { createIndexNowKeyRoute } from "@jdevalk/astro-seo-graph";
134
+
135
+ export const GET = createIndexNowKeyRoute({ key: "your-key-here" });
136
+ ```
137
+
138
+ > **Deploy the key file before enabling the toggle.** IndexNow verifies
139
+ > host ownership on every submission by fetching
140
+ > `https://<host>/<key>.txt`. Submissions sent before the key file is
141
+ > reachable in production are rejected (HTTP 403) and the key gets
142
+ > marked invalid — you'll have to delete the stored key from plugin KV
143
+ > and mint a new one. Ship the Astro route, deploy, confirm the `.txt`
144
+ > loads over HTTPS, _then_ flip the **IndexNow submission** toggle.
145
+
146
+ When rejections occur, the plugin logs on `ctx.log.warn` but does not
147
+ throw — transitions still succeed locally.
148
+
149
+ ## llms.txt
150
+
151
+ The plugin generates a small-form [`llms.txt`](https://llmstxt.org) index of all published content, grouped by collection label, and exposes it on the plugin route `llms/txt`. Only collections with a `urlPattern` are included. The `llms-full.txt` variant is out of scope.
152
+
153
+ Serve it from your Astro site by creating an endpoint that proxies the plugin route:
154
+
155
+ ```ts
156
+ // src/pages/llms.txt.ts
157
+ import type { APIRoute } from "astro";
158
+
159
+ export const GET: APIRoute = async ({ request }) => {
160
+ const origin = new URL(request.url).origin;
161
+ const res = await fetch(`${origin}/_dineway/api/plugins/seo-graph/llms/txt`, {
162
+ method: "POST",
163
+ headers: { "Content-Type": "application/json" },
164
+ body: "{}",
165
+ });
166
+ const { data } = (await res.json()) as { data: { enabled: boolean; body: string } };
167
+ if (!data.enabled) return new Response("Not found", { status: 404 });
168
+ return new Response(data.body, { headers: { "Content-Type": "text/plain; charset=utf-8" } });
169
+ };
170
+ ```
171
+
172
+ ## Fuzzy Redirects
173
+
174
+ The admin page at **Plugins > SEO Graph > Fuzzy Redirects** turns Dineway's core 404 log into a redirect work queue. It fetches published URLs from `schema/map`, scores them against each missing path with Levenshtein distance, token overlap, and last-segment matching, then lets an editor create a 301 redirect. Created redirects are grouped under `seo-graph-fuzzy-suggester` for later auditing.
175
+
176
+ ## Schema Map
177
+
178
+ The public `schema/map` plugin route returns JSON shaped for a `schemamap.xml` endpoint:
179
+
180
+ ```json
181
+ {
182
+ "data": {
183
+ "items": [
184
+ {
185
+ "url": "https://example.com/journal/spring-menu/",
186
+ "collection": "posts",
187
+ "updatedAt": "2026-02-01T00:00:00Z"
188
+ }
189
+ ]
190
+ }
191
+ }
192
+ ```
193
+
194
+ Wire it to `/schemamap.xml` at your site root with a small Astro endpoint:
195
+
196
+ ```ts
197
+ // src/pages/schemamap.xml.ts
198
+ import type { APIRoute } from "astro";
199
+
200
+ interface SchemaMapEntry {
201
+ url: string;
202
+ collection: string;
203
+ updatedAt: string;
204
+ }
205
+
206
+ export const GET: APIRoute = async ({ request }) => {
207
+ const origin = new URL(request.url).origin;
208
+ const res = await fetch(`${origin}/_dineway/api/plugins/seo-graph/schema/map`, {
209
+ method: "POST",
210
+ headers: { "Content-Type": "application/json" },
211
+ body: "{}",
212
+ });
213
+ const { data } = (await res.json()) as { data: { items: SchemaMapEntry[] } };
214
+
215
+ const urls = data.items
216
+ .map(({ url, updatedAt }) => ` <url><loc>${url}</loc><lastmod>${updatedAt}</lastmod></url>`)
217
+ .join("\n");
218
+
219
+ return new Response(
220
+ `<?xml version="1.0" encoding="UTF-8"?>
221
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
222
+ ${urls}
223
+ </urlset>`,
224
+ { headers: { "Content-Type": "application/xml; charset=utf-8" } },
225
+ );
226
+ };
227
+ ```
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@dineway-ai/plugin-seo-graph",
3
+ "version": "0.1.7",
4
+ "description": "SEO Graph plugin for the Dineway Agentic Website builder — meta tags, Open Graph, canonical URLs, robots directives, and JSON-LD schema markup",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "keywords": [
10
+ "dineway",
11
+ "seo",
12
+ "schema",
13
+ "json-ld",
14
+ "open-graph",
15
+ "meta-tags",
16
+ "canonical",
17
+ "robots",
18
+ "llms.txt",
19
+ "schema-map",
20
+ "indexnow"
21
+ ],
22
+ "author": "Dineway",
23
+ "license": "MIT",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "src/"
29
+ ],
30
+ "scripts": {
31
+ "typecheck": "tsgo --noEmit",
32
+ "test": "vitest run"
33
+ },
34
+ "peerDependencies": {
35
+ "dineway": "workspace:*"
36
+ },
37
+ "dependencies": {
38
+ "@jdevalk/astro-seo-graph": "^0.7.0",
39
+ "@jdevalk/seo-graph-core": "^0.6.0",
40
+ "schema-dts": "^2.0.0",
41
+ "react": "^18.0.0 || ^19.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "catalog:",
45
+ "dineway": "workspace:*",
46
+ "typescript": "^5.6.0",
47
+ "vitest": "^2.1.0"
48
+ }
49
+ }
@@ -0,0 +1,317 @@
1
+ import { apiFetch as baseFetch, getErrorMessage, parseApiResponse } from "dineway/plugin-utils";
2
+ import * as React from "react";
3
+
4
+ import { rankCandidates, type RankedMatch } from "./fuzzy.js";
5
+
6
+ const PLUGIN_API = "/_dineway/api/plugins/seo-graph";
7
+ const CORE_API = "/_dineway/api";
8
+
9
+ interface NotFoundSummary {
10
+ path: string;
11
+ count: number;
12
+ lastSeen: string;
13
+ topReferrer: string | null;
14
+ }
15
+
16
+ interface SchemaMapEntry {
17
+ url: string;
18
+ collection: string;
19
+ updatedAt: string;
20
+ }
21
+
22
+ interface Suggestion {
23
+ entry: NotFoundSummary;
24
+ matches: RankedMatch[];
25
+ chosen: string;
26
+ created: boolean;
27
+ error: string | null;
28
+ saving: boolean;
29
+ }
30
+
31
+ async function fetchNotFoundSummary(): Promise<NotFoundSummary[]> {
32
+ const res = await baseFetch(`${CORE_API}/redirects/404s/summary?limit=100`, {
33
+ method: "GET",
34
+ });
35
+ const data = await parseApiResponse<{ items: NotFoundSummary[] }>(
36
+ res,
37
+ "Failed to load 404 summary",
38
+ );
39
+ return data.items ?? [];
40
+ }
41
+
42
+ async function fetchSchemaMap(): Promise<SchemaMapEntry[]> {
43
+ const res = await baseFetch(`${PLUGIN_API}/schema/map`, {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: "{}",
47
+ });
48
+ const data = await parseApiResponse<{ items: SchemaMapEntry[] }>(
49
+ res,
50
+ "Failed to load schema map",
51
+ );
52
+ return data.items ?? [];
53
+ }
54
+
55
+ async function createRedirect(source: string, destination: string): Promise<void> {
56
+ const res = await baseFetch(`${CORE_API}/redirects`, {
57
+ method: "POST",
58
+ headers: { "Content-Type": "application/json" },
59
+ body: JSON.stringify({
60
+ source,
61
+ destination,
62
+ type: 301,
63
+ enabled: true,
64
+ groupName: "seo-graph-fuzzy-suggester",
65
+ }),
66
+ });
67
+ if (!res.ok) {
68
+ throw new Error(await getErrorMessage(res, "Failed to create redirect"));
69
+ }
70
+ }
71
+
72
+ function urlToPath(url: string): string {
73
+ try {
74
+ return new URL(url).pathname;
75
+ } catch {
76
+ return url;
77
+ }
78
+ }
79
+
80
+ const rowStyle: React.CSSProperties = {
81
+ border: "1px solid #e5e7eb",
82
+ borderRadius: 8,
83
+ padding: "1rem",
84
+ marginBottom: "0.75rem",
85
+ background: "#fff",
86
+ };
87
+
88
+ const codeStyle: React.CSSProperties = {
89
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
90
+ fontSize: "0.8125rem",
91
+ background: "#f3f4f6",
92
+ padding: "2px 6px",
93
+ borderRadius: 4,
94
+ };
95
+
96
+ const inputStyle: React.CSSProperties = {
97
+ width: "100%",
98
+ padding: "0.375rem 0.5rem",
99
+ borderRadius: 6,
100
+ border: "1px solid #d1d5db",
101
+ fontSize: "0.8125rem",
102
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
103
+ };
104
+
105
+ const primaryButtonStyle: React.CSSProperties = {
106
+ padding: "0.375rem 0.875rem",
107
+ borderRadius: 6,
108
+ background: "#4a1525",
109
+ color: "white",
110
+ border: "none",
111
+ cursor: "pointer",
112
+ fontSize: "0.8125rem",
113
+ fontWeight: 500,
114
+ };
115
+
116
+ export function FuzzyRedirectsPage() {
117
+ const [loading, setLoading] = React.useState(true);
118
+ const [loadError, setLoadError] = React.useState<string | null>(null);
119
+ const [suggestions, setSuggestions] = React.useState<Suggestion[]>([]);
120
+ const [minScore, setMinScore] = React.useState(0.5);
121
+
122
+ const load = React.useCallback(async () => {
123
+ setLoading(true);
124
+ setLoadError(null);
125
+ try {
126
+ const [log, map] = await Promise.all([fetchNotFoundSummary(), fetchSchemaMap()]);
127
+ const candidatePaths = map.map((entry) => urlToPath(entry.url));
128
+ const next: Suggestion[] = log.map((entry) => {
129
+ const matches = rankCandidates(entry.path, candidatePaths, { limit: 3, minScore });
130
+ return {
131
+ entry,
132
+ matches,
133
+ chosen: matches[0]?.candidate ?? "",
134
+ created: false,
135
+ error: null,
136
+ saving: false,
137
+ };
138
+ });
139
+ setSuggestions(next);
140
+ } catch (err) {
141
+ setLoadError(err instanceof Error ? err.message : String(err));
142
+ } finally {
143
+ setLoading(false);
144
+ }
145
+ }, [minScore]);
146
+
147
+ React.useEffect(() => {
148
+ void load();
149
+ }, [load]);
150
+
151
+ const updateRow = (index: number, patch: Partial<Suggestion>) => {
152
+ setSuggestions((prev) => prev.map((s, i) => (i === index ? { ...s, ...patch } : s)));
153
+ };
154
+
155
+ const handleCreate = async (index: number) => {
156
+ const row = suggestions[index];
157
+ if (!row?.chosen) return;
158
+ updateRow(index, { saving: true, error: null });
159
+ try {
160
+ await createRedirect(row.entry.path, row.chosen);
161
+ updateRow(index, { saving: false, created: true });
162
+ } catch (err) {
163
+ updateRow(index, {
164
+ saving: false,
165
+ error: err instanceof Error ? err.message : String(err),
166
+ });
167
+ }
168
+ };
169
+
170
+ const visible = suggestions.filter((suggestion) => !suggestion.created);
171
+
172
+ return (
173
+ <div style={{ maxWidth: 820, padding: "1.5rem 0" }}>
174
+ <h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "0.5rem" }}>
175
+ Fuzzy Redirects
176
+ </h1>
177
+ <p style={{ fontSize: "0.875rem", color: "#4b5563", marginBottom: "1.5rem" }}>
178
+ Reviews the 404 log, pairs each missing path with the closest matching published URLs, and
179
+ lets you create a 301 redirect. Matches are scored by path similarity, and the slider tunes
180
+ how aggressive suggestions are.
181
+ </p>
182
+
183
+ <div style={{ display: "flex", alignItems: "center", gap: 16, marginBottom: "1rem" }}>
184
+ <label style={{ fontSize: "0.8125rem", color: "#4b5563" }}>
185
+ Minimum match score: <strong>{minScore.toFixed(2)}</strong>
186
+ <input
187
+ type="range"
188
+ min="0"
189
+ max="1"
190
+ step="0.05"
191
+ value={minScore}
192
+ onChange={(event) => setMinScore(parseFloat(event.target.value))}
193
+ style={{ marginLeft: 8, verticalAlign: "middle" }}
194
+ />
195
+ </label>
196
+ <button
197
+ onClick={() => void load()}
198
+ disabled={loading}
199
+ style={{
200
+ padding: "0.375rem 0.75rem",
201
+ borderRadius: 6,
202
+ background: "#f3f4f6",
203
+ color: "#374151",
204
+ border: "1px solid #d1d5db",
205
+ cursor: loading ? "wait" : "pointer",
206
+ fontSize: "0.8125rem",
207
+ }}
208
+ >
209
+ {loading ? "Loading..." : "Refresh"}
210
+ </button>
211
+ </div>
212
+
213
+ {loadError && (
214
+ <div
215
+ style={{
216
+ padding: "0.75rem",
217
+ background: "#fef2f2",
218
+ color: "#991b1b",
219
+ borderRadius: 6,
220
+ marginBottom: "1rem",
221
+ fontSize: "0.875rem",
222
+ }}
223
+ >
224
+ Failed to load: {loadError}
225
+ </div>
226
+ )}
227
+
228
+ {!loading && !loadError && suggestions.length === 0 && (
229
+ <div
230
+ style={{ padding: "2rem", textAlign: "center", color: "#6b7280", fontSize: "0.875rem" }}
231
+ >
232
+ No 404s logged. Hit a dead link on the live site to populate the log.
233
+ </div>
234
+ )}
235
+
236
+ {!loading && !loadError && suggestions.length > 0 && visible.length === 0 && (
237
+ <div
238
+ style={{ padding: "2rem", textAlign: "center", color: "#16a34a", fontSize: "0.875rem" }}
239
+ >
240
+ All suggestions handled.
241
+ </div>
242
+ )}
243
+
244
+ {visible.map((row) => {
245
+ const index = suggestions.indexOf(row);
246
+ return (
247
+ <div key={row.entry.path} style={rowStyle}>
248
+ <div style={{ marginBottom: "0.5rem" }}>
249
+ <span style={codeStyle}>{row.entry.path}</span>
250
+ <span style={{ marginLeft: 12, fontSize: "0.75rem", color: "#6b7280" }}>
251
+ {row.entry.count} hit{row.entry.count === 1 ? "" : "s"}
252
+ {row.entry.topReferrer ? ` from ${row.entry.topReferrer}` : ""}
253
+ </span>
254
+ </div>
255
+
256
+ {row.matches.length === 0 ? (
257
+ <div style={{ fontSize: "0.8125rem", color: "#9ca3af", fontStyle: "italic" }}>
258
+ No matches above the score threshold. Enter a destination manually if you know one.
259
+ </div>
260
+ ) : (
261
+ <div style={{ marginBottom: "0.5rem" }}>
262
+ {row.matches.map((match) => (
263
+ <label
264
+ key={match.candidate}
265
+ style={{
266
+ display: "flex",
267
+ alignItems: "center",
268
+ gap: 8,
269
+ marginBottom: 4,
270
+ fontSize: "0.8125rem",
271
+ }}
272
+ >
273
+ <input
274
+ type="radio"
275
+ name={`dest-${index}`}
276
+ checked={row.chosen === match.candidate}
277
+ onChange={() => updateRow(index, { chosen: match.candidate })}
278
+ />
279
+ <span style={codeStyle}>{match.candidate}</span>
280
+ <span style={{ color: "#6b7280", fontSize: "0.75rem" }}>
281
+ score {match.score.toFixed(2)}
282
+ </span>
283
+ </label>
284
+ ))}
285
+ </div>
286
+ )}
287
+
288
+ <div style={{ display: "flex", gap: 8, alignItems: "center", marginTop: "0.5rem" }}>
289
+ <input
290
+ type="text"
291
+ value={row.chosen}
292
+ onChange={(event) => updateRow(index, { chosen: event.target.value })}
293
+ placeholder="/destination/path"
294
+ style={{ ...inputStyle, flex: 1 }}
295
+ />
296
+ <button
297
+ onClick={() => void handleCreate(index)}
298
+ disabled={row.saving || !row.chosen}
299
+ style={{
300
+ ...primaryButtonStyle,
301
+ cursor: row.saving ? "wait" : row.chosen ? "pointer" : "not-allowed",
302
+ opacity: row.chosen ? 1 : 0.5,
303
+ }}
304
+ >
305
+ {row.saving ? "Creating..." : "Create redirect"}
306
+ </button>
307
+ </div>
308
+
309
+ {row.error && (
310
+ <div style={{ marginTop: 6, fontSize: "0.75rem", color: "#dc2626" }}>{row.error}</div>
311
+ )}
312
+ </div>
313
+ );
314
+ })}
315
+ </div>
316
+ );
317
+ }