@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 +2 -2
- package/dist/element.d.mts +6 -9
- package/dist/element.mjs +1 -2
- 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
|
|
|
@@ -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
|
|
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
|
|
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);
|
|
@@ -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
|
-
|
|
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
|
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