From 37a329c0be2c682ed37a797a2440050e74834a5c Mon Sep 17 00:00:00 2001 From: LuLaValva Date: Mon, 13 Apr 2026 12:11:53 -0700 Subject: [PATCH 1/2] feat: use flexsearch instead of algolia --- .gitignore | 4 +- cspell.json | 14 +- docs/reference/core-tag.md | 2 +- package-lock.json | 396 ++---------------- package.json | 10 +- skills-lock.json | 10 + src/routes/+layout.marko | 1 - src/tags/app-header/app-header.marko | 32 +- .../app-header/app-header.style.module.scss | 16 +- src/tags/app-header/docsearch.scss | 79 ---- src/tags/app-header/docsearch.ts | 3 - src/tags/search-dialog/search-dialog.marko | 111 +++++ .../search-dialog.style.module.scss | 97 +++++ .../search-dialog/search-worker-client.ts | 39 ++ src/util/markodown.ts | 8 +- src/util/search-index-builder.ts | 244 +++++++++++ src/util/search-worker.ts | 174 ++++++++ 17 files changed, 773 insertions(+), 467 deletions(-) create mode 100644 skills-lock.json delete mode 100644 src/tags/app-header/docsearch.scss delete mode 100644 src/tags/app-header/docsearch.ts create mode 100644 src/tags/search-dialog/search-dialog.marko create mode 100644 src/tags/search-dialog/search-dialog.style.module.scss create mode 100644 src/tags/search-dialog/search-worker-client.ts create mode 100644 src/util/search-index-builder.ts create mode 100644 src/util/search-worker.ts diff --git a/.gitignore b/.gitignore index 0f41e376f..cc207d37f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,7 @@ dist .env *.scss.d.ts *.css.d.ts +.agents -**/_compiled-docs \ No newline at end of file +**/_compiled-docs +public/search-index.json \ No newline at end of file diff --git a/cspell.json b/cspell.json index 59fd3eb1c..2cf197c0e 100644 --- a/cspell.json +++ b/cspell.json @@ -20,8 +20,10 @@ "desugars", "Fastly", "figcaption", + "flexsearch", "fortawesome", "hastscript", + "hrefs", "importmap", "Jetpack", "jridgewell", @@ -39,6 +41,7 @@ "openjsf", "optgroup", "popovertarget", + "recents", "renderable", "reorder", "reorderer", @@ -55,6 +58,13 @@ "WHATWG" ], "ignoreRegExpList": [], - "files": ["*", "docs/**/*", "src/**/*"], - "ignorePaths": ["**/_compiled-docs", "dist"] + "files": [ + "*", + "docs/**/*", + "src/**/*" + ], + "ignorePaths": [ + "**/_compiled-docs", + "dist" + ] } diff --git a/docs/reference/core-tag.md b/docs/reference/core-tag.md index de6c5b3b2..94125779f 100644 --- a/docs/reference/core-tag.md +++ b/docs/reference/core-tag.md @@ -179,7 +179,7 @@ This creates two possible behaviors: ## `` -The `` exposes its `value=` attribute (usually with a [shorthand](./language.md#shorthand-value)) through its [Tag Variable](./language.md#tag-variables). +The `` tag exposes its `value=` attribute (usually with a [shorthand](./language.md#shorthand-value)) through its [Tag Variable](./language.md#tag-variables). Extending the [``](#let) example we could derive data from the `count` state like so: diff --git a/package-lock.json b/package-lock.json index 6b2bd067e..1701a4c67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,6 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", - "@docsearch/css": "^3.9.0", - "@docsearch/js": "^3.9.0", "@fontsource/ubuntu": "^5.2.8", "@fontsource/ubuntu-mono": "^5.2.8", "@fortawesome/free-brands-svg-icons": "^7.2.0", @@ -29,12 +27,14 @@ "@marko/type-check": "^2.1.28", "@rollup/browser": "^4.59.0", "@shikijs/langs": "^4.0.2", + "@types/flexsearch": "^0.7.6", "@types/node": "^25.5.0", "assert": "./shim/assert", "autoprefixer": "^10.4.27", "codemirror": "^6.0.2", "cspell": "^9.7.0", "events": "^3.3.0", + "flexsearch": "^0.8.212", "gh-pages": "^6.3.0", "github-slugger": "^2.0.0", "hastscript": "^9.0.1", @@ -62,265 +62,6 @@ "writable-dom": "^1.0.6" } }, - "node_modules/@algolia/abtesting": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.15.2.tgz", - "integrity": "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.17.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.9.tgz", - "integrity": "sha512-O7BxrpLDPJWWHv/DLA9DRFWs+iY1uOJZkqUwjS5HSZAGcl0hIVCQ97LTLewiZmZ402JYUrun+8NqFP+hCknlbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.9", - "@algolia/autocomplete-shared": "1.17.9" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.9.tgz", - "integrity": "sha512-u1fEHkCbWF92DBeB/KHeMacsjsoI0wFhjZtlCq2ddZbAehshbZST6Hs0Avkc0s+4UyBGbMDnSuXHLuvRWK5iDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.9" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.17.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.9.tgz", - "integrity": "sha512-Na1OuceSJeg8j7ZWn5ssMu/Ax3amtOwk76u4h5J4eK2Nx2KB5qt0Z4cOapCsxot9VcEN11ADV5aUSlQF4RhGjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.9" - }, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.17.9", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.9.tgz", - "integrity": "sha512-iDf05JDQ7I0b7JEA/9IektxN/80a2MZ1ToohfmNS3rfeuQnIKI3IJlIafD0xu4StbtQTghx9T3Maa97ytkXenQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.49.2.tgz", - "integrity": "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.49.2.tgz", - "integrity": "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.49.2.tgz", - "integrity": "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.49.2.tgz", - "integrity": "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.49.2.tgz", - "integrity": "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.49.2.tgz", - "integrity": "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.49.2.tgz", - "integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.49.2", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.49.2.tgz", - "integrity": "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.49.2", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.49.2.tgz", - "integrity": "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.49.2.tgz", - "integrity": "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.2.tgz", - "integrity": "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.49.2.tgz", - "integrity": "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.49.2.tgz", - "integrity": "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -2483,57 +2224,6 @@ "postcss": "^8.4" } }, - "node_modules/@docsearch/css": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.9.0.tgz", - "integrity": "sha512-cQbnVbq0rrBwNAKegIac/t6a8nWoUAn8frnkLFW6YARaRmAQr5/Eoe6Ln2fqkUCZ40KpdrKbpSAmgrkviOxuWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@docsearch/js": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.9.0.tgz", - "integrity": "sha512-4bKHcye6EkLgRE8ze0vcdshmEqxeiJM77M0JXjef7lrYZfSlMunrDOCqyLjiZyo1+c0BhUqA2QpFartIjuHIjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/react": "3.9.0", - "preact": "^10.0.0" - } - }, - "node_modules/@docsearch/react": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.9.0.tgz", - "integrity": "sha512-mb5FOZYZIkRQ6s/NWnM98k879vu5pscWqTLubLFBO87igYYT4VzVazh4h5o/zCvTIZgEt3PvsCOMOswOUo9yHQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-core": "1.17.9", - "@algolia/autocomplete-preset-algolia": "1.17.9", - "@docsearch/css": "3.9.0", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 20.0.0", - "react": ">= 16.8.0 < 20.0.0", - "react-dom": ">= 16.8.0 < 20.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, "node_modules/@ebay/browserslist-config": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@ebay/browserslist-config/-/browserslist-config-2.13.0.tgz", @@ -4585,6 +4275,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/flexsearch": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@types/flexsearch/-/flexsearch-0.7.6.tgz", + "integrity": "sha512-H5IXcRn96/gaDmo+rDl2aJuIJsob8dgOXDqf8K0t8rWZd1AFNaaspmRsElESiU+EWE33qfbFPgI0OC/B1g9FCA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -4657,33 +4354,6 @@ "node": ">=0.4.0" } }, - "node_modules/algoliasearch": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.49.2.tgz", - "integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@algolia/abtesting": "1.15.2", - "@algolia/client-abtesting": "5.49.2", - "@algolia/client-analytics": "5.49.2", - "@algolia/client-common": "5.49.2", - "@algolia/client-insights": "5.49.2", - "@algolia/client-personalization": "5.49.2", - "@algolia/client-query-suggestions": "5.49.2", - "@algolia/client-search": "5.49.2", - "@algolia/ingestion": "1.49.2", - "@algolia/monitoring": "1.49.2", - "@algolia/recommend": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -6122,6 +5792,35 @@ "dev": true, "license": "ISC" }, + "node_modules/flexsearch": { + "version": "0.8.212", + "resolved": "https://registry.npmjs.org/flexsearch/-/flexsearch-0.8.212.tgz", + "integrity": "sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/ts-thomas" + }, + { + "type": "paypal", + "url": "https://www.paypal.com/donate/?hosted_button_id=GEVR88FC9BWRW" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/flexsearch" + }, + { + "type": "patreon", + "url": "https://patreon.com/user?u=96245532" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/ts-thomas" + } + ], + "license": "Apache-2.0" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -9052,17 +8751,6 @@ "dev": true, "license": "MIT" }, - "node_modules/preact": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", - "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, "node_modules/prettier": { "version": "3.8.1", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", @@ -9859,14 +9547,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/self-closing-tags": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/self-closing-tags/-/self-closing-tags-1.0.1.tgz", diff --git a/package.json b/package.json index 527ee3fee..e2ebbf0d7 100644 --- a/package.json +++ b/package.json @@ -13,16 +13,17 @@ } }, "scripts": { - "prepare": "husky", "build": "marko-run build", "deploy": "marko-run build && gh-pages --cname markojs.com --nojekyll -d dist/public -u \"marko-js \"", "dev": "marko-run", "format": "prettier . -w", "lint": "markdownlint \"docs/**/*.md\" && cspell \"**/*.{md,ts,marko}\"", + "prepare": "husky", "preview": "marko-run preview" }, "dependencies": { - "@ebay/browserslist-config": "^2.13.0" + "@ebay/browserslist-config": "^2.13.0", + "flexsearch": "^0.8.212" }, "devDependencies": { "@codemirror/autocomplete": "^6.20.1", @@ -31,8 +32,6 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", - "@docsearch/css": "^3.9.0", - "@docsearch/js": "^3.9.0", "@fontsource/ubuntu": "^5.2.8", "@fontsource/ubuntu-mono": "^5.2.8", "@fortawesome/free-brands-svg-icons": "^7.2.0", @@ -43,6 +42,7 @@ "@marko/type-check": "^2.1.28", "@rollup/browser": "^4.59.0", "@shikijs/langs": "^4.0.2", + "@types/flexsearch": "^0.7.6", "@types/node": "^25.5.0", "assert": "./shim/assert", "autoprefixer": "^10.4.27", @@ -50,9 +50,9 @@ "cspell": "^9.7.0", "events": "^3.3.0", "gh-pages": "^6.3.0", - "husky": "^9.1.7", "github-slugger": "^2.0.0", "hastscript": "^9.0.1", + "husky": "^9.1.7", "lightningcss-wasm": "^1.32.0", "lz-string": "^1.5.0", "markdownlint-cli": "^0.48.0", diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 000000000..907c42725 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,10 @@ +{ + "version": 1, + "skills": { + "marko-best-practices": { + "source": "marko-js/marko", + "sourceType": "github", + "computedHash": "a3efd14e770fa7dcfe21da12d47f9e3c7e4147bcc178fe4a526687fab46e9f81" + } + } +} diff --git a/src/routes/+layout.marko b/src/routes/+layout.marko index ec32c1286..7f54c77fe 100644 --- a/src/routes/+layout.marko +++ b/src/routes/+layout.marko @@ -12,7 +12,6 @@ html lang="en" } link rel="icon" sizes="32x32" href="/fav.png" link rel="icon" type="image/svg+xml" href="/fav.svg" - link rel="preconnect" href="https://GB0QQV5RQM-dsn.algolia.net" crossorigin prefetch-links diff --git a/src/tags/app-header/app-header.marko b/src/tags/app-header/app-header.marko index 502c474b2..bd97a6736 100644 --- a/src/tags/app-header/app-header.marko +++ b/src/tags/app-header/app-header.marko @@ -2,9 +2,19 @@ import * as footerStyles from "../app-footer/app-footer.style.module.scss"; import * as styles from "./app-header.style.module.scss"; let/mobileMenu=false +let/searchOpen=false script -- document.documentElement.classList.toggle("show-mobile-menu", mobileMenu); +// Global Cmd+K shortcut +script -- + document.addEventListener("keydown", (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + searchOpen = !searchOpen; + } + }, { signal: $signal }); + header#header a ,title="Home" @@ -35,22 +45,22 @@ header#header ,onClick() { mobileMenu = false; } - ,id=styles.docsearch - button disabled aria-hidden="true" + ,id=styles.search + button + ,aria-label="Search docs (Cmd+K)" + ,onClick() { + searchOpen = true; + } + kbd -- K + html-script -- ${`document.currentScript.previousElementSibling.prepend(navigator.platform.startsWith("Mac")?"\u2318":"ctrl+")`} + +search-dialog open:=searchOpen script -- - onLoad(async () => { + onLoad(() => { if ("ScrollTimeline" in window) { setupStickyScroll(); } - const docsearch = (await import("./docsearch")).default; - docsearch({ - container: document.getElementById(styles.docsearch)!, - indexName: "markojs", - placeholder: "", - appId: "GB0QQV5RQM", - apiKey: "82f1b630f11e1afa4767f051af953a28", - }); }); static function onLoad(cb: () => void) { if (document.readyState === "interactive") { diff --git a/src/tags/app-header/app-header.style.module.scss b/src/tags/app-header/app-header.style.module.scss index b76f420d7..3c350d37c 100644 --- a/src/tags/app-header/app-header.style.module.scss +++ b/src/tags/app-header/app-header.style.module.scss @@ -157,7 +157,7 @@ margin-right: auto; } -#docsearch { +#search { display: flex; place-items: center; place-content: center; @@ -174,6 +174,7 @@ button { display: flex; + justify-content: space-between; height: 4rem; width: 100%; padding: 1rem; @@ -187,10 +188,16 @@ -webkit-tap-highlight-color: transparent; @media (pointer: fine) { &:not(:disabled):is(:hover, :active, :focus) { - color: var(--color-blue-dark); + color: var(--color-blue); border-color: transparent; } } + & > kbd { + font-family: inherit; + font-size: 0.7rem; + + display: none; + } &::before { content: ""; display: block; @@ -202,12 +209,15 @@ background-color: var(--color-foreground); } @media (min-width: 64rem) and (min-height: 38rem) { + & > kbd { + display: inline-block; + } border: 2px solid var(--color-foreground); height: 2.5rem; background: linear-gradient(var(--color-background), var(--color-background)) padding-box, - linear-gradient(120deg, var(--color-blue-dark), var(--color-blue-light)) + linear-gradient(120deg, var(--color-blue-alt), var(--color-blue)) border-box !important; } } diff --git a/src/tags/app-header/docsearch.scss b/src/tags/app-header/docsearch.scss deleted file mode 100644 index 4895038be..000000000 --- a/src/tags/app-header/docsearch.scss +++ /dev/null @@ -1,79 +0,0 @@ -.DocSearch { - &-Button-Keys, - &-Search-Icon, - &-Button-Placeholder { - display: none; - } - - &-Modal { - border: 2px solid transparent; - border-radius: 0; - - @media (min-width: 48rem) { - border-color: var(--color-blue-dark); - border-radius: 35px; - } - } - - &-Container { - backdrop-filter: blur(0.6rem); - } - - &-Form { - border: 2px solid var(--color-blue-dark); - border-radius: 1.25rem; - &:before { - display: block; - height: 1.25rem; - width: 1.25rem; - margin-left: 0.2rem; - mask-image: url(./search.svg?no-inline); - background-color: var(--color-foreground); - content: ""; - } - } - - &-Hit a { - border-radius: 1.25rem; - } - - &-Logo { - .cls-1, - .cls-2 { - fill: var(--docsearch-muted-color); - } - } - - &-Footer { - padding: 2rem; - } -} - -:root { - --docsearch-primary-color: var(--color-blue-dark); - --docsearch-text-color: currentColor; - --docsearch-spacing: 1rem; - --docsearch-icon-stroke-width: 1.4; - --docsearch-highlight-color: var(--color-blue-dark); - --docsearch-muted-color: #888; - --docsearch-container-background: transparent; - --docsearch-container-background: color-mix( - in srgb, - var(--color-blue-dim) 25%, - transparent - ); - --docsearch-logo-color: var(--color-blue-dark); - --docsearch-modal-width: 50rem; - --docsearch-modal-background: var(--color-background); - --docsearch-modal-shadow: 0 0.5rem 5rem 0.5rem var(--color-blue-dim); - --docsearch-searchbox-background: transparent; - --docsearch-searchbox-focus-background: transparent; - --docsearch-searchbox-shadow: transparent; - --docsearch-hit-color: var(--color-foreground); - --docsearch-hit-background: transparent; - --docsearch-hit-shadow: transparent; - --docsearch-key-shadow: transparent; - --docsearch-key-pressed-shadow: transparent; - --docsearch-footer-background: transparent; - --docsearch-footer-shadow: transparent; -} diff --git a/src/tags/app-header/docsearch.ts b/src/tags/app-header/docsearch.ts deleted file mode 100644 index 493cc55e8..000000000 --- a/src/tags/app-header/docsearch.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "@docsearch/css"; -import "./docsearch.scss"; -export { default } from "@docsearch/js"; diff --git a/src/tags/search-dialog/search-dialog.marko b/src/tags/search-dialog/search-dialog.marko new file mode 100644 index 000000000..4d3f2a6cc --- /dev/null +++ b/src/tags/search-dialog/search-dialog.marko @@ -0,0 +1,111 @@ +import * as styles from "./search-dialog.style.module.scss"; +client import { sendQuery } from "./search-worker-client"; +import type { SearchHit } from "../../util/search-worker"; + +static function highlightSegments(text: string, query: string) { + const q = query.trim(); + if (!q) return [{ text, hl: false }]; + const escaped = q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const parts = text.split(new RegExp(`(${escaped})`, "gi")); + const lower = q.toLowerCase(); + return parts.filter(Boolean).map((p) => ({ text: p, hl: p.toLowerCase() === lower })); +} + +export interface Input { + open?: boolean; + openChange?: (open: boolean) => void; +} + +let/open:=input.open +let/query="" +let/results=undefined as SearchHit[] | undefined +let/activeIndex=-1 + +script -- + if (open) { + if (!$dialog().open) { + $dialog().showModal(); + $input().focus(); + $input().select(); + } + } else if ($dialog().open) { + $dialog().close(); + } + +script -- + if (query.trim()) { + const hits = await sendQuery(query); + results = hits; + activeIndex = hits.length > 0 ? 0 : -1; + } else { + results = undefined; + activeIndex = -1; + } + +dialog/$dialog + ,class=styles.dialog + ,onCancel(e) { + e.preventDefault(); + open = false; + } + ,onClick(e) { + if (e.target === $dialog()) open = false; + } + ,onKeyDown(e) { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (results && results.length > 0) activeIndex = (activeIndex + 1) % results.length; + } else if (e.key === "ArrowUp") { + e.preventDefault(); + if (results && results.length > 0) activeIndex = (activeIndex - 1 + results.length) % results.length; + } + } + form + ,method="dialog" + ,class=styles.panel + ,onSubmit() { + if (results && results[activeIndex]) { + window.location.href = results[activeIndex].href; + open = false; + query = ""; + } + } + input/$input + ,class=styles.input + ,type="text" + ,placeholder="Search docs..." + ,value:=query + if=(results && results.length > 0) + div class=styles.results + for|result, i| of=results + a/$el + ,href=result.href + ,class=[styles.result, i === activeIndex && styles.active] + ,onClick() { + const url = new URL(result.href, window.location.origin); + if (url.pathname === window.location.pathname) { + open = false; + query = ""; + } + } + ,onPointerMove() { + activeIndex = i; + } + ,onPointerDown() { + activeIndex = i; + } + script -- + if (i === activeIndex) { + $el().scrollIntoView({ block: "nearest" }); + } + const/titleMatch = query.trim() && result.title.toLowerCase().includes(query.trim().toLowerCase()) + const/displayText = titleMatch ? result.title : (result.snippet || result.title) + span class=styles.displayText + for|seg| of=highlightSegments(displayText, query) + if=(seg.hl) + mark class=styles.highlight -- ${seg.text} + else + -- ${seg.text} + span class=styles.breadcrumbs -- ${result.breadcrumbs.join(" > ")} + else if=(query.trim() && results) + div class=styles.empty -- No results found. diff --git a/src/tags/search-dialog/search-dialog.style.module.scss b/src/tags/search-dialog/search-dialog.style.module.scss new file mode 100644 index 000000000..09970652b --- /dev/null +++ b/src/tags/search-dialog/search-dialog.style.module.scss @@ -0,0 +1,97 @@ +html:has(.dialog[open]) { + overflow: hidden; + scrollbar-gutter: stable; +} + +.dialog { + max-width: 100vw; + max-height: 100vh; + width: 100%; + height: 100%; + margin: 0; + padding: 10vh 0 0; + border: none; + background: transparent; + align-items: flex-start; + justify-content: center; + + &[open] { + display: flex; + } + + &::backdrop { + background: rgba(0, 0, 0, 0.4); + } +} + +.panel { + width: min(36rem, 90vw); + max-height: 70vh; + display: flex; + flex-direction: column; + background: var(--color-background); + border: 1px solid var(--color-gray-dim); + border-radius: 0.75rem; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.input { + width: 100%; + padding: 1rem 1.25rem; + font-size: 1.125rem; + border: none; + background: transparent; + color: var(--color-foreground); + outline: none; + + &::placeholder { + color: var(--color-gray); + } +} + +.results { + border-top: 1px solid var(--color-gray-dim); + overflow-y: auto; + padding: 0.5rem 0; +} + +.result { + display: flex; + flex-direction: column; + gap: 0.125rem; + padding: 0.5rem 1.25rem; + text-decoration: none; + color: var(--color-foreground); + cursor: pointer; + + &.active { + background: var(--color-blue-alt-dim); + color: var(--color-white); + } +} + +.displayText { + font-size: 0.875rem; + color: var(--color-foreground); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.highlight { + background: none; + color: var(--color-blue); + font-weight: 600; +} + +.breadcrumbs { + font-size: 0.75rem; + color: var(--color-blue-alt); +} + +.empty { + padding: 2rem 1.25rem; + text-align: center; + color: var(--color-gray); +} diff --git a/src/tags/search-dialog/search-worker-client.ts b/src/tags/search-dialog/search-worker-client.ts new file mode 100644 index 000000000..94668fcad --- /dev/null +++ b/src/tags/search-dialog/search-worker-client.ts @@ -0,0 +1,39 @@ +import type { SearchHit } from "../../util/search-worker"; + +let worker: Worker | undefined; +let ready: Promise | undefined; + +function ensureWorker(): Promise { + if (ready) return ready; + worker = new Worker(new URL("../../util/search-worker", import.meta.url), { + type: "module", + }); + ready = new Promise((resolve) => { + worker!.addEventListener( + "message", + (e: MessageEvent) => { + if (e.data.type === "ready") resolve(); + }, + { once: true }, + ); + }); + worker.postMessage({ type: "init" }); + return ready; +} + +ensureWorker(); + +export async function sendQuery(q: string): Promise { + await ensureWorker(); + const result = new Promise((resolve) => { + worker!.addEventListener( + "message", + (e: MessageEvent) => { + if (e.data.type === "results") resolve(e.data.results); + }, + { once: true }, + ); + }); + worker!.postMessage({ type: "query", query: q }); + return result; +} diff --git a/src/util/markodown.ts b/src/util/markodown.ts index e13d9e3a8..0a1ef666a 100644 --- a/src/util/markodown.ts +++ b/src/util/markodown.ts @@ -13,6 +13,7 @@ import * as prettierMarko from "prettier-plugin-marko"; import * as compiler from "@marko/compiler"; import { glob } from "glob"; import type { HeadingList } from "../types"; +import { buildSearchIndex } from "./search-index-builder"; export default function markodownPlugin(): PluginOption { return { @@ -35,8 +36,8 @@ export default function markodownPlugin(): PluginOption { cwd: docsPath, }); - await Promise.all( - mdFiles.map(async (file) => { + await Promise.all([ + ...mdFiles.map(async (file) => { const content = await fs.readFile(path.join(docsPath, file), "utf-8"); await fs.mkdir(path.dirname(path.join(docsPages, file)), { recursive: true, @@ -56,7 +57,8 @@ export default function markodownPlugin(): PluginOption { ), ]); }), - ); + buildSearchIndex(docsPath), + ]); }, }; } diff --git a/src/util/search-index-builder.ts b/src/util/search-index-builder.ts new file mode 100644 index 000000000..e692a05aa --- /dev/null +++ b/src/util/search-index-builder.ts @@ -0,0 +1,244 @@ +import fs from "fs/promises"; +import path from "path"; +import { glob } from "glob"; +import GithubSlugger from "github-slugger"; +import { marked, type Token, type Tokens } from "marked"; +import type { SearchBlock } from "./search-worker"; + +/** + * Category metadata: weight (base search score) + display label. + */ +const CATEGORIES: Record = { + reference: { weight: 100, label: "Reference" }, + introduction: { weight: 75, label: "Introduction" }, + explanation: { weight: 50, label: "Explanation" }, + guide: { weight: 50, label: "Guides" }, + tutorial: { weight: 25, label: "Tutorial" }, + "marko-run": { weight: 25, label: "Marko Run" }, +}; + +/** + * Read all markdown docs, parse with marked.lexer(), split at h2/h3 + * boundaries, extract plaintext, and write `public/search-index.json`. + */ +export async function buildSearchIndex(docsPath: string): Promise { + const mdFiles = glob.sync("**/*.md", { cwd: docsPath }); + const blocks: SearchBlock[] = []; + + for (const file of mdFiles) { + const raw = await fs.readFile(path.join(docsPath, file), "utf-8"); + const category = file.split("/")[0]; + const slug = file.replace(/\.md$/, ""); + const href = `/docs/${slug}`; + const { weight, label: categoryLabel } = CATEGORIES[category] ?? { + weight: 25, + label: category, + }; + + const tokens = marked.lexer(raw); + + // Extract page title from the first h1 + const h1 = tokens.find( + (t): t is Tokens.Heading => t.type === "heading" && t.depth === 1, + ); + const pageTitle = h1 ? extractText(h1.tokens) : slug.split("/").pop()!; + + // Split tokens into sections at h2/h3 boundaries + const sections = splitAtHeadings(tokens); + + // GithubSlugger handles deduplication automatically + const slugger = new GithubSlugger(); + + for (const section of sections) { + const content = section.body.trim(); + // Filter out tiny blocks that add noise without useful content + if (!content || content.length < 30) continue; + + const breadcrumbs = [categoryLabel, pageTitle]; + const title = section.heading ?? pageTitle; + if (section.heading) { + breadcrumbs.push(section.heading); + } + + const sectionHref = section.heading + ? `${href}#${slugger.slug(section.heading)}` + : href; + + blocks.push({ href: sectionHref, breadcrumbs, title, content, weight }); + } + } + + const outPath = path.join(process.cwd(), "public", "search-index.json"); + await fs.mkdir(path.dirname(outPath), { recursive: true }); + await fs.writeFile(outPath, JSON.stringify(blocks)); +} + +interface Section { + /** The h2/h3 heading text (plaintext), or undefined for the intro section. */ + heading: string | undefined; + /** Plaintext body content below the heading. */ + body: string; +} + +/** + * Split a flat token list into sections at h2/h3 boundaries. + * The first section (before any h2/h3) is the intro with no heading. + */ +function splitAtHeadings(tokens: Token[]): Section[] { + const sections: Section[] = []; + let currentHeading: string | undefined; + let currentParts: string[] = []; + + for (const token of tokens) { + if (token.type === "heading" && (token.depth === 2 || token.depth === 3)) { + // Flush the previous section + if (currentParts.length > 0 || currentHeading !== undefined) { + sections.push({ + heading: currentHeading, + body: currentParts.join(" "), + }); + } + currentHeading = extractText((token as Tokens.Heading).tokens); + currentParts = []; + } else if (token.type === "heading") { + // h1 or h4+ headings — just extract their text into the current section + currentParts.push(extractText((token as Tokens.Heading).tokens)); + } else { + const text = extractBlockText(token); + if (text) currentParts.push(text); + } + } + + // Flush the last section + if (currentParts.length > 0 || currentHeading !== undefined) { + sections.push({ + heading: currentHeading, + body: currentParts.join(" "), + }); + } + + return sections; +} + +/** + * Extract plaintext from a block-level token. + * Skips code blocks entirely (they're not useful for search plaintext). + */ +function extractBlockText(token: Token): string { + switch (token.type) { + case "paragraph": + case "text": + return extractText((token as Tokens.Paragraph | Tokens.Text).tokens); + + case "blockquote": + // Block quotes contain nested block tokens (paragraphs, etc.) + return (token as Tokens.Blockquote).tokens + .map(extractBlockText) + .filter(Boolean) + .join(" "); + + case "list": + return (token as Tokens.List).items + .map((item) => + item.tokens.map(extractBlockText).filter(Boolean).join(" "), + ) + .join(" "); + + case "table": { + const table = token as Tokens.Table; + const parts: string[] = []; + // Header cells + for (const cell of table.header) { + parts.push(extractText(cell.tokens)); + } + // Body rows + for (const row of table.rows) { + for (const cell of row) { + parts.push(extractText(cell.tokens)); + } + } + return parts.filter(Boolean).join(" "); + } + + case "code": + // Skip code blocks — they add noise, not useful search plaintext + return ""; + + case "space": + case "hr": + return ""; + + case "html": + // Strip HTML tags, keep any text content + return (token as Tokens.HTML).text.replace(/<[^>]+>/g, "").trim(); + + default: + // For any other token types, try to extract text if it has tokens + if ("tokens" in token && Array.isArray(token.tokens)) { + return (token.tokens as Token[]) + .map(extractBlockText) + .filter(Boolean) + .join(" "); + } + return ""; + } +} + +/** + * Extract plaintext from an array of inline tokens. + * For codespan tokens, strips angle brackets so `` becomes `lifecycle`. + */ +function extractText(tokens: Token[] | undefined): string { + if (!tokens) return ""; + + const parts: string[] = []; + + for (const token of tokens) { + switch (token.type) { + case "text": + case "strong": + case "em": + case "del": + // These may have nested inline tokens + if ("tokens" in token && Array.isArray(token.tokens)) { + parts.push(extractText(token.tokens as Token[])); + } else { + parts.push((token as Tokens.Text).text); + } + break; + + case "codespan": + parts.push((token as Tokens.Codespan).text); + break; + + case "link": + // Use the link's display text, not the URL + parts.push(extractText((token as Tokens.Link).tokens)); + break; + + case "image": + // Use alt text if available + if ((token as Tokens.Image).text) { + parts.push((token as Tokens.Image).text); + } + break; + + case "br": + parts.push(" "); + break; + + case "escape": + parts.push((token as Tokens.Escape).text); + break; + + default: + // Fallback: try .text property + if ("text" in token && typeof token.text === "string") { + parts.push(token.text); + } + break; + } + } + + return parts.join(""); +} diff --git a/src/util/search-worker.ts b/src/util/search-worker.ts new file mode 100644 index 000000000..083c68117 --- /dev/null +++ b/src/util/search-worker.ts @@ -0,0 +1,174 @@ +import flexsearch, { type Index } from "flexsearch"; + +// ── Types ──────────────────────────────────────────────────────────── + +export interface SearchBlock { + href: string; + breadcrumbs: string[]; + title: string; + content: string; + weight: number; +} + +export interface SearchHit { + href: string; + breadcrumbs: string[]; + category: string; + title: string; + snippet?: string; + score: number; +} + +// ── FlexSearch engine ──────────────────────────────────────────────── + +let index: Index; +const blockMap = new Map(); +function init(blocks: SearchBlock[]) { + blockMap.clear(); + index = new flexsearch.Index({ tokenize: "forward" }); + for (const block of blocks) { + blockMap.set(block.href, block); + index.add( + block.href, + `${block.title} ${block.breadcrumbs.join(" ")} ${block.content}`, + ); + } +} + +// ── Scoring helpers ────────────────────────────────────────────────── + +function escapeRegex(s: string) { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function matchBoost( + text: string, + q: string, + qEscaped: string, + [exact, word, substring]: [number, number, number], +): number { + if (text === q) return exact; + if (new RegExp(`\\b${qEscaped}\\b`).test(text)) return word; + if (text.includes(q)) return substring; + return 0; +} + +function contentScore(content: string, q: string): number { + const firstPos = content.indexOf(q); + if (firstPos < 0) return 0; + + let score = firstPos < 200 ? 10 : firstPos < 500 ? 5 : 0; + + let count = 0; + let from = 0; + while (count < 10) { + const pos = content.indexOf(q, from); + if (pos < 0) break; + count++; + from = pos + q.length; + } + return score + count * 2; +} + +// ── Snippet builder ───────────────────────────────────────────────── + +function buildSnippet(content: string, query: string): string | undefined { + const pos = content.toLowerCase().indexOf(query); + if (pos < 0) return undefined; + + const radius = 60; + let start = Math.max(0, pos - radius); + let end = Math.min(content.length, pos + query.length + radius); + + if (start > 0) start = content.lastIndexOf(" ", start) + 1 || start; + if (end < content.length) end = content.indexOf(" ", end) || end; + + return ( + (start > 0 ? "\u2026" : "") + + content.slice(start, end) + + (end < content.length ? "\u2026" : "") + ); +} + +// ── Search ─────────────────────────────────────────────────────────── + +function search(query: string, limit = 25): SearchHit[] { + const q = query.trim().toLowerCase(); + if (!q) return []; + + const qEscaped = escapeRegex(q); + const scored: SearchHit[] = []; + + for (const id of index.search(query, { limit: 200 })) { + const href = String(id); + const block = blockMap.get(href); + if (!block) continue; + + scored.push({ + href, + breadcrumbs: block.breadcrumbs, + category: block.breadcrumbs[0] || "Other", + title: block.title, + snippet: buildSnippet(block.content, q), + score: + block.weight + + matchBoost(block.title.toLowerCase(), q, qEscaped, [50, 30, 15]) + + matchBoost( + block.breadcrumbs.join(" ").toLowerCase(), + q, + qEscaped, + [25, 15, 8], + ) + + contentScore(block.content.toLowerCase(), q) - + (href.includes("#") ? 0.5 : 0), + }); + } + + scored.sort((a, b) => b.score - a.score); + return scored.slice(0, limit); +} + +function lookup(hrefs: string[]): SearchHit[] { + return hrefs.flatMap((href) => { + const block = blockMap.get(href); + return block + ? [ + { + href: block.href, + breadcrumbs: block.breadcrumbs, + category: block.breadcrumbs[0] || "Other", + title: block.title, + score: 0, + }, + ] + : []; + }); +} + +// ── Worker message handler ─────────────────────────────────────────── + +self.onmessage = async (e: MessageEvent) => { + const { type, query, hrefs } = e.data; + + switch (type) { + case "init": { + const res = await fetch("/search-index.json"); + const blocks: SearchBlock[] = await res.json(); + init(blocks); + self.postMessage({ type: "ready" }); + break; + } + + case "query": { + const results = search(query); + self.postMessage({ type: "results", results, query }); + break; + } + + case "recents": { + const hits = lookup(hrefs); + self.postMessage({ type: "results", results: hits, query: "" }); + break; + } + } +}; From 126c4e899268caff306e7c277b7d48a4c8e7b861 Mon Sep 17 00:00:00 2001 From: LuLaValva Date: Mon, 13 Apr 2026 13:27:18 -0700 Subject: [PATCH 2/2] fix: polish --- src/tags/search-dialog/search-dialog.marko | 7 +++--- .../search-dialog.style.module.scss | 1 + .../search-dialog/search-worker-client.ts | 19 +++++++++------- src/util/search-worker.ts | 22 ++++++++++++++----- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/tags/search-dialog/search-dialog.marko b/src/tags/search-dialog/search-dialog.marko index 4d3f2a6cc..3c3b2d024 100644 --- a/src/tags/search-dialog/search-dialog.marko +++ b/src/tags/search-dialog/search-dialog.marko @@ -35,6 +35,7 @@ script -- script -- if (query.trim()) { const hits = await sendQuery(query); + if ($signal.aborted) return; results = hits; activeIndex = hits.length > 0 ? 0 : -1; } else { @@ -61,9 +62,9 @@ dialog/$dialog } } form - ,method="dialog" ,class=styles.panel - ,onSubmit() { + ,onSubmit(e) { + e.preventDefault(); if (results && results[activeIndex]) { window.location.href = results[activeIndex].href; open = false; @@ -106,6 +107,6 @@ dialog/$dialog mark class=styles.highlight -- ${seg.text} else -- ${seg.text} - span class=styles.breadcrumbs -- ${result.breadcrumbs.join(" > ")} + span class=styles.breadcrumbs -- ${result.breadcrumbs.join(" · ")} else if=(query.trim() && results) div class=styles.empty -- No results found. diff --git a/src/tags/search-dialog/search-dialog.style.module.scss b/src/tags/search-dialog/search-dialog.style.module.scss index 09970652b..c20893816 100644 --- a/src/tags/search-dialog/search-dialog.style.module.scss +++ b/src/tags/search-dialog/search-dialog.style.module.scss @@ -91,6 +91,7 @@ html:has(.dialog[open]) { } .empty { + border-top: 1px solid var(--color-gray-dim); padding: 2rem 1.25rem; text-align: center; color: var(--color-gray); diff --git a/src/tags/search-dialog/search-worker-client.ts b/src/tags/search-dialog/search-worker-client.ts index 94668fcad..f56452eb4 100644 --- a/src/tags/search-dialog/search-worker-client.ts +++ b/src/tags/search-dialog/search-worker-client.ts @@ -8,14 +8,17 @@ function ensureWorker(): Promise { worker = new Worker(new URL("../../util/search-worker", import.meta.url), { type: "module", }); - ready = new Promise((resolve) => { - worker!.addEventListener( - "message", - (e: MessageEvent) => { - if (e.data.type === "ready") resolve(); - }, - { once: true }, - ); + ready = new Promise((resolve, reject) => { + function onMessage(e: MessageEvent) { + if (e.data.type === "ready") { + worker!.removeEventListener("message", onMessage); + resolve(); + } else if (e.data.type === "init-error") { + worker!.removeEventListener("message", onMessage); + reject(new Error(e.data.error)); + } + } + worker!.addEventListener("message", onMessage); }); worker.postMessage({ type: "init" }); return ready; diff --git a/src/util/search-worker.ts b/src/util/search-worker.ts index 083c68117..492f299a1 100644 --- a/src/util/search-worker.ts +++ b/src/util/search-worker.ts @@ -80,8 +80,14 @@ function buildSnippet(content: string, query: string): string | undefined { let start = Math.max(0, pos - radius); let end = Math.min(content.length, pos + query.length + radius); - if (start > 0) start = content.lastIndexOf(" ", start) + 1 || start; - if (end < content.length) end = content.indexOf(" ", end) || end; + if (start > 0) { + const prevSpace = content.lastIndexOf(" ", start); + start = prevSpace !== -1 ? prevSpace + 1 : start; + } + if (end < content.length) { + const nextSpace = content.indexOf(" ", end); + end = nextSpace !== -1 ? nextSpace : content.length; + } return ( (start > 0 ? "\u2026" : "") + @@ -152,10 +158,14 @@ self.onmessage = async (e: MessageEvent) => { switch (type) { case "init": { - const res = await fetch("/search-index.json"); - const blocks: SearchBlock[] = await res.json(); - init(blocks); - self.postMessage({ type: "ready" }); + try { + const res = await fetch("/search-index.json"); + const blocks: SearchBlock[] = await res.json(); + init(blocks); + self.postMessage({ type: "ready" }); + } catch (err) { + self.postMessage({ type: "init-error", error: String(err) }); + } break; }