@xsynaptic/path-suggestions 0.2.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 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
 
@@ -75,7 +75,7 @@ getPathSuggestions({ path: '/example', entries });
75
75
  // { type: 'redirect', url: '/example-post/' }
76
76
  ```
77
77
 
78
- 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`.
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`.
79
79
 
80
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.
81
81
 
@@ -1,15 +1,14 @@
1
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 {
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);
@@ -37,6 +36,4 @@ declare global {
37
36
  'path-suggestions': PathSuggestionsElement;
38
37
  }
39
38
  }
40
- //#endregion
41
- export { PathSuggestionsDoneDetail, PathSuggestionsElement, PathSuggestionsRedirectDetail, pathSuggestionsDoneEvent, pathSuggestionsElementName, pathSuggestionsRedirectEvent };
42
39
  //# sourceMappingURL=element.d.mts.map
package/dist/element.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { getPathSuggestions, pathSuggestionDefaults } from "./index.mjs";
2
- //#region src/element.ts
2
+ // src/element.ts
3
3
  const pathSuggestionsElementName = "path-suggestions";
4
4
  const pathSuggestionsRedirectEvent = "path-suggestions:redirect";
5
5
  const pathSuggestionsDoneEvent = "path-suggestions:done";
@@ -117,7 +117,6 @@ var PathSuggestionsElement = class extends HTMLElement {
117
117
  }
118
118
  };
119
119
  if (!customElements.get("path-suggestions")) customElements.define(pathSuggestionsElementName, PathSuggestionsElement);
120
- //#endregion
121
120
  export { PathSuggestionsElement, pathSuggestionsDoneEvent, pathSuggestionsElementName, pathSuggestionsRedirectEvent };
122
121
 
123
122
  //# sourceMappingURL=element.mjs.map
@@ -1 +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\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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"}
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
- //#region src/index.d.ts
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` and is not the path already requested, and a list otherwise
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
- //#region src/index.ts
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` and is not the path already requested, and a list otherwise
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
- if (best.score >= redirectThreshold && normalizePath(best.url) !== current) return {
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
@@ -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` 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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xsynaptic/path-suggestions",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "A did-you-mean for 404 pages, as a DOM-free scoring function plus an optional custom element",
5
5
  "type": "module",
6
6
  "license": "MIT",