@xsynaptic/path-suggestions 0.1.0 → 0.2.1
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 +5 -3
- package/dist/element.d.mts +15 -9
- package/dist/element.mjs +8 -5
- package/dist/element.mjs.map +1 -1
- package/dist/index.d.mts +10 -12
- package/dist/index.mjs +6 -4
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @xsynaptic/path-suggestions
|
|
2
2
|
|
|
3
|
-
A helper utility for 404 pages. Scores the path a visitor asked for against the paths that actually exist, then lists the closest matches, or redirects outright when one scores high enough. It ships as two independent pieces: a scoring function with no DOM dependency, and a custom element that drives a 404 page. Either works without the other.
|
|
3
|
+
A helper utility for 404 pages. Scores the path a visitor asked for against the paths that actually exist, then lists the closest matches, or redirects outright when one scores high enough and nothing ties it. It ships as two independent pieces: a scoring function with no DOM dependency, and a custom element that drives a 404 page. Either works without the other.
|
|
4
4
|
|
|
5
5
|
_Note_: this package is ESM-only.
|
|
6
6
|
|
|
@@ -41,7 +41,9 @@ document.querySelector('path-suggestions').entries = [{ url: '/about/', title: '
|
|
|
41
41
|
| `redirect-threshold` | `0.92` | Score at or above which the best candidate triggers a redirect |
|
|
42
42
|
| `limit` | `5` | Most suggestions to render |
|
|
43
43
|
| `min-length` | `3` | Paths shorter than this carry too little signal to score |
|
|
44
|
-
| `link
|
|
44
|
+
| `link-*` | | Forwarded to each anchor with the prefix stripped |
|
|
45
|
+
|
|
46
|
+
`link-class` sets the class, `link-data-astro-history` sets `data-astro-history`, and so on for whatever a client-side router reads off a link. The `link-` prefix is reserved for this, and `link-href` is ignored.
|
|
45
47
|
|
|
46
48
|
### State and events
|
|
47
49
|
|
|
@@ -73,7 +75,7 @@ getPathSuggestions({ path: '/example', entries });
|
|
|
73
75
|
// { type: 'redirect', url: '/example-post/' }
|
|
74
76
|
```
|
|
75
77
|
|
|
76
|
-
Returns `undefined` when nothing clears the threshold, `{ type: 'redirect', url }` when the best candidate clears `redirectThreshold
|
|
78
|
+
Returns `undefined` when nothing clears the threshold, `{ type: 'redirect', url }` when the best candidate clears `redirectThreshold` and nothing ties it, and `{ type: 'list', items }` otherwise, where each item carries its `score`.
|
|
77
79
|
|
|
78
80
|
Trailing slashes are stripped before comparing, so a candidate matching the requested path never triggers a redirect, and neither does one differing only by a trailing slash. That last case is canonicalization, and belongs in a 301 at the host.
|
|
79
81
|
|
package/dist/element.d.mts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { PathEntry } from "./index.mjs";
|
|
2
|
-
|
|
3
|
-
declare const
|
|
4
|
-
declare const
|
|
5
|
-
|
|
6
|
-
interface PathSuggestionsDoneDetail {
|
|
2
|
+
export declare const pathSuggestionsElementName = "path-suggestions";
|
|
3
|
+
export declare const pathSuggestionsRedirectEvent = "path-suggestions:redirect";
|
|
4
|
+
export declare const pathSuggestionsDoneEvent = "path-suggestions:done";
|
|
5
|
+
export interface PathSuggestionsDoneDetail {
|
|
7
6
|
count: number;
|
|
8
7
|
}
|
|
9
|
-
interface PathSuggestionsRedirectDetail {
|
|
8
|
+
export interface PathSuggestionsRedirectDetail {
|
|
10
9
|
url: string;
|
|
11
10
|
}
|
|
12
|
-
declare class PathSuggestionsElement extends HTMLElement {
|
|
11
|
+
export declare class PathSuggestionsElement extends HTMLElement {
|
|
13
12
|
#private;
|
|
14
13
|
get entries(): Array<PathEntry> | undefined;
|
|
15
14
|
set entries(value: Array<PathEntry> | undefined);
|
|
@@ -18,16 +17,23 @@ declare class PathSuggestionsElement extends HTMLElement {
|
|
|
18
17
|
run(): Promise<void>;
|
|
19
18
|
private fetchEntries;
|
|
20
19
|
private finish;
|
|
20
|
+
private getLinkAttributes;
|
|
21
21
|
private getList;
|
|
22
22
|
private readNumber;
|
|
23
23
|
private redirect;
|
|
24
24
|
private render;
|
|
25
25
|
}
|
|
26
26
|
declare global {
|
|
27
|
+
interface DocumentEventMap {
|
|
28
|
+
'path-suggestions:done': CustomEvent<PathSuggestionsDoneDetail>;
|
|
29
|
+
'path-suggestions:redirect': CustomEvent<PathSuggestionsRedirectDetail>;
|
|
30
|
+
}
|
|
31
|
+
interface HTMLElementEventMap {
|
|
32
|
+
'path-suggestions:done': CustomEvent<PathSuggestionsDoneDetail>;
|
|
33
|
+
'path-suggestions:redirect': CustomEvent<PathSuggestionsRedirectDetail>;
|
|
34
|
+
}
|
|
27
35
|
interface HTMLElementTagNameMap {
|
|
28
36
|
'path-suggestions': PathSuggestionsElement;
|
|
29
37
|
}
|
|
30
38
|
}
|
|
31
|
-
//#endregion
|
|
32
|
-
export { PathSuggestionsDoneDetail, PathSuggestionsElement, PathSuggestionsRedirectDetail, pathSuggestionsDoneEvent, pathSuggestionsElementName, pathSuggestionsRedirectEvent };
|
|
33
39
|
//# sourceMappingURL=element.d.mts.map
|
package/dist/element.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { getPathSuggestions, pathSuggestionDefaults } from "./index.mjs";
|
|
2
|
-
|
|
2
|
+
// src/element.ts
|
|
3
3
|
const pathSuggestionsElementName = "path-suggestions";
|
|
4
4
|
const pathSuggestionsRedirectEvent = "path-suggestions:redirect";
|
|
5
5
|
const pathSuggestionsDoneEvent = "path-suggestions:done";
|
|
6
6
|
const defaultMinLength = 3;
|
|
7
7
|
const listAttribute = "data-path-suggestions-list";
|
|
8
|
+
const linkAttributePrefix = "link-";
|
|
8
9
|
var PathSuggestionsElement = class extends HTMLElement {
|
|
9
10
|
get entries() {
|
|
10
11
|
return this.#entries;
|
|
@@ -22,7 +23,7 @@ var PathSuggestionsElement = class extends HTMLElement {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
connectedCallback() {
|
|
25
|
-
this.run();
|
|
26
|
+
queueMicrotask(() => void this.run());
|
|
26
27
|
}
|
|
27
28
|
async run() {
|
|
28
29
|
this.dataset.state = "loading";
|
|
@@ -73,6 +74,9 @@ var PathSuggestionsElement = class extends HTMLElement {
|
|
|
73
74
|
detail: { count }
|
|
74
75
|
}));
|
|
75
76
|
}
|
|
77
|
+
getLinkAttributes() {
|
|
78
|
+
return [...this.attributes].filter((attribute) => attribute.name.startsWith(linkAttributePrefix)).map((attribute) => [attribute.name.slice(5), attribute.value]).filter(([name]) => name !== "href");
|
|
79
|
+
}
|
|
76
80
|
getList() {
|
|
77
81
|
const existing = this.querySelector(`ul[${CSS.escape(listAttribute)}]`);
|
|
78
82
|
if (existing) return existing;
|
|
@@ -99,21 +103,20 @@ var PathSuggestionsElement = class extends HTMLElement {
|
|
|
99
103
|
}
|
|
100
104
|
render(items) {
|
|
101
105
|
const list = this.getList();
|
|
102
|
-
const
|
|
106
|
+
const linkAttributes = this.getLinkAttributes();
|
|
103
107
|
list.replaceChildren();
|
|
104
108
|
for (const item of items) {
|
|
105
109
|
const listItem = document.createElement("li");
|
|
106
110
|
const link = document.createElement("a");
|
|
107
111
|
link.href = item.url;
|
|
108
112
|
link.textContent = item.title;
|
|
109
|
-
|
|
113
|
+
for (const [name, value] of linkAttributes) link.setAttribute(name, value);
|
|
110
114
|
listItem.append(link);
|
|
111
115
|
list.append(listItem);
|
|
112
116
|
}
|
|
113
117
|
}
|
|
114
118
|
};
|
|
115
119
|
if (!customElements.get("path-suggestions")) customElements.define(pathSuggestionsElementName, PathSuggestionsElement);
|
|
116
|
-
//#endregion
|
|
117
120
|
export { PathSuggestionsElement, pathSuggestionsDoneEvent, pathSuggestionsElementName, pathSuggestionsRedirectEvent };
|
|
118
121
|
|
|
119
122
|
//# sourceMappingURL=element.mjs.map
|
package/dist/element.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"element.mjs","names":[
|
|
1
|
+
{"version":3,"file":"element.mjs","names":[],"sources":["../src/element.ts"],"sourcesContent":["import type { PathEntry, PathSuggestion } from './index.js';\n\nimport { getPathSuggestions, pathSuggestionDefaults } from './index.js';\n\nexport const pathSuggestionsElementName = 'path-suggestions';\n\nexport const pathSuggestionsRedirectEvent = 'path-suggestions:redirect';\nexport const pathSuggestionsDoneEvent = 'path-suggestions:done';\n\nexport interface PathSuggestionsDoneDetail {\n\tcount: number;\n}\n\nexport interface PathSuggestionsRedirectDetail {\n\turl: string;\n}\n\ntype PathSuggestionsState = 'empty' | 'list' | 'redirect';\n\n// A path this short carries too little signal to score against anything\nconst defaultMinLength = 3;\n\n// Marks the list this element owns, so host-authored children survive a re-render\nconst listAttribute = 'data-path-suggestions-list';\n\n// The element takes no option of its own under this prefix\nconst linkAttributePrefix = 'link-';\n\nexport class PathSuggestionsElement extends HTMLElement {\n\t// Score against an inlined list instead of fetching one; scoring reruns when set after connection\n\tget entries(): Array<PathEntry> | undefined {\n\t\treturn this.#entries;\n\t}\n\n\tset entries(value: Array<PathEntry> | undefined) {\n\t\tthis.#entries = value;\n\n\t\tif (this.isConnected) void this.run();\n\t}\n\n\t#entries: Array<PathEntry> | undefined;\n\n\tconstructor() {\n\t\tsuper();\n\n\t\t// A value assigned before this element upgraded sits in an own property, shadowing the accessor\n\t\tif (Object.hasOwn(this, 'entries')) {\n\t\t\tthis.#entries = this.entries;\n\t\t\tReflect.deleteProperty(this, 'entries');\n\t\t}\n\t}\n\n\tconnectedCallback(): void {\n\t\t// Without this a redirect can dispatch before a host script has attached its listeners\n\t\tqueueMicrotask(() => void this.run());\n\t}\n\n\tasync run(): Promise<void> {\n\t\tthis.dataset.state = 'loading';\n\n\t\tconst path = globalThis.location.pathname;\n\n\t\tif (path.length < this.readNumber('min-length', defaultMinLength)) {\n\t\t\tthis.finish(0, 'empty');\n\t\t\treturn;\n\t\t}\n\n\t\tconst entries = this.entries ?? (await this.fetchEntries());\n\n\t\tif (!entries) {\n\t\t\tthis.finish(0, 'empty');\n\t\t\treturn;\n\t\t}\n\n\t\tconst result = getPathSuggestions({\n\t\t\tentries,\n\t\t\tlimit: this.readNumber('limit', pathSuggestionDefaults.limit),\n\t\t\tpath,\n\t\t\tredirectThreshold: this.readNumber(\n\t\t\t\t'redirect-threshold',\n\t\t\t\tpathSuggestionDefaults.redirectThreshold,\n\t\t\t),\n\t\t\tthreshold: this.readNumber('threshold', pathSuggestionDefaults.threshold),\n\t\t});\n\n\t\tif (!result) {\n\t\t\tthis.finish(0, 'empty');\n\t\t\treturn;\n\t\t}\n\n\t\tif (result.type === 'redirect') {\n\t\t\tthis.redirect(result.url);\n\t\t\treturn;\n\t\t}\n\n\t\tthis.render(result.items);\n\t\tthis.finish(result.items.length, 'list');\n\t}\n\n\tprivate async fetchEntries(): Promise<Array<PathEntry> | undefined> {\n\t\tconst source = this.getAttribute('src');\n\n\t\tif (!source) return;\n\n\t\ttry {\n\t\t\tconst response = await fetch(source);\n\n\t\t\tif (!response.ok) return;\n\n\t\t\tconst entries: unknown = await response.json();\n\n\t\t\treturn Array.isArray(entries) ? (entries as Array<PathEntry>) : undefined;\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t}\n\n\tprivate finish(count: number, state: PathSuggestionsState): void {\n\t\tthis.dataset.state = state;\n\t\tthis.dispatchEvent(\n\t\t\tnew CustomEvent<PathSuggestionsDoneDetail>(pathSuggestionsDoneEvent, {\n\t\t\t\tbubbles: true,\n\t\t\t\tdetail: { count },\n\t\t\t}),\n\t\t);\n\t}\n\n\tprivate getLinkAttributes(): Array<[string, string]> {\n\t\treturn [...this.attributes]\n\t\t\t.filter((attribute) => attribute.name.startsWith(linkAttributePrefix))\n\t\t\t.map((attribute): [string, string] => [\n\t\t\t\tattribute.name.slice(linkAttributePrefix.length),\n\t\t\t\tattribute.value,\n\t\t\t])\n\t\t\t.filter(([name]) => name !== 'href');\n\t}\n\n\tprivate getList(): HTMLUListElement {\n\t\tconst existing = this.querySelector<HTMLUListElement>(`ul[${CSS.escape(listAttribute)}]`);\n\n\t\tif (existing) return existing;\n\n\t\tconst list = document.createElement('ul');\n\n\t\tlist.setAttribute(listAttribute, '');\n\t\tthis.append(list);\n\n\t\treturn list;\n\t}\n\n\tprivate readNumber(attribute: string, fallback: number): number {\n\t\tconst raw = this.getAttribute(attribute);\n\n\t\tif (raw === null) return fallback;\n\n\t\tconst parsed = Number(raw);\n\n\t\treturn Number.isFinite(parsed) ? parsed : fallback;\n\t}\n\n\t// Cancel the event to navigate some other way, such as an SPA router\n\tprivate redirect(url: string): void {\n\t\tconst event = new CustomEvent<PathSuggestionsRedirectDetail>(pathSuggestionsRedirectEvent, {\n\t\t\tbubbles: true,\n\t\t\tcancelable: true,\n\t\t\tdetail: { url },\n\t\t});\n\n\t\tconst isProceed = this.dispatchEvent(event);\n\n\t\tthis.finish(1, 'redirect');\n\n\t\tif (isProceed) globalThis.location.replace(url);\n\t}\n\n\tprivate render(items: Array<PathSuggestion>): void {\n\t\tconst list = this.getList();\n\t\tconst linkAttributes = this.getLinkAttributes();\n\n\t\tlist.replaceChildren();\n\n\t\tfor (const item of items) {\n\t\t\tconst listItem = document.createElement('li');\n\t\t\tconst link = document.createElement('a');\n\n\t\t\tlink.href = item.url;\n\t\t\tlink.textContent = item.title;\n\n\t\t\tfor (const [name, value] of linkAttributes) link.setAttribute(name, value);\n\n\t\t\tlistItem.append(link);\n\t\t\tlist.append(listItem);\n\t\t}\n\t}\n}\n\ndeclare global {\n\tinterface DocumentEventMap {\n\t\t'path-suggestions:done': CustomEvent<PathSuggestionsDoneDetail>;\n\t\t'path-suggestions:redirect': CustomEvent<PathSuggestionsRedirectDetail>;\n\t}\n\n\tinterface HTMLElementEventMap {\n\t\t'path-suggestions:done': CustomEvent<PathSuggestionsDoneDetail>;\n\t\t'path-suggestions:redirect': CustomEvent<PathSuggestionsRedirectDetail>;\n\t}\n\n\tinterface HTMLElementTagNameMap {\n\t\t'path-suggestions': PathSuggestionsElement;\n\t}\n}\n\nif (!customElements.get(pathSuggestionsElementName)) {\n\tcustomElements.define(pathSuggestionsElementName, PathSuggestionsElement);\n}\n"],"mappings":"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"}
|
package/dist/index.d.mts
CHANGED
|
@@ -1,26 +1,25 @@
|
|
|
1
|
-
|
|
2
|
-
interface PathEntry {
|
|
1
|
+
export interface PathEntry {
|
|
3
2
|
title: string;
|
|
4
3
|
url: string;
|
|
5
4
|
}
|
|
6
|
-
interface PathSuggestion extends PathEntry {
|
|
5
|
+
export interface PathSuggestion extends PathEntry {
|
|
7
6
|
score: number;
|
|
8
7
|
}
|
|
9
|
-
interface PathSuggestionsOptions {
|
|
8
|
+
export interface PathSuggestionsOptions {
|
|
10
9
|
entries: Array<PathEntry>;
|
|
11
10
|
limit?: number;
|
|
12
11
|
path: string;
|
|
13
12
|
redirectThreshold?: number;
|
|
14
13
|
threshold?: number;
|
|
15
14
|
}
|
|
16
|
-
type PathSuggestionsResult = {
|
|
15
|
+
export type PathSuggestionsResult = {
|
|
17
16
|
items: Array<PathSuggestion>;
|
|
18
17
|
type: 'list';
|
|
19
18
|
} | {
|
|
20
19
|
type: 'redirect';
|
|
21
20
|
url: string;
|
|
22
21
|
};
|
|
23
|
-
declare const pathSuggestionDefaults: {
|
|
22
|
+
export declare const pathSuggestionDefaults: {
|
|
24
23
|
readonly limit: 5;
|
|
25
24
|
readonly redirectThreshold: 0.92;
|
|
26
25
|
readonly threshold: 0.5;
|
|
@@ -30,15 +29,14 @@ declare const pathSuggestionDefaults: {
|
|
|
30
29
|
*
|
|
31
30
|
* Edit distance relative to the longer string, plus a bonus when one contains the other
|
|
32
31
|
*/
|
|
33
|
-
declare function getPathSimilarity(a: string, b: string): number;
|
|
32
|
+
export declare function getPathSimilarity(a: string, b: string): number;
|
|
34
33
|
/**
|
|
35
34
|
* Rank known paths against the one requested
|
|
36
35
|
*
|
|
37
36
|
* Returns `undefined` when nothing clears the threshold, a redirect when the best candidate clears
|
|
38
|
-
* `redirectThreshold
|
|
37
|
+
* `redirectThreshold`, outscores every other path, and is not the path already requested, and a
|
|
38
|
+
* list otherwise
|
|
39
39
|
*/
|
|
40
|
-
declare function getPathSuggestions(options: PathSuggestionsOptions): PathSuggestionsResult | undefined;
|
|
41
|
-
declare function normalizePath(path: string): string;
|
|
42
|
-
//#endregion
|
|
43
|
-
export { PathEntry, PathSuggestion, PathSuggestionsOptions, PathSuggestionsResult, getPathSimilarity, getPathSuggestions, normalizePath, pathSuggestionDefaults };
|
|
40
|
+
export declare function getPathSuggestions(options: PathSuggestionsOptions): PathSuggestionsResult | undefined;
|
|
41
|
+
export declare function normalizePath(path: string): string;
|
|
44
42
|
//# sourceMappingURL=index.d.mts.map
|
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { distance } from "fastest-levenshtein";
|
|
2
|
-
|
|
2
|
+
// src/index.ts
|
|
3
3
|
const pathSuggestionDefaults = {
|
|
4
4
|
limit: 5,
|
|
5
5
|
redirectThreshold: .92,
|
|
@@ -22,7 +22,8 @@ function getPathSimilarity(a, b) {
|
|
|
22
22
|
* Rank known paths against the one requested
|
|
23
23
|
*
|
|
24
24
|
* Returns `undefined` when nothing clears the threshold, a redirect when the best candidate clears
|
|
25
|
-
* `redirectThreshold
|
|
25
|
+
* `redirectThreshold`, outscores every other path, and is not the path already requested, and a
|
|
26
|
+
* list otherwise
|
|
26
27
|
*/
|
|
27
28
|
function getPathSuggestions(options) {
|
|
28
29
|
const limit = options.limit ?? pathSuggestionDefaults.limit;
|
|
@@ -35,7 +36,9 @@ function getPathSuggestions(options) {
|
|
|
35
36
|
})).sort((suggestionA, suggestionB) => suggestionB.score - suggestionA.score);
|
|
36
37
|
const best = scored.at(0);
|
|
37
38
|
if (!best || best.score < threshold) return;
|
|
38
|
-
|
|
39
|
+
const bestPath = normalizePath(best.url);
|
|
40
|
+
const runnerUp = scored.find((suggestion) => normalizePath(suggestion.url) !== bestPath);
|
|
41
|
+
if ((runnerUp === void 0 || runnerUp.score < best.score) && bestPath !== current && best.score >= redirectThreshold) return {
|
|
39
42
|
type: "redirect",
|
|
40
43
|
url: best.url
|
|
41
44
|
};
|
|
@@ -47,7 +50,6 @@ function getPathSuggestions(options) {
|
|
|
47
50
|
function normalizePath(path) {
|
|
48
51
|
return path.replace(/\/+$/, "") || "/";
|
|
49
52
|
}
|
|
50
|
-
//#endregion
|
|
51
53
|
export { getPathSimilarity, getPathSuggestions, normalizePath, pathSuggestionDefaults };
|
|
52
54
|
|
|
53
55
|
//# sourceMappingURL=index.mjs.map
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { distance } from 'fastest-levenshtein';\n\nexport interface PathEntry {\n\ttitle: string;\n\turl: string;\n}\n\nexport interface PathSuggestion extends PathEntry {\n\tscore: number;\n}\n\nexport interface PathSuggestionsOptions {\n\tentries: Array<PathEntry>;\n\tlimit?: number;\n\t// The path the visitor asked for, normally location.pathname\n\tpath: string;\n\t// Score at or above which the best candidate is redirected to instead of listed\n\tredirectThreshold?: number;\n\t// Score below which a candidate is discarded\n\tthreshold?: number;\n}\n\nexport type PathSuggestionsResult =\n\t{ items: Array<PathSuggestion>; type: 'list' } | { type: 'redirect'; url: string };\n\n// Shared with the custom element, which reads its own attribute fallbacks from here\nexport const pathSuggestionDefaults = {\n\tlimit: 5,\n\tredirectThreshold: 0.92,\n\tthreshold: 0.5,\n} as const;\n\n// Lifts a truncated path (\"/sanwan-yue\" for \"/sanwan-yuemei-suspension-bridge\") above length-similar candidates\nconst substringBonus = 0.3;\n\n/**\n * Score two strings from 0 (nothing in common) to 1 (identical)\n *\n * Edit distance relative to the longer string, plus a bonus when one contains the other\n */\nexport function getPathSimilarity(a: string, b: string): number {\n\tconst longest = Math.max(a.length, b.length);\n\n\tif (longest === 0) return 1;\n\n\tconst base = 1 - distance(a, b) / longest;\n\tconst bonus = a.includes(b) || b.includes(a) ? substringBonus : 0;\n\n\treturn Math.min(1, base + bonus);\n}\n\n/**\n * Rank known paths against the one requested\n *\n * Returns `undefined` when nothing clears the threshold, a redirect when the best candidate clears\n * `redirectThreshold
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import { distance } from 'fastest-levenshtein';\n\nexport interface PathEntry {\n\ttitle: string;\n\turl: string;\n}\n\nexport interface PathSuggestion extends PathEntry {\n\tscore: number;\n}\n\nexport interface PathSuggestionsOptions {\n\tentries: Array<PathEntry>;\n\tlimit?: number;\n\t// The path the visitor asked for, normally location.pathname\n\tpath: string;\n\t// Score at or above which the best candidate is redirected to instead of listed\n\tredirectThreshold?: number;\n\t// Score below which a candidate is discarded\n\tthreshold?: number;\n}\n\nexport type PathSuggestionsResult =\n\t{ items: Array<PathSuggestion>; type: 'list' } | { type: 'redirect'; url: string };\n\n// Shared with the custom element, which reads its own attribute fallbacks from here\nexport const pathSuggestionDefaults = {\n\tlimit: 5,\n\tredirectThreshold: 0.92,\n\tthreshold: 0.5,\n} as const;\n\n// Lifts a truncated path (\"/sanwan-yue\" for \"/sanwan-yuemei-suspension-bridge\") above length-similar candidates\nconst substringBonus = 0.3;\n\n/**\n * Score two strings from 0 (nothing in common) to 1 (identical)\n *\n * Edit distance relative to the longer string, plus a bonus when one contains the other\n */\nexport function getPathSimilarity(a: string, b: string): number {\n\tconst longest = Math.max(a.length, b.length);\n\n\tif (longest === 0) return 1;\n\n\tconst base = 1 - distance(a, b) / longest;\n\tconst bonus = a.includes(b) || b.includes(a) ? substringBonus : 0;\n\n\treturn Math.min(1, base + bonus);\n}\n\n/**\n * Rank known paths against the one requested\n *\n * Returns `undefined` when nothing clears the threshold, a redirect when the best candidate clears\n * `redirectThreshold`, outscores every other path, and is not the path already requested, and a\n * list otherwise\n */\nexport function getPathSuggestions(\n\toptions: PathSuggestionsOptions,\n): PathSuggestionsResult | undefined {\n\tconst limit = options.limit ?? pathSuggestionDefaults.limit;\n\tconst redirectThreshold = options.redirectThreshold ?? pathSuggestionDefaults.redirectThreshold;\n\tconst threshold = options.threshold ?? pathSuggestionDefaults.threshold;\n\n\tconst current = normalizePath(options.path);\n\n\tconst scored = options.entries\n\t\t.map((entry) => ({ ...entry, score: getPathSimilarity(current, normalizePath(entry.url)) }))\n\t\t.sort((suggestionA, suggestionB) => suggestionB.score - suggestionA.score);\n\n\tconst best = scored.at(0);\n\n\tif (!best || best.score < threshold) return;\n\n\tconst bestPath = normalizePath(best.url);\n\n\tconst runnerUp = scored.find((suggestion) => normalizePath(suggestion.url) !== bestPath);\n\tconst isClearWinner = runnerUp === undefined || runnerUp.score < best.score;\n\n\tif (isClearWinner && bestPath !== current && best.score >= redirectThreshold) {\n\t\treturn { type: 'redirect', url: best.url };\n\t}\n\n\treturn {\n\t\titems: scored.filter((suggestion) => suggestion.score >= threshold).slice(0, limit),\n\t\ttype: 'list',\n\t};\n}\n\nexport function normalizePath(path: string): string {\n\treturn path.replace(/\\/+$/, '') || '/';\n}\n"],"mappings":";;AA0BA,MAAa,yBAAyB;CACrC,OAAO;CACP,mBAAmB;CACnB,WAAW;AACZ;AAGA,MAAM,iBAAiB;;;;;;AAOvB,SAAgB,kBAAkB,GAAW,GAAmB;CAC/D,MAAM,UAAU,KAAK,IAAI,EAAE,QAAQ,EAAE,MAAM;CAE3C,IAAI,YAAY,GAAG,OAAO;CAE1B,MAAM,OAAO,IAAI,SAAS,GAAG,CAAC,IAAI;CAClC,MAAM,QAAQ,EAAE,SAAS,CAAC,KAAK,EAAE,SAAS,CAAC,IAAI,iBAAiB;CAEhE,OAAO,KAAK,IAAI,GAAG,OAAO,KAAK;AAChC;;;;;;;;AASA,SAAgB,mBACf,SACoC;CACpC,MAAM,QAAQ,QAAQ,SAAS,uBAAuB;CACtD,MAAM,oBAAoB,QAAQ,qBAAqB,uBAAuB;CAC9E,MAAM,YAAY,QAAQ,aAAa,uBAAuB;CAE9D,MAAM,UAAU,cAAc,QAAQ,IAAI;CAE1C,MAAM,SAAS,QAAQ,QACrB,KAAK,WAAW;EAAE,GAAG;EAAO,OAAO,kBAAkB,SAAS,cAAc,MAAM,GAAG,CAAC;CAAE,EAAE,CAAC,CAC3F,MAAM,aAAa,gBAAgB,YAAY,QAAQ,YAAY,KAAK;CAE1E,MAAM,OAAO,OAAO,GAAG,CAAC;CAExB,IAAI,CAAC,QAAQ,KAAK,QAAQ,WAAW;CAErC,MAAM,WAAW,cAAc,KAAK,GAAG;CAEvC,MAAM,WAAW,OAAO,MAAM,eAAe,cAAc,WAAW,GAAG,MAAM,QAAQ;CAGvF,KAFsB,aAAa,KAAA,KAAa,SAAS,QAAQ,KAAK,UAEjD,aAAa,WAAW,KAAK,SAAS,mBAC1D,OAAO;EAAE,MAAM;EAAY,KAAK,KAAK;CAAI;CAG1C,OAAO;EACN,OAAO,OAAO,QAAQ,eAAe,WAAW,SAAS,SAAS,CAAC,CAAC,MAAM,GAAG,KAAK;EAClF,MAAM;CACP;AACD;AAEA,SAAgB,cAAc,MAAsB;CACnD,OAAO,KAAK,QAAQ,QAAQ,EAAE,KAAK;AACpC"}
|
package/package.json
CHANGED