From 8696a62e67b5e488f2778386ac4d1538990c69fd Mon Sep 17 00:00:00 2001 From: Maddison Hellstrom Date: Wed, 16 Nov 2022 20:08:30 -0800 Subject: [PATCH] Use uhtml+dompurify to eliminate XSS potential in search engines --- package-lock.json | 170 +++++++++++++--- package.json | 10 +- src/search-engines.js | 442 +++++++++++++++++------------------------- src/util.js | 47 +++-- 4 files changed, 364 insertions(+), 305 deletions(-) diff --git a/package-lock.json b/package-lock.json index fbb7896..aea21b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "1.1.0", "license": "MIT", "dependencies": { - "github-reserved-names": "^2.0.4" + "dompurify": "^2.4.1", + "github-reserved-names": "^2.0.4", + "uhtml": "^3.1.0" }, "devDependencies": { "del": "^7.0.0", - "eslint": "8.26.0", + "eslint": "8.27.0", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "^2.26.0", "express": "^4.18.2", @@ -22,11 +24,11 @@ "gulp-notify": "^4.0.0", "gulp-rename": "^2.0.0", "gulp-replace": "^1.1.3", - "node-fetch": "^3.2.10", + "node-fetch": "^3.3.0", "platform-folders": "^0.6.0", "to-string-loader": "^1.2.0", "unicode": "^14.0.0", - "webpack": "^5.74.0", + "webpack": "^5.75.0", "webpack-stream": "^7.0.0" }, "peerDependencies": { @@ -388,6 +390,21 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@webreflection/mapset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@webreflection/mapset/-/mapset-1.0.1.tgz", + "integrity": "sha512-cfHPwoviBs7Y/sewLQqE6Ic3XJfUr+LbNEYtR2uW4Od41y5Mg8TTQ8hUb3zBp3cepZTPpwhI6YMnjWk+olqO2w==" + }, + "node_modules/@webreflection/uparser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@webreflection/uparser/-/uparser-0.2.4.tgz", + "integrity": "sha512-4cYSODHAbjsIlvlTLffaN+QiFcNSLTYkRLOrDqpK+m6Bzqyjudq/xHTiSl4/LxeijcQE48nJQuaBnJcnizXxrA==" + }, + "node_modules/@webreflection/uwire": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@webreflection/uwire/-/uwire-1.2.1.tgz", + "integrity": "sha512-3FIqIFzqij5NPWKWCQKJhfcRQpfS8RHAcceFosSDDiD6WrMHRAp3QoBqYt7dfrPhJ8Fg2b6T6Ea8ttClzVkZHA==" + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -940,6 +957,11 @@ "node": ">= 0.10" } }, + "node_modules/async-tag": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/async-tag/-/async-tag-0.2.0.tgz", + "integrity": "sha512-hNstPiQvxVVJdkBjfBsNb3zDEM2IUY3Xp7qaadEnhaGXq1/OFdS+TuwjEJxnIvZRm7e13KrPW74fIeDra7P5vw==" + }, "node_modules/atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -1891,6 +1913,11 @@ "node": ">=6.0.0" } }, + "node_modules/dompurify": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.1.tgz", + "integrity": "sha512-ewwFzHzrrneRjxzmK6oVz/rZn9VWspGFRDb4/rRtIsM1n36t9AKma/ye8syCpcw+XJ25kOK/hOG7t1j2I2yBqA==" + }, "node_modules/each-props": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/each-props/-/each-props-1.3.2.tgz", @@ -2132,9 +2159,9 @@ } }, "node_modules/eslint": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.26.0.tgz", - "integrity": "sha512-kzJkpaw1Bfwheq4VXUezFriD1GxszX6dUekM7Z3aC2o4hju+tsR/XyTC3RcoSD7jmy9VkPU3+N6YjVU2e96Oyg==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.27.0.tgz", + "integrity": "sha512-0y1bfG2ho7mty+SiILVf9PfuRA49ek4Nc60Wmmu62QlobNR+CeXa4xXIJgcuwSQgZiWaPH+5BDsctpIW0PR/wQ==", "dev": true, "dependencies": { "@eslint/eslintrc": "^1.3.3", @@ -4372,6 +4399,11 @@ "json5": "lib/cli.js" } }, + "node_modules/jsx2tag": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/jsx2tag/-/jsx2tag-0.3.1.tgz", + "integrity": "sha512-S1ACW3N4yDiE49x9y9f2d+utB3Kci3A3Bx7wb2bK+5sl0B60lZM60pZ8L/A+FA55DYuAy2fgHu1i47yt+xgL9Q==" + }, "node_modules/just-debounce": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.1.0.tgz", @@ -5167,9 +5199,9 @@ } }, "node_modules/node-fetch": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", - "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.0.tgz", + "integrity": "sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==", "dev": true, "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -7526,6 +7558,38 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, + "node_modules/uarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/uarray/-/uarray-1.0.0.tgz", + "integrity": "sha512-LHmiAd5QuAv7pU2vbh+Zq9YOnqVK0H764p2Ozinpfy9ka58OID4IsGLiXsitqH7n0NAIDxvax1A/kDXpii/Ckg==" + }, + "node_modules/udomdiff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/udomdiff/-/udomdiff-1.1.0.tgz", + "integrity": "sha512-aqjTs5x/wsShZBkVagdafJkP8S3UMGhkHKszsu1cszjjZ7iOp86+Qb3QOFYh01oWjPMy5ZTuxD6hw5uTKxd+VA==" + }, + "node_modules/uhandlers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/uhandlers/-/uhandlers-0.7.0.tgz", + "integrity": "sha512-MG6Q6Dc+xIfyFnHU8APpR916XWhnb+m30qVjPXxAmHUaVFmzUAcd6BCWws5LedyG43onCk1RRPNq0N02wcn4NA==", + "dependencies": { + "uarray": "^1.0.0" + } + }, + "node_modules/uhtml": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uhtml/-/uhtml-3.1.0.tgz", + "integrity": "sha512-OxX1LNtsieg9hdVV2fG7IGuk/BuemGDIGiv45KGTQxy37do7vK3EDjsa+KL1juLif1f0YelGqfnxzIrMRXHziQ==", + "dependencies": { + "@webreflection/mapset": "^1.0.1", + "@webreflection/uparser": "^0.2.4", + "@webreflection/uwire": "^1.2.1", + "async-tag": "^0.2.0", + "jsx2tag": "^0.3.1", + "udomdiff": "^1.1.0", + "uhandlers": "^0.7.0" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -7919,9 +7983,9 @@ } }, "node_modules/webpack": { - "version": "5.74.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz", - "integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==", + "version": "5.75.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz", + "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==", "dev": true, "dependencies": { "@types/eslint-scope": "^3.7.3", @@ -8721,6 +8785,21 @@ "@xtuc/long": "4.2.2" } }, + "@webreflection/mapset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@webreflection/mapset/-/mapset-1.0.1.tgz", + "integrity": "sha512-cfHPwoviBs7Y/sewLQqE6Ic3XJfUr+LbNEYtR2uW4Od41y5Mg8TTQ8hUb3zBp3cepZTPpwhI6YMnjWk+olqO2w==" + }, + "@webreflection/uparser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@webreflection/uparser/-/uparser-0.2.4.tgz", + "integrity": "sha512-4cYSODHAbjsIlvlTLffaN+QiFcNSLTYkRLOrDqpK+m6Bzqyjudq/xHTiSl4/LxeijcQE48nJQuaBnJcnizXxrA==" + }, + "@webreflection/uwire": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@webreflection/uwire/-/uwire-1.2.1.tgz", + "integrity": "sha512-3FIqIFzqij5NPWKWCQKJhfcRQpfS8RHAcceFosSDDiD6WrMHRAp3QoBqYt7dfrPhJ8Fg2b6T6Ea8ttClzVkZHA==" + }, "@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -9139,6 +9218,11 @@ "async-done": "^1.2.2" } }, + "async-tag": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/async-tag/-/async-tag-0.2.0.tgz", + "integrity": "sha512-hNstPiQvxVVJdkBjfBsNb3zDEM2IUY3Xp7qaadEnhaGXq1/OFdS+TuwjEJxnIvZRm7e13KrPW74fIeDra7P5vw==" + }, "atob": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", @@ -9861,6 +9945,11 @@ "esutils": "^2.0.2" } }, + "dompurify": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.4.1.tgz", + "integrity": "sha512-ewwFzHzrrneRjxzmK6oVz/rZn9VWspGFRDb4/rRtIsM1n36t9AKma/ye8syCpcw+XJ25kOK/hOG7t1j2I2yBqA==" + }, "each-props": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/each-props/-/each-props-1.3.2.tgz", @@ -10064,9 +10153,9 @@ "dev": true }, "eslint": { - "version": "8.26.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.26.0.tgz", - "integrity": "sha512-kzJkpaw1Bfwheq4VXUezFriD1GxszX6dUekM7Z3aC2o4hju+tsR/XyTC3RcoSD7jmy9VkPU3+N6YjVU2e96Oyg==", + "version": "8.27.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.27.0.tgz", + "integrity": "sha512-0y1bfG2ho7mty+SiILVf9PfuRA49ek4Nc60Wmmu62QlobNR+CeXa4xXIJgcuwSQgZiWaPH+5BDsctpIW0PR/wQ==", "dev": true, "requires": { "@eslint/eslintrc": "^1.3.3", @@ -11813,6 +11902,11 @@ "minimist": "^1.2.0" } }, + "jsx2tag": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/jsx2tag/-/jsx2tag-0.3.1.tgz", + "integrity": "sha512-S1ACW3N4yDiE49x9y9f2d+utB3Kci3A3Bx7wb2bK+5sl0B60lZM60pZ8L/A+FA55DYuAy2fgHu1i47yt+xgL9Q==" + }, "just-debounce": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.1.0.tgz", @@ -12433,9 +12527,9 @@ "dev": true }, "node-fetch": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.10.tgz", - "integrity": "sha512-MhuzNwdURnZ1Cp4XTazr69K0BTizsBroX7Zx3UgDSVcZYKF/6p0CBe4EUb/hLqmzVhl0UpYfgRljQ4yxE+iCxA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.0.tgz", + "integrity": "sha512-BKwRP/O0UvoMKp7GNdwPlObhYGB5DQqwhEDQlNKuoqwVYSxkSZCSbHjnFFmUEtwSKRPU4kNK8PbDYYitwaE3QA==", "dev": true, "requires": { "data-uri-to-buffer": "^4.0.0", @@ -14270,6 +14364,38 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, + "uarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/uarray/-/uarray-1.0.0.tgz", + "integrity": "sha512-LHmiAd5QuAv7pU2vbh+Zq9YOnqVK0H764p2Ozinpfy9ka58OID4IsGLiXsitqH7n0NAIDxvax1A/kDXpii/Ckg==" + }, + "udomdiff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/udomdiff/-/udomdiff-1.1.0.tgz", + "integrity": "sha512-aqjTs5x/wsShZBkVagdafJkP8S3UMGhkHKszsu1cszjjZ7iOp86+Qb3QOFYh01oWjPMy5ZTuxD6hw5uTKxd+VA==" + }, + "uhandlers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/uhandlers/-/uhandlers-0.7.0.tgz", + "integrity": "sha512-MG6Q6Dc+xIfyFnHU8APpR916XWhnb+m30qVjPXxAmHUaVFmzUAcd6BCWws5LedyG43onCk1RRPNq0N02wcn4NA==", + "requires": { + "uarray": "^1.0.0" + } + }, + "uhtml": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/uhtml/-/uhtml-3.1.0.tgz", + "integrity": "sha512-OxX1LNtsieg9hdVV2fG7IGuk/BuemGDIGiv45KGTQxy37do7vK3EDjsa+KL1juLif1f0YelGqfnxzIrMRXHziQ==", + "requires": { + "@webreflection/mapset": "^1.0.1", + "@webreflection/uparser": "^0.2.4", + "@webreflection/uwire": "^1.2.1", + "async-tag": "^0.2.0", + "jsx2tag": "^0.3.1", + "udomdiff": "^1.1.0", + "uhandlers": "^0.7.0" + } + }, "unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -14580,9 +14706,9 @@ "dev": true }, "webpack": { - "version": "5.74.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.74.0.tgz", - "integrity": "sha512-A2InDwnhhGN4LYctJj6M1JEaGL7Luj6LOmyBHjcI8529cm5p6VXiTIW2sn6ffvEAKmveLzvu4jrihwXtPojlAA==", + "version": "5.75.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.75.0.tgz", + "integrity": "sha512-piaIaoVJlqMsPtX/+3KTTO6jfvrSYgauFVdt8cr9LTHKmcq/AMd4mhzsiP7ZF/PGRNPGA8336jldh9l2Kt2ogQ==", "dev": true, "requires": { "@types/eslint-scope": "^3.7.3", diff --git a/package.json b/package.json index a7fd90b..910fd40 100644 --- a/package.json +++ b/package.json @@ -22,11 +22,13 @@ "contributors": [], "license": "MIT", "dependencies": { - "github-reserved-names": "^2.0.4" + "dompurify": "^2.4.1", + "github-reserved-names": "^2.0.4", + "uhtml": "^3.1.0" }, "devDependencies": { "del": "^7.0.0", - "eslint": "8.26.0", + "eslint": "8.27.0", "eslint-config-airbnb-base": "15.0.0", "eslint-plugin-import": "^2.26.0", "express": "^4.18.2", @@ -35,11 +37,11 @@ "gulp-notify": "^4.0.0", "gulp-rename": "^2.0.0", "gulp-replace": "^1.1.3", - "node-fetch": "^3.2.10", + "node-fetch": "^3.3.0", "platform-folders": "^0.6.0", "to-string-loader": "^1.2.0", "unicode": "^14.0.0", - "webpack": "^5.74.0", + "webpack": "^5.75.0", "webpack-stream": "^7.0.0" }, "peerDependencies": { diff --git a/src/search-engines.js b/src/search-engines.js index b1e3e36..2492734 100644 --- a/src/search-engines.js +++ b/src/search-engines.js @@ -2,18 +2,19 @@ import priv from "./conf.priv.js" import util from "./util.js" const { - escapeHTML, - createSuggestionItem, - createURLItem, + htmlPurify, + htmlNode, + htmlForEach, + suggestionItem, + urlItem, prettyDate, getDuckduckgoFaviconUrl, localStorage, runtimeHttpRequest, } = util -// TODO: use a Babel loader to import these images +// TODO: use a Babel loader to import this image const wpDefaultIcon = "data:image/svg+xml,%3C%3Fxml%20version%3D%221.0%22%20encoding%3D%22utf-8%22%3F%3E%0A%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2056%2056%22%20enable-background%3D%22new%200%200%2056%2056%22%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23eee%22%20d%3D%22M0%200h56v56h-56z%22%2F%3E%0A%20%20%20%20%3Cpath%20fill%3D%22%23999%22%20d%3D%22M36.4%2013.5h-18.6v24.9c0%201.4.9%202.3%202.3%202.3h18.7v-25c.1-1.4-1-2.2-2.4-2.2zm-6.2%203.5h5.1v6.4h-5.1v-6.4zm-8.8%200h6v1.8h-6v-1.8zm0%204.6h6v1.8h-6v-1.8zm0%2015.5v-1.8h13.8v1.8h-13.8zm13.8-4.5h-13.8v-1.8h13.8v1.8zm0-4.7h-13.8v-1.8h13.8v1.8z%22%2F%3E%0A%3C%2Fsvg%3E%0A" -const cbDefaultIcon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAAAAAByaaZbAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAACYktHRAD/h4/MvwAAAAlwSFlzAAAOwwAADsMBx2+oZAAAAAd0SU1FB+EICxEMErRVWUQAAABOdEVYdFJhdyBwcm9maWxlIHR5cGUgZXhpZgAKZXhpZgogICAgICAyMAo0NTc4Njk2NjAwMDA0OTQ5MmEwMDA4MDAwMDAwMDAwMDAwMDAwMDAwCnwMkD0AAAGXSURBVEjH1ZRvc4IwDMb7/T8dbVr/sEPlPJQd3g22GzJdmxVOHaQa8N2WN7wwvyZ5Eh/hngzxTwDr0If/TAK67POxbqxnpgCIx9dkrkEvswYnAFiutFSgtQapS4ejwFYqbXQXBmC+QxawuI/MJb0LiCq0DICNHoZRKQdYLKQZEhATcQmwDYD5GR8DDtfqaYAMActvTiVMaUvqhZPVYhYAK2SBAwGMTHngnc4wVmFPW9L6k1PJxbSCkfvhqolKSQhsWSClizNyxwAWdzIADixQRXRmdWSHthsg+TknaztFMZgC3vh/nG/qo68TLAKrCSrUg1ulp3cH+BpItBp3DZf0lFXVOIDnBdwKkLO4D5Q3QMO6HJ+hUb1NKNWMGJn3jf4ejPKn99CXOtsuyab95obGL/rpdZ7oIJK87iPiumG01drbdggoCZuq/f0XaB8/FbG62Ta5cD97XJwuZUT7ONbZTIK5m94hBuQs8535MsL5xxPw6ZoNj0DiyzhhcyMf9BJ0Jk1uRRpNyb4y0UaM9UI7E8+kt/EHgR/R6042JzmiwgAAACV0RVh0ZGF0ZTpjcmVhdGUAMjAxNy0wOC0xMVQxNzoxMjoxOC0wNDowMLy29LgAAAAldEVYdGRhdGU6bW9kaWZ5ADIwMTctMDgtMTFUMTc6MTI6MTgtMDQ6MDDN60wEAAAAAElFTkSuQmCC" const locale = typeof navigator !== "undefined" ? navigator.language : "" @@ -34,15 +35,12 @@ const googleCustomSearch = (opts) => { favicon, compl: `https://www.googleapis.com/customsearch/v1?key=${priv.keys.google_cs}&cx=${priv.keys[`google_cx_${opts.alias}`]}&q=`, search: `https://cse.google.com/cse/publicurl?cx=${priv.keys[`google_cx_${opts.alias}`]}&q=`, - callback: (response) => { - const res = JSON.parse(response.text).items - return res.map((s) => createSuggestionItem(` + callback: (response) => JSON.parse(response.text).items.map((s) => suggestionItem({ url: s.link })`
-
${s.htmlTitle}
-
${s.htmlSnippet}
+
${htmlPurify(s.htmlTitle)}
+
${htmlPurify(s.htmlSnippet)}
- `, { url: s.link })) - }, + `), priv: true, ...opts, } @@ -67,7 +65,7 @@ completions.au = { completions.au.callback = (response) => { const res = JSON.parse(response.text) - return res.map((s) => createURLItem(s, `https://aur.archlinux.org/packages/${encodeURIComponent(s)}`)) + return res.map((s) => urlItem(s, `https://aur.archlinux.org/packages/${s}`)) } // Arch Linux Wiki @@ -137,15 +135,15 @@ completions.at.callback = async (response) => { } const icon = s.HasIcon ? `https://d2.alternativeto.net/dist/icons/${s.UrlName}_${s.IconId}${s.IconExtension}?width=100&height=100&mode=crop&upscale=false` : wpDefaultIcon - return createSuggestionItem(` + return suggestionItem({ url: `https://${s.InternalUrl}` })`
- ${escapeHTML(s.Name)} + ${s.Name}
-
${(prefix)}${(title)}
- ${escapeHTML(s.TagLine || s.Description || "")} +
${(prefix)}${htmlPurify(title)}
+ ${htmlPurify(s.TagLine || s.Description || "")}
- `, { url: `https://${s.InternalUrl}` }) + ` }) } @@ -156,6 +154,8 @@ completions.cs = googleCustomSearch({ search: "https://chrome.google.com/webstore/search/", }) +// Firefox + const parseFirefoxAddonsRes = (response) => JSON.parse(response.text).results.map((s) => { let { name } = s if (typeof name === "object") { @@ -165,7 +165,6 @@ const parseFirefoxAddonsRes = (response) => JSON.parse(response.text).results.ma [name] = Object.values(name) } } - name = escapeHTML(name) let prefix = "" switch (s.type) { case "extension": @@ -178,14 +177,14 @@ const parseFirefoxAddonsRes = (response) => JSON.parse(response.text).results.ma break } - return createSuggestionItem(` + return suggestionItem({ url: s.url })`
- +
-
${escapeHTML(prefix)}${escapeHTML(name)}
+
${prefix}${name}
- `, { url: s.url }) + ` }) // Firefox Addons @@ -233,7 +232,7 @@ completions.so = { compl: "https://api.stackexchange.com/2.2/search/advanced?pagesize=10&order=desc&sort=relevance&site=stackoverflow&q=", } -completions.so.callback = (response) => JSON.parse(response.text).items.map((s) => createURLItem(`[${s.score}] ${s.title}`, s.link, { query: false })) +completions.so.callback = (response) => JSON.parse(response.text).items.map((s) => urlItem(`[${s.score}] ${s.title}`, s.link, { query: false })) // StackExchange - all sites completions.se = { @@ -256,18 +255,18 @@ completions.dh = { completions.dh.callback = (response) => JSON.parse(response.text).results.map((s) => { let meta = "" let repo = s.repo_name - meta += `[★${escapeHTML(s.star_count)}] ` - meta += `[↓${escapeHTML(s.pull_count)}] ` + meta += `[★${s.star_count}] ` + meta += `[↓${s.pull_count}] ` if (repo.indexOf("/") === -1) { repo = `_/${repo}` } - return createSuggestionItem(` + return suggestionItem({ url: `https://hub.docker.com/r/${repo}` })`
-
${escapeHTML(repo)}
+
${repo}
${meta}
-
${escapeHTML(s.short_description)}
+
${s.short_description}
- `, { url: `https://hub.docker.com/r/${encodeURIComponent(repo)}` }) + ` }) // GitHub @@ -283,7 +282,7 @@ completions.gh.callback = (response) => JSON.parse(response.text).items.map((s) if (s.stargazers_count) { prefix += `[★${parseInt(s.stargazers_count, 10)}] ` } - return createURLItem(prefix + s.full_name, s.html_url, { query: s.full_name, desc: s.description }) + return urlItem(prefix + s.full_name, s.html_url, { query: s.full_name, desc: s.description }) }) // Domainr domain search @@ -296,23 +295,13 @@ completions.do = { completions.do.callback = (response) => Object.entries(JSON.parse(response.text)) .map(([domain, data]) => { - let color = "inherit" - let symbol = "? " - switch (data.summary) { - case "inactive": - color = "#23b000" - symbol = "✔ " - break - case "unknown": - break - default: - color = "#ff4d00" - symbol = "✘ " - } - return createSuggestionItem( - `
${symbol}${escapeHTML(domain)}
`, - { url: `https://domainr.com/${encodeURIComponent(domain)}` }, - ) + const [color = "inherit", symbol = "?"] = ({ + inactive: ["#23b000", "✔"], + active: ["#ff4d00", "✘"], + })[data.summary] ?? [] + return suggestionItem({ url: `https://domainr.com/${domain}` })` +
${symbol} ${domain}
+ ` }) // Vim Wiki @@ -324,7 +313,7 @@ completions.vw = { } completions.vw.callback = (response) => JSON.parse(response.text)[1] - .map((r) => createURLItem(r, `https://vim.fandom.com/wiki/${encodeURIComponent(r)}`, { query: false })) + .map((r) => urlItem(r, `https://vim.fandom.com/wiki/${encodeURIComponent(r)}`, { query: false })) // ****** Shopping & Food ****** // @@ -394,29 +383,33 @@ completions.un.callback = (response) => { const titleCase = (s) => s.split(" ") .map((word) => `${word[0]?.toUpperCase() ?? ""}${word.length > 1 ? word.slice(1) : ""}`) .join(" ") - const codeSpan = (text) => `${escapeHTML(text)}` - return res.map(({ symbol, name, value }) => createSuggestionItem(` - - ${symbol} - ${codeSpan(`U+${parseInt(value, 10)}`)} ${codeSpan(`&#${parseInt(value, 16)};`)} ${escapeHTML(titleCase(name.toLowerCase()))} -`, { url: `https://unicode-table.com/en/${encodeURIComponent(value)}/`, copy: symbol })) + const codeSpanStyle = "font-family: monospace; background-color: rgba(0,0,0,0.1); border: 1px solid rgba(0,0,0,0.4); border-radius: 5px; padding: 2px 4px; opacity: 70%" + return res.map(({ symbol, name, value }) => + suggestionItem({ url: `https://unicode-table.com/en/${value}`, copy: symbol })` +
+ ${symbol} + U+${parseInt(value, 10)} + &#${parseInt(value, 16)}; + ${titleCase(name.toLowerCase())} +
+ ` + ) } const parseDatamuseRes = (res, o = {}) => { const opts = { maxDefs: -1, ellipsis: false, + ...o } - Object.assign(opts, o) - return res.map((r) => { const defs = [] let defsHtml = "" if ((opts.maxDefs <= -1 || opts.maxDefs > 0) && r.defs && r.defs.length > 0) { for (const d of r.defs.slice(0, opts.maxDefs <= -1 ? undefined : opts.maxDefs)) { const ds = d.split("\t") - const partOfSpeech = `(${escapeHTML(ds[0])})` - const def = escapeHTML(ds[1]) + const partOfSpeech = `(${ds[0]})` + const def = ds[1] defs.push(`${partOfSpeech} ${def}`) } if (opts.ellipsis && r.defs.length > opts.maxDefs) { @@ -424,12 +417,12 @@ const parseDatamuseRes = (res, o = {}) => { } defsHtml = `
${defs.join("
")}
` } - return createSuggestionItem(` -
-
${escapeHTML(r.word)}
- ${defsHtml} -
- `, { url: `${opts.wordBaseURL}${r.word}` }) + return suggestionItem({ url: `${opts.wordBaseURL}${r.word}` })` +
+
${r.word}
+ ${htmlPurify(defsHtml)} +
+ ` }) } @@ -479,19 +472,16 @@ completions.wp = { completions.wp.callback = (response) => Object.values(JSON.parse(response.text).query.pages) .map((p) => { - const img = p.thumbnail ? encodeURI(p.thumbnail.source) : wpDefaultIcon - return createSuggestionItem( - ` + const img = p.thumbnail ? p.thumbnail.source : wpDefaultIcon + return suggestionItem({ url: p.fullurl })`
-
${escapeHTML(p.title)}
-
${escapeHTML(p.description ?? "")}
+
${p.title}
+
${p.description ?? ""}
- `, - { url: p.fullurl }, - ) + ` }) // Wikipedia - Simple English version @@ -527,39 +517,51 @@ completions.wa.callback = (response, { query }) => { const res = JSON.parse(response.text).queryresult if (res.error) { - return [createSuggestionItem(` -
-
Error (Code ${escapeHTML(res.error.code)})
-
${escapeHTML(res.error.msg)}
-
`, { url: "https://www.wolframalpha.com/" })] + return [ + suggestionItem({ url: "https://www.wolframalpha.com/" })` +
+
Error (Code ${res.error.code})
+
${res.error.msg}
+
+ ` + ] } if (!res.success) { if (res.tips) { - return [createSuggestionItem(` -
-
No Results
-
${escapeHTML(res.tips.text)}
-
`, { url: "https://www.wolframalpha.com/" })] + return [ + suggestionItem({ url: "https://www.wolframalpha.com/" })` +
+
No Results
+
${res.tips.text}
+
+ ` + ] } if (res.didyoumeans) { - return res.didyoumeans.map((s) => createSuggestionItem(` -
+ return res.didyoumeans.map((s) => + suggestionItem({ url: "https://www.wolframalpha.com/" })` +
Did you mean...?
-
${escapeHTML(s.val)}
-
`, { url: "https://www.wolframalpha.com/" })) +
${s.val}
+
+ ` + ) } - return [createSuggestionItem(` -
-
Error
-
An unknown error occurred.
-
`, { url: "https://www.wolframalpha.com/" })] + return [ + suggestionItem({ url: "https://www.wolframalpha.com/" })` +
+
Error
+
An unknown error occurred.
+
+ ` + ] } const results = [] res.pods.forEach((p) => { const result = { - title: escapeHTML(p.title), + title: p.title, values: [], url: `http://www.wolframalpha.com/input/?i=${encodeURIComponent(query)}`, } @@ -571,25 +573,25 @@ completions.wa.callback = (response, { query }) => { p.subpods.forEach((sp) => { let v = "" if (sp.title) { - v = `${escapeHTML(sp.title)}: ` + v = htmlNode`${sp.title}: ` } if (sp.img) { - v = ` + v = htmlNode`
${v}
` } else if (sp.plaintext) { - v = `${v}${escapeHTML(sp.plaintext)}` + v = `${v}${sp.plaintext}` } if (v) { - v = `
${v}
` + v = htmlNode`
${v}
` } result.values.push(v) }) @@ -599,11 +601,11 @@ completions.wa.callback = (response, { query }) => { } }) - return results.map((r) => createSuggestionItem(` + return results.map((r) => suggestionItem({ url: r.url, copy: r.copy, query: r.query })`
${r.title}
- ${r.values.join("\n")} -
`, { url: r.url, copy: r.copy, query: r.query })) + ${htmlForEach(r.values)} + `) } // ****** Search Engines ****** // @@ -720,27 +722,15 @@ completions.ka = { if (r.goto) { u.href = r.goto } - - const thumbImg = document.createElement("img") - thumbImg.style = "width: 32px" - thumbImg.src = r.img ? new URL(r.img, "https://kagi.com") : wpDefaultIcon - - const txtNode = document.createElement("div") - txtNode.className = "title" - txtNode.innerText = r.txt ?? "" - - return createSuggestionItem( - ` + return suggestionItem({ url: u.href })`
- ${thumbImg.outerHTML} +
${r.t}
- ${txtNode.outerHTML} +
${r.txt ?? ""}
- `, - { url: u.href }, - ) + ` }), } @@ -754,31 +744,15 @@ completions.hx = { compl: "https://hex.pm/api/packages?sort=downloads&hx&search=", } -completions.hx.callback = (response) => JSON.parse(response.text).map((s) => { - let dls = "" - let desc = "" - let liscs = "" - if (s.downloads && s.downloads.all) { - dls = `[↓${escapeHTML(s.downloads.all)}] ` - } - if (s.meta) { - if (s.meta.description) { - desc = escapeHTML(s.meta.description) - } - if (s.meta.licenses) { - s.meta.licenses.forEach((l) => { - liscs += `[©${escapeHTML(l)}] ` - }) - } - } - return createSuggestionItem(` +completions.hx.callback = (response) => JSON.parse(response.text).map((s) => + suggestionItem({ url: s.html_url })`
-
${escapeHTML(s.repository)}/${escapeHTML(s.name)}
-
${dls}${liscs}
-
${desc}
+
${s.repository}/${s.name}
+
${s.downloads?.all ? `[↓${s.downloads.all}]` : ""}
+
${s.meta?.description ?? ""}
- `, { url: s.html_url }) -}) + ` +) // hexdocs // Same as hex but links to documentation pages @@ -789,28 +763,15 @@ completions.hd = { compl: "https://hex.pm/api/packages?sort=downloads&hd&search=", } -completions.hd.callback = (response) => JSON.parse(response.text).map((s) => { - let dls = "" - let desc = "" - if (s.downloads && s.downloads.all) { - dls = `[↓${escapeHTML(s.downloads.all)}]` - } - if (s.meta) { - if (s.meta.description) { - desc = escapeHTML(s.meta.description) - } - } - return createSuggestionItem(` -
-
${escapeHTML(s.repository)}/${escapeHTML(s.name)}${dls}
-
-
${desc}
-
- `, { url: `https://hexdocs.pm/${encodeURIComponent(s.name)}` }) -}) - -// Exdocs -// Similar to `hd` but searches inside docs using Google Custom Search +completions.hd.callback = (response) => JSON.parse(response.text).map((s) => + suggestionItem({ url: `https://hexdocs.pm/${encodeURIComponent(s.name)}` })` +
+
${s.repository}/${s.name}
+
${s.downloads?.all ? `[↓${s.downloads.all}]` : ""}
+
${s.meta?.description ?? ""}
+
+ ` +) // ****** Golang ****** // @@ -838,7 +799,7 @@ completions.gg = googleCustomSearch({ // if (s.stars) { // prefix += `[★${s.stars}] ` // } -// return createURLItem(prefix + s.path, `https://godoc.org/${s.path}`) +// return urlItem(prefix + s.path, `https://godoc.org/${s.path}`) // }) // ****** Haskell ****** // @@ -853,7 +814,7 @@ completions.gg = googleCustomSearch({ // } // // completions.ha.callback = (response) => JSON.parse(response.text) -// .map((s) => createURLItem(s.name, `https://hackage.haskell.org/package/${s.name}`)) +// .map((s) => urlItem(s.name, `https://hackage.haskell.org/package/${s.name}`)) // Hoogle completions.ho = { @@ -865,15 +826,15 @@ completions.ho = { completions.ho.callback = (response) => JSON.parse(response.text).map((s) => { const pkgInfo = s.package.name && s.module.name - ? `
[${escapeHTML(s.package.name)}] ${escapeHTML(s.module.name)}
` + ? htmlNode`
[${s.package.name}] ${s.module.name}
` : "" - return createSuggestionItem(` -
-
${escapeHTML(s.item)}
- ${pkgInfo} -
${escapeHTML(s.docs)}
-
- `, { url: s.url }) + return suggestionItem({ url: s.url })` +
+
${htmlPurify(s.item)}
+ ${pkgInfo} +
${htmlPurify(s.docs)}
+
+ ` }) // Haskell Wiki @@ -901,15 +862,9 @@ completions.ci.getData = async () => { const storageKey = "completions.ci.data" const storedData = await localStorage.get(storageKey) if (storedData) { - // console.log("data found in localStorage", { storedData }) return JSON.parse(storedData) } - // console.log("data not found in localStorage", { storedData }) const data = JSON.parse(await runtimeHttpRequest("https://caniuse.com/data.json")) - // console.log({ dataRes }) - // const data = await dataRes.json() - // - // console.log({ data }) localStorage.set(storageKey, JSON.stringify(data)) return data } @@ -917,34 +872,17 @@ completions.ci.getData = async () => { completions.ci.callback = async (response) => { const { featureIds } = JSON.parse(response.text) const allData = await completions.ci.getData() - // console.log("featureIds", featureIds) - // console.log("allData", allData) return featureIds.map((featId) => { const feat = allData.data[featId] return feat - ? createSuggestionItem(` + ? suggestionItem({ url: `https://caniuse.com/${featId}` })`
-
${escapeHTML(feat.title)}
-
${escapeHTML(feat.description)}
+
${feat.title}
+
${feat.description}
- `, { url: "https://caniuse.com/?search=" }) + ` : null - }) - .filter(Boolean) - - // const [allDataRes, featureDataRes] = await Promise.all([ - // completions.ci.getData(), - // fetch(`https://caniuse.com/process/get_feat_data.php?type=support-data&feat=${featureIds.join(",")}`), - // ]) - // const featureData = await featureDataRes.json() - // console.log("featureIds", featureIds) - // console.log("featureData", featureData) - // return featureData.map((feat) => - // createSuggestionItem(` - //
- // ${feat.description ?? feat.title ?? ""} - //
- // `, { url: "https://caniuse.com/?search=" })) + }).filter(item => !!item) } // jQuery API documentation @@ -970,16 +908,17 @@ completions.md = { } completions.md.callback = (response) => { - // console.log({response}) const res = JSON.parse(response.text) return res.documents.map((s) => - createSuggestionItem(` + suggestionItem({ + url: `https://developer.mozilla.org/${encodeURIComponent(s.locale)}/docs/${encodeURIComponent(s.slug)}` + })`
-
${escapeHTML(s.title)}
-
${escapeHTML(s.slug)}
-
${escapeHTML(s.summary)}
+
${s.title}
+
${s.slug}
+
${s.summary}
- `, { url: `https://developer.mozilla.org/${encodeURLComponent(s.locale)}/docs/${encodeURIComponent(s.slug)}` })) + `) } // NPM registry search @@ -993,38 +932,22 @@ completions.np = { completions.np.callback = (response) => JSON.parse(response.text) .map((s) => { - let flags = "" - let desc = "" - let date = "" - if (s.package.description) { - desc = escapeHTML(s.package.description) - } - if (s.flags) { - Object.keys(s.flags).forEach((f) => { - flags += `[ ${escapeHTML(f)}] ` - }) - } - if (s.package.date) { - date = prettyDate(new Date(s.package.date)) - } - return createSuggestionItem(` + const desc = s.package?.description ? s.package.description : "" + const date = s.package?.date ? prettyDate(new Date(s.package.date)) : "" + const flags = s.flags ? Object.keys(s.flags).map((f) => htmlNode`[ ${f}] `) : [] + return suggestionItem({ url: s.package.links.npm })`
-
- ${s.highlight} - v${escapeHTML(s.package.version)} + ${htmlPurify(s.highlight)} + v${s.package.version}
- ${date} - ${flags} + ${date} + ${htmlForEach(flags)}
${desc}
- `, { url: s.package.links.npm }) + ` }) // ****** Social Media & Entertainment ****** // @@ -1044,10 +967,10 @@ completions.hn.callback = (response) => { let title = "" let prefix = "" if (s.points) { - prefix += `[↑${escapeHTML(s.points)}] ` + prefix += `[↑${s.points}] ` } if (s.num_comments) { - prefix += `[↲${escapeHTML(s.num_comments)}] ` + prefix += `[↲${s.num_comments}] ` } switch (s._tags[0]) { case "story": @@ -1060,12 +983,12 @@ completions.hn.callback = (response) => { title = s.objectID } const url = `https://news.ycombinator.com/item?id=${encodeURIComponent(s.objectID)}` - return createSuggestionItem(` + return suggestionItem({ url })`
-
${prefix}${escapeHTML(title)}
-
${encodeURI(url)}
+
${prefix}${title}
+
${url}
- `, { url }) + ` }) } @@ -1080,9 +1003,9 @@ completions.tw = { completions.tw.callback = (response, {query}) => { const results = JSON.parse(response.text).map((r) => { const q = r.phrase.replace(/^twitter /, "") - return createURLItem(q, `https://twitter.com/search?q=${encodeURIComponent(q)}`)}) + return urlItem(q, `https://twitter.com/search?q=${encodeURIComponent(q)}`)}) if (query.length >= 2 && query.match(/^@/)) { - results.unshift(createURLItem(query, `https://twitter.com/${encodeURIComponent(query.replace(/^@/, ""))}`)) + results.unshift(urlItem(query, `https://twitter.com/${encodeURIComponent(query.replace(/^@/, ""))}`)) } return results } @@ -1111,24 +1034,25 @@ completions.re.callback = async (response, {query}) => { } } else if (sub) { const res = await runtimeHttpRequest(`https://www.reddit.com/api/search_reddit_names.json?typeahead=true&exact=false&query=${encodeURIComponent(sub)}`) - return JSON.parse(res).names.map((name) => createURLItem(`r/${name}`, `https://reddit.com/r/${encodeURIComponent(name)}`, { query: `r/${name}` })) + return JSON.parse(res).names.map((name) => urlItem(`r/${name}`, `https://reddit.com/r/${encodeURIComponent(name)}`, { query: `r/${name}` })) } return JSON.parse(response.text).data.children.map(({ data }) => { const thumb = data.thumbnail?.match(/^https?:\/\//) ? data.thumbnail : completions.re.thumbs[data.thumbnail] ?? completions.re.thumbs["default"] const relDate = prettyDate(new Date(parseInt(data.created, 10) * 1000)) - return createSuggestionItem(` + return suggestionItem({ url: encodeURI(`https://reddit.com${data.permalink}`) })`
- thumbnail + thumbnail
- ${escapeHTML(data.score)} ${escapeHTML(data.title)} (${escapeHTML(data.domain)}) + ${data.score} ${data.title} (${data.domain})
- r/${escapeHTML(data.subreddit)}${parseInt(data.num_comments, 10) ?? "unknown"} commentssubmitted ${relDate} by ${escapeHTML(data.author)} + r/${data.subreddit}${data.num_comments ?? "unknown"} commentssubmitted ${relDate} by ${data.author}
- `, { url: `https://reddit.com${encodeURIComponent(data.permalink)}` }) }) + ` + }) } // YouTube @@ -1145,43 +1069,43 @@ completions.yt.callback = (response) => JSON.parse(response.text).items const thumb = s.snippet.thumbnails.default switch (s.id.kind) { case "youtube#channel": - return createSuggestionItem(` + return suggestionItem({ url: `https://youtube.com/channel/${s.id.channelId}` })`
- thumbnail + thumbnail
- ${escapeHTML(s.snippet.channelTitle)} + ${s.snippet.channelTitle}
- ${escapeHTML(s.snippet.description)} + ${s.snippet.description}
channel
- `, { url: `https://youtube.com/channel/${s.id.channelId}` }) + ` case "youtube#video": const relDate = prettyDate(new Date(s.snippet.publishTime)) - return createSuggestionItem(` + return suggestionItem({ url: `https://youtu.be/${encodeURIComponent(s.id.videoId)}` })`
- thumbnail + thumbnail
- ${escapeHTML(s.snippet.title)} + ${s.snippet.title}
- ${escapeHTML(s.snippet.description)} + ${s.snippet.description}
- video by ${escapeHTML(s.snippet.channelTitle)}${escapeHTML(relDate)} + video by ${s.snippet.channelTitle}${relDate}
- `, { url: `https://youtu.be/${encodeURIComponent(s.id.videoId)}` }) + ` default: return null } - }).filter((s) => s !== null) + }).filter((s) => !!s) export default completions diff --git a/src/util.js b/src/util.js index c5a06a2..e6fac81 100644 --- a/src/util.js +++ b/src/util.js @@ -1,3 +1,6 @@ +import { html } from "uhtml" +import DOMPurify from 'dompurify' + import api from "./api.js" const { Hints, RUNTIME } = api @@ -36,7 +39,7 @@ util.getMap = (mode, keys) => keys.split("").reduce((acc, c) => acc[c] || acc, mode.mappings).meta || null util.escapeHTML = (text) => { - const el = document.createElement("a") + const el = document.createElement("span") el.textContent = text return el.innerHTML } @@ -92,26 +95,30 @@ util.localStorage.set = async (key, val) => { return localStorageSet(storageObj) } -util.createSuggestionItem = (html, props = {}) => { - const li = document.createElement("li") - li.innerHTML = html - return { html: li.outerHTML, props } -} +util.htmlUnsafe = (content) => html.node([content]) -util.createURLItem = (title, url, { desc = null, query = null } = {}) => { - const e = { - title: util.escapeHTML(title), - url: new URL(url).toString(), - desc: null, - } - if (desc && desc.length > 0) { - e.desc = (Array.isArray(desc) ? desc : [desc]).map((d) => `
${util.escapeHTML(d)}
`).join("") - } - return util.createSuggestionItem(` -
${e.title}
- ${e.desc ?? ""} -
${e.url}
- `, { url: e.url, query: query ?? e.title }) +util.htmlPurify = (content, config = { USE_PROFILES: { html: true } }) => util.htmlUnsafe(DOMPurify.sanitize(content, config)) + +util.htmlNode = (template, ...values) => html.node(template, ...values) + +util.htmlForEach = (items) => items.map((item) => html.for(item)`${item}`) + +util.html = (template, ...values) => util.htmlNode(template, ...values).outerHTML + +util.suggestionItem = (props = {}) => (template, ...values) => ({ + html: util.html(template, ...values), + props, +}) + +util.urlItem = (title, url, { desc = null, query = null } = {}) => { + const descItems = desc && desc.length > 0 ? (Array.isArray(desc) ? desc : [desc]).map((d) => util.htmlNode`
${d}
`) : [] + return util.suggestionItem({ url: url, query: query ?? title })` +
+
${title}
+ ${util.htmlForEach(descItems)} +
${url}
+
+ ` } util.defaultSelector = "a[href]:not([href^=javascript])"