@jdevalk/astro-seo-graph 0.4.1 → 0.5.0

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 CHANGED
@@ -1,5 +1,8 @@
1
1
  # @jdevalk/astro-seo-graph
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@jdevalk/astro-seo-graph)](https://www.npmjs.com/package/@jdevalk/astro-seo-graph)
4
+ [![license](https://img.shields.io/npm/l/@jdevalk/astro-seo-graph)](https://github.com/jdevalk/seo-graph/blob/main/LICENSE)
5
+
3
6
  Astro integration for [`@jdevalk/seo-graph-core`](../seo-graph-core). Ships a
4
7
  `<Seo>` component, route factories for agent-ready schema endpoints, a
5
8
  content-collection aggregator, breadcrumb helpers, and Zod helpers for content
@@ -0,0 +1,147 @@
1
+ ---
2
+ interface Props {
3
+ /**
4
+ * Minimum similarity score (0–1) for a suggestion to appear.
5
+ * Defaults to 0.7.
6
+ */
7
+ threshold?: number;
8
+ /**
9
+ * Similarity score (0–1) above which the user is automatically
10
+ * redirected instead of shown a suggestion. Set to 1 to disable
11
+ * auto-redirect. Defaults to 0.95.
12
+ */
13
+ autoRedirectThreshold?: number;
14
+ /**
15
+ * URL of the sitemap index or sitemap file to fetch.
16
+ * Defaults to '/sitemap-index.xml'.
17
+ */
18
+ sitemapUrl?: string;
19
+ /**
20
+ * Text shown before the suggestion link. Defaults to 'Did you mean'.
21
+ */
22
+ suggestionText?: string;
23
+ }
24
+
25
+ const {
26
+ threshold = 0.7,
27
+ autoRedirectThreshold = 0.95,
28
+ sitemapUrl = '/sitemap-index.xml',
29
+ suggestionText = 'Did you mean',
30
+ } = Astro.props;
31
+ ---
32
+
33
+ <div
34
+ id="fuzzy-redirect"
35
+ data-threshold={threshold}
36
+ data-auto-redirect-threshold={autoRedirectThreshold}
37
+ data-sitemap-url={sitemapUrl}
38
+ data-suggestion-text={suggestionText}
39
+ ></div>
40
+
41
+ <script>
42
+ function levenshtein(a: string, b: string): number {
43
+ const m = a.length;
44
+ const n = b.length;
45
+ if (m === 0) return n;
46
+ if (n === 0) return m;
47
+
48
+ // Single-row DP: prev[j] holds the cost for (i-1, j).
49
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
50
+ for (let i = 1; i <= m; i++) {
51
+ let prevDiag = prev[0]!;
52
+ prev[0] = i;
53
+ for (let j = 1; j <= n; j++) {
54
+ const temp = prev[j]!;
55
+ prev[j] =
56
+ a[i - 1] === b[j - 1]
57
+ ? prevDiag
58
+ : 1 + Math.min(prevDiag, prev[j - 1]!, prev[j]!);
59
+ prevDiag = temp;
60
+ }
61
+ }
62
+ return prev[n]!;
63
+ }
64
+
65
+ function similarity(a: string, b: string): number {
66
+ const maxLen = Math.max(a.length, b.length);
67
+ if (maxLen === 0) return 1;
68
+ return 1 - levenshtein(a, b) / maxLen;
69
+ }
70
+
71
+ async function fetchSitemapUrls(sitemapUrl: string): Promise<string[]> {
72
+ const response = await fetch(sitemapUrl);
73
+ if (!response.ok) return [];
74
+
75
+ const text = await response.text();
76
+ const parser = new DOMParser();
77
+ const doc = parser.parseFromString(text, 'text/xml');
78
+
79
+ // Check if this is a sitemap index.
80
+ const sitemapLocs = doc.querySelectorAll('sitemap > loc');
81
+ if (sitemapLocs.length > 0) {
82
+ // Fetch each child sitemap in parallel.
83
+ const childUrls = await Promise.all(
84
+ Array.from(sitemapLocs).map((loc) => fetchSitemapUrls(loc.textContent?.trim() ?? '')),
85
+ );
86
+ return childUrls.flat();
87
+ }
88
+
89
+ // Regular sitemap — extract <url><loc> entries.
90
+ return Array.from(doc.querySelectorAll('url > loc'))
91
+ .map((loc) => loc.textContent?.trim() ?? '')
92
+ .filter(Boolean);
93
+ }
94
+
95
+ async function run() {
96
+ const container = document.getElementById('fuzzy-redirect');
97
+ if (!container) return;
98
+
99
+ const threshold = parseFloat(container.dataset.threshold ?? '0.7');
100
+ const autoRedirectThreshold = parseFloat(container.dataset.autoRedirectThreshold ?? '0.95');
101
+ const sitemapUrl = container.dataset.sitemapUrl ?? '/sitemap-index.xml';
102
+ const suggestionText = container.dataset.suggestionText ?? 'Did you mean';
103
+
104
+ const currentPath = window.location.pathname;
105
+ const urls = await fetchSitemapUrls(sitemapUrl);
106
+
107
+ // Extract paths from full URLs.
108
+ const paths = urls.map((url) => {
109
+ try {
110
+ return new URL(url).pathname;
111
+ } catch {
112
+ return url;
113
+ }
114
+ });
115
+
116
+ // Find the closest match.
117
+ let bestPath = '';
118
+ let bestScore = 0;
119
+ for (const path of paths) {
120
+ const score = similarity(currentPath, path);
121
+ if (score > bestScore) {
122
+ bestScore = score;
123
+ bestPath = path;
124
+ }
125
+ }
126
+
127
+ // Exact match means the 404 is correct — the path exists in the
128
+ // sitemap but returned a 404 (possibly a stale sitemap entry).
129
+ if (bestScore >= 1 || bestScore < threshold) return;
130
+
131
+ if (bestScore >= autoRedirectThreshold) {
132
+ window.location.replace(bestPath);
133
+ return;
134
+ }
135
+
136
+ // Show suggestion.
137
+ const link = document.createElement('a');
138
+ link.href = bestPath;
139
+ link.textContent = bestPath;
140
+
141
+ container.textContent = `${suggestionText} `;
142
+ container.appendChild(link);
143
+ container.append('?');
144
+ }
145
+
146
+ run();
147
+ </script>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jdevalk/astro-seo-graph",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Astro integration for @jdevalk/seo-graph-core. Seo component, route factories, content-collection aggregator, Zod content helpers.",
5
5
  "keywords": [
6
6
  "astro",
@@ -22,7 +22,8 @@
22
22
  "types": "./dist/index.d.ts",
23
23
  "import": "./dist/index.js"
24
24
  },
25
- "./Seo.astro": "./dist/components/Seo.astro"
25
+ "./Seo.astro": "./dist/components/Seo.astro",
26
+ "./FuzzyRedirect.astro": "./dist/components/FuzzyRedirect.astro"
26
27
  },
27
28
  "files": [
28
29
  "dist",
@@ -47,7 +48,7 @@
47
48
  "astro-seo": "^1.1.0",
48
49
  "schema-dts": "^2.0.0",
49
50
  "zod": "^3.24.0",
50
- "@jdevalk/seo-graph-core": "0.5.1"
51
+ "@jdevalk/seo-graph-core": "0.5.2"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@types/node": "^22.0.0",