diff --git a/package-lock.json b/package-lock.json index 3438748..8acad4b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,16 +9,80 @@ "version": "0.0.1", "dependencies": { "@astrojs/mdx": "^4.3.13", - "astro": "^5.0.0" + "astro": "^5.0.0", + "fuzzysort": "^3.1.0" }, "devDependencies": { "glob": "^11.0.0", "husky": "^9.1.7", + "jsdom": "^27.3.0", "lint-staged": "^16.2.7", "tsx": "^4.19.0", "vitest": "^4.0.15" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.29", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.29.tgz", + "integrity": "sha512-G90x0VW+9nW4dFajtjCoT+NM0scAfH9Mb08IcjgFHYbfiL/lU04dTF9JuVOi3/OH+DJCQdcIseSXkdCB9Ky6JA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.0.tgz", + "integrity": "sha512-9xiBAtLn4aNsa4mDnpovJvBn72tNEIACyvlqaNJ+ADemR+yeMJWnBudOi2qGDviJa7SwcDOU/TRh5dnET7qk0w==", + "dev": true, + "license": "MIT", + "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.2" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.6", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.6.tgz", + "integrity": "sha512-hBaJER6A9MpdG3WgdlOolHmbOYvSk46y7IQN/1+iqiCuUu6iWdQrs9DGKF8ocqsEqWujWf/V7b7vaDgiUmIvUg==", + "dev": true, + "license": "MIT", + "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.4" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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" + }, "node_modules/@astrojs/compiler": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.0.tgz", @@ -175,6 +239,144 @@ "node": ">=18" } }, + "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", + "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", + "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", + "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", + "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", + "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", + "engines": { + "node": ">=18" + } + }, "node_modules/@emnapi/runtime": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", @@ -1840,6 +2042,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "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", + "engines": { + "node": ">= 14" + } + }, "node_modules/ansi-align": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", @@ -2130,6 +2342,16 @@ ], "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", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -2565,6 +2787,35 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/cssstyle": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.4.tgz", + "integrity": "sha512-KyOS/kJMEq5O9GdPnaf82noigg5X5DYn0kZPJTaAsCUaBizp6Xa1y9D4Qoqf/JazEXWuruErHgVXwjN5391ZJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.0", + "@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", + "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", @@ -2582,6 +2833,13 @@ } } }, + "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" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -3099,6 +3357,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fuzzysort": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-3.1.0.tgz", + "integrity": "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==", + "license": "MIT" + }, "node_modules/get-east-asian-width": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz", @@ -3403,6 +3667,19 @@ "url": "https://opencollective.com/unified" } }, + "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": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -3425,6 +3702,34 @@ "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, + "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/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -3441,6 +3746,19 @@ "url": "https://github.com/sponsors/typicode" } }, + "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", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/import-meta-resolve": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", @@ -3574,6 +3892,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "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-wsl": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", @@ -3624,6 +3949,59 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "27.3.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.3.0.tgz", + "integrity": "sha512-GtldT42B8+jefDUC4yUKAvsaOrH7PDHmZxZXNgF2xMmymjUbRYJvpAybZAKEmXDGTM0mCsz8duOa4vTm5AY2Kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "cssstyle": "^5.3.4", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.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": "^8.0.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.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -5275,6 +5653,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -5556,6 +5944,16 @@ "url": "https://opencollective.com/unified" } }, + "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/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -5698,12 +6096,32 @@ "fsevents": "~2.3.2" } }, + "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" + }, "node_modules/sax": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz", "integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==", "license": "BlueOak-1.0.0" }, + "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.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -6082,6 +6500,13 @@ "url": "https://opencollective.com/svgo" } }, + "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/tiny-inflate": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz", @@ -6130,6 +6555,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", + "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.19" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.19", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", + "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6143,6 +6588,32 @@ "node": ">=8.0" } }, + "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", + "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", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -7212,6 +7683,19 @@ } } }, + "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/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -7222,6 +7706,53 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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", + "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", + "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", + "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", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7376,6 +7907,45 @@ "node": ">=8" } }, + "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", + "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/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/package.json b/package.json index a1da8bc..4e2dd15 100644 --- a/package.json +++ b/package.json @@ -16,11 +16,13 @@ }, "dependencies": { "@astrojs/mdx": "^4.3.13", - "astro": "^5.0.0" + "astro": "^5.0.0", + "fuzzysort": "^3.1.0" }, "devDependencies": { "glob": "^11.0.0", "husky": "^9.1.7", + "jsdom": "^27.3.0", "lint-staged": "^16.2.7", "tsx": "^4.19.0", "vitest": "^4.0.15" diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 9aa7e74..406e271 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -127,6 +127,42 @@ const collections = await getAllCollectionsSorted(); font-weight: var(--font-medium); } + .nav-item.filter-highlight { + background-color: var(--color-accent); + color: var(--color-bg); + } + + /* Sidebar Filter */ + .sidebar-filter { + margin-bottom: var(--space-4); + } + + .sidebar-filter-input { + width: 100%; + padding: var(--space-2) var(--space-3); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text); + font-size: var(--text-sm); + font-family: inherit; + transition: border-color var(--transition-base); + } + + .sidebar-filter-input:focus { + outline: none; + border-color: var(--color-accent); + } + + .sidebar-filter-input::placeholder { + color: var(--color-text-muted); + } + + .nav-item.filter-hidden, + .nav-section-title.filter-hidden { + display: none; + } + /* Main Content Area */ .main-content { flex: 1; @@ -430,6 +466,17 @@ const collections = await getAllCollectionsSorted(); Light + + + @@ -447,51 +495,9 @@ const collections = await getAllCollectionsSorted(); - diff --git a/src/utils/keyboard-nav.test.ts b/src/utils/keyboard-nav.test.ts new file mode 100644 index 0000000..859da3f --- /dev/null +++ b/src/utils/keyboard-nav.test.ts @@ -0,0 +1,538 @@ +import { describe, it, expect } from 'vitest'; +import { + isMobileMode, + isTypingInInput, + computeFilterVisibility, + calculateNextHighlightIndex, + parseKeyEvent, + matchesShortcut, + DEFAULT_CONFIG, +} from './keyboard-nav'; + +// ============================================================================ +// isMobileMode +// ============================================================================ + +describe('isMobileMode', () => { + it('returns true when viewport is at breakpoint', () => { + expect(isMobileMode(768, 768)).toBe(true); + }); + + it('returns true when viewport is below breakpoint', () => { + expect(isMobileMode(500, 768)).toBe(true); + }); + + it('returns false when viewport is above breakpoint', () => { + expect(isMobileMode(1024, 768)).toBe(false); + }); + + it('returns false when viewport is just above breakpoint', () => { + expect(isMobileMode(769, 768)).toBe(false); + }); + + it('works with custom breakpoints', () => { + expect(isMobileMode(1000, 1024)).toBe(true); + expect(isMobileMode(1025, 1024)).toBe(false); + }); +}); + +// ============================================================================ +// isTypingInInput +// ============================================================================ + +describe('isTypingInInput', () => { + it('returns false for null element', () => { + expect(isTypingInInput(null)).toBe(false); + }); + + it('returns true for INPUT elements', () => { + const input = document.createElement('input'); + expect(isTypingInInput(input)).toBe(true); + }); + + it('returns true for TEXTAREA elements', () => { + const textarea = document.createElement('textarea'); + expect(isTypingInInput(textarea)).toBe(true); + }); + + it('returns true for contenteditable elements', () => { + const div = document.createElement('div'); + div.setAttribute('contenteditable', 'true'); + expect(isTypingInInput(div)).toBe(true); + }); + + it('returns false for regular div elements', () => { + const div = document.createElement('div'); + expect(isTypingInInput(div)).toBe(false); + }); + + it('returns false for button elements', () => { + const button = document.createElement('button'); + expect(isTypingInInput(button)).toBe(false); + }); + + it('returns false for anchor elements', () => { + const anchor = document.createElement('a'); + expect(isTypingInInput(anchor)).toBe(false); + }); +}); + +// ============================================================================ +// computeFilterVisibility +// ============================================================================ + +describe('computeFilterVisibility', () => { + function createNavItem(title: string): { + element: HTMLElement; + title: string; + href: string; + } { + const el = document.createElement('a'); + el.textContent = title; + return { element: el, title, href: `/${title.toLowerCase()}/` }; + } + + it('shows all items when query is empty', () => { + const items = [ + createNavItem('First'), + createNavItem('Second'), + createNavItem('Third'), + ]; + + const visibility = computeFilterVisibility(items, ''); + + expect(visibility.get(items[0].element)).toBe(true); + expect(visibility.get(items[1].element)).toBe(true); + expect(visibility.get(items[2].element)).toBe(true); + }); + + it('shows all items when query is whitespace', () => { + const items = [createNavItem('First'), createNavItem('Second')]; + + const visibility = computeFilterVisibility(items, ' '); + + expect(visibility.get(items[0].element)).toBe(true); + expect(visibility.get(items[1].element)).toBe(true); + }); + + it('filters items by title match', () => { + const items = [ + createNavItem('Context Pruning'), + createNavItem('Context Expanding'), + createNavItem('Failure Modes'), + ]; + + const visibility = computeFilterVisibility(items, 'context'); + + expect(visibility.get(items[0].element)).toBe(true); + expect(visibility.get(items[1].element)).toBe(true); + expect(visibility.get(items[2].element)).toBe(false); + }); + + it('is case insensitive', () => { + const items = [ + createNavItem('Hello World'), + createNavItem('Goodbye'), + ]; + + const visibility = computeFilterVisibility(items, 'HELLO'); + + expect(visibility.get(items[0].element)).toBe(true); + expect(visibility.get(items[1].element)).toBe(false); + }); + + it('matches partial strings', () => { + const items = [ + createNavItem('Introduction'), + createNavItem('Getting Started'), + ]; + + const visibility = computeFilterVisibility(items, 'intro'); + + expect(visibility.get(items[0].element)).toBe(true); + expect(visibility.get(items[1].element)).toBe(false); + }); + + it('hides all items when no match', () => { + const items = [createNavItem('Alpha'), createNavItem('Beta')]; + + const visibility = computeFilterVisibility(items, 'xyz'); + + expect(visibility.get(items[0].element)).toBe(false); + expect(visibility.get(items[1].element)).toBe(false); + }); + + it('handles empty items array', () => { + const visibility = computeFilterVisibility([], 'test'); + expect(visibility.size).toBe(0); + }); + + it('matches anywhere in title', () => { + const items = [createNavItem('Advanced Topics')]; + + const visibility = computeFilterVisibility(items, 'topic'); + + expect(visibility.get(items[0].element)).toBe(true); + }); + + it('fuzzy matches non-consecutive characters', () => { + const items = [ + createNavItem('Context Pruning'), + createNavItem('Getting Started'), + createNavItem('Advanced Topics'), + ]; + + // "ctxprn" should fuzzy match "Context Pruning" (c-t-x from context, p-r-n from pruning) + const visibility = computeFilterVisibility(items, 'ctxprn'); + + expect(visibility.get(items[0].element)).toBe(true); + expect(visibility.get(items[1].element)).toBe(false); + expect(visibility.get(items[2].element)).toBe(false); + }); + + it('fuzzy matches with typo-like queries', () => { + const items = [ + createNavItem('Introduction'), + createNavItem('Configuration'), + ]; + + // "intro" matches "Introduction", "config" matches "Configuration" + const visibility1 = computeFilterVisibility(items, 'intduc'); + expect(visibility1.get(items[0].element)).toBe(true); + + const visibility2 = computeFilterVisibility(items, 'confg'); + expect(visibility2.get(items[1].element)).toBe(true); + }); +}); + +// ============================================================================ +// calculateNextHighlightIndex +// ============================================================================ + +describe('calculateNextHighlightIndex', () => { + describe('moving down', () => { + it('moves from -1 to 0', () => { + expect(calculateNextHighlightIndex(-1, 5, 'down')).toBe(0); + }); + + it('moves from 0 to 1', () => { + expect(calculateNextHighlightIndex(0, 5, 'down')).toBe(1); + }); + + it('wraps from last to first', () => { + expect(calculateNextHighlightIndex(4, 5, 'down')).toBe(0); + }); + + it('stays at 0 for single item', () => { + expect(calculateNextHighlightIndex(0, 1, 'down')).toBe(0); + }); + }); + + describe('moving up', () => { + it('moves from 1 to 0', () => { + expect(calculateNextHighlightIndex(1, 5, 'up')).toBe(0); + }); + + it('wraps from first to last', () => { + expect(calculateNextHighlightIndex(0, 5, 'up')).toBe(4); + }); + + it('wraps from -1 to last', () => { + expect(calculateNextHighlightIndex(-1, 5, 'up')).toBe(4); + }); + + it('stays at 0 for single item', () => { + expect(calculateNextHighlightIndex(0, 1, 'up')).toBe(0); + }); + }); + + describe('edge cases', () => { + it('returns -1 when no items', () => { + expect(calculateNextHighlightIndex(0, 0, 'down')).toBe(-1); + expect(calculateNextHighlightIndex(0, 0, 'up')).toBe(-1); + }); + + it('handles two items correctly', () => { + expect(calculateNextHighlightIndex(0, 2, 'down')).toBe(1); + expect(calculateNextHighlightIndex(1, 2, 'down')).toBe(0); + expect(calculateNextHighlightIndex(0, 2, 'up')).toBe(1); + expect(calculateNextHighlightIndex(1, 2, 'up')).toBe(0); + }); + }); +}); + +// ============================================================================ +// parseKeyEvent +// ============================================================================ + +describe('parseKeyEvent', () => { + function createKeyEvent( + key: string, + modifiers: Partial<{ + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; + shiftKey: boolean; + }> = {} + ): KeyboardEvent { + return new KeyboardEvent('keydown', { + key, + ctrlKey: modifiers.ctrlKey ?? false, + metaKey: modifiers.metaKey ?? false, + altKey: modifiers.altKey ?? false, + shiftKey: modifiers.shiftKey ?? false, + }); + } + + it('normalizes key to lowercase', () => { + const event = createKeyEvent('J'); + const parsed = parseKeyEvent(event); + expect(parsed.key).toBe('j'); + }); + + it('captures ctrl modifier', () => { + const event = createKeyEvent('d', { ctrlKey: true }); + const parsed = parseKeyEvent(event); + expect(parsed.ctrl).toBe(true); + expect(parsed.meta).toBe(false); + }); + + it('captures meta modifier', () => { + const event = createKeyEvent('d', { metaKey: true }); + const parsed = parseKeyEvent(event); + expect(parsed.meta).toBe(true); + expect(parsed.ctrl).toBe(false); + }); + + it('captures alt modifier', () => { + const event = createKeyEvent('d', { altKey: true }); + const parsed = parseKeyEvent(event); + expect(parsed.alt).toBe(true); + }); + + it('captures shift modifier', () => { + const event = createKeyEvent('d', { shiftKey: true }); + const parsed = parseKeyEvent(event); + expect(parsed.shift).toBe(true); + }); + + it('captures multiple modifiers', () => { + const event = createKeyEvent('d', { ctrlKey: true, shiftKey: true }); + const parsed = parseKeyEvent(event); + expect(parsed.ctrl).toBe(true); + expect(parsed.shift).toBe(true); + expect(parsed.meta).toBe(false); + expect(parsed.alt).toBe(false); + }); + + it('handles special keys', () => { + const escape = createKeyEvent('Escape'); + const enter = createKeyEvent('Enter'); + const arrow = createKeyEvent('ArrowDown'); + + expect(parseKeyEvent(escape).key).toBe('escape'); + expect(parseKeyEvent(enter).key).toBe('enter'); + expect(parseKeyEvent(arrow).key).toBe('arrowdown'); + }); +}); + +// ============================================================================ +// matchesShortcut +// ============================================================================ + +describe('matchesShortcut', () => { + it('matches simple key without modifiers', () => { + const event = { key: 'n', ctrl: false, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'n' })).toBe(true); + }); + + it('does not match different key', () => { + const event = { key: 'n', ctrl: false, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'j' })).toBe(false); + }); + + it('matches key with ctrl modifier', () => { + const event = { key: 'd', ctrl: true, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'd', ctrl: true })).toBe(true); + }); + + it('does not match when ctrl required but not pressed', () => { + const event = { key: 'd', ctrl: false, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'd', ctrl: true })).toBe(false); + }); + + it('does not match when ctrl pressed but not required', () => { + const event = { key: 'd', ctrl: true, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'd' })).toBe(false); + }); + + it('matches key with meta modifier', () => { + const event = { key: 'k', ctrl: false, meta: true, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'k', meta: true })).toBe(true); + }); + + it('does not match when meta required but not pressed', () => { + const event = { key: 'k', ctrl: false, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'k', meta: true })).toBe(false); + }); + + it('matches key with alt modifier', () => { + const event = { key: 'x', ctrl: false, meta: false, alt: true, shift: false }; + expect(matchesShortcut(event, { key: 'x', alt: true })).toBe(true); + }); + + it('does not match when alt required but not pressed', () => { + const event = { key: 'x', ctrl: false, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'x', alt: true })).toBe(false); + }); + + it('matches ctrl+d shortcut', () => { + const event = { key: 'd', ctrl: true, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'd', ctrl: true })).toBe(true); + }); + + it('matches ctrl+u shortcut', () => { + const event = { key: 'u', ctrl: true, meta: false, alt: false, shift: false }; + expect(matchesShortcut(event, { key: 'u', ctrl: true })).toBe(true); + }); +}); + +// ============================================================================ +// DEFAULT_CONFIG +// ============================================================================ + +describe('DEFAULT_CONFIG', () => { + it('has all required selectors', () => { + expect(DEFAULT_CONFIG.selectors.sidebar).toBe('#sidebar'); + expect(DEFAULT_CONFIG.selectors.overlay).toBe('#sidebar-overlay'); + expect(DEFAULT_CONFIG.selectors.filterInput).toBe('#sidebar-filter-input'); + expect(DEFAULT_CONFIG.selectors.navList).toBe('#nav-list'); + expect(DEFAULT_CONFIG.selectors.navItem).toBe('.nav-item'); + expect(DEFAULT_CONFIG.selectors.navSectionTitle).toBe('.nav-section-title'); + expect(DEFAULT_CONFIG.selectors.nextLink).toBe('.nav-next'); + expect(DEFAULT_CONFIG.selectors.menuButton).toBe('#mobile-menu-btn'); + expect(DEFAULT_CONFIG.selectors.closeButton).toBe('#sidebar-close'); + }); + + it('has all required classes', () => { + expect(DEFAULT_CONFIG.classes.sidebarOpen).toBe('open'); + expect(DEFAULT_CONFIG.classes.overlayVisible).toBe('visible'); + expect(DEFAULT_CONFIG.classes.filterHidden).toBe('filter-hidden'); + expect(DEFAULT_CONFIG.classes.filterHighlight).toBe('filter-highlight'); + expect(DEFAULT_CONFIG.classes.active).toBe('active'); + }); + + it('has scroll configuration', () => { + expect(DEFAULT_CONFIG.scroll.small).toBe(100); + expect(DEFAULT_CONFIG.scroll.largeRatio).toBe(0.5); + }); + + it('has mobile breakpoint', () => { + expect(DEFAULT_CONFIG.mobileBreakpoint).toBe(768); + }); + + it('has smooth scroll enabled by default', () => { + expect(DEFAULT_CONFIG.smoothScroll).toBe(true); + }); +}); + +// ============================================================================ +// Integration-style tests for shortcut combinations +// ============================================================================ + +describe('shortcut combinations', () => { + function createParsedEvent( + key: string, + modifiers: Partial<{ + ctrl: boolean; + meta: boolean; + alt: boolean; + }> = {} + ) { + return { + key, + ctrl: modifiers.ctrl ?? false, + meta: modifiers.meta ?? false, + alt: modifiers.alt ?? false, + shift: false, + }; + } + + describe('vim-style navigation', () => { + it('j without modifiers matches scroll down', () => { + const event = createParsedEvent('j'); + expect(matchesShortcut(event, { key: 'j' })).toBe(true); + }); + + it('k without modifiers matches scroll up', () => { + const event = createParsedEvent('k'); + expect(matchesShortcut(event, { key: 'k' })).toBe(true); + }); + + it('n without modifiers matches next article', () => { + const event = createParsedEvent('n'); + expect(matchesShortcut(event, { key: 'n' })).toBe(true); + }); + + it('h without modifiers matches toggle sidebar', () => { + const event = createParsedEvent('h'); + expect(matchesShortcut(event, { key: 'h' })).toBe(true); + }); + + it('l without modifiers matches toggle sidebar', () => { + const event = createParsedEvent('l'); + expect(matchesShortcut(event, { key: 'l' })).toBe(true); + }); + + it('t without modifiers matches focus filter', () => { + const event = createParsedEvent('t'); + expect(matchesShortcut(event, { key: 't' })).toBe(true); + }); + }); + + describe('page scroll shortcuts', () => { + it('ctrl+d matches page down', () => { + const event = createParsedEvent('d', { ctrl: true }); + expect(matchesShortcut(event, { key: 'd', ctrl: true })).toBe(true); + }); + + it('ctrl+u matches page up', () => { + const event = createParsedEvent('u', { ctrl: true }); + expect(matchesShortcut(event, { key: 'u', ctrl: true })).toBe(true); + }); + + it('plain d does not match ctrl+d', () => { + const event = createParsedEvent('d'); + expect(matchesShortcut(event, { key: 'd', ctrl: true })).toBe(false); + }); + + it('plain u does not match ctrl+u', () => { + const event = createParsedEvent('u'); + expect(matchesShortcut(event, { key: 'u', ctrl: true })).toBe(false); + }); + }); + + describe('escape key', () => { + it('escape matches clear/close', () => { + const event = createParsedEvent('escape'); + expect(matchesShortcut(event, { key: 'escape' })).toBe(true); + }); + }); + + describe('filter navigation', () => { + it('arrowdown matches filter navigation', () => { + const event = createParsedEvent('arrowdown'); + expect(matchesShortcut(event, { key: 'arrowdown' })).toBe(true); + }); + + it('arrowup matches filter navigation', () => { + const event = createParsedEvent('arrowup'); + expect(matchesShortcut(event, { key: 'arrowup' })).toBe(true); + }); + + it('enter matches filter selection', () => { + const event = createParsedEvent('enter'); + expect(matchesShortcut(event, { key: 'enter' })).toBe(true); + }); + }); +}); diff --git a/src/utils/keyboard-nav.ts b/src/utils/keyboard-nav.ts new file mode 100644 index 0000000..3ae39c7 --- /dev/null +++ b/src/utils/keyboard-nav.ts @@ -0,0 +1,732 @@ +/** + * Keyboard Navigation Module + * + * A modular, testable keyboard navigation system for documentation sites. + * Provides vim-style navigation, sidebar filtering, and mobile sidebar control. + * + * Designed for potential extraction as a standalone plugin. + */ + +import fuzzysort from 'fuzzysort'; + +// ============================================================================ +// Types & Configuration +// ============================================================================ + +export interface KeyboardNavConfig { + /** Selectors for DOM elements */ + selectors: { + sidebar: string; + overlay: string; + filterInput: string; + navList: string; + navItem: string; + navSectionTitle: string; + nextLink: string; + menuButton: string; + closeButton: string; + }; + /** CSS classes used by the system */ + classes: { + sidebarOpen: string; + overlayVisible: string; + filterHidden: string; + filterHighlight: string; + active: string; + }; + /** Scroll amounts */ + scroll: { + /** Small scroll increment (j/k) */ + small: number; + /** Large scroll ratio of viewport (Ctrl+D/U) */ + largeRatio: number; + }; + /** Breakpoint for mobile detection */ + mobileBreakpoint: number; + /** Enable smooth scrolling */ + smoothScroll: boolean; +} + +export interface KeyboardNavState { + /** Current filter highlight index */ + filterHighlightIndex: number; + /** Saved sidebar scroll position */ + sidebarScrollPosition: number; +} + +export interface NavItem { + element: HTMLElement; + title: string; + href: string; +} + +// ============================================================================ +// Default Configuration +// ============================================================================ + +export const DEFAULT_CONFIG: KeyboardNavConfig = { + selectors: { + sidebar: '#sidebar', + overlay: '#sidebar-overlay', + filterInput: '#sidebar-filter-input', + navList: '#nav-list', + navItem: '.nav-item', + navSectionTitle: '.nav-section-title', + nextLink: '.nav-next', + menuButton: '#mobile-menu-btn', + closeButton: '#sidebar-close', + }, + classes: { + sidebarOpen: 'open', + overlayVisible: 'visible', + filterHidden: 'filter-hidden', + filterHighlight: 'filter-highlight', + active: 'active', + }, + scroll: { + small: 100, + largeRatio: 0.5, + }, + mobileBreakpoint: 768, + smoothScroll: true, +}; + +// ============================================================================ +// Pure Helper Functions (Testable) +// ============================================================================ + +/** + * Check if we're in mobile mode based on viewport width + */ +export function isMobileMode( + viewportWidth: number, + breakpoint: number +): boolean { + return viewportWidth <= breakpoint; +} + +/** + * Check if the active element is an input that should block shortcuts + */ +export function isTypingInInput(activeElement: Element | null): boolean { + if (!activeElement) return false; + + const tagName = activeElement.tagName; + if (tagName === 'INPUT' || tagName === 'TEXTAREA') return true; + + // Check both the property and the attribute for contentEditable + // (property for browser, attribute for jsdom compatibility) + const htmlElement = activeElement as HTMLElement; + if (htmlElement.isContentEditable) return true; + if (htmlElement.getAttribute?.('contenteditable') === 'true') return true; + + return false; +} + +/** + * Filter nav items based on a query string using fuzzy matching + * Returns which items and sections should be visible + */ +export function computeFilterVisibility( + items: NavItem[], + query: string +): Map { + const visibility = new Map(); + const normalizedQuery = query.trim(); + + // If no query, show everything + if (!normalizedQuery) { + for (const item of items) { + visibility.set(item.element, true); + } + return visibility; + } + + // Use fuzzysort for fuzzy matching + const results = fuzzysort.go(normalizedQuery, items, { + key: 'title', + threshold: -10000, // Allow loose matches + }); + + // Create a set of matched elements for quick lookup + const matchedElements = new Set(results.map((r) => r.obj.element)); + + for (const item of items) { + visibility.set(item.element, matchedElements.has(item.element)); + } + + return visibility; +} + +/** + * Determine which section titles should be visible based on their items + */ +export function computeSectionVisibility( + sectionTitles: HTMLElement[], + navItems: HTMLElement[], + itemVisibility: Map +): Map { + const visibility = new Map(); + + for (const section of sectionTitles) { + // Find all items that belong to this section (items after this title, before next title) + let hasVisibleItem = false; + let currentElement = section.nextElementSibling; + + while (currentElement) { + if ( + currentElement.classList.contains('nav-section-title') || + currentElement.classList.contains( + sectionTitles[0]?.className.split(' ')[0] || 'nav-section-title' + ) + ) { + break; + } + + if (navItems.includes(currentElement as HTMLElement)) { + if (itemVisibility.get(currentElement as HTMLElement)) { + hasVisibleItem = true; + break; + } + } + + currentElement = currentElement.nextElementSibling; + } + + visibility.set(section, hasVisibleItem); + } + + return visibility; +} + +/** + * Calculate the next highlight index for filter navigation + */ +export function calculateNextHighlightIndex( + currentIndex: number, + totalItems: number, + direction: 'up' | 'down' +): number { + if (totalItems === 0) return -1; + + if (direction === 'down') { + return currentIndex < totalItems - 1 ? currentIndex + 1 : 0; + } else { + return currentIndex > 0 ? currentIndex - 1 : totalItems - 1; + } +} + +/** + * Parse a keyboard event into a normalized key descriptor + */ +export function parseKeyEvent(event: KeyboardEvent): { + key: string; + ctrl: boolean; + meta: boolean; + alt: boolean; + shift: boolean; +} { + return { + key: event.key.toLowerCase(), + ctrl: event.ctrlKey, + meta: event.metaKey, + alt: event.altKey, + shift: event.shiftKey, + }; +} + +/** + * Check if a key matches a shortcut definition + */ +export function matchesShortcut( + event: ReturnType, + shortcut: { + key: string; + ctrl?: boolean; + meta?: boolean; + alt?: boolean; + } +): boolean { + if (event.key !== shortcut.key) return false; + if (shortcut.ctrl && !event.ctrl) return false; + if (!shortcut.ctrl && event.ctrl) return false; + if (shortcut.meta && !event.meta) return false; + if (shortcut.alt && !event.alt) return false; + return true; +} + +// ============================================================================ +// DOM Interaction Layer +// ============================================================================ + +/** + * Create a DOM helper bound to a specific configuration + */ +export function createDOMHelper(config: KeyboardNavConfig) { + function getElement(selector: string): T | null { + return document.querySelector(selector); + } + + function getAllElements(selector: string): T[] { + return Array.from(document.querySelectorAll(selector)); + } + + return { + getSidebar: () => getElement(config.selectors.sidebar), + getOverlay: () => getElement(config.selectors.overlay), + getFilterInput: () => + getElement(config.selectors.filterInput), + getNavList: () => getElement(config.selectors.navList), + getNavItems: () => getAllElements(config.selectors.navItem), + getNavSectionTitles: () => + getAllElements(config.selectors.navSectionTitle), + getNextLink: () => getElement(config.selectors.nextLink), + getMenuButton: () => getElement(config.selectors.menuButton), + getCloseButton: () => + getElement(config.selectors.closeButton), + + getVisibleNavItems: () => + getAllElements(config.selectors.navItem).filter( + (item) => !item.classList.contains(config.classes.filterHidden) + ), + }; +} + +// ============================================================================ +// Sidebar Controller +// ============================================================================ + +export function createSidebarController( + config: KeyboardNavConfig, + dom: ReturnType, + state: KeyboardNavState +) { + function open() { + const sidebar = dom.getSidebar(); + const overlay = dom.getOverlay(); + + sidebar?.classList.add(config.classes.sidebarOpen); + overlay?.classList.add(config.classes.overlayVisible); + + // Restore scroll position + if (sidebar) { + sidebar.scrollTop = state.sidebarScrollPosition; + } + } + + function close() { + const sidebar = dom.getSidebar(); + const overlay = dom.getOverlay(); + + // Save scroll position before closing + if (sidebar) { + state.sidebarScrollPosition = sidebar.scrollTop; + } + + sidebar?.classList.remove(config.classes.sidebarOpen); + overlay?.classList.remove(config.classes.overlayVisible); + } + + function toggle() { + const sidebar = dom.getSidebar(); + if (sidebar?.classList.contains(config.classes.sidebarOpen)) { + close(); + } else { + open(); + } + } + + function isOpen() { + return ( + dom.getSidebar()?.classList.contains(config.classes.sidebarOpen) ?? false + ); + } + + return { open, close, toggle, isOpen }; +} + +// ============================================================================ +// Filter Controller +// ============================================================================ + +export function createFilterController( + config: KeyboardNavConfig, + dom: ReturnType, + state: KeyboardNavState +) { + function applyFilter(query: string) { + const navItems = dom.getNavItems(); + const sectionTitles = dom.getNavSectionTitles(); + + const items: NavItem[] = navItems.map((el) => ({ + element: el, + title: el.textContent || '', + href: el.getAttribute('href') || '', + })); + + const itemVisibility = computeFilterVisibility(items, query); + const sectionVisibility = computeSectionVisibility( + sectionTitles, + navItems, + itemVisibility + ); + + // Apply visibility to items + for (const [element, visible] of itemVisibility) { + element.classList.toggle(config.classes.filterHidden, !visible); + } + + // Apply visibility to sections + for (const [element, visible] of sectionVisibility) { + element.classList.toggle(config.classes.filterHidden, !visible); + } + + // Reset highlight + resetHighlight(); + } + + function resetHighlight() { + state.filterHighlightIndex = -1; + dom.getNavItems().forEach((item) => { + item.classList.remove(config.classes.filterHighlight); + }); + } + + function updateHighlight(newIndex: number) { + const items = dom.getVisibleNavItems(); + + // Remove previous highlight + items.forEach((item) => item.classList.remove(config.classes.filterHighlight)); + + state.filterHighlightIndex = newIndex; + + // Add new highlight + if (newIndex >= 0 && newIndex < items.length) { + items[newIndex].classList.add(config.classes.filterHighlight); + items[newIndex].scrollIntoView({ block: 'nearest' }); + } + } + + function moveHighlight(direction: 'up' | 'down') { + const items = dom.getVisibleNavItems(); + const newIndex = calculateNextHighlightIndex( + state.filterHighlightIndex, + items.length, + direction + ); + updateHighlight(newIndex); + } + + function selectHighlighted(): string | null { + const items = dom.getVisibleNavItems(); + const index = state.filterHighlightIndex; + + if (index >= 0 && index < items.length) { + return items[index].getAttribute('href'); + } + + // If only one result, select it + if (items.length === 1) { + return items[0].getAttribute('href'); + } + + return null; + } + + function clear() { + const input = dom.getFilterInput(); + if (input) { + input.value = ''; + } + applyFilter(''); + } + + function focus() { + const input = dom.getFilterInput(); + input?.focus(); + input?.select(); + } + + return { + applyFilter, + resetHighlight, + updateHighlight, + moveHighlight, + selectHighlighted, + clear, + focus, + }; +} + +// ============================================================================ +// Scroll Controller +// ============================================================================ + +export function createScrollController(config: KeyboardNavConfig) { + const behavior = config.smoothScroll ? 'smooth' : 'auto'; + + function scrollSmall(direction: 'up' | 'down') { + const amount = direction === 'down' ? config.scroll.small : -config.scroll.small; + window.scrollBy({ top: amount, behavior }); + } + + function scrollLarge(direction: 'up' | 'down') { + const amount = + direction === 'down' + ? window.innerHeight * config.scroll.largeRatio + : -window.innerHeight * config.scroll.largeRatio; + window.scrollBy({ top: amount, behavior }); + } + + return { scrollSmall, scrollLarge }; +} + +// ============================================================================ +// Keyboard Handler +// ============================================================================ + +export function createKeyboardHandler( + config: KeyboardNavConfig, + dom: ReturnType, + sidebar: ReturnType, + filter: ReturnType, + scroll: ReturnType +) { + function handleFilterKeydown(event: KeyboardEvent) { + const parsed = parseKeyEvent(event); + + // Arrow down or j - move highlight down + if ( + matchesShortcut(parsed, { key: 'arrowdown' }) || + matchesShortcut(parsed, { key: 'j' }) + ) { + event.preventDefault(); + filter.moveHighlight('down'); + return; + } + + // Arrow up or k - move highlight up + if ( + matchesShortcut(parsed, { key: 'arrowup' }) || + matchesShortcut(parsed, { key: 'k' }) + ) { + event.preventDefault(); + filter.moveHighlight('up'); + return; + } + + // Enter - select highlighted + if (matchesShortcut(parsed, { key: 'enter' })) { + event.preventDefault(); + const href = filter.selectHighlighted(); + if (href) { + window.location.href = href; + } + return; + } + + // Escape - clear and blur + if (matchesShortcut(parsed, { key: 'escape' })) { + event.preventDefault(); + filter.clear(); + dom.getFilterInput()?.blur(); + return; + } + } + + function handleGlobalKeydown(event: KeyboardEvent) { + const activeElement = document.activeElement; + const filterInput = dom.getFilterInput(); + + // If in filter input, let filter handler deal with it + if (activeElement === filterInput) { + return; + } + + // Block shortcuts when typing in other inputs + if (isTypingInInput(activeElement)) { + return; + } + + const parsed = parseKeyEvent(event); + const inMobileMode = isMobileMode(window.innerWidth, config.mobileBreakpoint); + + // n - Go to next article + if (matchesShortcut(parsed, { key: 'n' })) { + const nextLink = dom.getNextLink(); + if (nextLink) { + event.preventDefault(); + window.location.href = nextLink.getAttribute('href') || ''; + } + return; + } + + // j - Scroll down (small) + if (matchesShortcut(parsed, { key: 'j' })) { + event.preventDefault(); + scroll.scrollSmall('down'); + return; + } + + // k - Scroll up (small) + if (matchesShortcut(parsed, { key: 'k' })) { + event.preventDefault(); + scroll.scrollSmall('up'); + return; + } + + // Ctrl+D - Scroll down (large) + if (matchesShortcut(parsed, { key: 'd', ctrl: true })) { + event.preventDefault(); + scroll.scrollLarge('down'); + return; + } + + // Ctrl+U - Scroll up (large) + if (matchesShortcut(parsed, { key: 'u', ctrl: true })) { + event.preventDefault(); + scroll.scrollLarge('up'); + return; + } + + // h or l - Toggle sidebar (mobile only) + if ( + matchesShortcut(parsed, { key: 'h' }) || + matchesShortcut(parsed, { key: 'l' }) + ) { + if (inMobileMode) { + event.preventDefault(); + sidebar.toggle(); + } + return; + } + + // t - Focus filter input + if (matchesShortcut(parsed, { key: 't' })) { + event.preventDefault(); + if (inMobileMode) { + sidebar.open(); + } + filter.focus(); + return; + } + + // Escape - Clear filter and close sidebar + if (matchesShortcut(parsed, { key: 'escape' })) { + filter.clear(); + if (inMobileMode) { + sidebar.close(); + } + return; + } + } + + return { handleFilterKeydown, handleGlobalKeydown }; +} + +// ============================================================================ +// Main Initialization +// ============================================================================ + +export interface KeyboardNavInstance { + /** Current configuration */ + config: KeyboardNavConfig; + /** Current state */ + state: KeyboardNavState; + /** Sidebar controller */ + sidebar: ReturnType; + /** Filter controller */ + filter: ReturnType; + /** Scroll controller */ + scroll: ReturnType; + /** Cleanup function to remove event listeners */ + destroy: () => void; +} + +/** + * Initialize keyboard navigation on the page + */ +export function initKeyboardNav( + userConfig: Partial = {} +): KeyboardNavInstance { + // Merge user config with defaults + const config: KeyboardNavConfig = { + ...DEFAULT_CONFIG, + ...userConfig, + selectors: { ...DEFAULT_CONFIG.selectors, ...userConfig.selectors }, + classes: { ...DEFAULT_CONFIG.classes, ...userConfig.classes }, + scroll: { ...DEFAULT_CONFIG.scroll, ...userConfig.scroll }, + }; + + // Initialize state + const state: KeyboardNavState = { + filterHighlightIndex: -1, + sidebarScrollPosition: 0, + }; + + // Create helpers and controllers + const dom = createDOMHelper(config); + const sidebar = createSidebarController(config, dom, state); + const filter = createFilterController(config, dom, state); + const scroll = createScrollController(config); + const keyboard = createKeyboardHandler(config, dom, sidebar, filter, scroll); + + // Set up event listeners + const filterInput = dom.getFilterInput(); + const menuButton = dom.getMenuButton(); + const closeButton = dom.getCloseButton(); + const overlay = dom.getOverlay(); + const navItems = dom.getNavItems(); + + // Filter input events + const onFilterInput = (e: Event) => { + filter.applyFilter((e.target as HTMLInputElement).value); + }; + filterInput?.addEventListener('input', onFilterInput); + filterInput?.addEventListener('keydown', keyboard.handleFilterKeydown); + + // Sidebar control events + menuButton?.addEventListener('click', sidebar.toggle); + closeButton?.addEventListener('click', sidebar.close); + overlay?.addEventListener('click', sidebar.close); + + // Close sidebar on nav item click (mobile) + const navItemClickHandler = () => sidebar.close(); + navItems.forEach((item) => item.addEventListener('click', navItemClickHandler)); + + // Global keyboard events + document.addEventListener('keydown', keyboard.handleGlobalKeydown); + + // Highlight current nav item and scroll to it + const currentPath = window.location.pathname; + navItems.forEach((link) => { + if (link.getAttribute('href') === currentPath) { + link.classList.add(config.classes.active); + setTimeout(() => { + link.scrollIntoView({ block: 'center', behavior: 'instant' }); + }, 0); + } + }); + + // Cleanup function + function destroy() { + filterInput?.removeEventListener('input', onFilterInput); + filterInput?.removeEventListener('keydown', keyboard.handleFilterKeydown); + menuButton?.removeEventListener('click', sidebar.toggle); + closeButton?.removeEventListener('click', sidebar.close); + overlay?.removeEventListener('click', sidebar.close); + navItems.forEach((item) => + item.removeEventListener('click', navItemClickHandler) + ); + document.removeEventListener('keydown', keyboard.handleGlobalKeydown); + } + + return { + config, + state, + sidebar, + filter, + scroll, + destroy, + }; +} diff --git a/vitest.config.ts b/vitest.config.ts index 7382f40..5270893 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,5 +3,6 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, + environment: 'jsdom', }, });