From fbecc5ccd533894e04825076c4754f6b3b049308 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 18 Feb 2025 20:41:52 +0400 Subject: [PATCH 01/52] Fixed background color of image size selector to not be transparent --- src/styles/content/listing.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles/content/listing.scss b/src/styles/content/listing.scss index 71cf423..ef53e9b 100644 --- a/src/styles/content/listing.scss +++ b/src/styles/content/listing.scss @@ -203,6 +203,7 @@ top: 5px; left: 5px; z-index: 1; + background-color: booru-vars.$background-color; } .close { From a858888252f35bfa8b37efcfb0f61472adccac31 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 21:38:10 +0400 Subject: [PATCH 02/52] Renamed classes to TS --- src/lib/booru/scraped/{ScrapedAPI.js => ScrapedAPI.ts} | 0 src/lib/booru/scraped/parsing/{PageParser.js => PageParser.ts} | 0 src/lib/booru/scraped/parsing/{PostParser.js => PostParser.ts} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/lib/booru/scraped/{ScrapedAPI.js => ScrapedAPI.ts} (100%) rename src/lib/booru/scraped/parsing/{PageParser.js => PageParser.ts} (100%) rename src/lib/booru/scraped/parsing/{PostParser.js => PostParser.ts} (100%) diff --git a/src/lib/booru/scraped/ScrapedAPI.js b/src/lib/booru/scraped/ScrapedAPI.ts similarity index 100% rename from src/lib/booru/scraped/ScrapedAPI.js rename to src/lib/booru/scraped/ScrapedAPI.ts diff --git a/src/lib/booru/scraped/parsing/PageParser.js b/src/lib/booru/scraped/parsing/PageParser.ts similarity index 100% rename from src/lib/booru/scraped/parsing/PageParser.js rename to src/lib/booru/scraped/parsing/PageParser.ts diff --git a/src/lib/booru/scraped/parsing/PostParser.js b/src/lib/booru/scraped/parsing/PostParser.ts similarity index 100% rename from src/lib/booru/scraped/parsing/PostParser.js rename to src/lib/booru/scraped/parsing/PostParser.ts From 3f245bb621c6aa4f459c04dfe0283cf80a261f33 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 21:47:09 +0400 Subject: [PATCH 03/52] Converting classes to TS --- src/lib/booru/scraped/ScrapedAPI.ts | 18 ++++++---- src/lib/booru/scraped/parsing/PageParser.ts | 21 +++++------- src/lib/booru/scraped/parsing/PostParser.ts | 37 +++++++++++---------- 3 files changed, 39 insertions(+), 37 deletions(-) diff --git a/src/lib/booru/scraped/ScrapedAPI.ts b/src/lib/booru/scraped/ScrapedAPI.ts index f880eb8..7bcb929 100644 --- a/src/lib/booru/scraped/ScrapedAPI.ts +++ b/src/lib/booru/scraped/ScrapedAPI.ts @@ -1,19 +1,25 @@ import PostParser from "$lib/booru/scraped/parsing/PostParser"; +type UpdaterFunction = (tags: Set) => Set; + export default class ScrapedAPI { /** * Update the tags of the image using callback. - * @param {number} imageId ID of the image. - * @param {function(Set): Set} callback Callback to call to change the content. - * @return {Promise|null>} Updated tags and aliases list for updating internal cached state. + * @param imageId ID of the image. + * @param callback Callback to call to change the content. + * @return Updated tags and aliases list for updating internal cached state. */ - async updateImageTags(imageId, callback) { + async updateImageTags(imageId: number, callback: UpdaterFunction): Promise | null> { const postParser = new PostParser(imageId); const formData = await postParser.resolveTagEditorFormData(); + const tagsFieldValue = formData.get(PostParser.tagsInputName); + + if (typeof tagsFieldValue !== 'string') { + throw new Error('Missing tags field!'); + } const tagsList = new Set( - formData - .get(PostParser.tagsInputName) + tagsFieldValue .split(',') .map(tagName => tagName.trim()) ); diff --git a/src/lib/booru/scraped/parsing/PageParser.ts b/src/lib/booru/scraped/parsing/PageParser.ts index 8078f75..49cd9f1 100644 --- a/src/lib/booru/scraped/parsing/PageParser.ts +++ b/src/lib/booru/scraped/parsing/PageParser.ts @@ -1,17 +1,12 @@ export default class PageParser { - /** @type {string} */ - #url; - /** @type {DocumentFragment|null} */ - #fragment = null; + readonly #url: string; + #fragment: DocumentFragment | null = null; - constructor(url) { + constructor(url: string) { this.#url = url; } - /** - * @return {Promise} - */ - async resolveFragment() { + async resolveFragment(): Promise { if (this.#fragment) { return this.#fragment; } @@ -34,12 +29,12 @@ export default class PageParser { /** * Create a document fragment from the following response. * - * @param {Response} response Response to create a fragment from. Note, that this response will be used. If you need - * to use the same response somewhere else, then you need to pass a cloned version of the response. + * @param response Response to create a fragment from. Note, that this response will be used. If you need to use the + * same response somewhere else, then you need to pass a cloned version of the response. * - * @return {Promise} Resulting document fragment ready for processing. + * @return Resulting document fragment ready for processing. */ - static async resolveFragmentFromResponse(response) { + static async resolveFragmentFromResponse(response: Response): Promise { const documentFragment = document.createDocumentFragment(); const template = document.createElement('template'); template.innerHTML = await response.text(); diff --git a/src/lib/booru/scraped/parsing/PostParser.ts b/src/lib/booru/scraped/parsing/PostParser.ts index af6d104..43704b8 100644 --- a/src/lib/booru/scraped/parsing/PostParser.ts +++ b/src/lib/booru/scraped/parsing/PostParser.ts @@ -2,23 +2,19 @@ import PageParser from "$lib/booru/scraped/parsing/PageParser"; import { buildTagsAndAliasesMap } from "$lib/booru/tag-utils"; export default class PostParser extends PageParser { - /** @type {HTMLFormElement} */ - #tagEditorForm; + #tagEditorForm: HTMLFormElement | null = null; - constructor(imageId) { + constructor(imageId: number) { super(`/images/${imageId}`); } - /** - * @return {Promise} - */ - async resolveTagEditorForm() { + async resolveTagEditorForm(): Promise { if (this.#tagEditorForm) { return this.#tagEditorForm; } const documentFragment = await this.resolveFragment(); - const tagsFormElement = documentFragment.querySelector("#tags-form"); + const tagsFormElement = documentFragment.querySelector("#tags-form"); if (!tagsFormElement) { throw new Error("Failed to find the tag editor form"); @@ -37,10 +33,8 @@ export default class PostParser extends PageParser { /** * Resolve the tags and aliases mapping from the post page. - * - * @return {Promise|null>} */ - async resolveTagsAndAliases() { + async resolveTagsAndAliases(): Promise | null> { return PostParser.resolveTagsAndAliasesFromPost( await this.resolveFragment() ); @@ -49,25 +43,32 @@ export default class PostParser extends PageParser { /** * Resolve the list of tags and aliases from the post content. * - * @param {DocumentFragment} documentFragment Real content to parse the data from. + * @param documentFragment Real content to parse the data from. * - * @return {Map|null} Tags and aliases or null if failed to parse. + * @return Tags and aliases or null if failed to parse. */ - static resolveTagsAndAliasesFromPost(documentFragment) { - const imageShowContainer = documentFragment.querySelector('.image-show-container'); - const tagsForm = documentFragment.querySelector('#tags-form'); + static resolveTagsAndAliasesFromPost(documentFragment: DocumentFragment): Map | null { + const imageShowContainer = documentFragment.querySelector('.image-show-container'); + const tagsForm = documentFragment.querySelector('#tags-form'); if (!imageShowContainer || !tagsForm) { return null; } const tagsFormData = new FormData(tagsForm); + const tagsAndAliasesValue = imageShowContainer.dataset.imageTagAliases; + const tagsValue = tagsFormData.get(this.tagsInputName); - const tagsAndAliasesList = imageShowContainer.dataset.imageTagAliases + if (!tagsAndAliasesValue || !tagsValue || typeof tagsValue !== 'string') { + console.warn('Failed to locate tags & aliases!'); + return null; + } + + const tagsAndAliasesList = tagsAndAliasesValue .split(',') .map(tagName => tagName.trim()); - const actualTagsList = tagsFormData.get(this.tagsInputName) + const actualTagsList = tagsValue .split(',') .map(tagName => tagName.trim()); From ab5a6daa0743eafdbb858e726eff17c4ebf73fea Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 22:16:28 +0400 Subject: [PATCH 04/52] Installing vitest with jsdom, setting up configuration --- .gitignore | 3 +- package-lock.json | 1853 ++++++++++++++++++++++++++++++++++++++++++++- package.json | 9 +- vite.config.ts | 14 +- 4 files changed, 1871 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 28409d9..bfbf150 100644 --- a/.gitignore +++ b/.gitignore @@ -2,10 +2,11 @@ .DS_Store node_modules /build +/coverage /.svelte-kit /package .env .env.* !.env.example vite.config.js.timestamp-* -vite.config.ts.timestamp-* \ No newline at end of file +vite.config.ts.timestamp-* diff --git a/package-lock.json b/package-lock.json index 39ee1b9..b309f2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,12 +17,15 @@ "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/chrome": "^0.0.304", + "@vitest/coverage-v8": "^3.0.6", "cheerio": "^1.0.0", + "jsdom": "^26.0.0", "sass": "^1.85.0", "svelte": "^5.20.1", "svelte-check": "^4.1.4", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.0.6" } }, "node_modules/@ampproject/remapping": { @@ -38,6 +41,195 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.3.tgz", + "integrity": "sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.1", + "@csstools/css-color-parser": "^3.0.7", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.9.tgz", + "integrity": "sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.9.tgz", + "integrity": "sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.1.tgz", + "integrity": "sha512-MKtmkA0BX87PKaO1NFRTFH+UnkgnmySQOvNxJubsadusqPEC2aJ9MOQiMceZJJ6oitUl/i0L6u0M1IrmAOmgBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.1.tgz", + "integrity": "sha512-rL7kaUnTkL9K+Cvo2pnCieqNpTKgQzy5f+N+5Iuko9HAoasP+xgprVh7KN/MaJVvVL1l0EzQq2MoqBHKSrDrag==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.7.tgz", + "integrity": "sha512-nkMp2mTICw32uE5NN+EsJ4f5N+IGFeCFu4bGpiKgb2Pq/7J/MpyLBeQ5ry4KKtRFZaYs6sTmcMYrSRIyj5DFKA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.1", + "@csstools/css-calc": "^2.1.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.4.tgz", + "integrity": "sha512-Up7rBoV77rv29d3uKHUIVubz1BTcgyUK72IvCQAbfbMv584xHcGKCKbWh7i8hPrRJ7qU4Y8IO3IY9m+iTB7P3A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.3" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.3.tgz", + "integrity": "sha512-UJnjoFsmxfKUdNYdWgOB0mWUypuLvAfQPH1+pyvRJs6euowbFkFC6P13w1l8mJyi3vxYMxc9kld5jZEGRQs6bw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/linux-x64": { "version": "0.24.2", "cpu": [ @@ -62,6 +254,34 @@ "node": ">=6" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", @@ -183,6 +403,17 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.28", "dev": true, @@ -583,6 +814,152 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/coverage-v8": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz", + "integrity": "sha512-JRTlR8Bw+4BcmVTICa7tJsxqphAktakiLsAmibVLAWbu1lauFddY/tXeM6sAyl1cgkPuXtpnUgaCPhTdz1Qapg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "debug": "^4.4.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.0.6", + "vitest": "3.0.6" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.6.tgz", + "integrity": "sha512-zBduHf/ja7/QRX4HdP1DSq5XrPgdN+jzLOwaTq/0qZjYfgETNFCKf9nOAp2j3hmom3oTbczuUzrzg9Hafh7hNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.6", + "@vitest/utils": "3.0.6", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.0.6.tgz", + "integrity": "sha512-KPztr4/tn7qDGZfqlSPQoF2VgJcKxnDNhmfR3VgZ6Fy1bO8T9Fc1stUiTXtqz0yG24VpD00pZP5f8EOFknjNuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.0.6", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.0.6.tgz", + "integrity": "sha512-Zyctv3dbNL+67qtHfRnUE/k8qxduOamRfAL1BurEIQSyOEFffoMvx2pnDSSbKAAVxY0Ej2J/GH2dQKI0W2JyVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.0.6.tgz", + "integrity": "sha512-JopP4m/jGoaG1+CBqubV/5VMbi7L+NQCJTu1J1Pf6YaUbk7bZtaq5CX7p+8sY64Sjn1UQ1XJparHfcvTTdu9cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.0.6", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.0.6.tgz", + "integrity": "sha512-qKSmxNQwT60kNwwJHMVwavvZsMGXWmngD023OHSgn873pV0lylK7dwBTfYP7e4URy5NiBCHHiQGA9DHkYkqRqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.6", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.0.6.tgz", + "integrity": "sha512-HfOGx/bXtjy24fDlTOpgiAEJbRfFxoX3zIGagCqACkFKKZ/TTOE6gYMKXlqecvxEndKFuNHcHqP081ggZ2yM0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.0.6.tgz", + "integrity": "sha512-18ktZpf4GQFTbf9jK543uspU03Q2qya7ZGya5yiZ0Gx0nnnalBvd5ZBislbl2EhLjM8A8rt4OilqKG7QwcGkvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.0.6", + "loupe": "^3.1.3", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/acorn": { "version": "8.14.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", @@ -604,6 +981,42 @@ "acorn": ">=8.9.0" } }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -613,6 +1026,23 @@ "node": ">= 0.4" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -622,11 +1052,28 @@ "node": ">= 0.4" } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "dev": true, "license": "ISC" }, + "node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, "node_modules/braces": { "version": "3.0.3", "dev": true, @@ -639,6 +1086,57 @@ "node": ">=8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", + "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/cheerio": { "version": "1.0.0", "dev": true, @@ -703,6 +1201,39 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cookie": { "version": "0.6.0", "dev": true, @@ -711,6 +1242,21 @@ "node": ">= 0.6" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/css-select": { "version": "5.1.0", "dev": true, @@ -737,6 +1283,34 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cssstyle": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.2.1.tgz", + "integrity": "sha512-9+vem03dMXG7gDmZ62uqmRiMRNtinIZ9ZyuF6BdxzfOD+FdN5hretzynkn0ReS2DO2GSw76RWHs0UmJPI2zUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^2.8.2", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -754,6 +1328,23 @@ } } }, + "node_modules/decimal.js": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.5.0.tgz", + "integrity": "sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "dev": true, @@ -762,6 +1353,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "dev": true, @@ -830,6 +1431,35 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/encoding-sniffer": { "version": "0.2.0", "dev": true, @@ -853,6 +1483,62 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", + "integrity": "sha512-qqnD1yMU6tk/jnaMosogGySTZP8YtUgAffA9nMN+E/rjxcfRQ6IEk7IiozUjgxKoFHBGjTLnrHB/YC45r/59EQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.24.2", "dev": true, @@ -907,6 +1593,26 @@ "@jridgewell/sourcemap-codec": "^1.4.15" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.1.0.tgz", + "integrity": "sha512-bFi65yM+xZgk+u/KRIpekdSYkTB5W1pEf0Lt8Q8Msh7b+eQ7LXVtIB1Bkm4fvclDEL1b2CZkMhv2mOeF8tMdkA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fill-range": { "version": "7.1.1", "dev": true, @@ -919,6 +1625,39 @@ "node": ">=8" } }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", + "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -934,6 +1673,161 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "function-bind": "^1.1.2", + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/htmlparser2": { "version": "9.1.0", "dev": true, @@ -952,6 +1846,34 @@ "entities": "^4.5.0" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "dev": true, @@ -986,6 +1908,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "dev": true, @@ -1007,6 +1939,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -1016,6 +1955,124 @@ "@types/estree": "^1.0.6" } }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jsdom": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.0.0.tgz", + "integrity": "sha512-BZYDGVAIriBWTpIxYzrXjv3E/4u8+/pSG5bQdIYCbNCGOvsPkDQfTVLAIXAf9ETdCpduCVTkDe2NNZ8NIwUVzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.4.3", + "form-data": "^4.0.1", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.0", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/kleur": { "version": "4.1.5", "dev": true, @@ -1029,6 +2086,20 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz", + "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/lz-string": { "version": "1.5.0", "license": "MIT", @@ -1045,6 +2116,44 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/micromatch": { "version": "4.0.8", "dev": true, @@ -1058,6 +2167,55 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/mri": { "version": "1.2.0", "dev": true, @@ -1114,12 +2272,28 @@ "url": "https://github.com/fb55/nth-check?sponsor=1" } }, + "node_modules/nwsapi": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.16.tgz", + "integrity": "sha512-F1I/bimDpj3ncaNDhfyMWuFqmQDBwDB0Fogc2qpL3BWvkQteFD/8BzWuIRl83rq0DXfm8SGt/HFhLXZyljTXcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parse5": { - "version": "7.1.2", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^4.4.0" + "entities": "^4.5.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -1148,6 +2322,50 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", + "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "dev": true, @@ -1192,6 +2410,16 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/readdirp": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.1.tgz", @@ -1244,6 +2472,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/sade": { "version": "1.8.1", "dev": true, @@ -1281,11 +2516,80 @@ "@parcel/watcher": "^2.4.1" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/set-cookie-parser": { "version": "2.6.0", "dev": true, "license": "MIT" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.0", "dev": true, @@ -1307,6 +2611,137 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.8.0.tgz", + "integrity": "sha512-Bc3YwwCB+OzldMxOXJIIvC6cPRWr/LxOp48CdQTOkPyk/t4JWWJbrilwBd7RJzKV8QW7tJkcgAmeuLLJugl5/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/svelte": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.20.1.tgz", @@ -1384,6 +2819,92 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz", + "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.78", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.78.tgz", + "integrity": "sha512-fSgYrW0ITH0SR/CqKMXIruYIPpNu5aDgUp22UhYoSrnUQwc7SBqifEBFNce7AAcygUPBo6a/gbtcguWdmko4RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.78" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.78", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.78.tgz", + "integrity": "sha512-jS0svNsB99jR6AJBmfmEWuKIgz91Haya91Z43PATaeHJ24BkMoNRb/jlaD37VYjb0mYf6gRL/HOnvS1zEnYBiw==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -1404,6 +2925,32 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.1.tgz", + "integrity": "sha512-Ek7HndSVkp10hmHP9V4qZO1u+pn1RU5sI0Fw+jCU3lyvuMZcgqsNgc6CmJJZyByK4Vm/qotGRJlfgAX8q+4JiA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.0.0.tgz", + "integrity": "sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", @@ -1497,6 +3044,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.0.6.tgz", + "integrity": "sha512-s51RzrTkXKJrhNbUzQRsarjmAae7VmMPAsRT7lppVpIg6mK3zGthP9Hgz0YQQKuNcF+Ii7DfYk3Fxz40jRmePw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.0", + "es-module-lexer": "^1.6.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vitefu": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.0.5.tgz", @@ -1511,6 +3081,99 @@ } } }, + "node_modules/vitest": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.6.tgz", + "integrity": "sha512-/iL1Sc5VeDZKPDe58oGK4HUFLhw6b5XdY1MYawjuSaDA4sEfYlY9HnS6aCEG26fX+MgUi7MwlduTBHHAI/OvMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "3.0.6", + "@vitest/mocker": "3.0.6", + "@vitest/pretty-format": "^3.0.6", + "@vitest/runner": "3.0.6", + "@vitest/snapshot": "3.0.6", + "@vitest/spy": "3.0.6", + "@vitest/utils": "3.0.6", + "chai": "^5.2.0", + "debug": "^4.4.0", + "expect-type": "^1.1.0", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinypool": "^1.0.2", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0", + "vite-node": "3.0.6", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.0.6", + "@vitest/ui": "3.0.6", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "dev": true, @@ -1530,6 +3193,190 @@ "node": ">=18" } }, + "node_modules/whatwg-url": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.1.1.tgz", + "integrity": "sha512-mDGf9diDad/giZ/Sm9Xi2YcyzaFpbdLpJPr+E9fSkyQ7KpQD4SdFcugkRQYzhmfI4KeV4Qpnn2sKPdo+kmsgRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/zimmerframe": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", diff --git a/package.json b/package.json index a683f59..16299d9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,9 @@ "build:popup": "vite build", "build:extension": "node build-extension.js", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "test": "vitest run --coverage", + "test:watch": "vitest watch --coverage" }, "devDependencies": { "@sveltejs/adapter-auto": "^4.0.0", @@ -15,12 +17,15 @@ "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/chrome": "^0.0.304", + "@vitest/coverage-v8": "^3.0.6", "cheerio": "^1.0.0", + "jsdom": "^26.0.0", "sass": "^1.85.0", "svelte": "^5.20.1", "svelte-check": "^4.1.4", "typescript": "^5.7.3", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.0.6" }, "type": "module", "dependencies": { diff --git a/vite.config.ts b/vite.config.ts index d55cc05..fc4f095 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,5 @@ -import {sveltekit} from '@sveltejs/kit/vite'; -import {defineConfig} from 'vite'; +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vitest/config'; export default defineConfig({ build: { @@ -9,4 +9,14 @@ export default defineConfig({ plugins: [ sveltekit(), ], + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.{js,ts}'], + exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'], + coverage: { + reporter: ['text', 'html'], + include: ['src/**/*.{js,ts}'], + } + } }); From 6a34822f6ad6276fe9d67bf74ae841ab8ef00ac9 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 22:27:04 +0400 Subject: [PATCH 05/52] Incorrect include for tests, only test lib directory, import vitest globals --- tsconfig.json | 3 +++ vite.config.ts | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index 4b0a17f..310c8e0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,8 @@ "strict": true, "moduleResolution": "bundler", "allowImportingTsExtensions": false, + "types": [ + "vitest/globals" + ] } } diff --git a/vite.config.ts b/vite.config.ts index fc4f095..f8d70c5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,11 +12,10 @@ export default defineConfig({ test: { globals: true, environment: 'jsdom', - include: ['src/**/*.{js,ts}'], exclude: ['**/node_modules/**', '.*\\.d\\.ts$', '.*\\.spec\\.ts$'], coverage: { reporter: ['text', 'html'], - include: ['src/**/*.{js,ts}'], + include: ['src/lib/**/*.{js,ts}'], } } }); From 9d097436a527bba919db920c668d68bb6fb3f78a Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 23:49:26 +0400 Subject: [PATCH 06/52] Added chrome types to autoload --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 310c8e0..1a10c54 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "moduleResolution": "bundler", "allowImportingTsExtensions": false, "types": [ - "vitest/globals" + "vitest/globals", + "@types/chrome", ] } } From 769a63ccffe3373ad4d403894655dfafbe24eea4 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 23:49:42 +0400 Subject: [PATCH 07/52] Added alias `$tests` for test directory --- svelte.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/svelte.config.js b/svelte.config.js index e32e94c..1c9eb41 100644 --- a/svelte.config.js +++ b/svelte.config.js @@ -19,6 +19,7 @@ const config = { "$styles": "./src/styles", "$stores": "./src/stores", "$entities": "./src/lib/extension/entities", + "$tests": "./tests" }, typescript: { config: config => { From 68b68d3efd7fa9733f28b6ecea91c41ca3ad7fdf Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 20 Feb 2025 23:52:26 +0400 Subject: [PATCH 08/52] Added initial implementation of mocks for chrome StorageArea, adding first tests for storage helper --- tests/lib/browser/StorageHelper.spec.ts | 40 ++++++++++++++ tests/mocks/ChromeEvent.ts | 9 ++++ tests/mocks/ChromeLocalStorageArea.ts | 5 ++ tests/mocks/ChromeStorageArea.ts | 71 +++++++++++++++++++++++++ 4 files changed, 125 insertions(+) create mode 100644 tests/lib/browser/StorageHelper.spec.ts create mode 100644 tests/mocks/ChromeEvent.ts create mode 100644 tests/mocks/ChromeLocalStorageArea.ts create mode 100644 tests/mocks/ChromeStorageArea.ts diff --git a/tests/lib/browser/StorageHelper.spec.ts b/tests/lib/browser/StorageHelper.spec.ts new file mode 100644 index 0000000..c73cedd --- /dev/null +++ b/tests/lib/browser/StorageHelper.spec.ts @@ -0,0 +1,40 @@ +import ChromeStorageArea from "$tests/mocks/ChromeStorageArea"; +import StorageHelper from "$lib/browser/StorageHelper"; +import { expect } from "vitest"; + +describe('StorageHelper', () => { + let storageAreaMock: ChromeStorageArea; + let storageHelper: StorageHelper; + + beforeEach(() => { + storageAreaMock = new ChromeStorageArea(); + storageHelper = new StorageHelper(storageAreaMock); + }); + + it("should return value when data exists", async () => { + const key = 'existingKey'; + const value = 'test value'; + + storageAreaMock.insertMockedData({[key]: value}); + + expect(await storageHelper.read(key)).toBe(value); + }); + + it('should return default when data is not present', async () => { + const fallbackValue = 'fallback'; + + expect(await storageHelper.read('nonexistent', fallbackValue)).toBe(fallbackValue); + }); + + it('should treat falsy values as existing values', async () => { + const falsyValues = [false, '', 0]; + const key = 'testedKey'; + const fallbackValue = 'fallback'; + + for (let testedValue of falsyValues) { + storageAreaMock.insertMockedData({[key]: testedValue}); + + expect(await storageHelper.read(key, fallbackValue)).toBe(testedValue); + } + }); +}); diff --git a/tests/mocks/ChromeEvent.ts b/tests/mocks/ChromeEvent.ts new file mode 100644 index 0000000..7c3029b --- /dev/null +++ b/tests/mocks/ChromeEvent.ts @@ -0,0 +1,9 @@ +export default class ChromeEvent implements chrome.events.Event { + addListener = vi.fn(); + getRules = vi.fn(); + hasListener = vi.fn(); + removeRules = vi.fn(); + addRules = vi.fn(); + removeListener = vi.fn(); + hasListeners = vi.fn(); +} diff --git a/tests/mocks/ChromeLocalStorageArea.ts b/tests/mocks/ChromeLocalStorageArea.ts new file mode 100644 index 0000000..8aa4f77 --- /dev/null +++ b/tests/mocks/ChromeLocalStorageArea.ts @@ -0,0 +1,5 @@ +import ChromeStorageArea from "$tests/mocks/ChromeStorageArea"; + +export class ChromeLocalStorageArea extends ChromeStorageArea implements chrome.storage.LocalStorageArea { + QUOTA_BYTES = 100000; +} diff --git a/tests/mocks/ChromeStorageArea.ts b/tests/mocks/ChromeStorageArea.ts new file mode 100644 index 0000000..1372fd3 --- /dev/null +++ b/tests/mocks/ChromeStorageArea.ts @@ -0,0 +1,71 @@ +import ChromeEvent from "./ChromeEvent"; + +type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void + +export default class ChromeStorageArea implements chrome.storage.StorageArea { + #mockedData: Record = {}; + + getBytesInUse = vi.fn(); + clear = vi.fn((): Promise => { + return new Promise(resolve => { + this.#mockedData = {}; + resolve(); + }) + }); + set = vi.fn((...args: any[]): Promise => { + return new Promise((resolve, reject) => { + this.#mockedData = Object.assign(this.#mockedData, args[0]); + resolve(); + }) + }); + remove = vi.fn((...args: any[]): Promise => { + return new Promise((resolve, reject) => { + const key = args[0]; + + if (typeof key === 'string') { + delete this.#mockedData[key]; + resolve(); + } + + reject(new Error('This behavior is not mocked!')); + }); + }); + get = vi.fn((...args: any[]) => { + return new Promise((resolve, reject) => { + const key = args[0]; + + if (!key) { + resolve(this.#mockedData); + return; + } + + if (typeof key === 'string') { + resolve({[key]: this.#mockedData[key]}); + return; + } + + if (Array.isArray(key)) { + resolve( + (key as string[]).reduce((entries, key) => { + entries[key] = this.#mockedData[key]; + return entries; + }, {} as Record) + ); + return; + } + + reject(new Error('This behavior is not implemented by the mock.')); + }); + }); + setAccessLevel = vi.fn(); + onChanged = new ChromeEvent(); + getKeys = vi.fn(); + + insertMockedData(data: Record) { + this.#mockedData = data; + } + + get mockedData(): Record { + return this.#mockedData; + } +} From 91c44adbd39c36b3f350fdc7339e3affe41767e2 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 21 Feb 2025 00:25:31 +0400 Subject: [PATCH 09/52] Added workflow for GitHub CI --- .github/workflows/build-and-tests.yml | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/build-and-tests.yml diff --git a/.github/workflows/build-and-tests.yml b/.github/workflows/build-and-tests.yml new file mode 100644 index 0000000..ab6f7c6 --- /dev/null +++ b/.github/workflows/build-and-tests.yml @@ -0,0 +1,32 @@ +name: Testing + +on: + push: + branches: + - master + - 'release/**' + pull_request: + branches: + - master + - 'release/**' + +jobs: + run-tests: + name: 'Run Unit Tests' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install npm dependencies + run: npm ci + + - name: Running unit tests + run: npm run test + + - name: Building the extension + run: npm run build From faaef1305a5d46908ef8777c29a5d3ab597e4cd5 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 21 Feb 2025 00:28:39 +0400 Subject: [PATCH 10/52] Fixed storage helper treating falsy values as non-existing values --- src/lib/browser/StorageHelper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/browser/StorageHelper.ts b/src/lib/browser/StorageHelper.ts index 19c4afd..85ec6cc 100644 --- a/src/lib/browser/StorageHelper.ts +++ b/src/lib/browser/StorageHelper.ts @@ -22,7 +22,7 @@ export default class StorageHelper { * @return The JSON object or the default value if the entry does not exist. */ async read(key: string, defaultValue: DefaultType | null = null): Promise { - return (await this.#storageArea.get(key))?.[key] || defaultValue; + return (await this.#storageArea.get(key))?.[key] ?? defaultValue; } /** From 8b2e0722f0272cfcb8eb1dc4a6b619523ddf1053 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 21 Feb 2025 00:49:58 +0400 Subject: [PATCH 11/52] Building the extension first since testing depends on tsconfig provided by SvelteKit --- .github/workflows/build-and-tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-tests.yml b/.github/workflows/build-and-tests.yml index ab6f7c6..a2fd0b2 100644 --- a/.github/workflows/build-and-tests.yml +++ b/.github/workflows/build-and-tests.yml @@ -25,8 +25,8 @@ jobs: - name: Install npm dependencies run: npm ci - - name: Running unit tests - run: npm run test - - name: Building the extension run: npm run build + + - name: Running unit tests + run: npm run test From b9165302e7c3587184fd29d6ec8ceb1cb0538dc2 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 21 Feb 2025 00:55:19 +0400 Subject: [PATCH 12/52] Removed double-testing for release PRs --- .github/workflows/build-and-tests.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-and-tests.yml b/.github/workflows/build-and-tests.yml index a2fd0b2..a672dd9 100644 --- a/.github/workflows/build-and-tests.yml +++ b/.github/workflows/build-and-tests.yml @@ -4,7 +4,6 @@ on: push: branches: - master - - 'release/**' pull_request: branches: - master From 3a010f93034da6107366f6fe3ca5ac0f6add5091 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 21 Feb 2025 03:04:11 +0400 Subject: [PATCH 13/52] Renaming all content scripts to TS --- manifest.json | 8 ++++---- src/content/{header.js => header.ts} | 0 src/content/{listing.js => listing.ts} | 0 src/content/{tags-editor.js => tags-editor.ts} | 0 src/content/{tags.js => tags.ts} | 0 .../{FullscreenViewer.js => FullscreenViewer.ts} | 0 ...owFullscreenButton.js => ImageShowFullscreenButton.ts} | 0 .../{MaintenancePopup.js => MaintenancePopup.ts} | 0 ...{MaintenanceStatusIcon.js => MaintenanceStatusIcon.ts} | 0 src/lib/components/{MediaBoxTools.js => MediaBoxTools.ts} | 0 .../components/{MediaBoxWrapper.js => MediaBoxWrapper.ts} | 0 src/lib/components/{SearchWrapper.js => SearchWrapper.ts} | 0 .../{SiteHeaderWrapper.js => SiteHeaderWrapper.ts} | 0 .../{TagDropdownWrapper.js => TagDropdownWrapper.ts} | 0 src/lib/components/{TagsForm.js => TagsForm.ts} | 0 .../base/{BaseComponent.js => BaseComponent.ts} | 0 16 files changed, 4 insertions(+), 4 deletions(-) rename src/content/{header.js => header.ts} (100%) rename src/content/{listing.js => listing.ts} (100%) rename src/content/{tags-editor.js => tags-editor.ts} (100%) rename src/content/{tags.js => tags.ts} (100%) rename src/lib/components/{FullscreenViewer.js => FullscreenViewer.ts} (100%) rename src/lib/components/{ImageShowFullscreenButton.js => ImageShowFullscreenButton.ts} (100%) rename src/lib/components/{MaintenancePopup.js => MaintenancePopup.ts} (100%) rename src/lib/components/{MaintenanceStatusIcon.js => MaintenanceStatusIcon.ts} (100%) rename src/lib/components/{MediaBoxTools.js => MediaBoxTools.ts} (100%) rename src/lib/components/{MediaBoxWrapper.js => MediaBoxWrapper.ts} (100%) rename src/lib/components/{SearchWrapper.js => SearchWrapper.ts} (100%) rename src/lib/components/{SiteHeaderWrapper.js => SiteHeaderWrapper.ts} (100%) rename src/lib/components/{TagDropdownWrapper.js => TagDropdownWrapper.ts} (100%) rename src/lib/components/{TagsForm.js => TagsForm.ts} (100%) rename src/lib/components/base/{BaseComponent.js => BaseComponent.ts} (100%) diff --git a/manifest.json b/manifest.json index dfc9922..8ee8dd0 100644 --- a/manifest.json +++ b/manifest.json @@ -27,7 +27,7 @@ "*://*.furbooru.org/galleries/*" ], "js": [ - "src/content/listing.js" + "src/content/listing.ts" ], "css": [ "src/styles/content/listing.scss" @@ -38,7 +38,7 @@ "*://*.furbooru.org/*" ], "js": [ - "src/content/header.js" + "src/content/header.ts" ], "css": [ "src/styles/content/header.scss" @@ -59,7 +59,7 @@ "*://*.furbooru.org/filters/*" ], "js": [ - "src/content/tags.js" + "src/content/tags.ts" ] }, { @@ -67,7 +67,7 @@ "*://*.furbooru.org/images/*" ], "js": [ - "src/content/tags-editor.js" + "src/content/tags-editor.ts" ] } ], diff --git a/src/content/header.js b/src/content/header.ts similarity index 100% rename from src/content/header.js rename to src/content/header.ts diff --git a/src/content/listing.js b/src/content/listing.ts similarity index 100% rename from src/content/listing.js rename to src/content/listing.ts diff --git a/src/content/tags-editor.js b/src/content/tags-editor.ts similarity index 100% rename from src/content/tags-editor.js rename to src/content/tags-editor.ts diff --git a/src/content/tags.js b/src/content/tags.ts similarity index 100% rename from src/content/tags.js rename to src/content/tags.ts diff --git a/src/lib/components/FullscreenViewer.js b/src/lib/components/FullscreenViewer.ts similarity index 100% rename from src/lib/components/FullscreenViewer.js rename to src/lib/components/FullscreenViewer.ts diff --git a/src/lib/components/ImageShowFullscreenButton.js b/src/lib/components/ImageShowFullscreenButton.ts similarity index 100% rename from src/lib/components/ImageShowFullscreenButton.js rename to src/lib/components/ImageShowFullscreenButton.ts diff --git a/src/lib/components/MaintenancePopup.js b/src/lib/components/MaintenancePopup.ts similarity index 100% rename from src/lib/components/MaintenancePopup.js rename to src/lib/components/MaintenancePopup.ts diff --git a/src/lib/components/MaintenanceStatusIcon.js b/src/lib/components/MaintenanceStatusIcon.ts similarity index 100% rename from src/lib/components/MaintenanceStatusIcon.js rename to src/lib/components/MaintenanceStatusIcon.ts diff --git a/src/lib/components/MediaBoxTools.js b/src/lib/components/MediaBoxTools.ts similarity index 100% rename from src/lib/components/MediaBoxTools.js rename to src/lib/components/MediaBoxTools.ts diff --git a/src/lib/components/MediaBoxWrapper.js b/src/lib/components/MediaBoxWrapper.ts similarity index 100% rename from src/lib/components/MediaBoxWrapper.js rename to src/lib/components/MediaBoxWrapper.ts diff --git a/src/lib/components/SearchWrapper.js b/src/lib/components/SearchWrapper.ts similarity index 100% rename from src/lib/components/SearchWrapper.js rename to src/lib/components/SearchWrapper.ts diff --git a/src/lib/components/SiteHeaderWrapper.js b/src/lib/components/SiteHeaderWrapper.ts similarity index 100% rename from src/lib/components/SiteHeaderWrapper.js rename to src/lib/components/SiteHeaderWrapper.ts diff --git a/src/lib/components/TagDropdownWrapper.js b/src/lib/components/TagDropdownWrapper.ts similarity index 100% rename from src/lib/components/TagDropdownWrapper.js rename to src/lib/components/TagDropdownWrapper.ts diff --git a/src/lib/components/TagsForm.js b/src/lib/components/TagsForm.ts similarity index 100% rename from src/lib/components/TagsForm.js rename to src/lib/components/TagsForm.ts diff --git a/src/lib/components/base/BaseComponent.js b/src/lib/components/base/BaseComponent.ts similarity index 100% rename from src/lib/components/base/BaseComponent.js rename to src/lib/components/base/BaseComponent.ts From c4a904c0467102ed3803a9538e145bce51dc95d5 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 21 Feb 2025 20:54:56 +0400 Subject: [PATCH 14/52] Converting content script components to TS --- src/lib/components/FullscreenViewer.ts | 106 +++++-------- .../components/ImageShowFullscreenButton.ts | 46 +++--- src/lib/components/MaintenancePopup.ts | 123 ++++++--------- src/lib/components/MaintenanceStatusIcon.ts | 13 +- src/lib/components/MediaBoxTools.ts | 36 ++--- src/lib/components/MediaBoxWrapper.ts | 68 ++++----- src/lib/components/SearchWrapper.ts | 142 ++++++++++-------- src/lib/components/SiteHeaderWrapper.ts | 7 +- src/lib/components/TagDropdownWrapper.ts | 84 ++++++----- src/lib/components/TagsForm.ts | 40 +++-- src/lib/components/base/BaseComponent.ts | 71 ++++----- src/lib/components/base/component-utils.ts | 8 +- src/lib/components/events/comms.ts | 3 +- .../events/fullscreen-viewer-events.ts | 7 + src/lib/extension/settings/MiscSettings.ts | 2 +- 15 files changed, 356 insertions(+), 400 deletions(-) create mode 100644 src/lib/components/events/fullscreen-viewer-events.ts diff --git a/src/lib/components/FullscreenViewer.ts b/src/lib/components/FullscreenViewer.ts index 9449dac..48f3c1c 100644 --- a/src/lib/components/FullscreenViewer.ts +++ b/src/lib/components/FullscreenViewer.ts @@ -1,30 +1,22 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; -import MiscSettings from "$lib/extension/settings/MiscSettings"; +import MiscSettings, { type FullscreenViewerSize } from "$lib/extension/settings/MiscSettings"; +import { emit, on } from "$lib/components/events/comms"; +import { eventSizeLoaded } from "$lib/components/events/fullscreen-viewer-events"; export class FullscreenViewer extends BaseComponent { - /** @type {HTMLVideoElement} */ - #videoElement = document.createElement('video'); - /** @type {HTMLImageElement} */ - #imageElement = document.createElement('img'); - #spinnerElement = document.createElement('i'); - #sizeSelectorElement = document.createElement('select'); - #closeButtonElement = document.createElement('i'); - /** @type {number|null} */ - #touchId = null; - /** @type {number|null} */ - #startX = null; - /** @type {number|null} */ - #startY = null; - /** @type {boolean|null} */ - #isClosingSwipeStarted = null; - #isSizeFetched = false; - /** @type {App.ImageURIs|null} */ - #currentURIs = null; + #videoElement: HTMLVideoElement = document.createElement('video'); + #imageElement: HTMLImageElement = document.createElement('img'); + #spinnerElement: HTMLElement = document.createElement('i'); + #sizeSelectorElement: HTMLSelectElement = document.createElement('select'); + #closeButtonElement: HTMLElement = document.createElement('i'); + #touchId: number | null = null; + #startX: number | null = null; + #startY: number | null = null; + #isClosingSwipeStarted: boolean | null = null; + #isSizeFetched: boolean = false; + #currentURIs: App.ImageURIs | null = null; - /** - * @protected - */ - build() { + protected build() { this.container.classList.add('fullscreen-viewer'); this.container.append( @@ -71,10 +63,7 @@ export class FullscreenViewer extends BaseComponent { this.container.classList.remove('loading'); } - /** - * @param {TouchEvent} event - */ - #onTouchStart(event) { + #onTouchStart(event: TouchEvent) { if (this.#touchId !== null) { return; } @@ -88,14 +77,12 @@ export class FullscreenViewer extends BaseComponent { this.#touchId = firstTouch.identifier; this.#startX = firstTouch.clientX; this.#startY = firstTouch.clientY; + this.container.classList.add(FullscreenViewer.#swipeState); } - /** - * @param {TouchEvent} event - */ - #onTouchEnd(event) { - if (this.#touchId === null) { + #onTouchEnd(event: TouchEvent) { + if (this.#touchId === null || this.#startY === null) { return; } @@ -126,11 +113,8 @@ export class FullscreenViewer extends BaseComponent { }); } - /** - * @param {TouchEvent} event - */ - #onTouchMove(event) { - if (this.#touchId === null) { + #onTouchMove(event: TouchEvent) { + if (this.#touchId === null || this.#startY === null || this.#startX === null) { return; } @@ -179,23 +163,17 @@ export class FullscreenViewer extends BaseComponent { } } - /** - * @param {KeyboardEvent} event - */ - #onDocumentKeyPressed(event) { + #onDocumentKeyPressed(event: KeyboardEvent) { if (event.code === 'Escape' || event.code === 'Esc') { this.#close(); } } - /** - * @param {import("$lib/extension/settings/MiscSettings").FullscreenViewerSize} size - */ - #onSizeResolved(size) { + #onSizeResolved(size: FullscreenViewerSize) { this.#sizeSelectorElement.value = size; this.#isSizeFetched = true; - this.emit('size-loaded'); + emit(this.container, eventSizeLoaded, size); } #watchForSizeSelectionChanges() { @@ -232,7 +210,7 @@ export class FullscreenViewer extends BaseComponent { this.#currentURIs = null; this.container.classList.remove(FullscreenViewer.#shownState); - document.body.style.overflow = null; + document.body.style.removeProperty('overflow'); requestAnimationFrame(() => { this.#videoElement.volume = 0; @@ -241,16 +219,18 @@ export class FullscreenViewer extends BaseComponent { }); } - /** - * @param {App.ImageURIs} imageUris - * @return {Promise} - */ - async #resolveCurrentSelectedSizeUrl(imageUris) { + async #resolveCurrentSelectedSizeUrl(imageUris: App.ImageURIs): Promise { if (!this.#isSizeFetched) { - await new Promise(resolve => this.on('size-loaded', resolve)) + await new Promise( + resolve => on( + this.container, + eventSizeLoaded, + resolve + ), + ); } - let targetSize = this.#sizeSelectorElement.value; + let targetSize: FullscreenViewerSize | string = this.#sizeSelectorElement.value; if (!imageUris.hasOwnProperty(targetSize)) { targetSize = FullscreenViewer.#fallbackSize; @@ -264,13 +244,10 @@ export class FullscreenViewer extends BaseComponent { return null; } - return imageUris[targetSize]; + return imageUris[targetSize as FullscreenViewerSize]; } - /** - * @param {App.ImageURIs} imageUris - */ - async show(imageUris) { + async show(imageUris: App.ImageURIs): Promise { this.#currentURIs = imageUris; const url = await this.#resolveCurrentSelectedSizeUrl(imageUris); @@ -308,11 +285,7 @@ export class FullscreenViewer extends BaseComponent { this.container.append(this.#imageElement); } - /** - * @param {string} url - * @return {boolean} - */ - static #isVideoUrl(url) { + static #isVideoUrl(url: string): boolean { return url.endsWith('.mp4') || url.endsWith('.webm'); } @@ -324,10 +297,7 @@ export class FullscreenViewer extends BaseComponent { static #swipeState = 'swiped'; static #minRequiredDistance = 50; - /** - * @type {Record} - */ - static #previewSizes = { + static #previewSizes: Record = { full: 'Full', large: 'Large', medium: 'Medium', diff --git a/src/lib/components/ImageShowFullscreenButton.ts b/src/lib/components/ImageShowFullscreenButton.ts index fc00370..01a1eaf 100644 --- a/src/lib/components/ImageShowFullscreenButton.ts +++ b/src/lib/components/ImageShowFullscreenButton.ts @@ -2,21 +2,23 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; import { getComponent } from "$lib/components/base/component-utils"; import MiscSettings from "$lib/extension/settings/MiscSettings"; import { FullscreenViewer } from "$lib/components/FullscreenViewer"; +import type { MediaBoxTools } from "$lib/components/MediaBoxTools"; export class ImageShowFullscreenButton extends BaseComponent { - /** - * @type {import('./MediaBoxTools').MediaBoxTools|null} - */ - #mediaBoxTools= null; - #isFullscreenButtonEnabled = false; + #mediaBoxTools: MediaBoxTools | null = null; + #isFullscreenButtonEnabled: boolean = false; - build() { + protected build() { this.container.innerText = '🔍'; ImageShowFullscreenButton.#miscSettings ??= new MiscSettings(); } - init() { + protected init() { + if (!this.container.parentElement) { + throw new Error('Missing parent element!'); + } + this.#mediaBoxTools = getComponent(this.container.parentElement); if (!this.#mediaBoxTools) { @@ -32,7 +34,7 @@ export class ImageShowFullscreenButton extends BaseComponent { this.#updateFullscreenButtonVisibility(); }) .then(() => { - ImageShowFullscreenButton.#miscSettings.subscribe(settings => { + ImageShowFullscreenButton.#miscSettings?.subscribe(settings => { this.#isFullscreenButtonEnabled = settings.fullscreenViewer ?? true; this.#updateFullscreenButtonVisibility(); }) @@ -45,28 +47,25 @@ export class ImageShowFullscreenButton extends BaseComponent { } #onButtonClicked() { + const imageLinks = this.#mediaBoxTools?.mediaBox.imageLinks; + + if (!imageLinks) { + throw new Error('Failed to resolve image links from media box tools!'); + } + ImageShowFullscreenButton .#resolveViewer() - .show(this.#mediaBoxTools.mediaBox.imageLinks); + ?.show(imageLinks); } - /** - * @type {FullscreenViewer|null} - */ - static #viewer = null; + static #viewer: FullscreenViewer | null = null; - /** - * @return {FullscreenViewer} - */ - static #resolveViewer() { + static #resolveViewer(): FullscreenViewer { this.#viewer ??= this.#buildViewer(); return this.#viewer; } - /** - * @return {FullscreenViewer} - */ - static #buildViewer() { + static #buildViewer(): FullscreenViewer { const element = document.createElement('div'); const viewer = new FullscreenViewer(element); @@ -77,10 +76,7 @@ export class ImageShowFullscreenButton extends BaseComponent { return viewer; } - /** - * @type {MiscSettings|null} - */ - static #miscSettings = null; + static #miscSettings: MiscSettings | null = null; } export function createImageShowFullscreenButton() { diff --git a/src/lib/components/MaintenancePopup.ts b/src/lib/components/MaintenancePopup.ts index 3966be1..718c6a8 100644 --- a/src/lib/components/MaintenancePopup.ts +++ b/src/lib/components/MaintenancePopup.ts @@ -10,47 +10,27 @@ import { eventMaintenanceStateChanged, eventTagsUpdated } from "$lib/components/events/maintenance-popup-events"; +import type { MediaBoxTools } from "$lib/components/MediaBoxTools"; class BlackListedTagsEncounteredError extends Error { - /** - * @param {string} tagName - */ - constructor(tagName) { - super(`This tag is blacklisted and prevents submission: ${tagName}`); + constructor(tagName: string) { + super(`This tag is blacklisted and prevents submission: ${tagName}`, { + cause: tagName + }); } } export class MaintenancePopup extends BaseComponent { - /** @type {HTMLElement} */ - #tagsListElement = null; - - /** @type {HTMLElement[]} */ - #tagsList = []; - - /** @type {Map} */ - #suggestedInvalidTags = new Map(); - - /** @type {MaintenanceProfile|null} */ - #activeProfile = null; - - /** @type {import('$lib/components/MediaBoxTools').MediaBoxTools} */ - #mediaBoxTools = null; - - /** @type {Set} */ - #tagsToRemove = new Set(); - - /** @type {Set} */ - #tagsToAdd = new Set(); - - /** @type {boolean} */ - #isPlanningToSubmit = false; - - /** @type {boolean} */ - #isSubmitting = false; - - /** @type {number|null} */ - #tagsSubmissionTimer = null; - + #tagsListElement: HTMLElement = document.createElement('div'); + #tagsList: HTMLElement[] = []; + #suggestedInvalidTags: Map = new Map(); + #activeProfile: MaintenanceProfile | null = null; + #mediaBoxTools: MediaBoxTools | null = null; + #tagsToRemove: Set = new Set(); + #tagsToAdd: Set = new Set(); + #isPlanningToSubmit: boolean = false; + #isSubmitting: boolean = false; + #tagsSubmissionTimer: number | null = null; #emitter = emitterAt(this); /** @@ -60,7 +40,6 @@ export class MaintenancePopup extends BaseComponent { this.container.innerHTML = ''; this.container.classList.add('maintenance-popup'); - this.#tagsListElement = document.createElement('div'); this.#tagsListElement.classList.add('tags-list'); this.container.append( @@ -72,14 +51,13 @@ export class MaintenancePopup extends BaseComponent { * @protected */ init() { - const mediaBoxToolsElement = this.container.closest('.media-box-tools'); + const mediaBoxToolsElement = this.container.closest('.media-box-tools'); if (!mediaBoxToolsElement) { throw new Error('Maintenance popup initialized outside of the media box tools!'); } - /** @type {MediaBoxTools|null} */ - const mediaBoxTools = getComponent(mediaBoxToolsElement); + const mediaBoxTools = getComponent(mediaBoxToolsElement); if (!mediaBoxTools) { throw new Error('Media box tools component not found!'); @@ -96,10 +74,7 @@ export class MaintenancePopup extends BaseComponent { mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this)); } - /** - * @param {MaintenanceProfile|null} activeProfile - */ - #onActiveProfileChanged(activeProfile) { + #onActiveProfileChanged(activeProfile: MaintenanceProfile | null) { this.#activeProfile = activeProfile; this.container.classList.toggle('is-active', activeProfile !== null); this.#refreshTagsList(); @@ -108,8 +83,11 @@ export class MaintenancePopup extends BaseComponent { } #refreshTagsList() { - /** @type {string[]} */ - const activeProfileTagsList = this.#activeProfile?.settings.tags || []; + if (!this.#mediaBoxTools) { + return; + } + + const activeProfileTagsList: string[] = this.#activeProfile?.settings.tags || []; for (const tagElement of this.#tagsList) { tagElement.remove(); @@ -147,17 +125,22 @@ export class MaintenancePopup extends BaseComponent { /** * Detect and process clicks made directly to the tags. - * @param {MouseEvent} event */ - #handleTagClick(event) { - /** @type {HTMLElement} */ - let tagElement = event.target; + #handleTagClick(event: MouseEvent) { + const targetObject = event.target; - if (!tagElement.classList.contains('tag')) { - tagElement = tagElement.closest('.tag'); + + if (!targetObject || !(targetObject instanceof HTMLElement)) { + return; } - if (!tagElement) { + let tagElement: HTMLElement | null = targetObject; + + if (!tagElement.classList.contains('tag')) { + tagElement = tagElement.closest('.tag'); + } + + if (!tagElement?.dataset.name) { return; } @@ -210,7 +193,7 @@ export class MaintenancePopup extends BaseComponent { } async #onSubmissionTimerPassed() { - if (!this.#isPlanningToSubmit || this.#isSubmitting) { + if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools) { return; } @@ -281,6 +264,10 @@ export class MaintenancePopup extends BaseComponent { } #revealInvalidTags() { + if (!this.#mediaBoxTools) { + return; + } + const tagsAndAliases = this.#mediaBoxTools.mediaBox.tagsAndAliases; if (!tagsAndAliases) { @@ -310,18 +297,11 @@ export class MaintenancePopup extends BaseComponent { } } - /** - * @return {boolean} - */ get isActive() { return this.container.classList.contains('is-active'); } - /** - * @param {string} tagName - * @return {HTMLElement} - */ - static #buildTagElement(tagName) { + static #buildTagElement(tagName: string): HTMLElement { const tagElement = document.createElement('span'); tagElement.classList.add('tag'); tagElement.innerText = tagName; @@ -332,28 +312,26 @@ export class MaintenancePopup extends BaseComponent { /** * Marks the tag with red color. - * @param {HTMLElement} tagElement Element to mark. + * @param tagElement Element to mark. */ - static #markTagAsInvalid(tagElement) { + static #markTagAsInvalid(tagElement: HTMLElement) { tagElement.dataset.tagCategory = 'error'; tagElement.setAttribute('data-tag-category', 'error'); } /** * Controller with maintenance settings. - * @type {MaintenanceSettings} */ static #maintenanceSettings = new MaintenanceSettings(); /** * Subscribe to all necessary feeds to watch for every active profile change. Additionally, will execute the callback * at the very start to retrieve the currently active profile. - * @param {function(MaintenanceProfile|null):void} callback Callback to execute whenever selection of active profile - * or profile itself has been changed. - * @return {function(): void} Unsubscribe function. Call it to stop watching for changes. + * @param callback Callback to execute whenever selection of active profile or profile itself has been changed. + * @return Unsubscribe function. Call it to stop watching for changes. */ - static #watchActiveProfile(callback) { - let lastActiveProfileId; + static #watchActiveProfile(callback: (profile: MaintenanceProfile | null) => void): () => void { + let lastActiveProfileId: string | null | undefined = null; const unsubscribeFromProfilesChanges = MaintenanceProfile.subscribe(profiles => { if (lastActiveProfileId) { @@ -393,9 +371,9 @@ export class MaintenancePopup extends BaseComponent { /** * Notify the frontend about new pending submission started. - * @param {boolean} isStarted True if started, false if ended. + * @param isStarted True if started, false if ended. */ - static #notifyAboutPendingSubmission(isStarted) { + static #notifyAboutPendingSubmission(isStarted: boolean) { if (this.#pendingSubmissionCount === null) { this.#pendingSubmissionCount = 0; this.#initializeExitPromptHandler(); @@ -424,9 +402,8 @@ export class MaintenancePopup extends BaseComponent { /** * Amount of pending submissions or NULL if logic was not yet initialized. - * @type {number|null} */ - static #pendingSubmissionCount = null; + static #pendingSubmissionCount: number|null = null; } export function createMaintenancePopup() { diff --git a/src/lib/components/MaintenanceStatusIcon.ts b/src/lib/components/MaintenanceStatusIcon.ts index 78ebd57..06c5bb6 100644 --- a/src/lib/components/MaintenanceStatusIcon.ts +++ b/src/lib/components/MaintenanceStatusIcon.ts @@ -2,16 +2,20 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; import { getComponent } from "$lib/components/base/component-utils"; import { on } from "$lib/components/events/comms"; import { eventMaintenanceStateChanged } from "$lib/components/events/maintenance-popup-events"; +import type { MediaBoxTools } from "$lib/components/MediaBoxTools"; export class MaintenanceStatusIcon extends BaseComponent { - /** @type {import('./MediaBoxTools').MediaBoxTools} */ - #mediaBoxTools; + #mediaBoxTools: MediaBoxTools | null = null; build() { this.container.innerText = '🔧'; } init() { + if (!this.container.parentElement) { + throw new Error('Missing parent element for the maintenance status icon!'); + } + this.#mediaBoxTools = getComponent(this.container.parentElement); if (!this.#mediaBoxTools) { @@ -21,10 +25,7 @@ export class MaintenanceStatusIcon extends BaseComponent { on(this.#mediaBoxTools, eventMaintenanceStateChanged, this.#onMaintenanceStateChanged.bind(this)); } - /** - * @param {CustomEvent} stateChangeEvent - */ - #onMaintenanceStateChanged(stateChangeEvent) { + #onMaintenanceStateChanged(stateChangeEvent: CustomEvent) { // TODO Replace those with FontAwesome icons later. Those icons can probably be sourced from the website itself. switch (stateChangeEvent.detail) { case "ready": diff --git a/src/lib/components/MediaBoxTools.ts b/src/lib/components/MediaBoxTools.ts index f2de6c5..754a424 100644 --- a/src/lib/components/MediaBoxTools.ts +++ b/src/lib/components/MediaBoxTools.ts @@ -3,16 +3,15 @@ import { getComponent } from "$lib/components/base/component-utils"; import { MaintenancePopup } from "$lib/components/MaintenancePopup"; import { on } from "$lib/components/events/comms"; import { eventActiveProfileChanged } from "$lib/components/events/maintenance-popup-events"; +import type { MediaBoxWrapper } from "$lib/components/MediaBoxWrapper"; +import type MaintenanceProfile from "$entities/MaintenanceProfile"; export class MediaBoxTools extends BaseComponent { - /** @type {import('./MediaBoxWrapper').MediaBoxWrapper|null} */ - #mediaBox; - - /** @type {MaintenancePopup|null} */ - #maintenancePopup = null; + #mediaBox: MediaBoxWrapper | null = null; + #maintenancePopup: MaintenancePopup | null = null; init() { - const mediaBoxElement = this.container.closest('.media-box'); + const mediaBoxElement = this.container.closest('.media-box'); if (!mediaBoxElement) { throw new Error('Toolbox element initialized outside of the media box!'); @@ -21,6 +20,10 @@ export class MediaBoxTools extends BaseComponent { this.#mediaBox = getComponent(mediaBoxElement); for (let childElement of this.container.children) { + if (!(childElement instanceof HTMLElement)) { + continue; + } + const component = getComponent(childElement); if (!component) { @@ -39,34 +42,25 @@ export class MediaBoxTools extends BaseComponent { on(this, eventActiveProfileChanged, this.#onActiveProfileChanged.bind(this)); } - /** - * @param {CustomEvent} profileChangedEvent - */ - #onActiveProfileChanged(profileChangedEvent) { + #onActiveProfileChanged(profileChangedEvent: CustomEvent) { this.container.classList.toggle('has-active-profile', profileChangedEvent.detail !== null); } - /** - * @return {MaintenancePopup|null} - */ - get maintenancePopup() { + get maintenancePopup(): MaintenancePopup | null { return this.#maintenancePopup; } - /** - * @return {import('./MediaBoxWrapper').MediaBoxWrapper|null} - */ - get mediaBox() { + get mediaBox(): MediaBoxWrapper | null { return this.#mediaBox; } } /** * Create a maintenance popup element. - * @param {HTMLElement[]} childrenElements List of children elements to append to the component. - * @return {HTMLElement} The maintenance popup element. + * @param childrenElements List of children elements to append to the component. + * @return The maintenance popup element. */ -export function createMediaBoxTools(...childrenElements) { +export function createMediaBoxTools(...childrenElements: HTMLElement[]): HTMLElement { const mediaBoxToolsContainer = document.createElement('div'); mediaBoxToolsContainer.classList.add('media-box-tools'); diff --git a/src/lib/components/MediaBoxWrapper.ts b/src/lib/components/MediaBoxWrapper.ts index ff0ec34..9e75ed3 100644 --- a/src/lib/components/MediaBoxWrapper.ts +++ b/src/lib/components/MediaBoxWrapper.ts @@ -5,23 +5,18 @@ import { on } from "$lib/components/events/comms"; import { eventTagsUpdated } from "$lib/components/events/maintenance-popup-events"; export class MediaBoxWrapper extends BaseComponent { - #thumbnailContainer = null; - #imageLinkElement = null; - - /** @type {Map|null} */ - #tagsAndAliases = null; + #thumbnailContainer: HTMLElement | null = null; + #imageLinkElement: HTMLAnchorElement | null = null; + #tagsAndAliases: Map | null = null; init() { this.#thumbnailContainer = this.container.querySelector('.image-container'); - this.#imageLinkElement = this.#thumbnailContainer.querySelector('a'); + this.#imageLinkElement = this.#thumbnailContainer?.querySelector('a') || null; on(this, eventTagsUpdated, this.#onTagsUpdatedRefreshTagsAndAliases.bind(this)); } - /** - * @param {CustomEvent|null>} tagsUpdatedEvent - */ - #onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent) { + #onTagsUpdatedRefreshTagsAndAliases(tagsUpdatedEvent: CustomEvent | null>) { const updatedMap = tagsUpdatedEvent.detail; if (!(updatedMap instanceof Map)) { @@ -32,18 +27,13 @@ export class MediaBoxWrapper extends BaseComponent { } #calculateMediaBoxTags() { - /** @type {string[]|string[]} */ - const - tagAliases = this.#thumbnailContainer.dataset.imageTagAliases?.split(', ') || [], - actualTags = this.#imageLinkElement.title.split(' | Tagged: ')[1]?.split(', ') || []; + const tagAliases: string[] = this.#thumbnailContainer?.dataset.imageTagAliases?.split(', ') || []; + const actualTags = this.#imageLinkElement?.title.split(' | Tagged: ')[1]?.split(', ') || []; return buildTagsAndAliasesMap(tagAliases, actualTags); } - /** - * @return {Map|null} - */ - get tagsAndAliases() { + get tagsAndAliases(): Map | null { if (!this.#tagsAndAliases) { this.#tagsAndAliases = this.#calculateMediaBoxTags(); } @@ -51,26 +41,31 @@ export class MediaBoxWrapper extends BaseComponent { return this.#tagsAndAliases; } - get imageId() { - return parseInt( - this.container.dataset.imageId - ); + get imageId(): number { + const imageId = this.container.dataset.imageId; + + if (!imageId) { + throw new Error('Missing image ID'); + } + + return parseInt(imageId); } - /** - * @return {App.ImageURIs} - */ - get imageLinks() { - return JSON.parse(this.#thumbnailContainer.dataset.uris); + get imageLinks(): App.ImageURIs { + const jsonUris = this.#thumbnailContainer?.dataset.uris; + + if (!jsonUris) { + throw new Error('Missing URIs!'); + } + + return JSON.parse(jsonUris); } } /** * Wrap the media box element into the special wrapper. - * @param {HTMLElement} mediaBoxContainer - * @param {HTMLElement[]} childComponentElements */ -export function initializeMediaBox(mediaBoxContainer, childComponentElements) { +export function initializeMediaBox(mediaBoxContainer: HTMLElement, childComponentElements: HTMLElement[]) { new MediaBoxWrapper(mediaBoxContainer) .initialize(); @@ -80,17 +75,12 @@ export function initializeMediaBox(mediaBoxContainer, childComponentElements) { } } -/** - * @param {NodeListOf} mediaBoxesList - */ -export function calculateMediaBoxesPositions(mediaBoxesList) { +export function calculateMediaBoxesPositions(mediaBoxesList: NodeListOf) { window.addEventListener('resize', () => { - /** @type {HTMLElement|null} */ - let lastMediaBox = null, - /** @type {number|null} */ - lastMediaBoxPosition = null; + let lastMediaBox: HTMLElement | null = null; + let lastMediaBoxPosition: number | null = null; - for (let mediaBoxElement of mediaBoxesList) { + for (const mediaBoxElement of mediaBoxesList) { const yPosition = mediaBoxElement.getBoundingClientRect().y; const isOnTheSameLine = yPosition === lastMediaBoxPosition; diff --git a/src/lib/components/SearchWrapper.ts b/src/lib/components/SearchWrapper.ts index 80f2da4..aeebdde 100644 --- a/src/lib/components/SearchWrapper.ts +++ b/src/lib/components/SearchWrapper.ts @@ -1,29 +1,25 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; import { QueryLexer, QuotedTermToken, TermToken, Token } from "$lib/booru/search/QueryLexer"; -import SearchSettings from "$lib/extension/settings/SearchSettings"; +import SearchSettings, { type SuggestionsPosition } from "$lib/extension/settings/SearchSettings"; export class SearchWrapper extends BaseComponent { - /** @type {HTMLInputElement|null} */ - #searchField = null; - /** @type {string|null} */ - #lastParsedSearchValue = null; - /** @type {Token[]} */ - #cachedParsedQuery = []; - #searchSettings = new SearchSettings(); - #arePropertiesSuggestionsEnabled = false; - /** @type {"start"|"end"} */ - #propertiesSuggestionsPosition = "start"; - /** @type {HTMLElement|null} */ - #cachedAutocompleteContainer = null; - /** @type {TermToken|QuotedTermToken|null} */ - #lastTermToken = null; + #searchField: HTMLInputElement | null = null; + #lastParsedSearchValue: string | null = null; + #cachedParsedQuery: Token[] = []; + #searchSettings: SearchSettings = new SearchSettings(); + #arePropertiesSuggestionsEnabled: boolean = false; + #propertiesSuggestionsPosition: SuggestionsPosition = "start"; + #cachedAutocompleteContainer: HTMLElement | null = null; + #lastTermToken: TermToken | QuotedTermToken | null = null; build() { this.#searchField = this.container.querySelector('input[name=q]'); } init() { - this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this)); + if (this.#searchField) { + this.#searchField.addEventListener('input', this.#onInputFindProperties.bind(this)) + } this.#searchSettings.resolvePropertiesSuggestionsEnabled() .then(isEnabled => this.#arePropertiesSuggestionsEnabled = isEnabled); @@ -31,18 +27,18 @@ export class SearchWrapper extends BaseComponent { .then(position => this.#propertiesSuggestionsPosition = position); this.#searchSettings.subscribe(settings => { - this.#arePropertiesSuggestionsEnabled = settings.suggestProperties; - this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition; + this.#arePropertiesSuggestionsEnabled = Boolean(settings.suggestProperties); + this.#propertiesSuggestionsPosition = settings.suggestPropertiesPosition || "start"; }); } /** * Catch the user input and execute suggestions logic. - * @param {InputEvent} event Source event to find the input element from. + * @param event Source event to find the input element from. */ - #onInputFindProperties(event) { + #onInputFindProperties(event: Event) { // Ignore events until option is enabled. - if (!this.#arePropertiesSuggestionsEnabled) { + if (!this.#arePropertiesSuggestionsEnabled || !(event.currentTarget instanceof HTMLInputElement)) { return; } @@ -60,20 +56,26 @@ export class SearchWrapper extends BaseComponent { /** * Get the selection position in the search field. - * @return {number} */ - #getInputUserSelection() { + #getInputUserSelection(): number { + if (!this.#searchField) { + throw new Error('Missing search field!'); + } + return Math.min( - this.#searchField.selectionStart, - this.#searchField.selectionEnd + this.#searchField.selectionStart ?? 0, + this.#searchField.selectionEnd ?? 0, ); } /** * Parse the search query and return the list of parsed tokens. Result will be cached for current search query. - * @return {Token[]} */ - #resolveQueryTokens() { + #resolveQueryTokens(): Token[] { + if (!this.#searchField) { + throw new Error('Missing search field!'); + } + const searchValue = this.#searchField.value; if (searchValue === this.#lastParsedSearchValue && this.#cachedParsedQuery) { @@ -88,9 +90,9 @@ export class SearchWrapper extends BaseComponent { /** * Find the currently selected term. - * @return {string|null} Selected term or null if none found. + * @return Selected term or null if none found. */ - #findCurrentTagFragment() { + #findCurrentTagFragment(): string | null { if (!this.#searchField) { return null; } @@ -127,9 +129,9 @@ export class SearchWrapper extends BaseComponent { * * This means, that properties will only be suggested once actual autocomplete logic was activated. * - * @return {HTMLElement|null} Resolved element or nothing. + * @return Resolved element or nothing. */ - #resolveAutocompleteContainer() { + #resolveAutocompleteContainer(): HTMLElement | null { if (this.#cachedAutocompleteContainer) { return this.#cachedAutocompleteContainer; } @@ -141,11 +143,10 @@ export class SearchWrapper extends BaseComponent { /** * Render the list of suggestions into the existing popup or create and populate a new one. - * @param {string[]} suggestions List of suggestion to render the popup from. - * @param {HTMLInputElement} targetInput Target input to attach the popup to. + * @param suggestions List of suggestion to render the popup from. + * @param targetInput Target input to attach the popup to. */ - #renderSuggestions(suggestions, targetInput) { - /** @type {HTMLElement[]} */ + #renderSuggestions(suggestions: string[], targetInput: HTMLInputElement) { const suggestedListItems = suggestions .map(suggestedTerm => this.#renderTermSuggestion(suggestedTerm)); @@ -170,6 +171,10 @@ export class SearchWrapper extends BaseComponent { const listContainer = autocompleteContainer.querySelector('ul'); + if (!listContainer) { + return; + } + switch (this.#propertiesSuggestionsPosition) { case "start": listContainer.prepend(...suggestedListItems); @@ -183,10 +188,11 @@ export class SearchWrapper extends BaseComponent { console.warn("Invalid position for property suggestions!"); } + const parentScrollTop = targetInput.parentElement?.scrollTop ?? 0; autocompleteContainer.style.position = 'absolute'; autocompleteContainer.style.left = `${targetInput.offsetLeft}px`; - autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - targetInput.parentElement.scrollTop}px`; + autocompleteContainer.style.top = `${targetInput.offsetTop + targetInput.offsetHeight - parentScrollTop}px`; document.body.append(autocompleteContainer); }) @@ -194,30 +200,28 @@ export class SearchWrapper extends BaseComponent { /** * Loosely estimate where current selected search term is located and return it if found. - * @param {Token[]} tokens Search value to find the actively selected term from. - * @param {number} userSelectionIndex The index of the user selection. - * @return {Token|null} Search term object or NULL if nothing found. + * @param tokens Search value to find the actively selected term from. + * @param userSelectionIndex The index of the user selection. + * @return Search term object or NULL if nothing found. */ - static #findActiveSearchTermPosition(tokens, userSelectionIndex) { + static #findActiveSearchTermPosition(tokens: Token[], userSelectionIndex: number): Token | null { return tokens.find( token => token.index < userSelectionIndex && token.index + token.value.length >= userSelectionIndex - ); + ) ?? null; } /** * Regular expression to search the properties' syntax. - * @type {RegExp} */ static #propertySearchTermHeadingRegExp = /^(?[a-z\d_]+)(?\.(?[a-z]*))?(?:(?.*))?$/; /** * Create a list of suggested elements using the input received from the user. - * @param {string} searchTermValue Original decoded term received from the user. + * @param searchTermValue Original decoded term received from the user. * @return {string[]} List of suggestions. Could be empty. */ - static #resolveSuggestionsFromTerm(searchTermValue) { - /** @type {string[]} */ - const suggestionsList = []; + static #resolveSuggestionsFromTerm(searchTermValue: string): string[] { + const suggestionsList: string[] = []; this.#propertySearchTermHeadingRegExp.lastIndex = 0; const parsedResult = this.#propertySearchTermHeadingRegExp.exec(searchTermValue); @@ -226,22 +230,28 @@ export class SearchWrapper extends BaseComponent { return suggestionsList; } - const propertyName = parsedResult.groups.name; + const propertyName = parsedResult.groups?.name; + + if (!propertyName) { + return suggestionsList; + } + const propertyType = this.#properties.get(propertyName); - const hasOperatorSyntax = Boolean(parsedResult.groups.op_syntax); - const hasValueSyntax = Boolean(parsedResult.groups.value_syntax); + const hasOperatorSyntax = Boolean(parsedResult.groups?.op_syntax); + const hasValueSyntax = Boolean(parsedResult.groups?.value_syntax); // No suggestions for values for now, maybe could add suggestions for namespaces like my:* - if (hasValueSyntax) { + if (hasValueSyntax && propertyType) { if (this.#typeValues.has(propertyType)) { - const givenValue = parsedResult.groups.value; + const givenValue = parsedResult.groups?.value; + const candidateValues = this.#typeValues.get(propertyType) || []; - for (let candidateValue of this.#typeValues.get(propertyType)) { + for (let candidateValue of candidateValues) { if (givenValue && !candidateValue.startsWith(givenValue)) { continue; } - suggestionsList.push(`${propertyName}${parsedResult.groups.op_syntax ?? ''}:${candidateValue}`); + suggestionsList.push(`${propertyName}${parsedResult.groups?.op_syntax ?? ''}:${candidateValue}`); } } @@ -249,11 +259,12 @@ export class SearchWrapper extends BaseComponent { } // If at least one dot placed, start suggesting operators - if (hasOperatorSyntax) { + if (hasOperatorSyntax && propertyType) { if (this.#typeOperators.has(propertyType)) { - const operatorName = parsedResult.groups.op; + const operatorName = parsedResult.groups?.op; + const candidateOperators = this.#typeOperators.get(propertyType) ?? []; - for (let candidateOperator of this.#typeOperators.get(propertyType)) { + for (let candidateOperator of candidateOperators) { if (operatorName && !candidateOperator.startsWith(operatorName)) { continue; } @@ -279,11 +290,10 @@ export class SearchWrapper extends BaseComponent { /** * Render a single suggestion item and connect required events to interact with the user. - * @param {string} suggestedTerm Term to use for suggestion item. - * @return {HTMLElement} Resulting element. + * @param suggestedTerm Term to use for suggestion item. + * @return Resulting element. */ - #renderTermSuggestion(suggestedTerm) { - /** @type {HTMLElement} */ + #renderTermSuggestion(suggestedTerm: string): HTMLElement { const suggestionItem = document.createElement('li'); suggestionItem.classList.add('autocomplete__item', 'autocomplete__item--property'); suggestionItem.dataset.value = suggestedTerm; @@ -311,10 +321,10 @@ export class SearchWrapper extends BaseComponent { /** * Automatically replace the last active token stored in the variable with the new value. - * @param {string} suggestedTerm Term to replace the value with. + * @param suggestedTerm Term to replace the value with. */ - #replaceLastActiveTokenWithSuggestion(suggestedTerm) { - if (!this.#lastTermToken) { + #replaceLastActiveTokenWithSuggestion(suggestedTerm: string) { + if (!this.#lastTermToken || !this.#searchField) { return; } @@ -334,10 +344,10 @@ export class SearchWrapper extends BaseComponent { /** * Find the selected suggestion item(s) and unselect them. Similar to the logic implemented by the Philomena's * front-end. - * @param {HTMLElement} suggestedElement Target element to search from. If element is disconnected from the DOM, - * search will be halted. + * @param suggestedElement Target element to search from. If element is disconnected from the DOM, search will be + * halted. */ - static #findAndResetSelectedSuggestion(suggestedElement) { + static #findAndResetSelectedSuggestion(suggestedElement: HTMLElement) { if (!suggestedElement.parentElement) { return; } diff --git a/src/lib/components/SiteHeaderWrapper.ts b/src/lib/components/SiteHeaderWrapper.ts index 3790097..c1b22fe 100644 --- a/src/lib/components/SiteHeaderWrapper.ts +++ b/src/lib/components/SiteHeaderWrapper.ts @@ -2,11 +2,10 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; import { SearchWrapper } from "$lib/components/SearchWrapper"; class SiteHeaderWrapper extends BaseComponent { - /** @type {SearchWrapper|null} */ - #searchWrapper = null; + #searchWrapper: SearchWrapper | null = null; build() { - const searchForm = this.container.querySelector('.header__search'); + const searchForm = this.container.querySelector('.header__search'); this.#searchWrapper = searchForm && new SearchWrapper(searchForm) || null; } @@ -17,7 +16,7 @@ class SiteHeaderWrapper extends BaseComponent { } } -export function initializeSiteHeader(siteHeaderElement) { +export function initializeSiteHeader(siteHeaderElement: HTMLElement) { new SiteHeaderWrapper(siteHeaderElement) .initialize(); } diff --git a/src/lib/components/TagDropdownWrapper.ts b/src/lib/components/TagDropdownWrapper.ts index 3b09f08..0f3335c 100644 --- a/src/lib/components/TagDropdownWrapper.ts +++ b/src/lib/components/TagDropdownWrapper.ts @@ -4,44 +4,35 @@ import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings"; import { getComponent } from "$lib/components/base/component-utils"; import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver"; -const isTagEditorProcessedKey = Symbol(); const categoriesResolver = new CustomCategoriesResolver(); export class TagDropdownWrapper extends BaseComponent { /** * Container with dropdown elements to insert options into. - * @type {HTMLElement} */ - #dropdownContainer; + #dropdownContainer: HTMLElement | null = null; /** * Button to add or remove the current tag into/from the active profile. - * @type {HTMLAnchorElement|null} */ - #toggleOnExistingButton = null; + #toggleOnExistingButton: HTMLAnchorElement | null = null; /** * Button to create a new profile, make it active and add the current tag into the active profile. - * @type {HTMLAnchorElement|null} */ - #addToNewButton = null; + #addToNewButton: HTMLAnchorElement | null = null; /** * Local clone of the currently active profile used for updating the list of tags. - * @type {MaintenanceProfile|null} */ - #activeProfile = null; + #activeProfile: MaintenanceProfile | null = null; /** * Is cursor currently entered the dropdown. - * @type {boolean} */ - #isEntered = false; + #isEntered: boolean = false; - /** - * @type {string|undefined|null} - */ - #originalCategory = null; + #originalCategory: string | undefined | null = null; build() { this.#dropdownContainer = this.container.querySelector('.dropdown__content'); @@ -116,7 +107,7 @@ export class TagDropdownWrapper extends BaseComponent { ); if (!this.#addToNewButton.isConnected) { - this.#dropdownContainer.append(this.#addToNewButton); + this.#dropdownContainer?.append(this.#addToNewButton); } } else { this.#addToNewButton?.remove(); @@ -130,15 +121,16 @@ export class TagDropdownWrapper extends BaseComponent { const profileName = this.#activeProfile.settings.name; let profileSpecificButtonText = `Add to profile "${profileName}"`; + const tagName = this.tagName; - if (this.#activeProfile.settings.tags.includes(this.tagName)) { + if (tagName && this.#activeProfile.settings.tags.includes(tagName)) { profileSpecificButtonText = `Remove from profile "${profileName}"`; } this.#toggleOnExistingButton.innerText = profileSpecificButtonText; if (!this.#toggleOnExistingButton.isConnected) { - this.#dropdownContainer.append(this.#toggleOnExistingButton); + this.#dropdownContainer?.append(this.#toggleOnExistingButton); } return; @@ -148,6 +140,12 @@ export class TagDropdownWrapper extends BaseComponent { } async #onAddToNewClicked() { + const tagName = this.tagName; + + if (!tagName) { + throw new Error('Missing tag name to create the profile!'); + } + const profile = new MaintenanceProfile(crypto.randomUUID(), { name: 'Temporary Profile (' + (new Date().toISOString()) + ')', tags: [this.tagName], @@ -166,6 +164,10 @@ export class TagDropdownWrapper extends BaseComponent { const tagsList = new Set(this.#activeProfile.settings.tags); const targetTagName = this.tagName; + if (!targetTagName) { + throw new Error('Missing tag name!'); + } + if (tagsList.has(targetTagName)) { tagsList.delete(targetTagName); } else { @@ -181,14 +183,14 @@ export class TagDropdownWrapper extends BaseComponent { /** * Watch for changes to active profile. - * @param {(profile: MaintenanceProfile|null) => void} onActiveProfileChange Callback to call when profile was + * @param onActiveProfileChange Callback to call when profile was * changed. */ - static #watchActiveProfile(onActiveProfileChange) { - let lastActiveProfile; + static #watchActiveProfile(onActiveProfileChange: (profile: MaintenanceProfile|null) => void) { + let lastActiveProfile: string | null = null; this.#maintenanceSettings.subscribe((settings) => { - lastActiveProfile = settings.activeProfile; + lastActiveProfile = settings.activeProfile ?? null; this.#maintenanceSettings .resolveActiveProfileAsObject() @@ -199,7 +201,8 @@ export class TagDropdownWrapper extends BaseComponent { const activeProfile = profiles .find(profile => profile.id === lastActiveProfile); - onActiveProfileChange(activeProfile); + onActiveProfileChange(activeProfile ?? null + ); }); this.#maintenanceSettings @@ -212,12 +215,11 @@ export class TagDropdownWrapper extends BaseComponent { /** * Create element for dropdown. - * @param {string} text Base text for the option. - * @param {(event: MouseEvent) => void} onClickHandler Click handler. Event will be prevented by default. - * @return {HTMLAnchorElement} + * @param text Base text for the option. + * @param onClickHandler Click handler. Event will be prevented by default. + * @return */ - static #createDropdownLink(text, onClickHandler) { - /** @type {HTMLAnchorElement} */ + static #createDropdownLink(text: string, onClickHandler: (event: MouseEvent) => void): HTMLAnchorElement { const dropdownLink = document.createElement('a'); dropdownLink.href = '#'; dropdownLink.innerText = text; @@ -232,7 +234,7 @@ export class TagDropdownWrapper extends BaseComponent { } } -export function wrapTagDropdown(element) { +export function wrapTagDropdown(element: HTMLElement) { // Skip initialization when tag component is already wrapped if (getComponent(element)) { return; @@ -244,6 +246,8 @@ export function wrapTagDropdown(element) { categoriesResolver.addElement(tagDropdown); } +const processedElementsSet = new WeakSet(); + export function watchTagDropdownsInTagsEditor() { // We only need to watch for new editor elements if there is a tag editor present on the page if (!document.querySelector('#image_tags_and_source')) { @@ -251,25 +255,27 @@ export function watchTagDropdownsInTagsEditor() { } document.body.addEventListener('mouseover', event => { - /** @type {HTMLElement} */ const targetElement = event.target; - if (targetElement[isTagEditorProcessedKey]) { + if (!(targetElement instanceof HTMLElement)) { return; } - /** @type {HTMLElement|null} */ - const closestTagEditor = targetElement.closest('#image_tags_and_source'); - - if (!closestTagEditor || closestTagEditor[isTagEditorProcessedKey]) { - targetElement[isTagEditorProcessedKey] = true; + if (processedElementsSet.has(targetElement)) { return; } - targetElement[isTagEditorProcessedKey] = true; - closestTagEditor[isTagEditorProcessedKey] = true; + const closestTagEditor = targetElement.closest('#image_tags_and_source'); - for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) { + if (!closestTagEditor || processedElementsSet.has(closestTagEditor)) { + processedElementsSet.add(targetElement); + return; + } + + processedElementsSet.add(targetElement); + processedElementsSet.add(closestTagEditor); + + for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) { wrapTagDropdown(tagDropdownElement); } }) diff --git a/src/lib/components/TagsForm.ts b/src/lib/components/TagsForm.ts index 5376ca3..60aa018 100644 --- a/src/lib/components/TagsForm.ts +++ b/src/lib/components/TagsForm.ts @@ -7,9 +7,9 @@ export class TagsForm extends BaseComponent { */ refreshTagColors() { const tagCategories = this.#gatherTagCategories(); - const editableTags = this.container.querySelectorAll('.tag'); + const editableTags = this.container.querySelectorAll('.tag'); - for (let tagElement of editableTags) { + for (const tagElement of editableTags) { // Tag name is stored in the "remove" link and not in the tag itself. const removeLink = tagElement.querySelector('a'); @@ -19,11 +19,11 @@ export class TagsForm extends BaseComponent { const tagName = removeLink.dataset.tagName; - if (!tagCategories.has(tagName)) { + if (!tagName || !tagCategories.has(tagName)) { continue; } - const categoryName = tagCategories.get(tagName); + const categoryName = tagCategories.get(tagName)!; tagElement.dataset.tagCategory = categoryName; tagElement.setAttribute('data-tag-category', categoryName); @@ -32,14 +32,21 @@ export class TagsForm extends BaseComponent { /** * Collect list of categories from the tags on the page. - * @return {Map} + * @return */ - #gatherTagCategories() { - /** @type {Map} */ - const tagCategories = new Map(); + #gatherTagCategories(): Map { + const tagCategories: Map = new Map(); - for (let tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) { - tagCategories.set(tagElement.dataset.tagName, tagElement.dataset.tagCategory); + for (const tagElement of document.querySelectorAll('.tag[data-tag-name][data-tag-category]')) { + const tagName = tagElement.dataset.tagName; + const tagCategory = tagElement.dataset.tagCategory; + + if (!tagName || !tagCategory) { + console.warn('Missing tag name or category!'); + continue; + } + + tagCategories.set(tagName, tagCategory); } return tagCategories; @@ -59,23 +66,26 @@ export class TagsForm extends BaseComponent { return; } - const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags') + const refreshTrigger = targetElement.closest('.js-taginput-show, #edit-tags') if (!refreshTrigger) { return; } - const tagFormElement = tagEditorWrapper.querySelector('#tags-form'); + const tagFormElement = tagEditorWrapper.querySelector('#tags-form'); + + if (!tagFormElement) { + return; + } - /** @type {TagsForm|null} */ let tagEditor = getComponent(tagFormElement); - if (!tagEditor || (!tagEditor instanceof TagsForm)) { + if (!tagEditor || !(tagEditor instanceof TagsForm)) { tagEditor = new TagsForm(tagFormElement); tagEditor.initialize(); } - tagEditor.refreshTagColors(); + (tagEditor as TagsForm).refreshTagColors(); }); } } diff --git a/src/lib/components/base/BaseComponent.ts b/src/lib/components/base/BaseComponent.ts index 04ac8fd..46596f0 100644 --- a/src/lib/components/base/BaseComponent.ts +++ b/src/lib/components/base/BaseComponent.ts @@ -1,18 +1,14 @@ import { bindComponent } from "$lib/components/base/component-utils"; -/** - * @abstract - */ -export class BaseComponent { - /** @type {HTMLElement} */ - #container; +type ComponentEventListener = + (this: HTMLElement, event: HTMLElementEventMap[EventName]) => void; + +export abstract class BaseComponent { + readonly #container: ContainerType; #isInitialized = false; - /** - * @param {HTMLElement} container - */ - constructor(container) { + constructor(container: ContainerType) { this.#container = container; bindComponent(container, this); @@ -29,42 +25,33 @@ export class BaseComponent { this.init(); } - /** - * @protected - */ - build() { + protected build(): void { // This method can be implemented by the component classes to modify or create the inner elements. } - /** - * @protected - */ - init() { + protected init(): void { // This method can be implemented by the component classes to initialize the component. - } + }; - /** - * @return {HTMLElement} - */ - get container() { + get container(): ContainerType { return this.#container; } /** * Check if the component is initialized already. If not checked, subsequent calls to the `initialize` method will * throw an error. - * @return {boolean} + * @return */ - get isInitialized() { + get isInitialized(): boolean { return this.#isInitialized; } /** * Emit the custom event on the container element. - * @param {keyof HTMLElementEventMap|string} event The event name. - * @param {any} [detail] The event detail. Can be omitted. + * @param event The event name. + * @param [detail] The event detail. Can be omitted. */ - emit(event, detail = undefined) { + emit(event: keyof HTMLElementEventMap | string, detail: any = undefined): void { this.#container.dispatchEvent( new CustomEvent( event, @@ -78,12 +65,16 @@ export class BaseComponent { /** * Subscribe to the DOM event on the container element. - * @param {keyof HTMLElementEventMap|string} event The event name. - * @param {function(Event): void} listener The event listener. - * @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted. - * @return {function(): void} The unsubscribe function. + * @param event The event name. + * @param listener The event listener. + * @param [options] The event listener options. Can be omitted. + * @return The unsubscribe function. */ - on(event, listener, options = undefined) { + on( + event: EventName, + listener: ComponentEventListener, + options?: AddEventListenerOptions, + ): () => void { this.#container.addEventListener(event, listener, options); return () => void this.#container.removeEventListener(event, listener, options); @@ -91,12 +82,16 @@ export class BaseComponent { /** * Subscribe to the DOM event on the container element. The event listener will be called only once. - * @param {keyof HTMLElementEventMap|string} event The event name. - * @param {function(Event): void} listener The event listener. - * @param {AddEventListenerOptions|undefined} [options] The event listener options. Can be omitted. - * @return {function(): void} The unsubscribe function. + * @param event The event name. + * @param listener The event listener. + * @param [options] The event listener options. Can be omitted. + * @return The unsubscribe function. */ - once(event, listener, options = undefined) { + once( + event: EventName, + listener: ComponentEventListener, + options?: AddEventListenerOptions, + ): () => void { options = options || {}; options.once = true; diff --git a/src/lib/components/base/component-utils.ts b/src/lib/components/base/component-utils.ts index 636b8f4..5f6bd42 100644 --- a/src/lib/components/base/component-utils.ts +++ b/src/lib/components/base/component-utils.ts @@ -2,8 +2,8 @@ import type { BaseComponent } from "$lib/components/base/BaseComponent"; const instanceSymbol = Symbol('instance'); -interface ElementWithComponent extends HTMLElement { - [instanceSymbol]?: BaseComponent; +interface ElementWithComponent extends HTMLElement { + [instanceSymbol]?: T; } /** @@ -11,7 +11,7 @@ interface ElementWithComponent extends HTMLElement { * @param {HTMLElement} element * @return */ -export function getComponent(element: ElementWithComponent): BaseComponent | null { +export function getComponent(element: ElementWithComponent): T | null { return element[instanceSymbol] || null; } @@ -20,7 +20,7 @@ export function getComponent(element: ElementWithComponent): BaseComponent | nul * @param element The element to bind the component to. * @param instance The component instance. */ -export function bindComponent(element: ElementWithComponent, instance: BaseComponent): void { +export function bindComponent(element: ElementWithComponent, instance: T): void { if (element[instanceSymbol]) { throw new Error('The element is already bound to a component.'); } diff --git a/src/lib/components/events/comms.ts b/src/lib/components/events/comms.ts index 8db137c..30f4961 100644 --- a/src/lib/components/events/comms.ts +++ b/src/lib/components/events/comms.ts @@ -1,7 +1,8 @@ import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events"; import { BaseComponent } from "$lib/components/base/BaseComponent"; +import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events"; -interface EventsMapping extends MaintenancePopupEventsMap { +interface EventsMapping extends MaintenancePopupEventsMap, FullscreenViewerEventsMap { } type EventCallback = (event: CustomEvent) => void; diff --git a/src/lib/components/events/fullscreen-viewer-events.ts b/src/lib/components/events/fullscreen-viewer-events.ts new file mode 100644 index 0000000..333a917 --- /dev/null +++ b/src/lib/components/events/fullscreen-viewer-events.ts @@ -0,0 +1,7 @@ +import type { FullscreenViewerSize } from "$lib/extension/settings/MiscSettings"; + +export const eventSizeLoaded = 'size-loaded'; + +export interface FullscreenViewerEventsMap { + [eventSizeLoaded]: FullscreenViewerSize; +} diff --git a/src/lib/extension/settings/MiscSettings.ts b/src/lib/extension/settings/MiscSettings.ts index 0544c29..c8b4508 100644 --- a/src/lib/extension/settings/MiscSettings.ts +++ b/src/lib/extension/settings/MiscSettings.ts @@ -1,6 +1,6 @@ import CacheableSettings from "$lib/extension/base/CacheableSettings"; -export type FullscreenViewerSize = 'small' | 'medium' | 'large' | 'full'; +export type FullscreenViewerSize = keyof App.ImageURIs; interface MiscSettingsFields { fullscreenViewer: boolean; From 7aabb683cf5236bbb5f85ed1e227bfdce797c07b Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 21 Feb 2025 22:03:20 +0400 Subject: [PATCH 15/52] Making component not abstract to run tests on it --- src/lib/components/base/BaseComponent.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/components/base/BaseComponent.ts b/src/lib/components/base/BaseComponent.ts index 46596f0..fd3ecdd 100644 --- a/src/lib/components/base/BaseComponent.ts +++ b/src/lib/components/base/BaseComponent.ts @@ -3,7 +3,7 @@ import { bindComponent } from "$lib/components/base/component-utils"; type ComponentEventListener = (this: HTMLElement, event: HTMLElementEventMap[EventName]) => void; -export abstract class BaseComponent { +export class BaseComponent { readonly #container: ContainerType; #isInitialized = false; From 45a8c436be39f6bdf12fb492a5bdcbd4bd5cce83 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 21 Feb 2025 22:03:35 +0400 Subject: [PATCH 16/52] Added tests for the base component class --- .../lib/components/base/BaseComponent.spec.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/lib/components/base/BaseComponent.spec.ts diff --git a/tests/lib/components/base/BaseComponent.spec.ts b/tests/lib/components/base/BaseComponent.spec.ts new file mode 100644 index 0000000..d15392c --- /dev/null +++ b/tests/lib/components/base/BaseComponent.spec.ts @@ -0,0 +1,109 @@ +import { BaseComponent } from "$lib/components/base/BaseComponent"; +import { getComponent } from "$lib/components/base/component-utils"; + +function randomString() { + return crypto.randomUUID(); +} + +describe('BaseComponent', () => { + it('should bind the component to the element', () => { + const element = document.createElement('div'); + const component = new BaseComponent(element); + + expect(getComponent(element)).toBe(component); + }); + + it('should throw an error when attempting to initialize component on same element multiple times', () => { + const element = document.createElement('div'); + + expect(() => new BaseComponent(element)).not.toThrowError(); + expect(() => new BaseComponent(element)).toThrowError(); + }); + + it('should return the element as component container', () => { + const element = document.createElement('div'); + const component = new BaseComponent(element); + + expect(component.container).toBe(element); + }); + + it('should mark itself as initialized after initialization', () => { + const element = document.createElement('div'); + const component = new BaseComponent(element); + + expect(component.isInitialized).toBe(false); + component.initialize(); + expect(component.isInitialized).toBe(true); + }); + + it('should throw error when attempting to initialize component multiple times', () => { + const element = document.createElement('div'); + const component = new BaseComponent(element); + + expect(() => component.initialize()).not.toThrowError(); + expect(() => component.initialize()).toThrowError(); + }); + + it('should emit custom events on element', () => { + const element = document.createElement('div'); + const component = new BaseComponent(element); + + let receivedEvent: CustomEvent | null = null; + + const eventName = randomString(); + const eventData = randomString(); + const eventHandler = vi.fn(event => { + receivedEvent = event; + }); + + element.addEventListener(eventName, eventHandler); + component.emit(eventName, eventData); + + expect(eventHandler).toBeCalled(); + expect(receivedEvent).toBeInstanceOf(CustomEvent); + expect(receivedEvent!.detail).toBe(eventData); + }); + + it('should listen events on element', () => { + const element = document.createElement('div'); + const component = new BaseComponent(element); + + const eventName = 'click'; + const eventHandler = vi.fn(); + + component.on(eventName, eventHandler); + element.dispatchEvent(new Event(eventName)); + expect(eventHandler).toBeCalled(); + }); + + it('should disconnect listener with unsubscribe function', () => { + const element = document.createElement('div'); + const component = new BaseComponent(element); + + const eventName = 'click'; + const eventHandler = vi.fn(); + + const unsubscribe = component.on(eventName, eventHandler); + + element.dispatchEvent(new Event(eventName)); + unsubscribe(); + element.dispatchEvent(new Event(eventName)); + + expect(eventHandler).toBeCalledTimes(1); + }); + + it('should listen for event once', () => { + const element = document.createElement('div'); + const component = new BaseComponent(element); + + const eventName = 'click'; + const eventHandler = vi.fn(); + + component.once(eventName, eventHandler); + + element.dispatchEvent(new Event(eventName)); + element.dispatchEvent(new Event(eventName)); + + expect(eventHandler).toBeCalledTimes(1); + }); +}); From dfab62599926f8303a1138b0ac40269e7f266784 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 23 Feb 2025 03:38:06 +0400 Subject: [PATCH 17/52] Added event returned from booru on form submissions --- src/lib/components/events/booru-events.ts | 5 +++++ src/lib/components/events/comms.ts | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 src/lib/components/events/booru-events.ts diff --git a/src/lib/components/events/booru-events.ts b/src/lib/components/events/booru-events.ts new file mode 100644 index 0000000..a726d9b --- /dev/null +++ b/src/lib/components/events/booru-events.ts @@ -0,0 +1,5 @@ +export const eventFetchComplete = 'fetchcomplete'; + +export interface BooruEventsMap { + [eventFetchComplete]: null; // Site sends the response, but extension will not get it due to isolation. +} diff --git a/src/lib/components/events/comms.ts b/src/lib/components/events/comms.ts index 30f4961..359e78d 100644 --- a/src/lib/components/events/comms.ts +++ b/src/lib/components/events/comms.ts @@ -1,12 +1,15 @@ import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenance-popup-events"; import { BaseComponent } from "$lib/components/base/BaseComponent"; import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events"; +import type { BooruEventsMap } from "$lib/components/events/booru-events"; -interface EventsMapping extends MaintenancePopupEventsMap, FullscreenViewerEventsMap { -} +type EventsMapping = + MaintenancePopupEventsMap + & FullscreenViewerEventsMap + & BooruEventsMap; type EventCallback = (event: CustomEvent) => void; -type UnsubscribeFunction = () => void; +export type UnsubscribeFunction = () => void; type ResolvableTarget = EventTarget | BaseComponent; function resolveTarget(componentOrElement: ResolvableTarget): EventTarget { From 5613c6fdca36f40211172b391f4e1cea46fef877 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 23 Feb 2025 04:22:29 +0400 Subject: [PATCH 18/52] Wait for new element to be inserted and notify tags dropdown about it --- src/lib/components/TagDropdownWrapper.ts | 11 +++- src/lib/components/TagsForm.ts | 59 +++++++++++++++++++ src/lib/components/events/comms.ts | 4 +- src/lib/components/events/tags-form-events.ts | 5 ++ 4 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/events/tags-form-events.ts diff --git a/src/lib/components/TagDropdownWrapper.ts b/src/lib/components/TagDropdownWrapper.ts index 0f3335c..cca7caa 100644 --- a/src/lib/components/TagDropdownWrapper.ts +++ b/src/lib/components/TagDropdownWrapper.ts @@ -3,6 +3,8 @@ import MaintenanceProfile from "$entities/MaintenanceProfile"; import MaintenanceSettings from "$lib/extension/settings/MaintenanceSettings"; import { getComponent } from "$lib/components/base/component-utils"; import CustomCategoriesResolver from "$lib/extension/CustomCategoriesResolver"; +import { on } from "$lib/components/events/comms"; +import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events"; const categoriesResolver = new CustomCategoriesResolver(); @@ -278,5 +280,12 @@ export function watchTagDropdownsInTagsEditor() { for (const tagDropdownElement of closestTagEditor.querySelectorAll('.tag.dropdown')) { wrapTagDropdown(tagDropdownElement); } - }) + }); + + // When form is submitted, its DOM is completely updated. We need to fetch those tags in this case. + on(document.body, eventFormEditorUpdated, event => { + for (const tagDropdownElement of event.detail.querySelectorAll('.tag.dropdown')) { + wrapTagDropdown(tagDropdownElement); + } + }); } diff --git a/src/lib/components/TagsForm.ts b/src/lib/components/TagsForm.ts index 60aa018..ed6681d 100644 --- a/src/lib/components/TagsForm.ts +++ b/src/lib/components/TagsForm.ts @@ -1,7 +1,66 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; import { getComponent } from "$lib/components/base/component-utils"; +import { emit, on, type UnsubscribeFunction } from "$lib/components/events/comms"; +import { eventFetchComplete } from "$lib/components/events/booru-events"; +import { eventFormEditorUpdated } from "$lib/components/events/tags-form-events"; export class TagsForm extends BaseComponent { + protected init() { + // Site sending the event when form is submitted vie Fetch API. We use this event to reload our logic here. + const unsubscribe = on( + this.container, + eventFetchComplete, + () => this.#waitAndDetectUpdatedForm(unsubscribe), + ); + } + + #waitAndDetectUpdatedForm(unsubscribe: UnsubscribeFunction): void { + const elementContainingTagEditor = this.container + .closest('#image_tags_and_source') + ?.parentElement; + + if (!elementContainingTagEditor) { + return; + } + + const observer = new MutationObserver(() => { + const tagsFormElement = elementContainingTagEditor.querySelector('#tags-form'); + + if (!tagsFormElement || getComponent(tagsFormElement)) { + return; + } + + const tagFormComponent = new TagsForm(tagsFormElement); + tagFormComponent.initialize(); + + const fullTagEditor = tagFormComponent.parentTagEditorElement; + + if (fullTagEditor) { + emit(document.body, eventFormEditorUpdated, fullTagEditor); + } else { + console.info('Tag form is not in the tag editor. Event is not sent.'); + } + + observer.disconnect(); + unsubscribe(); + }); + + observer.observe(elementContainingTagEditor, { + subtree: true, + childList: true, + }); + + // Make sure to forcibly disconnect everything after a while. + setTimeout(() => { + observer.disconnect(); + unsubscribe(); + }, 5000); + } + + get parentTagEditorElement(): HTMLElement | null { + return this.container.closest('.js-tagsauce') + } + /** * Collect all the tag categories available on the page and color the tags in the editor according to them. */ diff --git a/src/lib/components/events/comms.ts b/src/lib/components/events/comms.ts index 359e78d..3624491 100644 --- a/src/lib/components/events/comms.ts +++ b/src/lib/components/events/comms.ts @@ -2,11 +2,13 @@ import type { MaintenancePopupEventsMap } from "$lib/components/events/maintenan import { BaseComponent } from "$lib/components/base/BaseComponent"; import type { FullscreenViewerEventsMap } from "$lib/components/events/fullscreen-viewer-events"; import type { BooruEventsMap } from "$lib/components/events/booru-events"; +import type { TagsFormEventsMap } from "$lib/components/events/tags-form-events"; type EventsMapping = MaintenancePopupEventsMap & FullscreenViewerEventsMap - & BooruEventsMap; + & BooruEventsMap + & TagsFormEventsMap; type EventCallback = (event: CustomEvent) => void; export type UnsubscribeFunction = () => void; diff --git a/src/lib/components/events/tags-form-events.ts b/src/lib/components/events/tags-form-events.ts new file mode 100644 index 0000000..64461dc --- /dev/null +++ b/src/lib/components/events/tags-form-events.ts @@ -0,0 +1,5 @@ +export const eventFormEditorUpdated = 'tags-form-updated'; + +export interface TagsFormEventsMap { + [eventFormEditorUpdated]: HTMLElement; +} From cf8be2589d5fa29c81b693646bf88cc5b4a50e15 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 23 Feb 2025 04:30:41 +0400 Subject: [PATCH 19/52] Bumped version to 0.4.2 --- manifest.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/manifest.json b/manifest.json index 8ee8dd0..209070a 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "name": "Furbooru Tagging Assistant", "description": "Experimental extension with a set of tools to make the tagging faster and easier. Made specifically for Furbooru.", - "version": "0.4.1", + "version": "0.4.2", "browser_specific_settings": { "gecko": { "id": "furbooru-tagging-assistant@thecore.city" diff --git a/package-lock.json b/package-lock.json index b309f2b..9332b49 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "furbooru-tagging-assistant", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "furbooru-tagging-assistant", - "version": "0.4.1", + "version": "0.4.2", "dependencies": { "@fortawesome/fontawesome-free": "^6.7.2", "lz-string": "^1.5.0" diff --git a/package.json b/package.json index 16299d9..6f4ff32 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "furbooru-tagging-assistant", - "version": "0.4.1", + "version": "0.4.2", "private": true, "scripts": { "build": "npm run build:popup && npm run build:extension", From 9586d121e4d440fcbc03ab46e21604474c1d30c0 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:19:22 +0400 Subject: [PATCH 20/52] Moved storage definition to constructor for testability --- src/lib/extension/ConfigurationController.ts | 22 +++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/lib/extension/ConfigurationController.ts b/src/lib/extension/ConfigurationController.ts index 860b852..06d2e7f 100644 --- a/src/lib/extension/ConfigurationController.ts +++ b/src/lib/extension/ConfigurationController.ts @@ -2,12 +2,16 @@ import StorageHelper, { type StorageChangeSubscriber } from "$lib/browser/Storag export default class ConfigurationController { readonly #configurationName: string; + readonly #storage: StorageHelper; /** * @param {string} configurationName Name of the configuration to work with. + * @param {StorageHelper} [storage] Selected storage where the settings are stored in. If not provided, local storage + * is used. */ - constructor(configurationName: string) { + constructor(configurationName: string, storage: StorageHelper = new StorageHelper(chrome.storage.local)) { this.#configurationName = configurationName; + this.#storage = storage; } /** @@ -19,7 +23,7 @@ export default class ConfigurationController { * @return The setting value or the default value if the setting does not exist. */ async readSetting(settingName: string, defaultValue: DefaultType | null = null): Promise { - const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {}); + const settings = await this.#storage.read(this.#configurationName, {}); return settings[settingName] ?? defaultValue; } @@ -32,11 +36,11 @@ export default class ConfigurationController { * @return {Promise} */ async writeSetting(settingName: string, value: any): Promise { - const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {}); + const settings = await this.#storage.read(this.#configurationName, {}); settings[settingName] = value; - ConfigurationController.#storageHelper.write(this.#configurationName, settings); + this.#storage.write(this.#configurationName, settings); } /** @@ -45,11 +49,11 @@ export default class ConfigurationController { * @param {string} settingName Setting name to delete. */ async deleteSetting(settingName: string): Promise { - const settings = await ConfigurationController.#storageHelper.read(this.#configurationName, {}); + const settings = await this.#storage.read(this.#configurationName, {}); delete settings[settingName]; - ConfigurationController.#storageHelper.write(this.#configurationName, settings); + this.#storage.write(this.#configurationName, settings); } /** @@ -69,10 +73,8 @@ export default class ConfigurationController { callback(changes[this.#configurationName].newValue); } - ConfigurationController.#storageHelper.subscribe(subscriber); + this.#storage.subscribe(subscriber); - return () => ConfigurationController.#storageHelper.unsubscribe(subscriber); + return () => this.#storage.unsubscribe(subscriber); } - - static #storageHelper = new StorageHelper(chrome.storage.local); } From ed263d2da4f5ed0f4615b9cc66eaa036c0dcbf58 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:19:49 +0400 Subject: [PATCH 21/52] Installed types for NodeJS for testing --- package-lock.json | 18 ++++++++++++++++++ package.json | 1 + 2 files changed, 19 insertions(+) diff --git a/package-lock.json b/package-lock.json index 9332b49..354e124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/chrome": "^0.0.304", + "@types/node": "^22.13.5", "@vitest/coverage-v8": "^3.0.6", "cheerio": "^1.0.0", "jsdom": "^26.0.0", @@ -814,6 +815,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "22.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", + "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, "node_modules/@vitest/coverage-v8": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.0.6.tgz", @@ -2972,6 +2983,13 @@ "node": ">=18.17" } }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, "node_modules/vite": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", diff --git a/package.json b/package.json index 6f4ff32..409b3ed 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@sveltejs/kit": "^2.17.2", "@sveltejs/vite-plugin-svelte": "^5.0.3", "@types/chrome": "^0.0.304", + "@types/node": "^22.13.5", "@vitest/coverage-v8": "^3.0.6", "cheerio": "^1.0.0", "jsdom": "^26.0.0", From a9d53afdbe347885ffff4a0724ab6931ca4ebb22 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:20:43 +0400 Subject: [PATCH 22/52] Mocked storage change events for mocked storage area --- tests/mocks/ChromeEvent.ts | 14 ++++++------- tests/mocks/ChromeLocalStorageArea.ts | 2 +- tests/mocks/ChromeStorageArea.ts | 27 ++++++++++++++++++++++--- tests/mocks/ChromeStorageChangeEvent.ts | 27 +++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 tests/mocks/ChromeStorageChangeEvent.ts diff --git a/tests/mocks/ChromeEvent.ts b/tests/mocks/ChromeEvent.ts index 7c3029b..5b16ed9 100644 --- a/tests/mocks/ChromeEvent.ts +++ b/tests/mocks/ChromeEvent.ts @@ -1,9 +1,9 @@ export default class ChromeEvent implements chrome.events.Event { - addListener = vi.fn(); - getRules = vi.fn(); - hasListener = vi.fn(); - removeRules = vi.fn(); - addRules = vi.fn(); - removeListener = vi.fn(); - hasListeners = vi.fn(); + addListener = vi.fn(); + getRules = vi.fn(); + hasListener = vi.fn(); + removeRules = vi.fn(); + addRules = vi.fn(); + removeListener = vi.fn(); + hasListeners = vi.fn(); } diff --git a/tests/mocks/ChromeLocalStorageArea.ts b/tests/mocks/ChromeLocalStorageArea.ts index 8aa4f77..e14586e 100644 --- a/tests/mocks/ChromeLocalStorageArea.ts +++ b/tests/mocks/ChromeLocalStorageArea.ts @@ -1,5 +1,5 @@ import ChromeStorageArea from "$tests/mocks/ChromeStorageArea"; export class ChromeLocalStorageArea extends ChromeStorageArea implements chrome.storage.LocalStorageArea { - QUOTA_BYTES = 100000; + QUOTA_BYTES = 100000; } diff --git a/tests/mocks/ChromeStorageArea.ts b/tests/mocks/ChromeStorageArea.ts index 1372fd3..95609a6 100644 --- a/tests/mocks/ChromeStorageArea.ts +++ b/tests/mocks/ChromeStorageArea.ts @@ -1,4 +1,4 @@ -import ChromeEvent from "./ChromeEvent"; +import ChromeStorageChangeEvent from "$tests/mocks/ChromeStorageChangeEvent"; type ChangedEventCallback = (changes: chrome.storage.StorageChange) => void @@ -13,8 +13,20 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea { }) }); set = vi.fn((...args: any[]): Promise => { - return new Promise((resolve, reject) => { + return new Promise((resolve) => { + const change: Record = {}; + const setter = args[0]; + + for (let targetKey of Object.keys(setter)) { + change[targetKey] = { + oldValue: this.#mockedData[targetKey] ?? undefined, + newValue: setter[targetKey], + }; + } + this.#mockedData = Object.assign(this.#mockedData, args[0]); + this.onChanged.mockEmitStorageChange(change); + resolve(); }) }); @@ -23,7 +35,16 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea { const key = args[0]; if (typeof key === 'string') { + const change: chrome.storage.StorageChange = { + oldValue: this.#mockedData[key], + }; + delete this.#mockedData[key]; + + this.onChanged.mockEmitStorageChange({ + [key]: change + }); + resolve(); } @@ -58,7 +79,7 @@ export default class ChromeStorageArea implements chrome.storage.StorageArea { }); }); setAccessLevel = vi.fn(); - onChanged = new ChromeEvent(); + onChanged = new ChromeStorageChangeEvent(); getKeys = vi.fn(); insertMockedData(data: Record) { diff --git a/tests/mocks/ChromeStorageChangeEvent.ts b/tests/mocks/ChromeStorageChangeEvent.ts new file mode 100644 index 0000000..b4fb187 --- /dev/null +++ b/tests/mocks/ChromeStorageChangeEvent.ts @@ -0,0 +1,27 @@ +import ChromeEvent from "$tests/mocks/ChromeEvent"; +import { EventEmitter } from "node:events"; + +type MockedStorageChanges = Record; +type IncomingStorageChangeListener = (changes: MockedStorageChanges) => void; + +const storageChangeEvent = Symbol(); + +interface StorageChangeEventMap { + [storageChangeEvent]: [MockedStorageChanges]; +} + +export default class ChromeStorageChangeEvent extends ChromeEvent { + #emitter = new EventEmitter(); + + addListener = vi.fn((actualListener: IncomingStorageChangeListener) => { + this.#emitter.addListener(storageChangeEvent, actualListener); + }); + + removeListener = vi.fn((actualListener: IncomingStorageChangeListener) => { + this.#emitter.removeListener(storageChangeEvent, actualListener); + }); + + mockEmitStorageChange(changes: MockedStorageChanges) { + this.#emitter.emit(storageChangeEvent, changes); + } +} From 09edc44af846c340427afb3ef18c42f04e501a25 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:38:49 +0400 Subject: [PATCH 23/52] Added tests for configuration controller --- .../extension/ConfigurationController.spec.ts | 186 ++++++++++++++++++ tests/utils.ts | 7 + 2 files changed, 193 insertions(+) create mode 100644 tests/lib/extension/ConfigurationController.spec.ts create mode 100644 tests/utils.ts diff --git a/tests/lib/extension/ConfigurationController.spec.ts b/tests/lib/extension/ConfigurationController.spec.ts new file mode 100644 index 0000000..20e4577 --- /dev/null +++ b/tests/lib/extension/ConfigurationController.spec.ts @@ -0,0 +1,186 @@ +import ConfigurationController from "$lib/extension/ConfigurationController"; +import ChromeStorageArea from "$tests/mocks/ChromeStorageArea"; +import StorageHelper from "$lib/browser/StorageHelper"; +import { randomString } from "$tests/utils"; + +describe('ConfigurationController', () => { + const mockedStorageArea = new ChromeStorageArea(); + const mockedStorageHelper = new StorageHelper(mockedStorageArea); + + beforeEach(() => { + mockedStorageArea.clear(); + }); + + it('should read setting from the field inside the configuration object', async () => { + const name = randomString(); + const field = randomString(); + const value = randomString(); + + mockedStorageArea.insertMockedData({ + [name]: { + [field]: value + } + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + const returnedValue = await controller.readSetting(field); + + expect(returnedValue).toBe(value); + }); + + it('should return fallback value if configuration field does not exist', async () => { + const controller = new ConfigurationController(randomString(), mockedStorageHelper); + const fallbackValue = randomString(); + const returnedValue = await controller.readSetting(randomString(), fallbackValue); + + expect(returnedValue).toBe(fallbackValue); + }); + + it('should treat existing falsy values as existing values', async () => { + const name = randomString(); + + const falsyValuesStorage = [0, false, ''].reduce((record, value) => { + record[randomString()] = value; + return record; + }, {} as Record); + + mockedStorageArea.insertMockedData({ + [name]: falsyValuesStorage + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + + for (const fieldName of Object.keys(falsyValuesStorage)) { + const returnedValue = await controller.readSetting(fieldName, randomString()); + + expect(returnedValue).toBe(falsyValuesStorage[fieldName]); + } + }); + + it('should write data to storage', async () => { + const name = randomString(); + const field = randomString(); + const value = randomString(); + + const controller = new ConfigurationController(name, mockedStorageHelper); + await controller.writeSetting(field, value); + + const expectedStructure = { + [name]: { + [field]: value, + } + }; + + expect(mockedStorageArea.mockedData).toEqual(expectedStructure); + }); + + it('should update existing object without touching other entries', async () => { + const name = randomString(); + const existingField = randomString(); + const existingValue = randomString(); + const addedField = randomString(); + const addedValue = randomString(); + + mockedStorageArea.insertMockedData({ + [name]: { + [existingField]: existingValue, + } + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + await controller.writeSetting(addedField, addedValue); + + const expectedStructure = { + [name]: { + [existingField]: existingValue, + [addedField]: addedValue, + } + } + + expect(mockedStorageArea.mockedData).toEqual(expectedStructure); + }); + + it('should delete setting from storage', async () => { + const name = randomString(); + const field = randomString(); + const value = randomString(); + + mockedStorageArea.insertMockedData({ + [name]: { + [field]: value + } + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + await controller.deleteSetting(field); + + expect(mockedStorageArea.mockedData).toEqual({ + [name]: {}, + }); + }); + + it('should return updated settings contents on changes', async () => { + const name = randomString(); + const initialField = randomString(); + const initialValue = randomString(); + + const addedField = randomString(); + const addedValue = randomString(); + + const updatedInitialValue = randomString(); + const receivedData: Record[] = []; + + mockedStorageArea.insertMockedData({ + [name]: { + [initialField]: initialValue, + } + }); + + const controller = new ConfigurationController(name, mockedStorageHelper); + const subscriber = vi.fn((storageState: Record) => { + receivedData.push(JSON.parse(JSON.stringify(storageState))); + }); + + controller.subscribeToChanges(subscriber); + + await controller.writeSetting(addedField, addedValue); + await controller.writeSetting(initialField, updatedInitialValue); + await controller.deleteSetting(initialField); + + expect(subscriber).toBeCalledTimes(3); + + const expectedData: Record[] = [ + // First, initial data and added field are present + { + [initialField]: initialValue, + [addedField]: addedValue, + }, + // Then we get new value on initial field + { + [initialField]: updatedInitialValue, + [addedField]: addedValue, + }, + // And then the initial value is dropped + { + [addedField]: addedValue, + } + ]; + + expect(receivedData).toEqual(expectedData); + }); + + it('should stop listening once unsubscribe called', async () => { + const controller = new ConfigurationController(randomString(), mockedStorageHelper); + const subscriber = vi.fn(); + + const unsubscribe = controller.subscribeToChanges(subscriber); + + await controller.writeSetting(randomString(), randomString()); + expect(subscriber).toBeCalledTimes(1); + + unsubscribe(); + subscriber.mockReset(); + await controller.writeSetting(randomString(), randomString()) + expect(subscriber).not.toBeCalled(); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts new file mode 100644 index 0000000..61ac9a7 --- /dev/null +++ b/tests/utils.ts @@ -0,0 +1,7 @@ +export function randomString(): string { + return crypto.randomUUID(); +} + +export function copyValue(object: T): T { + return JSON.parse(JSON.stringify(object)); +} From dc0a9f0aa832c34ac70e29f886eee243b0f1160a Mon Sep 17 00:00:00 2001 From: KoloMl Date: Tue, 25 Feb 2025 03:39:30 +0400 Subject: [PATCH 24/52] Imported utils function for random string --- tests/lib/components/base/BaseComponent.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/lib/components/base/BaseComponent.spec.ts b/tests/lib/components/base/BaseComponent.spec.ts index d15392c..9a80330 100644 --- a/tests/lib/components/base/BaseComponent.spec.ts +++ b/tests/lib/components/base/BaseComponent.spec.ts @@ -1,9 +1,6 @@ import { BaseComponent } from "$lib/components/base/BaseComponent"; import { getComponent } from "$lib/components/base/component-utils"; - -function randomString() { - return crypto.randomUUID(); -} +import { randomString } from "$tests/utils"; describe('BaseComponent', () => { it('should bind the component to the element', () => { From d5ed86fb4005036d597bc90b860e21ca07d68205 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 27 Feb 2025 00:50:05 +0400 Subject: [PATCH 25/52] Exposing timer return type globally --- src/app.d.ts | 3 +++ src/lib/components/MaintenancePopup.ts | 2 +- src/lib/extension/CustomCategoriesResolver.ts | 6 ++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/app.d.ts b/src/app.d.ts index 5a58731..e0ceca7 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -4,6 +4,9 @@ import MaintenanceProfile from "$entities/MaintenanceProfile"; import type TagGroup from "$entities/TagGroup"; declare global { + // Helper type to not deal with differences between the setTimeout of @types/node and usual web browser's type. + type Timeout = ReturnType; + namespace App { // interface Error {} // interface Locals {} diff --git a/src/lib/components/MaintenancePopup.ts b/src/lib/components/MaintenancePopup.ts index 718c6a8..fa65ce5 100644 --- a/src/lib/components/MaintenancePopup.ts +++ b/src/lib/components/MaintenancePopup.ts @@ -30,7 +30,7 @@ export class MaintenancePopup extends BaseComponent { #tagsToAdd: Set = new Set(); #isPlanningToSubmit: boolean = false; #isSubmitting: boolean = false; - #tagsSubmissionTimer: number | null = null; + #tagsSubmissionTimer: Timeout | null = null; #emitter = emitterAt(this); /** diff --git a/src/lib/extension/CustomCategoriesResolver.ts b/src/lib/extension/CustomCategoriesResolver.ts index 169833d..4381d53 100644 --- a/src/lib/extension/CustomCategoriesResolver.ts +++ b/src/lib/extension/CustomCategoriesResolver.ts @@ -6,7 +6,7 @@ export default class CustomCategoriesResolver { #tagCategories = new Map(); #compiledRegExps = new Map(); #tagDropdowns: TagDropdownWrapper[] = []; - #nextQueuedUpdate = -1; + #nextQueuedUpdate: Timeout | null = null; constructor() { TagGroup.subscribe(this.#onTagGroupsReceived.bind(this)); @@ -24,7 +24,9 @@ export default class CustomCategoriesResolver { } #queueUpdatingTags() { - clearTimeout(this.#nextQueuedUpdate); + if (this.#nextQueuedUpdate) { + clearTimeout(this.#nextQueuedUpdate); + } this.#nextQueuedUpdate = setTimeout( this.#updateUnprocessedTags.bind(this), From 76e7bf15427b7f3230c6b7f7092459a28b14f65b Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 27 Feb 2025 00:53:44 +0400 Subject: [PATCH 26/52] Fixed missing empty checks for required components --- src/lib/components/ImageShowFullscreenButton.ts | 2 +- src/lib/components/MaintenancePopup.ts | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/lib/components/ImageShowFullscreenButton.ts b/src/lib/components/ImageShowFullscreenButton.ts index 01a1eaf..f2c0993 100644 --- a/src/lib/components/ImageShowFullscreenButton.ts +++ b/src/lib/components/ImageShowFullscreenButton.ts @@ -47,7 +47,7 @@ export class ImageShowFullscreenButton extends BaseComponent { } #onButtonClicked() { - const imageLinks = this.#mediaBoxTools?.mediaBox.imageLinks; + const imageLinks = this.#mediaBoxTools?.mediaBox?.imageLinks; if (!imageLinks) { throw new Error('Failed to resolve image links from media box tools!'); diff --git a/src/lib/components/MaintenancePopup.ts b/src/lib/components/MaintenancePopup.ts index fa65ce5..8792022 100644 --- a/src/lib/components/MaintenancePopup.ts +++ b/src/lib/components/MaintenancePopup.ts @@ -70,6 +70,10 @@ export class MaintenancePopup extends BaseComponent { const mediaBox = this.#mediaBoxTools.mediaBox; + if (!mediaBox) { + throw new Error('Media box component not found!'); + } + mediaBox.on('mouseout', this.#onMouseLeftArea.bind(this)); mediaBox.on('mouseover', this.#onMouseEnteredArea.bind(this)); } @@ -83,7 +87,7 @@ export class MaintenancePopup extends BaseComponent { } #refreshTagsList() { - if (!this.#mediaBoxTools) { + if (!this.#mediaBoxTools?.mediaBox) { return; } @@ -109,11 +113,11 @@ export class MaintenancePopup extends BaseComponent { this.#tagsList[index] = tagElement; this.#tagsListElement.appendChild(tagElement); - const isPresent = currentPostTags.has(tagName); + const isPresent = currentPostTags?.has(tagName); tagElement.classList.toggle('is-present', isPresent); tagElement.classList.toggle('is-missing', !isPresent); - tagElement.classList.toggle('is-aliased', isPresent && currentPostTags.get(tagName) !== tagName); + tagElement.classList.toggle('is-aliased', isPresent && currentPostTags?.get(tagName) !== tagName); // Just to prevent duplication, we need to include this tag to the map of suggested invalid tags if (tagsBlacklist.includes(tagName)) { @@ -193,7 +197,7 @@ export class MaintenancePopup extends BaseComponent { } async #onSubmissionTimerPassed() { - if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools) { + if (!this.#isPlanningToSubmit || this.#isSubmitting || !this.#mediaBoxTools?.mediaBox) { return; } @@ -264,7 +268,7 @@ export class MaintenancePopup extends BaseComponent { } #revealInvalidTags() { - if (!this.#mediaBoxTools) { + if (!this.#mediaBoxTools?.mediaBox) { return; } From 8e843c2b19a118d663b816720ebc817541d8fae0 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Thu, 27 Feb 2025 00:54:00 +0400 Subject: [PATCH 27/52] Fixed element types not being set up for queries --- src/content/header.ts | 2 +- src/content/listing.ts | 3 +-- src/content/tags.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/content/header.ts b/src/content/header.ts index 00ccd65..a306a89 100644 --- a/src/content/header.ts +++ b/src/content/header.ts @@ -1,6 +1,6 @@ import { initializeSiteHeader } from "$lib/components/SiteHeaderWrapper"; -const siteHeader = document.querySelector('.header'); +const siteHeader = document.querySelector('.header'); if (siteHeader) { initializeSiteHeader(siteHeader); diff --git a/src/content/listing.ts b/src/content/listing.ts index 985eac1..63a436b 100644 --- a/src/content/listing.ts +++ b/src/content/listing.ts @@ -4,8 +4,7 @@ import { calculateMediaBoxesPositions, initializeMediaBox } from "$lib/component import { createMaintenanceStatusIcon } from "$lib/components/MaintenanceStatusIcon"; import { createImageShowFullscreenButton } from "$lib/components/ImageShowFullscreenButton"; -/** @type {NodeListOf} */ -const mediaBoxes = document.querySelectorAll('.media-box'); +const mediaBoxes = document.querySelectorAll('.media-box'); mediaBoxes.forEach(mediaBoxElement => { initializeMediaBox(mediaBoxElement, [ diff --git a/src/content/tags.ts b/src/content/tags.ts index f8d685b..a4fa996 100644 --- a/src/content/tags.ts +++ b/src/content/tags.ts @@ -1,6 +1,6 @@ import { watchTagDropdownsInTagsEditor, wrapTagDropdown } from "$lib/components/TagDropdownWrapper"; -for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) { +for (let tagDropdownElement of document.querySelectorAll('.tag.dropdown')) { wrapTagDropdown(tagDropdownElement); } From 92854f4d6b24d98e41c29be105d99f00f17fa188 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 28 Feb 2025 02:40:19 +0400 Subject: [PATCH 28/52] Renamed hooks to TS --- src/{hooks.js => hooks.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{hooks.js => hooks.ts} (100%) diff --git a/src/hooks.js b/src/hooks.ts similarity index 100% rename from src/hooks.js rename to src/hooks.ts From f687389516187628629831cf55bddd94a3ab2bf2 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 28 Feb 2025 03:18:18 +0400 Subject: [PATCH 29/52] Implemented routing to be more compatible for extension popup --- src/hooks.ts | 11 ++++++--- src/lib/popup-links.ts | 48 +++++++++++++++++++++++++++++++++++++++ src/routes/+layout.svelte | 8 +++++++ 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 src/lib/popup-links.ts diff --git a/src/hooks.ts b/src/hooks.ts index 3ca7fe9..6555677 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,8 +1,13 @@ -/** @type {import('@sveltejs/kit').Reroute} */ -export function reroute({url}) { +import type { Reroute } from "@sveltejs/kit"; + +export const reroute: Reroute = ({url}) => { // Reroute index.html as just / for the root. // Browser extension starts from with the index.html file in the pathname which is not correct for the router. if (url.pathname === '/index.html') { + if (url.searchParams.has('path')) { + return url.searchParams.get('path')!; + } + return "/"; } -} \ No newline at end of file +}; diff --git a/src/lib/popup-links.ts b/src/lib/popup-links.ts new file mode 100644 index 0000000..5287696 --- /dev/null +++ b/src/lib/popup-links.ts @@ -0,0 +1,48 @@ +function resolveReplaceableLink(target: EventTarget | null = null): HTMLAnchorElement | null { + if (!(target instanceof HTMLElement)) { + return null; + } + + const closestLink = target.closest('a'); + + if ( + closestLink instanceof HTMLAnchorElement + && !closestLink.search + && closestLink.origin === location.origin + ) { + return closestLink; + } + + return null; +} + +function replaceLink(linkElement: HTMLAnchorElement) { + const params = new URLSearchParams([ + ['path', linkElement.pathname] + ]); + + linkElement.search = params.toString(); + linkElement.pathname = "/index.html"; +} + +export function initializeLinksReplacement(): () => void { + const abortController = new AbortController(); + const replacementHandler = (event: Event) => { + const closestLink = resolveReplaceableLink(event.target); + + if (closestLink) { + replaceLink(closestLink); + } + } + + // Dynamically replace the links from the Svelte default links to the links usable for the popup. + document.body.addEventListener('mousedown', replacementHandler, { + signal: abortController.signal, + }); + + document.body.addEventListener('click', replacementHandler, { + signal: abortController.signal, + }) + + return () => abortController.abort(); +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 9d1f851..a7b87e2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -2,6 +2,8 @@ import "../styles/popup.scss"; import Header from "$components/layout/Header.svelte"; import Footer from "$components/layout/Footer.svelte"; + import { initializeLinksReplacement } from "$lib/popup-links"; + import { onDestroy } from "svelte"; interface Props { children?: import('svelte').Snippet; @@ -12,6 +14,12 @@ // Sort of a hack, detect if we rendered in the browser tab or in the popup. // Popup will always should have fixed 320px size, otherwise we consider it opened in the tab. document.body.classList.toggle('is-in-tab', window.innerWidth > 320); + + const disconnectLinkReplacement = initializeLinksReplacement(); + + onDestroy(() => { + disconnectLinkReplacement(); + })
From a2d884c9692de71bd316da50b3e59db4d6f6aaf7 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Fri, 28 Feb 2025 03:50:08 +0400 Subject: [PATCH 30/52] Added tests for the link replacement logic --- tests/lib/popup-links.spec.ts | 75 +++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 tests/lib/popup-links.spec.ts diff --git a/tests/lib/popup-links.spec.ts b/tests/lib/popup-links.spec.ts new file mode 100644 index 0000000..5a6d772 --- /dev/null +++ b/tests/lib/popup-links.spec.ts @@ -0,0 +1,75 @@ +import { randomString } from "$tests/utils"; +import { initializeLinksReplacement } from "$lib/popup-links"; + +describe('popup-links', () => { + let expectedPath = ''; + let testLink: HTMLAnchorElement = document.createElement('a'); + let disconnectCallback: (() => void) | null = null; + + function fireEventAt(target: EventTarget, eventName: string) { + target.dispatchEvent(new Event(eventName, {bubbles: true})); + } + + beforeEach(() => { + expectedPath = `/test/${randomString()}`; + testLink.href = expectedPath; + document.body.append(testLink); + }); + + afterEach(() => { + if (disconnectCallback) { + disconnectCallback(); + disconnectCallback = null; + } + }); + + it('should replace link on any mouse button down', () => { + disconnectCallback = initializeLinksReplacement(); + fireEventAt(testLink, "mousedown"); + + const resultUrl = new URL(testLink.href); + + expect(resultUrl.searchParams.get('path')).toBe(expectedPath); + }); + + it('should replace link when link is pressed by keyboard or clicked', () => { + disconnectCallback = initializeLinksReplacement(); + fireEventAt(testLink, "click"); + + const resultUrl = new URL(testLink.href); + + expect(resultUrl.searchParams.get('path')).toBe(expectedPath); + }); + + it('should not replace already replaced links', () => { + disconnectCallback = initializeLinksReplacement(); + fireEventAt(testLink, "click"); + const hrefAfterFirstClick = testLink.href; + + fireEventAt(testLink, "click"); + const hrefAfterSecondClick = testLink.href; + + expect(hrefAfterFirstClick).toBe(hrefAfterSecondClick); + }); + + it('should stop replacing links once disconnect is called', () => { + const hrefBefore = testLink.href; + + disconnectCallback = initializeLinksReplacement(); + disconnectCallback(); + fireEventAt(testLink, "mousedown"); + fireEventAt(testLink, "click"); + + expect(hrefBefore).toBe(testLink.href); + }); + + it('should not touch links with different origin', () => { + testLink.href = "https://external.example.com/" + randomString() + "/"; + + const hrefBefore = testLink.href; + disconnectCallback = initializeLinksReplacement(); + fireEventAt(testLink, "click"); + + expect(testLink.href).toBe(hrefBefore); + }); +}); From ff16c62e26678522b13f484a0ac83a6d6490f793 Mon Sep 17 00:00:00 2001 From: KoloMl Date: Sun, 2 Mar 2025 18:44:53 +0400 Subject: [PATCH 31/52] Added support for suffix-matching for groups --- src/components/features/GroupView.svelte | 15 ++++++++++++++- src/lib/extension/CustomCategoriesResolver.ts | 7 +++++++ src/lib/extension/entities/TagGroup.ts | 2 ++ src/lib/extension/transporting/exporters.ts | 1 + src/routes/features/groups/[id]/edit/+page.svelte | 8 ++++++++ 5 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/components/features/GroupView.svelte b/src/components/features/GroupView.svelte index e2a0da4..3b6c698 100644 --- a/src/components/features/GroupView.svelte +++ b/src/components/features/GroupView.svelte @@ -9,7 +9,8 @@ let { group }: GroupViewProps = $props(); let sortedTagsList = $derived(group.settings.tags.sort((a, b) => a.localeCompare(b))), - sortedPrefixes = $derived(group.settings.prefixes.sort((a, b) => a.localeCompare(b))); + sortedPrefixes = $derived(group.settings.prefixes.sort((a, b) => a.localeCompare(b))), + sortedSuffixes = $derived(group.settings.suffixes.sort((a, b) => a.localeCompare(b))); @@ -41,6 +42,18 @@ {/if} +{#if sortedSuffixes.length} +
+ Suffixes: + +
+ {#each sortedSuffixes as suffixName} + *{suffixName} + {/each} +
+
+
+{/if}