@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/src/admin.tsx ADDED
@@ -0,0 +1,529 @@
1
+ import { apiFetch as baseFetch, parseApiResponse } from "dineway/plugin-utils";
2
+ import * as React from "react";
3
+
4
+ import { FuzzyRedirectsPage } from "./admin-redirects.js";
5
+
6
+ export const SEO_GRAPH_PLUGIN_API_BASE = "/_dineway/api/plugins/seo-graph";
7
+
8
+ async function apiFetch(route: string, body?: unknown): Promise<Response> {
9
+ return baseFetch(`${SEO_GRAPH_PLUGIN_API_BASE}/${route}`, {
10
+ method: "POST",
11
+ headers: { "Content-Type": "application/json" },
12
+ body: JSON.stringify(body ?? {}),
13
+ });
14
+ }
15
+
16
+ function toSettingString(value: unknown): string {
17
+ if (typeof value === "string") return value;
18
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
19
+ return "";
20
+ }
21
+
22
+ interface FieldDef {
23
+ key: string;
24
+ type: "string" | "select";
25
+ label: string;
26
+ description?: string;
27
+ multiline?: boolean;
28
+ options?: Array<{ value: string; label: string }>;
29
+ default?: string;
30
+ section?: string;
31
+ }
32
+
33
+ const FIELDS: FieldDef[] = [
34
+ {
35
+ key: "siteRepresents",
36
+ type: "select",
37
+ label: "Site represents",
38
+ description: "Does this site represent a person or an organization?",
39
+ options: [
40
+ { value: "person", label: "Person" },
41
+ { value: "organization", label: "Organization" },
42
+ ],
43
+ default: "person",
44
+ section: "general",
45
+ },
46
+ {
47
+ key: "separator",
48
+ type: "select",
49
+ label: "Title separator",
50
+ description: "Character between page title and site name",
51
+ options: [
52
+ { value: " — ", label: "— (em dash)" },
53
+ { value: " | ", label: "| (pipe)" },
54
+ { value: " - ", label: "- (hyphen)" },
55
+ { value: " · ", label: "· (dot)" },
56
+ ],
57
+ default: " — ",
58
+ section: "general",
59
+ },
60
+ {
61
+ key: "defaultDescription",
62
+ type: "string",
63
+ label: "Default meta description",
64
+ description: "Fallback for pages without their own",
65
+ multiline: true,
66
+ section: "general",
67
+ },
68
+ {
69
+ key: "personName",
70
+ type: "string",
71
+ label: "Person name",
72
+ description: "Full name of the person this site represents",
73
+ section: "person",
74
+ },
75
+ {
76
+ key: "personDescription",
77
+ type: "string",
78
+ label: "Person bio",
79
+ description: "Short biography (max 250 characters for schema.org)",
80
+ multiline: true,
81
+ section: "person",
82
+ },
83
+ {
84
+ key: "personImageUrl",
85
+ type: "string",
86
+ label: "Person image URL",
87
+ description: "URL to the person's photo",
88
+ section: "person",
89
+ },
90
+ {
91
+ key: "personJobTitle",
92
+ type: "string",
93
+ label: "Person job title",
94
+ description: "Job title for schema.org Person",
95
+ section: "person",
96
+ },
97
+ {
98
+ key: "personUrl",
99
+ type: "string",
100
+ label: "Person URL",
101
+ description: "About page or personal website",
102
+ section: "person",
103
+ },
104
+ { key: "orgName", type: "string", label: "Organization name", section: "org" },
105
+ { key: "orgLogoUrl", type: "string", label: "Organization logo URL", section: "org" },
106
+ { key: "socialTwitter", type: "string", label: "X (Twitter) URL", section: "social" },
107
+ { key: "socialFacebook", type: "string", label: "Facebook URL", section: "social" },
108
+ { key: "socialLinkedIn", type: "string", label: "LinkedIn URL", section: "social" },
109
+ { key: "socialInstagram", type: "string", label: "Instagram URL", section: "social" },
110
+ { key: "socialYouTube", type: "string", label: "YouTube URL", section: "social" },
111
+ { key: "socialGitHub", type: "string", label: "GitHub URL", section: "social" },
112
+ { key: "socialBluesky", type: "string", label: "Bluesky URL", section: "social" },
113
+ { key: "socialMastodon", type: "string", label: "Mastodon URL", section: "social" },
114
+ { key: "socialWikipedia", type: "string", label: "Wikipedia URL", section: "social" },
115
+ {
116
+ key: "llmsTxtEnabled",
117
+ type: "select",
118
+ label: "llms.txt",
119
+ description: "Expose an llms.txt index of published content.",
120
+ options: [
121
+ { value: "true", label: "Enabled" },
122
+ { value: "false", label: "Disabled" },
123
+ ],
124
+ default: "true",
125
+ section: "discovery",
126
+ },
127
+ {
128
+ key: "llmsTxtDescription",
129
+ type: "string",
130
+ label: "llms.txt site description",
131
+ description: "Optional blurb rendered at the top of llms.txt.",
132
+ multiline: true,
133
+ section: "discovery",
134
+ },
135
+ {
136
+ key: "nlwebEndpoint",
137
+ type: "string",
138
+ label: "NLWeb endpoint URL",
139
+ description: "Absolute URL of the site's conversational endpoint.",
140
+ section: "discovery",
141
+ },
142
+ ];
143
+
144
+ const inputStyle: React.CSSProperties = {
145
+ width: "100%",
146
+ padding: "0.5rem 0.75rem",
147
+ borderRadius: 6,
148
+ border: "1px solid #d1d5db",
149
+ fontSize: "0.875rem",
150
+ fontFamily: "inherit",
151
+ };
152
+
153
+ const buttonStyle: React.CSSProperties = {
154
+ padding: "0.375rem 0.75rem",
155
+ borderRadius: 6,
156
+ background: "#f3f4f6",
157
+ color: "#374151",
158
+ border: "1px solid #d1d5db",
159
+ cursor: "pointer",
160
+ fontSize: "0.75rem",
161
+ fontFamily: "inherit",
162
+ };
163
+
164
+ function Field({
165
+ field,
166
+ value,
167
+ onChange,
168
+ }: {
169
+ field: FieldDef;
170
+ value: string;
171
+ onChange: (v: string) => void;
172
+ }) {
173
+ return (
174
+ <div style={{ marginBottom: "1rem" }}>
175
+ <label style={{ display: "block", fontWeight: 500, marginBottom: 4, fontSize: "0.875rem" }}>
176
+ {field.label}
177
+ </label>
178
+ {field.description && (
179
+ <div style={{ fontSize: "0.75rem", color: "#666", marginBottom: 4 }}>
180
+ {field.description}
181
+ </div>
182
+ )}
183
+ {field.type === "select" ? (
184
+ <select value={value} onChange={(e) => onChange(e.target.value)} style={inputStyle}>
185
+ {field.options?.map((opt) => (
186
+ <option key={opt.value} value={opt.value}>
187
+ {opt.label}
188
+ </option>
189
+ ))}
190
+ </select>
191
+ ) : field.multiline ? (
192
+ <textarea
193
+ value={value}
194
+ onChange={(e) => onChange(e.target.value)}
195
+ rows={3}
196
+ style={{ ...inputStyle, resize: "vertical" }}
197
+ />
198
+ ) : (
199
+ <input
200
+ type="text"
201
+ value={value}
202
+ onChange={(e) => onChange(e.target.value)}
203
+ style={inputStyle}
204
+ />
205
+ )}
206
+ </div>
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Editor for the breadcrumb segment → label map. Each row is a
212
+ * `(segment, label)` pair; "blog" → "Blog" fixes ugly auto-derived
213
+ * crumbs without requiring code changes to the theme.
214
+ *
215
+ * Serializes to JSON on every change; stored as the `breadcrumbLabels`
216
+ * setting value.
217
+ */
218
+ function BreadcrumbLabelsEditor({
219
+ value,
220
+ onChange,
221
+ }: {
222
+ value: string;
223
+ onChange: (v: string) => void;
224
+ }) {
225
+ const parsed = React.useMemo<Array<{ segment: string; label: string }>>(() => {
226
+ if (!value) return [];
227
+ try {
228
+ const obj = JSON.parse(value) as unknown;
229
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
230
+ return Object.entries(obj as Record<string, unknown>).map(([segment, label]) => ({
231
+ segment,
232
+ label: toSettingString(label),
233
+ }));
234
+ }
235
+ } catch {
236
+ // fall through
237
+ }
238
+ return [];
239
+ }, [value]);
240
+
241
+ const commit = (rows: Array<{ segment: string; label: string }>) => {
242
+ const obj: Record<string, string> = {};
243
+ for (const row of rows) {
244
+ const key = row.segment.trim();
245
+ if (key) obj[key] = row.label;
246
+ }
247
+ onChange(Object.keys(obj).length > 0 ? JSON.stringify(obj) : "");
248
+ };
249
+
250
+ const updateRow = (index: number, patch: Partial<{ segment: string; label: string }>) => {
251
+ const next = parsed.map((r, i) => (i === index ? { ...r, ...patch } : r));
252
+ commit(next);
253
+ };
254
+
255
+ const addRow = () => commit([...parsed, { segment: "", label: "" }]);
256
+ const removeRow = (index: number) => commit(parsed.filter((_, i) => i !== index));
257
+
258
+ return (
259
+ <div style={{ marginBottom: "1rem" }}>
260
+ <label style={{ display: "block", fontWeight: 500, marginBottom: 4, fontSize: "0.875rem" }}>
261
+ Segment labels
262
+ </label>
263
+ <div style={{ fontSize: "0.75rem", color: "#666", marginBottom: 8 }}>
264
+ Override the default title-cased segment name for breadcrumbs. Segments are matched anywhere
265
+ in the path — <code>blog</code> → <code>Blog</code> relabels the <code>/blog/</code> crumb
266
+ on every page under it.
267
+ </div>
268
+ {parsed.length === 0 && (
269
+ <div
270
+ style={{ fontSize: "0.75rem", color: "#9ca3af", fontStyle: "italic", marginBottom: 8 }}
271
+ >
272
+ No overrides — breadcrumbs will use cleaned-up segment names.
273
+ </div>
274
+ )}
275
+ {parsed.map((row, i) => (
276
+ <div key={i} style={{ display: "flex", gap: 8, marginBottom: 6 }}>
277
+ <input
278
+ type="text"
279
+ placeholder="segment"
280
+ value={row.segment}
281
+ onChange={(e) => updateRow(i, { segment: e.target.value })}
282
+ style={{ ...inputStyle, flex: "1 1 40%" }}
283
+ />
284
+ <input
285
+ type="text"
286
+ placeholder="Display label"
287
+ value={row.label}
288
+ onChange={(e) => updateRow(i, { label: e.target.value })}
289
+ style={{ ...inputStyle, flex: "1 1 60%" }}
290
+ />
291
+ <button
292
+ type="button"
293
+ onClick={() => removeRow(i)}
294
+ style={buttonStyle}
295
+ aria-label="Remove"
296
+ >
297
+ ×
298
+ </button>
299
+ </div>
300
+ ))}
301
+ <button type="button" onClick={addRow} style={buttonStyle}>
302
+ + Add label
303
+ </button>
304
+ </div>
305
+ );
306
+ }
307
+
308
+ /**
309
+ * Advanced editor for per-`pageType` breadcrumb rules. Raw JSON editor
310
+ * because the shape is nested (pageType → ordered array of crumbs)
311
+ * and a structured form would be heavy for a rarely-edited field.
312
+ *
313
+ * Validation is cosmetic (red border on parse error); the plugin's
314
+ * runtime `parseSettings` falls back to `{}` on malformed JSON, so
315
+ * bad input degrades to path derivation rather than crashing.
316
+ */
317
+ function BreadcrumbRulesEditor({
318
+ value,
319
+ onChange,
320
+ }: {
321
+ value: string;
322
+ onChange: (v: string) => void;
323
+ }) {
324
+ const [draft, setDraft] = React.useState(value);
325
+ const [error, setError] = React.useState<string | null>(null);
326
+
327
+ React.useEffect(() => {
328
+ setDraft(value);
329
+ }, [value]);
330
+
331
+ const handleChange = (next: string) => {
332
+ setDraft(next);
333
+ if (!next.trim()) {
334
+ setError(null);
335
+ onChange("");
336
+ return;
337
+ }
338
+ try {
339
+ JSON.parse(next);
340
+ setError(null);
341
+ onChange(next);
342
+ } catch (err) {
343
+ setError((err as Error).message);
344
+ }
345
+ };
346
+
347
+ return (
348
+ <div style={{ marginBottom: "1rem" }}>
349
+ <label style={{ display: "block", fontWeight: 500, marginBottom: 4, fontSize: "0.875rem" }}>
350
+ Page type rules (advanced)
351
+ </label>
352
+ <div style={{ fontSize: "0.75rem", color: "#666", marginBottom: 4 }}>
353
+ JSON map from <code>pageType</code> to an ordered list of crumbs. Use{" "}
354
+ <code>{"{title}"}</code> as a placeholder for the current page title, and omit{" "}
355
+ <code>href</code> on the last crumb to point it at the canonical URL.
356
+ </div>
357
+ <pre
358
+ style={{
359
+ fontSize: "0.7rem",
360
+ color: "#6b7280",
361
+ background: "#f9fafb",
362
+ padding: 8,
363
+ borderRadius: 4,
364
+ marginBottom: 6,
365
+ overflowX: "auto",
366
+ }}
367
+ >
368
+ {`{
369
+ "blogPost": [
370
+ { "label": "Home", "href": "/" },
371
+ { "label": "Blog", "href": "/blog/" },
372
+ { "label": "{title}" }
373
+ ]
374
+ }`}
375
+ </pre>
376
+ <textarea
377
+ value={draft}
378
+ onChange={(e) => handleChange(e.target.value)}
379
+ rows={8}
380
+ style={{
381
+ ...inputStyle,
382
+ resize: "vertical",
383
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
384
+ fontSize: "0.75rem",
385
+ borderColor: error ? "#dc2626" : "#d1d5db",
386
+ }}
387
+ placeholder="{}"
388
+ />
389
+ {error && (
390
+ <div style={{ fontSize: "0.7rem", color: "#dc2626", marginTop: 4 }}>
391
+ Invalid JSON: {error}
392
+ </div>
393
+ )}
394
+ </div>
395
+ );
396
+ }
397
+
398
+ function SettingsPage() {
399
+ const [settings, setSettings] = React.useState<Record<string, string>>({});
400
+ const [saving, setSaving] = React.useState(false);
401
+ const [saved, setSaved] = React.useState(false);
402
+ const [loading, setLoading] = React.useState(true);
403
+ const [error, setError] = React.useState<string | null>(null);
404
+
405
+ React.useEffect(() => {
406
+ const loadSettings = async () => {
407
+ try {
408
+ const res = await apiFetch("settings");
409
+ const data = await parseApiResponse<{ settings: Record<string, string> }>(res);
410
+ setSettings(data.settings || {});
411
+ } catch (err) {
412
+ setError(String(err));
413
+ } finally {
414
+ setLoading(false);
415
+ }
416
+ };
417
+
418
+ void loadSettings();
419
+ }, []);
420
+
421
+ const handleSave = async () => {
422
+ setSaving(true);
423
+ setSaved(false);
424
+ setError(null);
425
+ try {
426
+ await apiFetch("settings/save", { settings });
427
+ setSaved(true);
428
+ setTimeout(setSaved, 3000, false);
429
+ } catch (err) {
430
+ setError(String(err));
431
+ }
432
+ setSaving(false);
433
+ };
434
+
435
+ const update = (key: string, value: string) => {
436
+ setSettings((prev) => ({ ...prev, [key]: value }));
437
+ };
438
+
439
+ if (loading) return <div style={{ padding: "2rem" }}>Loading settings...</div>;
440
+ if (error) return <div style={{ padding: "2rem", color: "#dc2626" }}>Error: {error}</div>;
441
+
442
+ const siteRepresents = settings.siteRepresents || "person";
443
+
444
+ const sections = [
445
+ { id: "general", label: "General" },
446
+ ...(siteRepresents === "person"
447
+ ? [{ id: "person", label: "Person" }]
448
+ : [{ id: "org", label: "Organization" }]),
449
+ { id: "social", label: "Social Profiles" },
450
+ { id: "discovery", label: "Discovery" },
451
+ ];
452
+
453
+ return (
454
+ <div style={{ maxWidth: 640, padding: "1.5rem 0" }}>
455
+ <h1 style={{ fontSize: "1.5rem", fontWeight: 700, marginBottom: "1.5rem" }}>SEO Settings</h1>
456
+ {sections.map((section) => (
457
+ <div key={section.id} style={{ marginBottom: "2rem" }}>
458
+ <h3
459
+ style={{
460
+ fontSize: "1rem",
461
+ fontWeight: 600,
462
+ marginBottom: "0.75rem",
463
+ borderBottom: "1px solid #e5e7eb",
464
+ paddingBottom: "0.5rem",
465
+ }}
466
+ >
467
+ {section.label}
468
+ </h3>
469
+ {FIELDS.filter((f) => f.section === section.id).map((field) => (
470
+ <Field
471
+ key={field.key}
472
+ field={field}
473
+ value={settings[field.key] || field.default || ""}
474
+ onChange={(v) => update(field.key, v)}
475
+ />
476
+ ))}
477
+ </div>
478
+ ))}
479
+
480
+ <div style={{ marginBottom: "2rem" }}>
481
+ <h3
482
+ style={{
483
+ fontSize: "1rem",
484
+ fontWeight: 600,
485
+ marginBottom: "0.75rem",
486
+ borderBottom: "1px solid #e5e7eb",
487
+ paddingBottom: "0.5rem",
488
+ }}
489
+ >
490
+ Breadcrumbs
491
+ </h3>
492
+ <BreadcrumbLabelsEditor
493
+ value={settings.breadcrumbLabels || ""}
494
+ onChange={(v) => update("breadcrumbLabels", v)}
495
+ />
496
+ <BreadcrumbRulesEditor
497
+ value={settings.breadcrumbRules || ""}
498
+ onChange={(v) => update("breadcrumbRules", v)}
499
+ />
500
+ </div>
501
+
502
+ <button
503
+ onClick={handleSave}
504
+ disabled={saving}
505
+ style={{
506
+ padding: "0.5rem 1.5rem",
507
+ borderRadius: 6,
508
+ background: "#4a1525",
509
+ color: "white",
510
+ border: "none",
511
+ cursor: saving ? "wait" : "pointer",
512
+ fontWeight: 500,
513
+ }}
514
+ >
515
+ {saving ? "Saving..." : "Save Settings"}
516
+ </button>
517
+ {saved && (
518
+ <span style={{ marginLeft: 12, color: "#16a34a", fontSize: "0.875rem" }}>
519
+ Settings saved!
520
+ </span>
521
+ )}
522
+ </div>
523
+ );
524
+ }
525
+
526
+ export const pages = {
527
+ "/settings": SettingsPage,
528
+ "/fuzzy-redirects": FuzzyRedirectsPage,
529
+ };
@@ -0,0 +1,46 @@
1
+ import type { PublicPageContext } from "dineway";
2
+
3
+ const NOINDEX_PATHS = new Set(["/search"]);
4
+ const MULTI_SLASH_RE = /\/+/g;
5
+ const TRAILING_SLASH_RE = /\/$/;
6
+
7
+ /**
8
+ * Generate canonical URL.
9
+ *
10
+ * - Every indexable page gets one
11
+ * - Omit on 404 and noindex pages
12
+ * - Absolute, clean, with trailing slash
13
+ * - Respect user override
14
+ * - Include pagination parameter
15
+ */
16
+ export function generateCanonical(page: PublicPageContext, siteUrl: string): string | null {
17
+ const path = page.path || "/";
18
+
19
+ // No canonical for 404 or noindex pages
20
+ if (path === "/404") return null;
21
+ if (page.seo?.robots?.includes("noindex")) return null;
22
+ if (NOINDEX_PATHS.has(path)) return null;
23
+
24
+ // User override
25
+ if (page.canonical) return page.canonical;
26
+
27
+ // Build from page URL
28
+ try {
29
+ const u = new URL(page.url, siteUrl);
30
+ let pathname = u.pathname.toLowerCase().replace(MULTI_SLASH_RE, "/");
31
+
32
+ // Ensure trailing slash
33
+ if (!pathname.endsWith("/")) pathname += "/";
34
+
35
+ // Build clean URL with only pagination param
36
+ const pageParam = u.searchParams.get("page");
37
+ let canonical = `${siteUrl.replace(TRAILING_SLASH_RE, "")}${pathname}`;
38
+ if (pageParam && Number(pageParam) > 1) {
39
+ canonical += `?page=${pageParam}`;
40
+ }
41
+
42
+ return canonical;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
@@ -0,0 +1,17 @@
1
+ import type { PublicPageContext } from "dineway";
2
+
3
+ import type { SeoSettings } from "./settings.js";
4
+
5
+ /**
6
+ * Determine the meta description for a page.
7
+ * No auto-generation. Use explicit values or omit.
8
+ *
9
+ * Priority:
10
+ * 1. Per-content SEO description
11
+ * 2. Page description (excerpt for posts)
12
+ * 3. Settings default description (homepage/archives)
13
+ * 4. null (omit)
14
+ */
15
+ export function generateDescription(page: PublicPageContext, settings: SeoSettings): string | null {
16
+ return page.seo?.ogDescription || page.description || settings.defaultDescription || null;
17
+ }
package/src/fuzzy.ts ADDED
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Fuzzy URL-path matching for redirect suggestions.
3
+ */
4
+
5
+ const SPLIT_RE = /[/\-_]+/g;
6
+ const SEG_SPLIT_RE = /[-_]+/g;
7
+ const MULTI_SLASH_RE = /\/+/g;
8
+
9
+ function normalize(path: string): string {
10
+ let normalized = path.toLowerCase().trim();
11
+ normalized = normalized.replace(MULTI_SLASH_RE, "/");
12
+ if (normalized.endsWith("/") && normalized.length > 1) normalized = normalized.slice(0, -1);
13
+ if (!normalized.startsWith("/")) normalized = "/" + normalized;
14
+ return normalized;
15
+ }
16
+
17
+ function tokenize(path: string): string[] {
18
+ return path.split(SPLIT_RE).filter(Boolean);
19
+ }
20
+
21
+ function sortedStrings(values: string[]): string[] {
22
+ const sorted: string[] = [];
23
+ for (const value of values) {
24
+ let index = 0;
25
+ while (index < sorted.length && sorted[index]! <= value) index++;
26
+ sorted.splice(index, 0, value);
27
+ }
28
+ return sorted;
29
+ }
30
+
31
+ function lastSegmentKey(path: string): string {
32
+ const segments = path.split("/").filter(Boolean);
33
+ const last = segments.at(-1) ?? "";
34
+ return sortedStrings(last.split(SEG_SPLIT_RE).filter(Boolean)).join("|");
35
+ }
36
+
37
+ /**
38
+ * Classic dynamic-programming Levenshtein distance.
39
+ */
40
+ export function levenshtein(a: string, b: string): number {
41
+ if (a === b) return 0;
42
+ if (!a.length) return b.length;
43
+ if (!b.length) return a.length;
44
+
45
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
46
+ let curr: number[] = [];
47
+ for (let j = 0; j <= b.length; j++) curr.push(0);
48
+
49
+ for (let i = 1; i <= a.length; i++) {
50
+ curr[0] = i;
51
+ for (let j = 1; j <= b.length; j++) {
52
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
53
+ curr[j] = Math.min(curr[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost);
54
+ }
55
+ [prev, curr] = [curr, prev];
56
+ }
57
+
58
+ return prev[b.length]!;
59
+ }
60
+
61
+ /**
62
+ * Score the similarity of two URL paths on [0, 1]. Higher is better.
63
+ */
64
+ export function scoreSlugMatch(target: string, candidate: string): number {
65
+ const targetPath = normalize(target);
66
+ const candidatePath = normalize(candidate);
67
+ if (targetPath === candidatePath) return 1;
68
+
69
+ const distance = levenshtein(targetPath, candidatePath);
70
+ const maxLen = Math.max(targetPath.length, candidatePath.length);
71
+ const levSim = maxLen === 0 ? 0 : 1 - distance / maxLen;
72
+
73
+ const targetTokens = new Set(tokenize(targetPath));
74
+ const candidateTokens = new Set(tokenize(candidatePath));
75
+ const intersection = new Set([...targetTokens].filter((token) => candidateTokens.has(token)));
76
+ const union = new Set([...targetTokens, ...candidateTokens]);
77
+ const jaccard = union.size === 0 ? 0 : intersection.size / union.size;
78
+
79
+ const targetKey = lastSegmentKey(targetPath);
80
+ const slugMatch = targetKey && targetKey === lastSegmentKey(candidatePath) ? 1 : 0;
81
+
82
+ return levSim * 0.5 + jaccard * 0.2 + slugMatch * 0.3;
83
+ }
84
+
85
+ export interface RankedMatch {
86
+ candidate: string;
87
+ score: number;
88
+ }
89
+
90
+ /**
91
+ * Rank candidate paths against a target by fuzzy similarity.
92
+ */
93
+ export function rankCandidates(
94
+ target: string,
95
+ candidates: string[],
96
+ opts: { limit?: number; minScore?: number } = {},
97
+ ): RankedMatch[] {
98
+ const limit = opts.limit ?? 3;
99
+ const minScore = opts.minScore ?? 0.5;
100
+
101
+ const scored: RankedMatch[] = [];
102
+ for (const candidate of candidates) {
103
+ const match = { candidate, score: scoreSlugMatch(target, candidate) };
104
+ if (match.score < minScore) continue;
105
+
106
+ let index = 0;
107
+ while (index < scored.length && scored[index]!.score >= match.score) index++;
108
+ scored.splice(index, 0, match);
109
+ }
110
+
111
+ return scored.slice(0, limit);
112
+ }