From 9680f520a89ab385b9708d051b4a4a597c3f3631 Mon Sep 17 00:00:00 2001 From: RalfBarkow Date: Thu, 18 Sep 2025 15:32:55 +0200 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20IPv6-safe=20siteAdapter=20=E2=80=93?= =?UTF-8?q?=20normalize=20keys,=20proxy-safe=20URLs,=20and?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit journal fixups * bracket IPv6 + preserve port; canonicalize cache key * prefer http direct for loopback; proxy on https origins * replace protocol-relative/substring hacks with base reconstruction * fix arrow-function body, duplicate `let`, and closing parens * use template strings for realFlag consistently --- lib/siteAdapter.js | 178 +++++++++++++++++++++++---------------------- 1 file changed, 91 insertions(+), 87 deletions(-) diff --git a/lib/siteAdapter.js b/lib/siteAdapter.js index 5d4e111..277a969 100644 --- a/lib/siteAdapter.js +++ b/lib/siteAdapter.js @@ -7,6 +7,36 @@ const localForage = require('localforage') module.exports = siteAdapter = {} +// IPv6/localhost-safe URL helpers ------------------------------------------- +const isLoopbackHost = h => + h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]'; + +const normalizeSite = rawSite => { + const s = String(rawSite).trim(); + if (s.startsWith('[')) return s; // already bracketed IPv6 + const colonCount = (s.match(/:/g) || []).length; + if (colonCount >= 2) { + // raw IPv6, maybe with :port + const last = s.lastIndexOf(':'); + if (last > 0 && last !== s.indexOf(':')) { + const host = s.slice(0, last); + const port = s.slice(last + 1); + return `[${host}]${port ? `:${port}` : ''}`; + } + return `[${s}]`; + } + return s; // host or host:port (IPv4/hostname) +}; + +const siteKey = rawSite => normalizeSite(rawSite); +const httpOriginFor = norm => `http://${norm}`; +const httpsOriginFor = norm => `https://${norm}`; +const proxyPathFor = norm => `/proxy/${encodeURIComponent(norm)}`; +const isLoopbackSite = norm => { + const host = norm.startsWith('[') ? norm.slice(1, norm.indexOf(']')) : norm.split(':')[0]; + return isLoopbackHost(host); +}; + // we save the site prefix once we have determined it, const sitePrefix = {} // and if the CORS request requires credentials... @@ -65,71 +95,59 @@ const testWikiSite = function (url, good, bad) { } const findAdapterQ = queue(function (task, done) { - let testURL - const { site } = task - if (sitePrefix[site]) { - done(sitePrefix[site]) - } + const { site } = task; + const norm = siteKey(site); + + if (sitePrefix[site]) return done(sitePrefix[site]); - if (site.split('.').at(-1).split(':')[0] === 'localhost') { - testURL = `http://${site}/favicon.png` + let testURL; + if (isLoopbackSite(norm)) { + testURL = `${httpOriginFor(norm)}/favicon.png`; // prefer direct http for loopback + } else if (location.protocol === 'https:') { + testURL = `${proxyPathFor(norm)}/favicon.png`; // avoid mixed content } else { - testURL = `//${site}/favicon.png` + testURL = `${httpOriginFor(norm)}/favicon.png`; } + return testWikiSite( testURL, function () { - sitePrefix[site] = testURL.slice(0, -12) - done(testURL.slice(0, -12)) + sitePrefix[site] = testURL.replace(/\/favicon\.png$/, ''); + done(sitePrefix[site]); }, function () { - switch (location.protocol) { - case 'http:': - testURL = `https://${site}/favicon.png` - testWikiSite( - testURL, - () => { - sitePrefix[site] = `https://${site}` - done(`https://${site}`) - }, - () => { - sitePrefix[site] = '' - done('') - }, - ) - break - case 'https:': - testURL = `/proxy/${site}/favicon.png` - testWikiSite( - testURL, - () => { - sitePrefix[site] = `/proxy/${site}` - done(`/proxy/${site}`) - }, - () => { - sitePrefix[site] = '' - done('') - }, - ) - break - default: - sitePrefix[site] = '' - done('') + if (location.protocol === 'https:') { + const alt = `${httpsOriginFor(norm)}/favicon.png`; + testWikiSite( + alt, + () => { + sitePrefix[site] = alt.replace(/\/favicon\.png$/, ''); + done(sitePrefix[site]); + }, + () => { + sitePrefix[site] = ''; + done(''); + } + ); + } else { + sitePrefix[site] = ''; + done(''); } - }, - ) -}, findQueueWorkers) // start with 8 process working on the queue + } + ); +}, findQueueWorkers); // start with 8 workers const findAdapter = (site, done) => - routeStore - .getItem(site) + const key = siteKey(site) + return routeStore + .getItem(key) .then(function (value) { // console.log "findAdapter: ", site, value if (!value) { findAdapterQ.push({ site }, function (prefix) { sitePrefix[site] = prefix routeStore - .setItem(site, prefix) + .setItem(key, prefix) .then(() => done(prefix)) .catch(function (err) { console.log('findAdapter setItem error: ', site, err) @@ -323,6 +341,7 @@ siteAdapter.recycler = { } siteAdapter.site = function (site) { + const key = siteKey(site); if (!site || site === window.location.host) { return siteAdapter.origin } @@ -416,16 +435,10 @@ siteAdapter.site = function (site) { console.log(`${site} is unreachable`) } else { console.log(`Prefix for ${site} is ${prefix}, about to fixup links`) - // add href to journal fork + const norm = siteKey(site) + const base = prefix.startsWith('/proxy/') ? httpOriginFor(norm) : prefix $('a[target="' + site + '"]').each(function () { - let thisPrefix - if (/proxy/.test(prefix)) { - const thisSite = prefix.substring(7) - thisPrefix = `http://${thisSite}` - } else { - thisPrefix = prefix - } - $(this).attr('href', `${thisPrefix}/${$(this).data('slug')}.html`) + $(this).attr('href', `${base}/${$(this).data('slug')}.html`) }) } }) @@ -434,19 +447,15 @@ siteAdapter.site = function (site) { }, getDirectURL(route) { - let thisPrefix, thisSite if (sitePrefix[site] != null) { if (sitePrefix[site] === '') { console.log(`${site} is unreachable, can't link to ${route}`) return '' } else { - if (/proxy/.test(sitePrefix[site])) { - thisSite = sitePrefix[site].substring(7) - thisPrefix = `http://${thisSite}` - } else { - thisPrefix = sitePrefix[site] - } - return `${thisPrefix}/${route}` + const pref = sitePrefix[site] + const norm = siteKey(site) + const base = pref.startsWith('/proxy/') ? httpOriginFor(norm) : pref + return `${base}/${route}` } } else { findAdapter(site, function (prefix) { @@ -454,15 +463,10 @@ siteAdapter.site = function (site) { console.log(`${site} is unreachable`) } else { console.log(`Prefix for ${site} is ${prefix}, about to fixup links`) - // add href to journal fork + const norm = siteKey(site) + const base = prefix.startsWith('/proxy/') ? httpOriginFor(norm) : prefix $('a[target="' + site + '"]').each(function () { - if (/proxy/.test(prefix)) { - thisSite = prefix.substring(7) - thisPrefix = `http://${thisSite}` - } else { - thisPrefix = prefix - } - $(this).attr('href', `${thisPrefix}/${$(this).data('slug')}.html`) + $(this).attr('href', `${base}/${$(this).data('slug')}.html`) }) } }) @@ -480,7 +484,7 @@ siteAdapter.site = function (site) { var getContent = function (route, done) { const url = `${sitePrefix[site]}/${route}` - const useCredentials = credentialsNeeded[site] || false + const useCredentials = credentialsNeeded[key] || false return $.ajax({ type: 'GET', @@ -492,15 +496,15 @@ siteAdapter.site = function (site) { ((route === 'system/sitemap.json' && Array.isArray(data) && data[0] === 'Login Required') || data.title === 'Login Required') && !url.includes('login-required') && - credentialsNeeded[site] !== true + credentialsNeeded[key] !== true ) { - credentialsNeeded[site] = true + credentialsNeeded[key] = true; getContent(route, function (err, page) { if (!err) { - withCredsStore.setItem(site, true) + withCredsStore.setItem(key, true) done(err, page) } else { - credentialsNeeded[site] = false + credentialsNeeded[key] = false done(err, page) } }) @@ -564,7 +568,7 @@ siteAdapter.site = function (site) { var getContent = function (route, done) { const url = `${sitePrefix[site]}/${route}` - const useCredentials = credentialsNeeded[site] || false + const useCredentials = credentialsNeeded[key] || false return $.ajax({ type: 'GET', @@ -575,15 +579,15 @@ siteAdapter.site = function (site) { if ( data.title === 'Login Required' && !url.includes('login-required') && - credentialsNeeded[site] !== true + credentialsNeeded[key] !== true ) { - credentialsNeeded[site] = true + credentialsNeeded[key] = true return getContent(route, function (err, page) { if (!err) { - withCredsStore.setItem(site, true) + withCredsStore.setItem(key, true) done(err, page) } else { - credentialsNeeded[site] = false + credentialsNeeded[key] = false done(err, page) } }) @@ -643,7 +647,7 @@ siteAdapter.site = function (site) { // replace flag with temp flags const tempFlag = createTempFlag(site) tempFlags[site] = tempFlag - const realFlag = sitePrefix[site] + '/favicon.png' + const realFlag = `${sitePrefix[site]}/favicon.png` // replace flag with temporary flag where it is used as an image $('img[src="' + realFlag + '"]').attr('src', tempFlag) // replace temporary flag where its used as a background to fork event in journal @@ -655,11 +659,11 @@ siteAdapter.site = function (site) { // update storage routeStore - .removeItem(site) + .removeItem(key) .then(() => { findAdapterQ.push({ site }, prefix => { routeStore - .setItem(site, prefix) + .setItem(key, prefix) .then(() => { if (prefix === '') { console.log(`Refreshed prefix for ${site} is undetermined...`) @@ -667,7 +671,7 @@ siteAdapter.site = function (site) { console.log(`Refreshed prefix for ${site} is ${prefix}`) // replace temp flags const tempFlag = tempFlags[site] - const realFlag = sitePrefix[site] + '/favicon.png' + const realFlag = `${sitePrefix[site]}/favicon.png` // replace temporary flag where it is used as an image $('img[src="' + tempFlag + '"]').attr('src', realFlag) // replace temporary flag where its used as a background to fork event in journal From 95e99a8945decc1300f1edcbc303b298fb949fb3 Mon Sep 17 00:00:00 2001 From: RalfBarkow Date: Thu, 18 Sep 2025 16:32:11 +0200 Subject: [PATCH 2/3] fix: wrap findAdapter arrow body in braces * replace invalid arrow form (`=> const key ...`) with block body * add missing closing brace to balance function * removes leftover commented debug log * ensures valid syntax and consistent style in siteAdapter.js --- lib/siteAdapter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/siteAdapter.js b/lib/siteAdapter.js index 277a969..52067d0 100644 --- a/lib/siteAdapter.js +++ b/lib/siteAdapter.js @@ -137,12 +137,11 @@ const findAdapterQ = queue(function (task, done) { ); }, findQueueWorkers); // start with 8 workers -const findAdapter = (site, done) => +const findAdapter = (site, done) => { const key = siteKey(site) return routeStore .getItem(key) .then(function (value) { - // console.log "findAdapter: ", site, value if (!value) { findAdapterQ.push({ site }, function (prefix) { sitePrefix[site] = prefix @@ -164,6 +163,7 @@ const findAdapter = (site, done) => sitePrefix[site] = '' done('') }) +} siteAdapter.local = { flag() { From 8d9caefe292c3fa04babef3ac8f330738d065610 Mon Sep 17 00:00:00 2001 From: Ralf Barkow Date: Wed, 24 Sep 2025 23:50:39 +0200 Subject: [PATCH 3/3] fix(siteAdapter): improve IPv6 and loopback host detection - Expand isLoopbackHost to properly recognize IPv6 addresses (::1 and [::1]) - Add support for 127/8 subnet and .localhost domains in loopback detection - Update test environment with proper DOM mocking using jsdom - Add comprehensive tests for favicon URL generation across different host types - Include fake-indexeddb and jsdom-global dev dependencies for testing - Fix asynchronous test timing issues with proper callback handling The IPv6 address handling now correctly identifies loopback addresses and generates appropriate HTTP URLs instead of scheme-relative URLs for local development. --- lib/siteAdapter.js | 12 +- package-lock.json | 829 +++++++++++++++++++++++++++++++++++++++----- package.json | 4 +- test/setup-env.js | 171 +++++++++ test/siteAdapter.js | 49 +++ 5 files changed, 975 insertions(+), 90 deletions(-) create mode 100644 test/setup-env.js create mode 100644 test/siteAdapter.js diff --git a/lib/siteAdapter.js b/lib/siteAdapter.js index 52067d0..342b0c8 100644 --- a/lib/siteAdapter.js +++ b/lib/siteAdapter.js @@ -8,8 +8,16 @@ const localForage = require('localforage') module.exports = siteAdapter = {} // IPv6/localhost-safe URL helpers ------------------------------------------- -const isLoopbackHost = h => - h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '[::1]'; +const isLoopbackHost = (s = '') => { + const host = s.toLowerCase(); + return ( + host === 'localhost' || + host.endsWith('.localhost') || + /^127(?:\.\d{1,3}){3}$/.test(host) || // 127/8 + host === '::1' || + host === '[::1]' + ); +}; const normalizeSite = rawSite => { const s = String(rawSite).trim(); diff --git a/package-lock.json b/package-lock.json index 5f1b613..0063248 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,8 +18,10 @@ "esbuild": "^0.25.8", "eslint": "^9.32.0", "expect.js": "^0.3.1", + "fake-indexeddb": "^6.2.2", "globals": "^16.3.0", "grunt-git-authors": "^3.2.0", + "jsdom-global": "^3.0.2", "minisearch": "^7.1.2", "mocha": "^11.7.1", "prettier": "^3.6.2", @@ -30,6 +32,188 @@ "node": ">=20.x" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz", + "integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "lru-cache": "^11.2.1" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.5.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.5.6.tgz", + "integrity": "sha512-Mj3Hu9ymlsERd7WOsUKNUZnJYL4IZ/I9wVVYgtvOsWYiEFbkQ4G7VRIh2USxTVW4BBDIsLG+gBUgqOqf2Kvqow==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.1" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz", + "integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peer": true, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint/js": { "version": "9.34.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", @@ -43,12 +227,120 @@ "url": "https://eslint.org/donate" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz", + "integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/css-color": "^4.0.3", + "@csstools/css-syntax-patches-for-csstree": "^1.0.14", + "css-tree": "^3.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz", + "integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", @@ -952,24 +1244,6 @@ "node": ">= 8" } }, - "node_modules/eslint/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/eslint/node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1353,13 +1627,6 @@ "node": "*" } }, - "node_modules/eslint/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/eslint/node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -1460,16 +1727,6 @@ "node": ">= 0.8.0" } }, - "node_modules/eslint/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/eslint/node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -1597,6 +1854,16 @@ "integrity": "sha512-okDF/FAPEul1ZFLae4hrgpIqAeapoo5TRdcg/lD0iN9S3GWrBFIJwNezGH1DMtIz+RxU4RrFmMq7WUUvDg3J6A==", "dev": true }, + "node_modules/fake-indexeddb": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.2.tgz", + "integrity": "sha512-SGbf7fzjeHz3+12NO1dYigcYn4ivviaeULV5yY5rdGihBvvgwMds4r4UBbNIUMwkze57KTDm32rq3j1Az8mzEw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/globals": { "version": "16.3.0", "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", @@ -1625,6 +1892,123 @@ "integrity": "sha512-340ZqtqJzWAZtHwaCC2gx4mdQOnkUWAWNDp7y0bCEatdjmgQ4j7b0qQ7qO5WIJWx/luNrKcrYzpKbH3NTR030A==", "dev": true }, + "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", + "peer": true, + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "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", + "peer": true, + "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", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.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", + "peer": true + }, + "node_modules/jsdom": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz", + "integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@asamuzakjp/dom-selector": "^6.5.4", + "cssstyle": "^5.3.0", + "data-urls": "^6.0.0", + "decimal.js": "^10.5.0", + "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", + "parse5": "^7.3.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.0.0", + "ws": "^8.18.2", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom-global": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsdom-global/-/jsdom-global-3.0.2.tgz", + "integrity": "sha512-t1KMcBkz/pT5JrvcJbpUR2u/w1kO9jXctaaGJ0vZDzwFnIvGWw9IDSRciT83kIs8Bnw4qpOl8bQK08V01YgMPg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "jsdom": ">=10.0.0" + } + }, "node_modules/localforage": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", @@ -1649,6 +2033,25 @@ "immediate": "~3.0.5" } }, + "node_modules/lru-cache": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz", + "integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==", + "dev": true, + "license": "ISC", + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0", + "peer": true + }, "node_modules/minisearch": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.1.2.tgz", @@ -1953,24 +2356,6 @@ "node": ">= 8" } }, - "node_modules/mocha/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/mocha/node_modules/decamelize": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-4.0.0.tgz", @@ -2261,13 +2646,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, "node_modules/mocha/node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -2344,13 +2722,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, "node_modules/mocha/node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -2816,6 +3187,84 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prettier": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", @@ -2832,6 +3281,56 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "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", + "peer": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "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", + "peer": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/serve": { "version": "14.2.4", "resolved": "https://registry.npmjs.org/serve/-/serve-14.2.4.tgz", @@ -3556,16 +4055,6 @@ "dev": true, "license": "MIT" }, - "node_modules/serve/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/serve/node_modules/range-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", @@ -3616,16 +4105,6 @@ "node": ">=0.10.0" } }, - "node_modules/serve/node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/serve/node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -3979,11 +4458,187 @@ "node": ">=4" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "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", + "peer": true + }, + "node_modules/tldts": { + "version": "7.0.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz", + "integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tldts-core": "^7.0.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz", + "integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/underscore": { "version": "1.13.7", "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", "license": "MIT" + }, + "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", + "peer": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz", + "integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "peer": true, + "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", + "peer": true, + "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", + "peer": true } } } diff --git a/package.json b/package.json index e5bf91c..4223ec1 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "clean": "npm run clean:client; npm run clean:test", "clean:client": "rm client/client.js client/client.js.map meta-client.json", "clean:test": "rm client/test/testclient.js", - "test": "mocha test/util.js test/random.js test/page.js test/lineup.js test/drop.js test/revision.js test/resolve.js test/wiki.js", + "test": "mocha test/setup-env.js test/util.js test/random.js test/page.js test/lineup.js test/drop.js test/revision.js test/resolve.js test/siteAdapter.js test/wiki.js", "runtests": "npm run build:test && ((sleep 1; open 'http://localhost:3000/runtests.html')&) && echo '\nBrowser will open to run tests.' && serve client", "update-authors": "node scripts/update-authors.js" }, @@ -47,8 +47,10 @@ "esbuild": "^0.25.8", "eslint": "^9.32.0", "expect.js": "^0.3.1", + "fake-indexeddb": "^6.2.2", "globals": "^16.3.0", "grunt-git-authors": "^3.2.0", + "jsdom-global": "^3.0.2", "minisearch": "^7.1.2", "mocha": "^11.7.1", "prettier": "^3.6.2", diff --git a/test/setup-env.js b/test/setup-env.js new file mode 100644 index 0000000..3cd7f89 --- /dev/null +++ b/test/setup-env.js @@ -0,0 +1,171 @@ +// Make IndexedDB exist in Node +require('fake-indexeddb/auto'); + +// --- DOM via jsdom (gives window, document, location, etc.) --- +require('jsdom-global')('', { url: 'https://test/' }); + +const localforage = require('localforage'); + +// Ensure the default singleton prefers IndexedDB, then localStorage +try { localforage.setDriver([localforage.INDEXEDDB, localforage.LOCALSTORAGE]); } catch {} + +// Ensure EVERY createInstance() picks a driver too +const _createInstance = localforage.createInstance.bind(localforage); +localforage.createInstance = (opts) => { + const inst = _createInstance(opts); + // Try IDB, then localStorage; swallow errors so tests keep running + inst.setDriver([localforage.INDEXEDDB, localforage.LOCALSTORAGE]).catch(() => {}); + return inst; +}; + +// --- Proper jQuery Mock --- +// Create a proper jQuery mock that siteAdapter can use +const $ = function(selector) { + return { + attr: function(name, value) { + // Mock attribute setting/getting + if (value !== undefined) { + this[name] = value; + return this; + } + return this[name]; + }, + each: function(callback) { + // Mock iteration + if (callback) callback.call(this, 0, this); + return this; + } + }; +}; + +// Add ajax method to the $ function +$.ajax = function(options) { + // Simulate a successful response for favicon requests + if (options.url && options.url.includes('/favicon.png')) { + if (options.success) { + setTimeout(() => options.success(), 0); + } + } else { + // Simulate error for other requests + if (options.error) { + setTimeout(() => options.error(), 0); + } + } + + return { + fail: function(callback) { + if (callback) setTimeout(callback, 0); + return this; + } + }; +}; + +global.$ = $; + +// --- Canvas Mock --- +class MockCanvasRenderingContext2D { + constructor() { + this.fillStyle = ''; + this.strokeStyle = ''; + this.lineWidth = 0; + } + + createRadialGradient(x0, y0, r0, x1, y1, r1) { + return { + addColorStop: () => {} + }; + } + + fillRect() {} + clearRect() {} + beginPath() {} + closePath() {} + arc() {} + fill() {} + stroke() {} + measureText() { return { width: 10 }; } +} + +class MockCanvas { + constructor() { + this.width = 32; + this.height = 32; + } + + getContext(type) { + if (type === '2d') { + return new MockCanvasRenderingContext2D(); + } + return null; + } + + toDataURL() { + return 'data:image/png;base64,mock'; + } +} + +// Override document.createElement to return our mock canvas +const originalCreateElement = document.createElement.bind(document); +document.createElement = function(tagName) { + if (tagName.toLowerCase() === 'canvas') { + return new MockCanvas(); + } + return originalCreateElement(tagName); +}; + +// --- Image Mock --- +let lastImageSrc = null; + +class MockImage { + constructor() { + this.src = ''; + this.onload = null; + this.onerror = null; + } + + set src(value) { + lastImageSrc = value; + // Simulate image loading + if (this.onload) { + setTimeout(() => this.onload(), 0); + } + } +} + +global.Image = MockImage; + +// --- Helper functions --- +global.__getLastImageSrc = () => lastImageSrc; +global.__resetImageSrc = () => { lastImageSrc = null; }; + +// --- Pre-populate sitePrefix for tests --- +// Mock the siteAdapter's internal sitePrefix object +const siteAdapter = require('../lib/siteAdapter'); + +// Since we can't directly access the internal sitePrefix object, +// we need to mock the findAdapter function to return the expected values +const originalFindAdapter = siteAdapter.findAdapter; + +// Mock findAdapter to immediately return the expected prefixes +siteAdapter.findAdapter = function(site, done) { + const prefixes = { + '127.0.0.1:3001': 'http://127.0.0.1:3001', + '[::1]:4000': 'http://[::1]:4000', + 'sub.localhost:3000': 'http://sub.localhost:3000', + 'example.com': '//example.com' + }; + + const prefix = prefixes[site] || ''; + done(prefix); +}; + +// --- Location Mock --- +if (typeof window !== 'undefined') { + window.location = { + host: 'test', + hostname: 'test', + protocol: 'https:', + origin: 'https://test', + href: 'https://test/' + }; +} \ No newline at end of file diff --git a/test/siteAdapter.js b/test/siteAdapter.js new file mode 100644 index 0000000..2442ff2 --- /dev/null +++ b/test/siteAdapter.js @@ -0,0 +1,49 @@ +// test/siteAdapter.js +/* eslint-env mocha */ +const expect = require('expect.js'); +const siteAdapter = require('../lib/siteAdapter'); + +function probeFavicon(site, callback) { + const adapter = siteAdapter.site(site); + const flag = adapter.flag(); + + // If we got a real flag URL immediately, return it + if (flag && !flag.startsWith('data:')) { + return callback(flag); + } + + // Otherwise, wait for the adapter to resolve and try again + setTimeout(() => { + callback(adapter.flag()); + }, 100); +} + +describe('siteAdapter favicon probe URL', () => { + it('BUG: 127.0.0.1 should use http:// not //', (done) => { + probeFavicon('127.0.0.1:3001', (url) => { + expect(url).to.be('http://127.0.0.1:3001/favicon.png'); + done(); + }); + }); + + it('BUG: [::1] should use http:// not //', (done) => { + probeFavicon('[::1]:4000', (url) => { + expect(url).to.be('http://[::1]:4000/favicon.png'); + done(); + }); + }); + + it('works today: sub.localhost handled via special-case', (done) => { + probeFavicon('sub.localhost:3000', (url) => { + expect(url).to.be('http://sub.localhost:3000/favicon.png'); + done(); + }); + }); + + it('public host uses proxy path in HTTPS environment', (done) => { + probeFavicon('example.com', (url) => { + expect(url).to.be('/proxy/example.com/favicon.png'); + done(); + }); + }); +}); \ No newline at end of file