@xsynaptic/path-suggestions 0.1.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/LICENSE ADDED
@@ -0,0 +1,48 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Synaptic
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This software is derived in part from alfaaz (https://github.com/thecodrr/alfaaz),
26
+ which is distributed under the MIT License:
27
+
28
+ MIT License
29
+
30
+ Copyright (c) 2023 Abdullah Atta
31
+
32
+ Permission is hereby granted, free of charge, to any person obtaining a copy
33
+ of this software and associated documentation files (the "Software"), to deal
34
+ in the Software without restriction, including without limitation the rights
35
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
36
+ copies of the Software, and to permit persons to whom the Software is
37
+ furnished to do so, subject to the following conditions:
38
+
39
+ The above copyright notice and this permission notice shall be included in all
40
+ copies or substantial portions of the Software.
41
+
42
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
43
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
44
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
45
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
46
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
47
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
48
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # @xsynaptic/path-suggestions
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.
4
+
5
+ _Note_: this package is ESM-only.
6
+
7
+ ## Install
8
+
9
+ ```sh
10
+ npm install @xsynaptic/path-suggestions
11
+ ```
12
+
13
+ ## Use the element
14
+
15
+ Publish a JSON file listing the paths worth suggesting, as an array of `{ url, title }`, then point the element at it:
16
+
17
+ ```html
18
+ <path-suggestions src="/content-manifest.json" link-class="anchor">
19
+ <p>Did you mean one of these?</p>
20
+ </path-suggestions>
21
+
22
+ <script type="module">
23
+ import '@xsynaptic/path-suggestions/element';
24
+ </script>
25
+ ```
26
+
27
+ The element appends its own `<ul>` and leaves any other children in place, so the heading above survives. Everything renders in the light DOM, so ordinary stylesheet rules apply to the list.
28
+
29
+ Set `.entries` to score against an inlined list and skip the fetch. A value set before the element upgrades survives the upgrade, and a value set afterwards scores again, so it does not matter which script runs first:
30
+
31
+ ```js
32
+ document.querySelector('path-suggestions').entries = [{ url: '/about/', title: 'About' }];
33
+ ```
34
+
35
+ ### Attributes
36
+
37
+ | Attribute | Default | Meaning |
38
+ | -------------------- | ------- | -------------------------------------------------------------- |
39
+ | `src` | | URL of the JSON manifest; omit when setting `.entries` |
40
+ | `threshold` | `0.5` | Score below which a candidate is discarded |
41
+ | `redirect-threshold` | `0.92` | Score at or above which the best candidate triggers a redirect |
42
+ | `limit` | `5` | Most suggestions to render |
43
+ | `min-length` | `3` | Paths shorter than this carry too little signal to score |
44
+ | `link-class` | | Class applied to each anchor |
45
+
46
+ ### State and events
47
+
48
+ The element sets `data-state` to `loading`, then to `list`, `empty`, or `redirect`, which is enough to drive a loading indicator or hide a heading:
49
+
50
+ ```css
51
+ path-suggestions:not([data-state='list']) p {
52
+ display: none;
53
+ }
54
+ ```
55
+
56
+ Both events bubble. `path-suggestions:redirect` fires with `detail.url` before the element navigates, and is cancelable; cancel it to navigate through a client-side router instead. `path-suggestions:done` fires once the element has settled, with `detail.count`.
57
+
58
+ ```js
59
+ document.addEventListener('path-suggestions:redirect', (event) => {
60
+ event.preventDefault();
61
+ router.replace(event.detail.url);
62
+ });
63
+ ```
64
+
65
+ ## Use the scoring function
66
+
67
+ The scoring function has no DOM dependency, so it also runs in a build script or a link checker:
68
+
69
+ ```ts
70
+ import { getPathSuggestions } from '@xsynaptic/path-suggestions';
71
+
72
+ getPathSuggestions({ path: '/example', entries });
73
+ // { type: 'redirect', url: '/example-post/' }
74
+ ```
75
+
76
+ Returns `undefined` when nothing clears the threshold, `{ type: 'redirect', url }` when the best candidate clears `redirectThreshold`, and `{ type: 'list', items }` otherwise, where each item carries its `score`.
77
+
78
+ 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
+
80
+ `getPathSimilarity(a, b)` is the underlying score, from 0 to 1. It takes any two strings, so it has uses beyond 404 pages: naming the nearest valid ID when a content reference fails to resolve, for one.
81
+
82
+ ## How the score works
83
+
84
+ Edit distance, via [fastest-levenshtein][], measured against the longer of the two strings, so `1` is identical. A bonus applies when either string contains the other, which lifts a truncated path above candidates that merely run to a similar length. Truncation is the common failure: a half-typed slug, or a link that lost its tail. That bonus is clamped, so a path that is a strict prefix of a real one scores exactly `1`.
85
+
86
+ ## License
87
+
88
+ [MIT][mit-license]
89
+
90
+ [fastest-levenshtein]: https://github.com/ka-weihe/fastest-levenshtein
91
+ [mit-license]: https://opensource.org/licenses/MIT
@@ -0,0 +1,33 @@
1
+ import { PathEntry } from "./index.mjs";
2
+ //#region src/element.d.ts
3
+ declare const pathSuggestionsElementName = "path-suggestions";
4
+ declare const pathSuggestionsRedirectEvent = "path-suggestions:redirect";
5
+ declare const pathSuggestionsDoneEvent = "path-suggestions:done";
6
+ interface PathSuggestionsDoneDetail {
7
+ count: number;
8
+ }
9
+ interface PathSuggestionsRedirectDetail {
10
+ url: string;
11
+ }
12
+ declare class PathSuggestionsElement extends HTMLElement {
13
+ #private;
14
+ get entries(): Array<PathEntry> | undefined;
15
+ set entries(value: Array<PathEntry> | undefined);
16
+ constructor();
17
+ connectedCallback(): void;
18
+ run(): Promise<void>;
19
+ private fetchEntries;
20
+ private finish;
21
+ private getList;
22
+ private readNumber;
23
+ private redirect;
24
+ private render;
25
+ }
26
+ declare global {
27
+ interface HTMLElementTagNameMap {
28
+ 'path-suggestions': PathSuggestionsElement;
29
+ }
30
+ }
31
+ //#endregion
32
+ export { PathSuggestionsDoneDetail, PathSuggestionsElement, PathSuggestionsRedirectDetail, pathSuggestionsDoneEvent, pathSuggestionsElementName, pathSuggestionsRedirectEvent };
33
+ //# sourceMappingURL=element.d.mts.map
@@ -0,0 +1,119 @@
1
+ import { getPathSuggestions, pathSuggestionDefaults } from "./index.mjs";
2
+ //#region src/element.ts
3
+ const pathSuggestionsElementName = "path-suggestions";
4
+ const pathSuggestionsRedirectEvent = "path-suggestions:redirect";
5
+ const pathSuggestionsDoneEvent = "path-suggestions:done";
6
+ const defaultMinLength = 3;
7
+ const listAttribute = "data-path-suggestions-list";
8
+ var PathSuggestionsElement = class extends HTMLElement {
9
+ get entries() {
10
+ return this.#entries;
11
+ }
12
+ set entries(value) {
13
+ this.#entries = value;
14
+ if (this.isConnected) this.run();
15
+ }
16
+ #entries;
17
+ constructor() {
18
+ super();
19
+ if (Object.hasOwn(this, "entries")) {
20
+ this.#entries = this.entries;
21
+ Reflect.deleteProperty(this, "entries");
22
+ }
23
+ }
24
+ connectedCallback() {
25
+ this.run();
26
+ }
27
+ async run() {
28
+ this.dataset.state = "loading";
29
+ const path = globalThis.location.pathname;
30
+ if (path.length < this.readNumber("min-length", defaultMinLength)) {
31
+ this.finish(0, "empty");
32
+ return;
33
+ }
34
+ const entries = this.entries ?? await this.fetchEntries();
35
+ if (!entries) {
36
+ this.finish(0, "empty");
37
+ return;
38
+ }
39
+ const result = getPathSuggestions({
40
+ entries,
41
+ limit: this.readNumber("limit", pathSuggestionDefaults.limit),
42
+ path,
43
+ redirectThreshold: this.readNumber("redirect-threshold", pathSuggestionDefaults.redirectThreshold),
44
+ threshold: this.readNumber("threshold", pathSuggestionDefaults.threshold)
45
+ });
46
+ if (!result) {
47
+ this.finish(0, "empty");
48
+ return;
49
+ }
50
+ if (result.type === "redirect") {
51
+ this.redirect(result.url);
52
+ return;
53
+ }
54
+ this.render(result.items);
55
+ this.finish(result.items.length, "list");
56
+ }
57
+ async fetchEntries() {
58
+ const source = this.getAttribute("src");
59
+ if (!source) return;
60
+ try {
61
+ const response = await fetch(source);
62
+ if (!response.ok) return;
63
+ const entries = await response.json();
64
+ return Array.isArray(entries) ? entries : void 0;
65
+ } catch {
66
+ return;
67
+ }
68
+ }
69
+ finish(count, state) {
70
+ this.dataset.state = state;
71
+ this.dispatchEvent(new CustomEvent(pathSuggestionsDoneEvent, {
72
+ bubbles: true,
73
+ detail: { count }
74
+ }));
75
+ }
76
+ getList() {
77
+ const existing = this.querySelector(`ul[${CSS.escape(listAttribute)}]`);
78
+ if (existing) return existing;
79
+ const list = document.createElement("ul");
80
+ list.setAttribute(listAttribute, "");
81
+ this.append(list);
82
+ return list;
83
+ }
84
+ readNumber(attribute, fallback) {
85
+ const raw = this.getAttribute(attribute);
86
+ if (raw === null) return fallback;
87
+ const parsed = Number(raw);
88
+ return Number.isFinite(parsed) ? parsed : fallback;
89
+ }
90
+ redirect(url) {
91
+ const event = new CustomEvent(pathSuggestionsRedirectEvent, {
92
+ bubbles: true,
93
+ cancelable: true,
94
+ detail: { url }
95
+ });
96
+ const isProceed = this.dispatchEvent(event);
97
+ this.finish(1, "redirect");
98
+ if (isProceed) globalThis.location.replace(url);
99
+ }
100
+ render(items) {
101
+ const list = this.getList();
102
+ const linkClass = this.getAttribute("link-class");
103
+ list.replaceChildren();
104
+ for (const item of items) {
105
+ const listItem = document.createElement("li");
106
+ const link = document.createElement("a");
107
+ link.href = item.url;
108
+ link.textContent = item.title;
109
+ if (linkClass) link.className = linkClass;
110
+ listItem.append(link);
111
+ list.append(listItem);
112
+ }
113
+ }
114
+ };
115
+ if (!customElements.get("path-suggestions")) customElements.define(pathSuggestionsElementName, PathSuggestionsElement);
116
+ //#endregion
117
+ export { PathSuggestionsElement, pathSuggestionsDoneEvent, pathSuggestionsElementName, pathSuggestionsRedirectEvent };
118
+
119
+ //# sourceMappingURL=element.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"element.mjs","names":["#entries"],"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\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\tvoid 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 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 linkClass = this.getAttribute('link-class');\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\tif (linkClass) link.className = linkClass;\n\n\t\t\tlistItem.append(link);\n\t\t\tlist.append(listItem);\n\t\t}\n\t}\n}\n\ndeclare global {\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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"}
@@ -0,0 +1,44 @@
1
+ //#region src/index.d.ts
2
+ interface PathEntry {
3
+ title: string;
4
+ url: string;
5
+ }
6
+ interface PathSuggestion extends PathEntry {
7
+ score: number;
8
+ }
9
+ interface PathSuggestionsOptions {
10
+ entries: Array<PathEntry>;
11
+ limit?: number;
12
+ path: string;
13
+ redirectThreshold?: number;
14
+ threshold?: number;
15
+ }
16
+ type PathSuggestionsResult = {
17
+ items: Array<PathSuggestion>;
18
+ type: 'list';
19
+ } | {
20
+ type: 'redirect';
21
+ url: string;
22
+ };
23
+ declare const pathSuggestionDefaults: {
24
+ readonly limit: 5;
25
+ readonly redirectThreshold: 0.92;
26
+ readonly threshold: 0.5;
27
+ };
28
+ /**
29
+ * Score two strings from 0 (nothing in common) to 1 (identical)
30
+ *
31
+ * Edit distance relative to the longer string, plus a bonus when one contains the other
32
+ */
33
+ declare function getPathSimilarity(a: string, b: string): number;
34
+ /**
35
+ * Rank known paths against the one requested
36
+ *
37
+ * Returns `undefined` when nothing clears the threshold, a redirect when the best candidate clears
38
+ * `redirectThreshold` and is not the path already requested, and a list otherwise
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 };
44
+ //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs ADDED
@@ -0,0 +1,53 @@
1
+ import { distance } from "fastest-levenshtein";
2
+ //#region src/index.ts
3
+ const pathSuggestionDefaults = {
4
+ limit: 5,
5
+ redirectThreshold: .92,
6
+ threshold: .5
7
+ };
8
+ const substringBonus = .3;
9
+ /**
10
+ * Score two strings from 0 (nothing in common) to 1 (identical)
11
+ *
12
+ * Edit distance relative to the longer string, plus a bonus when one contains the other
13
+ */
14
+ function getPathSimilarity(a, b) {
15
+ const longest = Math.max(a.length, b.length);
16
+ if (longest === 0) return 1;
17
+ const base = 1 - distance(a, b) / longest;
18
+ const bonus = a.includes(b) || b.includes(a) ? substringBonus : 0;
19
+ return Math.min(1, base + bonus);
20
+ }
21
+ /**
22
+ * Rank known paths against the one requested
23
+ *
24
+ * Returns `undefined` when nothing clears the threshold, a redirect when the best candidate clears
25
+ * `redirectThreshold` and is not the path already requested, and a list otherwise
26
+ */
27
+ function getPathSuggestions(options) {
28
+ const limit = options.limit ?? pathSuggestionDefaults.limit;
29
+ const redirectThreshold = options.redirectThreshold ?? pathSuggestionDefaults.redirectThreshold;
30
+ const threshold = options.threshold ?? pathSuggestionDefaults.threshold;
31
+ const current = normalizePath(options.path);
32
+ const scored = options.entries.map((entry) => ({
33
+ ...entry,
34
+ score: getPathSimilarity(current, normalizePath(entry.url))
35
+ })).sort((suggestionA, suggestionB) => suggestionB.score - suggestionA.score);
36
+ const best = scored.at(0);
37
+ if (!best || best.score < threshold) return;
38
+ if (best.score >= redirectThreshold && normalizePath(best.url) !== current) return {
39
+ type: "redirect",
40
+ url: best.url
41
+ };
42
+ return {
43
+ items: scored.filter((suggestion) => suggestion.score >= threshold).slice(0, limit),
44
+ type: "list"
45
+ };
46
+ }
47
+ function normalizePath(path) {
48
+ return path.replace(/\/+$/, "") || "/";
49
+ }
50
+ //#endregion
51
+ export { getPathSimilarity, getPathSuggestions, normalizePath, pathSuggestionDefaults };
52
+
53
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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` and is not the path already requested, and a 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// eslint-disable-next-line unicorn/no-array-sort -- toSorted needs Firefox 115; map() above already returned a fresh array\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\tif (best.score >= redirectThreshold && normalizePath(best.url) !== current) {\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;;;;;;;AAQA,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,CAE3F,MAAM,aAAa,gBAAgB,YAAY,QAAQ,YAAY,KAAK;CAE1E,MAAM,OAAO,OAAO,GAAG,CAAC;CAExB,IAAI,CAAC,QAAQ,KAAK,QAAQ,WAAW;CAErC,IAAI,KAAK,SAAS,qBAAqB,cAAc,KAAK,GAAG,MAAM,SAClE,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 ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@xsynaptic/path-suggestions",
3
+ "version": "0.1.0",
4
+ "description": "A did-you-mean for 404 pages, as a DOM-free scoring function plus an optional custom element",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Alexander Synaptic <x@synapticism.com>",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/xsynaptic/astro-lab.git",
11
+ "directory": "packages/path-suggestions"
12
+ },
13
+ "homepage": "https://github.com/xsynaptic/astro-lab/tree/main/packages/path-suggestions#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/xsynaptic/astro-lab/issues"
16
+ },
17
+ "keywords": [
18
+ "404",
19
+ "custom-element",
20
+ "did-you-mean",
21
+ "levenshtein",
22
+ "not-found",
23
+ "suggestions"
24
+ ],
25
+ "main": "./dist/index.mjs",
26
+ "types": "./dist/index.d.mts",
27
+ "exports": {
28
+ ".": {
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs"
31
+ },
32
+ "./element": {
33
+ "types": "./dist/element.d.mts",
34
+ "import": "./dist/element.mjs"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist"
39
+ ],
40
+ "sideEffects": [
41
+ "./dist/element.mjs"
42
+ ],
43
+ "dependencies": {
44
+ "fastest-levenshtein": "^1.0.16"
45
+ },
46
+ "scripts": {
47
+ "build": "tsdown",
48
+ "dev": "tsdown --watch",
49
+ "check-types": "tsc --noEmit"
50
+ }
51
+ }