@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 +227 -0
- package/package.json +49 -0
- package/src/admin-redirects.tsx +317 -0
- package/src/admin.tsx +529 -0
- package/src/canonical.ts +46 -0
- package/src/descriptions.ts +17 -0
- package/src/fuzzy.ts +112 -0
- package/src/hreflang.ts +103 -0
- package/src/index.ts +98 -0
- package/src/indexnow.ts +139 -0
- package/src/llms.ts +151 -0
- package/src/metadata.ts +93 -0
- package/src/opengraph.ts +327 -0
- package/src/robots.ts +29 -0
- package/src/schema/article.ts +70 -0
- package/src/schema/breadcrumb.ts +158 -0
- package/src/schema/endpoints.ts +69 -0
- package/src/schema/index.ts +175 -0
- package/src/schema/organization.ts +133 -0
- package/src/schema/person.ts +54 -0
- package/src/schema/webpage.ts +84 -0
- package/src/schema/website.ts +52 -0
- package/src/settings.ts +330 -0
- package/src/terms.ts +33 -0
- package/src/titles.ts +59 -0
- package/src/urls.ts +72 -0
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
|
+
}
|