@jdevalk/astro-seo-graph 0.5.0 → 0.5.2
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 +54 -2
- package/dist/components/FuzzyRedirect.astro +22 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
Astro integration for [`@jdevalk/seo-graph-core`](../seo-graph-core). Ships a
|
|
7
7
|
`<Seo>` component, route factories for agent-ready schema endpoints, a
|
|
8
|
-
content-collection aggregator, breadcrumb helpers,
|
|
9
|
-
schemas.
|
|
8
|
+
content-collection aggregator, breadcrumb helpers, a fuzzy 404 redirect
|
|
9
|
+
component, and Zod helpers for content schemas.
|
|
10
10
|
|
|
11
11
|
For detailed usage — including all builder signatures, site-type recipes, and
|
|
12
12
|
schema.org best practices — see [AGENTS.md](https://github.com/jdevalk/seo-graph/blob/main/AGENTS.md).
|
|
@@ -23,6 +23,7 @@ schema.org best practices — see [AGENTS.md](https://github.com/jdevalk/seo-gra
|
|
|
23
23
|
| **`buildAstroSeoProps`** | Pure-TS logic that powers `<Seo>` — exported for users who want to feed a different head component. |
|
|
24
24
|
| **`buildAlternateLinks`** | Pure helper that turns a `{ hreflang, href }` entry list into normalized `<link rel="alternate">` tags plus an `x-default`. Used internally by `<Seo>`'s `alternates` prop, and exported for non-Astro callers (e.g. CMS plugins feeding their own metadata pipelines). |
|
|
25
25
|
| **`breadcrumbsFromUrl`** | Derives a breadcrumb trail from an Astro URL. Splits path segments, supports custom display names and segment skipping. Returns `BreadcrumbItem[]` ready to pass to `buildBreadcrumbList`. |
|
|
26
|
+
| **`<FuzzyRedirect>`** | Drop-in 404 component. Fetches your sitemap, fuzzy-matches the current URL against known paths, and suggests or auto-redirects to the closest match. |
|
|
26
27
|
|
|
27
28
|
## Installation
|
|
28
29
|
|
|
@@ -110,6 +111,57 @@ Segments without a `names` entry are title-cased from their slug
|
|
|
110
111
|
(e.g. `https://example.com/docs`) are supported — pass the base path as part
|
|
111
112
|
of `siteUrl`.
|
|
112
113
|
|
|
114
|
+
## Fuzzy 404 redirect
|
|
115
|
+
|
|
116
|
+
When a visitor hits a 404, `<FuzzyRedirect>` fetches your sitemap, compares
|
|
117
|
+
the mistyped URL against all known paths, and either suggests the closest
|
|
118
|
+
match or auto-redirects. Drop it into your `404.astro` page:
|
|
119
|
+
|
|
120
|
+
```astro
|
|
121
|
+
---
|
|
122
|
+
// src/pages/404.astro
|
|
123
|
+
import FuzzyRedirect from '@jdevalk/astro-seo-graph/FuzzyRedirect.astro';
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
<html lang="en">
|
|
127
|
+
<head>
|
|
128
|
+
<meta charset="utf-8" />
|
|
129
|
+
<title>Page not found</title>
|
|
130
|
+
</head>
|
|
131
|
+
<body>
|
|
132
|
+
<h1>Page not found</h1>
|
|
133
|
+
<p>Sorry, the page you're looking for doesn't exist.</p>
|
|
134
|
+
<p style="font-size: 1.25em; font-weight: bold;">
|
|
135
|
+
<FuzzyRedirect />
|
|
136
|
+
</p>
|
|
137
|
+
<p><a href="/">Go to the homepage</a></p>
|
|
138
|
+
</body>
|
|
139
|
+
</html>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
When a close match is found, the component renders a message like
|
|
143
|
+
**Did you mean [/seo-graph/](/seo-graph/)?** inside the element where
|
|
144
|
+
you place it. Style the surrounding element to make it prominent.
|
|
145
|
+
|
|
146
|
+
### How it works
|
|
147
|
+
|
|
148
|
+
1. Fetches `/sitemap-index.xml` (follows sitemap index → child sitemaps)
|
|
149
|
+
2. Extracts all paths and computes
|
|
150
|
+
[Levenshtein similarity](https://en.wikipedia.org/wiki/Levenshtein_distance)
|
|
151
|
+
against the current URL
|
|
152
|
+
3. **0.6–0.85 similarity**: shows "Did you mean /correct-path/?"
|
|
153
|
+
4. **Above 0.85**: auto-redirects with `window.location.replace`
|
|
154
|
+
5. **Below 0.6 or exact match**: does nothing
|
|
155
|
+
|
|
156
|
+
### Props
|
|
157
|
+
|
|
158
|
+
| Prop | Default | Description |
|
|
159
|
+
| ----------------------- | ---------------------- | -------------------------------------------------- |
|
|
160
|
+
| `threshold` | `0.6` | Minimum similarity for a suggestion to appear |
|
|
161
|
+
| `autoRedirectThreshold` | `0.85` | Similarity above which the user is auto-redirected |
|
|
162
|
+
| `sitemapUrl` | `'/sitemap-index.xml'` | URL of the sitemap index or sitemap file |
|
|
163
|
+
| `suggestionText` | `'Did you mean'` | Text shown before the suggested link |
|
|
164
|
+
|
|
113
165
|
## hreflang alternates
|
|
114
166
|
|
|
115
167
|
For multilingual sites, pass an `alternates` prop with one entry per locale.
|
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
interface Props {
|
|
3
3
|
/**
|
|
4
4
|
* Minimum similarity score (0–1) for a suggestion to appear.
|
|
5
|
-
* Defaults to 0.
|
|
5
|
+
* Defaults to 0.6.
|
|
6
6
|
*/
|
|
7
7
|
threshold?: number;
|
|
8
8
|
/**
|
|
9
9
|
* Similarity score (0–1) above which the user is automatically
|
|
10
10
|
* redirected instead of shown a suggestion. Set to 1 to disable
|
|
11
|
-
* auto-redirect. Defaults to 0.
|
|
11
|
+
* auto-redirect. Defaults to 0.85.
|
|
12
12
|
*/
|
|
13
13
|
autoRedirectThreshold?: number;
|
|
14
14
|
/**
|
|
@@ -23,8 +23,8 @@ interface Props {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const {
|
|
26
|
-
threshold = 0.
|
|
27
|
-
autoRedirectThreshold = 0.
|
|
26
|
+
threshold = 0.6,
|
|
27
|
+
autoRedirectThreshold = 0.85,
|
|
28
28
|
sitemapUrl = '/sitemap-index.xml',
|
|
29
29
|
suggestionText = 'Did you mean',
|
|
30
30
|
} = Astro.props;
|
|
@@ -96,12 +96,24 @@ const {
|
|
|
96
96
|
const container = document.getElementById('fuzzy-redirect');
|
|
97
97
|
if (!container) return;
|
|
98
98
|
|
|
99
|
-
const threshold = parseFloat(container.dataset.threshold ?? '0.
|
|
100
|
-
const autoRedirectThreshold = parseFloat(container.dataset.autoRedirectThreshold ?? '0.
|
|
99
|
+
const threshold = parseFloat(container.dataset.threshold ?? '0.6');
|
|
100
|
+
const autoRedirectThreshold = parseFloat(container.dataset.autoRedirectThreshold ?? '0.85');
|
|
101
101
|
const sitemapUrl = container.dataset.sitemapUrl ?? '/sitemap-index.xml';
|
|
102
102
|
const suggestionText = container.dataset.suggestionText ?? 'Did you mean';
|
|
103
103
|
|
|
104
104
|
const currentPath = window.location.pathname;
|
|
105
|
+
|
|
106
|
+
// Out-of-bounds pagination: /something/page/N/ → /something/
|
|
107
|
+
const paginationMatch = currentPath.match(/^(.+?)\/page\/\d+\/?$/);
|
|
108
|
+
if (paginationMatch) {
|
|
109
|
+
const basePath = paginationMatch[1]!.replace(/\/$/, '') + '/';
|
|
110
|
+
console.log(
|
|
111
|
+
`[FuzzyRedirect] Pagination redirect: "${currentPath}" → "${basePath}"`,
|
|
112
|
+
);
|
|
113
|
+
window.location.replace(basePath);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
105
117
|
const urls = await fetchSitemapUrls(sitemapUrl);
|
|
106
118
|
|
|
107
119
|
// Extract paths from full URLs.
|
|
@@ -124,6 +136,10 @@ const {
|
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
138
|
|
|
139
|
+
console.log(
|
|
140
|
+
`[FuzzyRedirect] Best match for "${currentPath}": "${bestPath}" (similarity: ${bestScore.toFixed(3)})`,
|
|
141
|
+
);
|
|
142
|
+
|
|
127
143
|
// Exact match means the 404 is correct — the path exists in the
|
|
128
144
|
// sitemap but returned a 404 (possibly a stale sitemap entry).
|
|
129
145
|
if (bestScore >= 1 || bestScore < threshold) return;
|
package/package.json
CHANGED