]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
v6: Add Pagefind with custom components (#42369)
authorMark Otto <markd.otto@gmail.com>
Fri, 1 May 2026 00:02:17 +0000 (17:02 -0700)
committerGitHub <noreply@github.com>
Fri, 1 May 2026 00:02:17 +0000 (17:02 -0700)
* Integrate new Pagefind custom components

Co-Authored-By: Julien Déramond <17381666+julien-deramond@users.noreply.github.com>
* fix some mobile styles, for fun, and fix color mode

* linter

---------

Co-authored-by: Julien Déramond <17381666+julien-deramond@users.noreply.github.com>
34 files changed:
.cspell.json
.github/dependabot.yml
.ncurc.json
README.md
config.yml
package-lock.json
package.json
pagefind.yml [new file with mode: 0644]
scss/_badge.scss
scss/buttons/_button.scss
site/astro.config.ts
site/postcss.config.cjs
site/src/assets/search.js
site/src/components/DocsScripts.astro
site/src/components/NewBadge.astro
site/src/components/Scripts.astro
site/src/components/head/Head.astro
site/src/components/header/Navigation.astro
site/src/components/header/SearchDialog.astro [new file with mode: 0644]
site/src/components/header/SearchTrigger.astro [new file with mode: 0644]
site/src/components/icons/Symbols.astro
site/src/components/shortcodes/Code.astro
site/src/components/shortcodes/Example.astro
site/src/components/shortcodes/ResizableExample.astro
site/src/layouts/DocsLayout.astro
site/src/libs/astro.ts
site/src/libs/config.ts
site/src/plugins/algolia-plugin.js [deleted file]
site/src/scss/_callouts.scss
site/src/scss/_content.scss
site/src/scss/_layout.scss
site/src/scss/_search.scss
site/src/scss/docs_search.scss
site/static/docs/[version]/assets/js/color-modes.js

index d3ef78f89d50c6b3faf08b830507b8b73833f10f..aeafee927e95fdc65a914ce5dbb5e07ea2f1e607 100644 (file)
@@ -35,7 +35,6 @@
     "Datalists",
     "Deque",
     "discoverability",
-    "docsearch",
     "docsref",
     "dropend",
     "dropleft",
index c12cddaf532754bc40c1141f36e423c610a1a861..85321b0c8b2953482b956156c9f3873ba9f93261 100644 (file)
@@ -28,10 +28,6 @@ updates:
     versioning-strategy: increase
     rebase-strategy: disabled
     ignore:
-      - dependency-name: "@docsearch/js"
-        update-types:
-          - "version-update:semver-major"
-          - "version-update:semver-minor"
       - dependency-name: "karma-browserstack-launcher"
         update-types:
           - "version-update:semver-major"
index f6f1ab188cd2c0ecf41a2deafbaf3e41ac77289e..d3d98de5c1d0c1249e56cd21d52a414a7f5eab20 100644 (file)
@@ -1,6 +1,5 @@
 {
   "reject": [
-    "@docsearch/js",
     "karma-browserstack-launcher",
     "karma-rollup-preprocessor",
     "stylelint"
index da8b9b338c7333586239c847b2e82cd47a14db26..cfbd4b97a925a143d17fb63fbf0547f1facbb533 100644 (file)
--- a/README.md
+++ b/README.md
@@ -123,7 +123,7 @@ Have a bug or a feature request? Please first read the [issue guidelines](https:
 
 Bootstrap’s documentation, included in this repo in the root directory, is built with [Astro](https://astro.build/) and publicly hosted on GitHub Pages at <https://getbootstrap.com/>. The docs may also be run locally.
 
-Documentation search is powered by [Algolia's DocSearch](https://docsearch.algolia.com/).
+Documentation search is powered by [Pagefind](https://pagefind.app/).
 
 ### Running documentation locally
 
index 1080117742de32ac491c0f371add11a5acbd2017..4032ed007f31da3318168da01fd1d292d6cf5b50 100644 (file)
@@ -22,11 +22,6 @@ swag:                   "https://cottonbureau.com/people/bootstrap"
 analytics:
   fathom_site:          "ITUSEYJG"
 
-algolia:
-  app_id:                "AK7KMZKZHQ"
-  api_key:               "3151f502c7b9e9dafd5e6372b691a24e"
-  index_name:            "bootstrap"
-
 download:
   source:               "https://github.com/twbs/bootstrap/archive/v6.0.0-alpha1.zip"
   dist:                 "https://github.com/twbs/bootstrap/releases/download/v6.0.0-alpha1/bootstrap-6.0.0-alpha1-dist.zip"
index b881756a56bd0e399ca0555829df5d7cea47e1f9..7bbb57d7dfc099457ebe2dc3dfa4224ba69290d6 100644 (file)
@@ -26,8 +26,8 @@
         "@babel/cli": "^7.28.6",
         "@babel/core": "^7.29.0",
         "@babel/preset-env": "^7.29.2",
-        "@docsearch/js": "3.9.0",
         "@floating-ui/dom": "^1.7.6",
+        "@pagefind/component-ui": "^1.5.2",
         "@rollup/plugin-babel": "^7.0.0",
         "@rollup/plugin-node-resolve": "^16.0.3",
         "@rollup/plugin-replace": "^6.0.3",
@@ -76,6 +76,7 @@
         "mime": "^4.1.0",
         "nodemon": "^3.1.14",
         "npm-run-all2": "^8.0.4",
+        "pagefind": "^1.5.0",
         "playwright": "^1.59.1",
         "postcss": "^8.5.10",
         "postcss-cli": "^11.0.1",
         "vanilla-calendar-pro": "^3.1.0"
       }
     },
-    "node_modules/@algolia/abtesting": {
-      "version": "1.12.2",
-      "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.12.2.tgz",
-      "integrity": "sha512-oWknd6wpfNrmRcH0vzed3UPX0i17o4kYLM5OMITyMVM2xLgaRbIafoxL0e8mcrNNb0iORCJA0evnNDKRYth5WQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.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.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.46.2.tgz",
-      "integrity": "sha512-oRSUHbylGIuxrlzdPA8FPJuwrLLRavOhAmFGgdAvMcX47XsyM+IOGa9tc7/K5SPvBqn4nhppOCEz7BrzOPWc4A==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/client-analytics": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.46.2.tgz",
-      "integrity": "sha512-EPBN2Oruw0maWOF4OgGPfioTvd+gmiNwx0HmD9IgmlS+l75DatcBkKOPNJN+0z3wBQWUO5oq602ATxIfmTQ8bA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/client-common": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.46.2.tgz",
-      "integrity": "sha512-Hj8gswSJNKZ0oyd0wWissqyasm+wTz1oIsv5ZmLarzOZAp3vFEda8bpDQ8PUhO+DfkbiLyVnAxsPe4cGzWtqkg==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/client-insights": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.46.2.tgz",
-      "integrity": "sha512-6dBZko2jt8FmQcHCbmNLB0kCV079Mx/DJcySTL3wirgDBUH7xhY1pOuUTLMiGkqM5D8moVZTvTdRKZUJRkrwBA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/client-personalization": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.46.2.tgz",
-      "integrity": "sha512-1waE2Uqh/PHNeDXGn/PM/WrmYOBiUGSVxAWqiJIj73jqPqvfzZgzdakHscIVaDl6Cp+j5dwjsZ5LCgaUr6DtmA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/client-query-suggestions": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.46.2.tgz",
-      "integrity": "sha512-EgOzTZkyDcNL6DV0V/24+oBJ+hKo0wNgyrOX/mePBM9bc9huHxIY2352sXmoZ648JXXY2x//V1kropF/Spx83w==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/client-search": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.46.2.tgz",
-      "integrity": "sha512-ZsOJqu4HOG5BlvIFnMU0YKjQ9ZI6r3C31dg2jk5kMWPSdhJpYL9xa5hEe7aieE+707dXeMI4ej3diy6mXdZpgA==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/ingestion": {
-      "version": "1.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.46.2.tgz",
-      "integrity": "sha512-1Uw2OslTWiOFDtt83y0bGiErJYy5MizadV0nHnOoHFWMoDqWW0kQoMFI65pXqRSkVvit5zjXSLik2xMiyQJDWQ==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/monitoring": {
-      "version": "1.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.46.2.tgz",
-      "integrity": "sha512-xk9f+DPtNcddWN6E7n1hyNNsATBCHIqAvVGG2EAGHJc4AFYL18uM/kMTiOKXE/LKDPyy1JhIerrh9oYb7RBrgw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/recommend": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.46.2.tgz",
-      "integrity": "sha512-NApbTPj9LxGzNw4dYnZmj2BoXiAc8NmbbH6qBNzQgXklGklt/xldTvu+FACN6ltFsTzoNU6j2mWNlHQTKGC5+Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/requester-browser-xhr": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.46.2.tgz",
-      "integrity": "sha512-ekotpCwpSp033DIIrsTpYlGUCF6momkgupRV/FA3m62SreTSZUKjgK6VTNyG7TtYfq9YFm/pnh65bATP/ZWJEg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/requester-fetch": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.46.2.tgz",
-      "integrity": "sha512-gKE+ZFi/6y7saTr34wS0SqYFDcjHW4Wminv8PDZEi0/mE99+hSrbKgJWxo2ztb5eqGirQTgIh1AMVacGGWM1iw==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
-    "node_modules/@algolia/requester-node-http": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.46.2.tgz",
-      "integrity": "sha512-ciPihkletp7ttweJ8Zt+GukSVLp2ANJHU+9ttiSxsJZThXc4Y2yJ8HGVWesW5jN1zrsZsezN71KrMx/iZsOYpg==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/client-common": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
     "node_modules/@astrojs/check": {
       "version": "0.9.8",
       "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.8.tgz",
         "postcss-selector-parser": "^7.0.0"
       }
     },
-    "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/@dual-bundle/import-meta-resolve": {
       "version": "4.2.1",
       "resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/@pagefind/component-ui": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/@pagefind/component-ui/-/component-ui-1.5.2.tgz",
+      "integrity": "sha512-t8/aE0tan4JiKa6cyhhSt/5qrEVwAK/qlYBHFpnRoq+qaFFVrhmXFFMY+r6n4GJtVIFCN2A5nUpeLN68cYjEjw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "adequate-little-templates": "^1.0.2",
+        "bcp-47": "^2.1.0"
+      }
+    },
+    "node_modules/@pagefind/darwin-arm64": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/@pagefind/darwin-arm64/-/darwin-arm64-1.5.2.tgz",
+      "integrity": "sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@pagefind/darwin-x64": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/@pagefind/darwin-x64/-/darwin-x64-1.5.2.tgz",
+      "integrity": "sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@pagefind/freebsd-x64": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/@pagefind/freebsd-x64/-/freebsd-x64-1.5.2.tgz",
+      "integrity": "sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@pagefind/linux-arm64": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/@pagefind/linux-arm64/-/linux-arm64-1.5.2.tgz",
+      "integrity": "sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@pagefind/linux-x64": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/@pagefind/linux-x64/-/linux-x64-1.5.2.tgz",
+      "integrity": "sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@pagefind/windows-arm64": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/@pagefind/windows-arm64/-/windows-arm64-1.5.2.tgz",
+      "integrity": "sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@pagefind/windows-x64": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/@pagefind/windows-x64/-/windows-x64-1.5.2.tgz",
+      "integrity": "sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
     "node_modules/@parcel/watcher": {
       "version": "2.5.1",
       "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz",
         "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
       }
     },
+    "node_modules/adequate-little-templates": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/adequate-little-templates/-/adequate-little-templates-1.0.2.tgz",
+      "integrity": "sha512-d0tFFG538l3Y4CElRKtticzrMr/OGohs32/nf3Ewn8rbTMBYwkVNe8mPdXWrDnMlz+ZVUFQjsTmsBD5zCtU4wg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      }
+    },
     "node_modules/agent-base": {
       "version": "4.3.0",
       "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-4.3.0.tgz",
         }
       }
     },
-    "node_modules/algoliasearch": {
-      "version": "5.46.2",
-      "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.46.2.tgz",
-      "integrity": "sha512-qqAXW9QvKf2tTyhpDA4qXv1IfBwD2eduSW6tUEBFIfCeE9gn9HQ9I5+MaKoenRuHrzk5sQoNh1/iof8mY7uD6Q==",
-      "dev": true,
-      "license": "MIT",
-      "dependencies": {
-        "@algolia/abtesting": "1.12.2",
-        "@algolia/client-abtesting": "5.46.2",
-        "@algolia/client-analytics": "5.46.2",
-        "@algolia/client-common": "5.46.2",
-        "@algolia/client-insights": "5.46.2",
-        "@algolia/client-personalization": "5.46.2",
-        "@algolia/client-query-suggestions": "5.46.2",
-        "@algolia/client-search": "5.46.2",
-        "@algolia/ingestion": "1.46.2",
-        "@algolia/monitoring": "1.46.2",
-        "@algolia/recommend": "5.46.2",
-        "@algolia/requester-browser-xhr": "5.46.2",
-        "@algolia/requester-fetch": "5.46.2",
-        "@algolia/requester-node-http": "5.46.2"
-      },
-      "engines": {
-        "node": ">= 14.0.0"
-      }
-    },
     "node_modules/ansi-regex": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
         "node": ">=6.0.0"
       }
     },
+    "node_modules/bcp-47": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.0.tgz",
+      "integrity": "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-alphabetical": "^2.0.0",
+        "is-alphanumerical": "^2.0.0",
+        "is-decimal": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/bcp-47/node_modules/is-alphabetical": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
+      "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/bcp-47/node_modules/is-alphanumerical": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
+      "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-alphabetical": "^2.0.0",
+        "is-decimal": "^2.0.0"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/bcp-47/node_modules/is-decimal": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
+      "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
     "node_modules/big-integer": {
       "version": "1.6.52",
       "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
       "dev": true,
       "license": "MIT"
     },
+    "node_modules/pagefind": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/pagefind/-/pagefind-1.5.2.tgz",
+      "integrity": "sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "pagefind": "lib/runner/bin.cjs"
+      },
+      "optionalDependencies": {
+        "@pagefind/darwin-arm64": "1.5.2",
+        "@pagefind/darwin-x64": "1.5.2",
+        "@pagefind/freebsd-x64": "1.5.2",
+        "@pagefind/linux-arm64": "1.5.2",
+        "@pagefind/linux-x64": "1.5.2",
+        "@pagefind/windows-arm64": "1.5.2",
+        "@pagefind/windows-x64": "1.5.2"
+      }
+    },
     "node_modules/parent-module": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
       "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
       "license": "MIT"
     },
-    "node_modules/preact": {
-      "version": "10.29.1",
-      "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.1.tgz",
-      "integrity": "sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==",
-      "dev": true,
-      "license": "MIT",
-      "funding": {
-        "type": "opencollective",
-        "url": "https://opencollective.com/preact"
-      }
-    },
     "node_modules/prelude-ls": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
         "node": ">=11.0.0"
       }
     },
-    "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/select": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/select/-/select-1.1.2.tgz",
index 4ada16086a32c0184276f96230ccd813f5d57cb6..b63928b9c424a2ff4180384905efd017c9e9559e 100644 (file)
     "watch-js-docs": "nodemon --watch site/src/assets/ --ext js --exec \"npm run js-lint\"",
     "astro-clean": "rm -rf site/.astro site/node_modules/.astro",
     "astro-dev": "astro dev --root site --port 9001",
-    "astro-build": "astro build --root site && rm -rf _site && cp -r site/dist _site",
+    "astro-build": "astro build --root site && rm -rf _site && cp -r site/dist _site && pagefind --site _site",
     "astro-preview": "astro preview --root site --port 9001",
     "spellcheck": "cspell --config .cspell.json \"**/*.{md,mdx}\""
   },
     "@babel/cli": "^7.28.6",
     "@babel/core": "^7.29.0",
     "@babel/preset-env": "^7.29.2",
-    "@docsearch/js": "3.9.0",
     "@floating-ui/dom": "^1.7.6",
+    "@pagefind/component-ui": "^1.5.2",
     "@rollup/plugin-babel": "^7.0.0",
     "@rollup/plugin-node-resolve": "^16.0.3",
     "@rollup/plugin-replace": "^6.0.3",
     "mime": "^4.1.0",
     "nodemon": "^3.1.14",
     "npm-run-all2": "^8.0.4",
+    "pagefind": "^1.5.0",
     "playwright": "^1.59.1",
     "postcss": "^8.5.10",
     "postcss-cli": "^11.0.1",
diff --git a/pagefind.yml b/pagefind.yml
new file mode 100644 (file)
index 0000000..5da24c2
--- /dev/null
@@ -0,0 +1,7 @@
+# Pagefind CLI configuration
+# https://pagefind.app/docs/config-options/
+
+# Index special characters that show up in CSS / Sass / HTML docs so
+# users can search for things like `$primary`, `--bs-primary`,
+# `&::before`, or `<head>`. Without this they're stripped at index time.
+include_characters: "$&-_<>:."
index 999b6819ee41adf4ec2903a63ec8de8a0226af24..b57ed0464246d172a4d2346f76d6e58a144cb039 100644 (file)
@@ -25,12 +25,12 @@ $badge-tokens: defaults(
 // scss-docs-start badge-variants
 $badge-variants: (
   "subtle": (
-    "color": "text",
+    "color": "fg",
     "bg": "bg-subtle",
     "border-color": "transparent"
   ),
   "outline": (
-    "color": "text",
+    "color": "fg",
     "bg": "transparent",
     "border-color": "border"
   )
index 4435e2e08c6d2f9da9f32d75e8f51fe27dca03b3..3653ffaafbc9b493beebf12b461e68fd36947f70 100644 (file)
@@ -111,7 +111,7 @@ $button-variants: defaults(
     "outline": (
       "base": (
         "bg": "transparent",
-        "color": "text",
+        "color": "fg",
         "border-color": "border"
       ),
       "hover": (
@@ -128,30 +128,30 @@ $button-variants: defaults(
     "subtle": (
       "base": (
         "bg": "bg-subtle",
-        "color": "text",
+        "color": "fg",
         "border-color": "transparent"
       ),
       "hover": (
         "bg": ("bg-muted", "bg-subtle"),
-        "color": "text-emphasis"
+        "color": "fg-emphasis"
       ),
       "active": (
         "bg": "bg-subtle",
-        "color": "text-emphasis"
+        "color": "fg-emphasis"
       )
     ),
     "text": (
       "base": (
-        "color": "text",
+        "color": "fg",
         "bg": "transparent",
         "border-color": "transparent"
       ),
       "hover": (
-        "color": "text",
+        "color": "fg",
         "bg": "bg-subtle"
       ),
       "active": (
-        "color": "text",
+        "color": "fg",
         "bg": "bg-subtle"
       )
     )
index 45142be4aaba72b3745f92bdb31c2053dd672974..40a849d4fd17ad7d8951750eb3c3087ba6ad1379 100644 (file)
@@ -8,7 +8,6 @@ import { transformerNotationDiff, transformerNotationHighlight } from '@shikijs/
 
 import { bootstrap } from './src/libs/astro'
 import { getConfig } from './src/libs/config'
-import { algoliaPlugin } from './src/plugins/algolia-plugin'
 import { stackblitzPlugin } from './src/plugins/stackblitz-plugin'
 
 // Resolve `@bootstrap` to the same on-disk Bootstrap bundle the docs ship, so
@@ -69,7 +68,7 @@ export default defineConfig({
   },
   site,
   vite: {
-    plugins: [algoliaPlugin(), stackblitzPlugin()],
+    plugins: [stackblitzPlugin()],
     resolve: {
       alias: {
         '@bootstrap': bootstrapBundlePath
index ad1f4aff597b8eec798aa3c1da7f72c02fcd6888..d90b0b90ed184c13e2e0c3545d3169f6c0f16d79 100644 (file)
@@ -2,7 +2,7 @@ module.exports = {
   plugins: [
     require('postcss-prefix-custom-properties')({
       prefix: 'bs-',
-      ignore: [/^--bs-/, /^--bd-/, /^--shell-/, /^--shiki-/]
+      ignore: [/^--bs-/, /^--bd-/, /^--pf-/, /^--shell-/, /^--shiki-/]
     }),
     require('autoprefixer')
   ]
index 4b8781deef0d5ae7ced2d6579c0f33b0a6dc3147..21e3e946ea3eb3b3f174f57c967a505b859c7130 100644 (file)
 // NOTICE: Internal docs helpers — not shipped in Bootstrap; not for reuse.
 
 /*!
- * JavaScript for Bootstrap's docs (https://getbootstrap.com/)
+ * JavaScript for Bootstrap's docs search (https://getbootstrap.com/)
  * Copyright 2024-2026 The Bootstrap Authors
  * Licensed under the Creative Commons Attribution 3.0 Unported License.
  * For details, see https://creativecommons.org/licenses/by/3.0/.
  */
 
-import docsearch from '@docsearch/js'
+// Custom Pagefind integration. We use `@pagefind/component-ui` only for its
+// instance manager / search engine; the trigger, dialog, input, and results
+// list are all built from Bootstrap primitives (Dialog, .form-control,
+// .list-group). No `<pagefind-*>` element is ever rendered — those custom
+// elements still self-register when the package loads, but stay unused.
 
-export default () => {
-  // These values will be replaced by Astro's Vite plugin
-  const CONFIG = {
-    apiKey: '__API_KEY__',
-    indexName: '__INDEX_NAME__',
-    appId: '__APP_ID__'
+import { getInstanceManager } from '@pagefind/component-ui'
+import { Dialog } from '../../../dist/js/bootstrap.bundle.js'
+
+const DIALOG_SELECTOR = '#bdSearchDialog'
+const SUB_RESULTS_LIMIT = 3
+// How long a search may take before we replace the previous results with the
+// loading skeleton. Most queries resolve in well under this, so the skeleton
+// stays hidden and the UI doesn't flash on every keystroke.
+const LOADING_SKELETON_DELAY_MS = 200
+const RECENT_STORAGE_KEY = 'bd:search:recent'
+const RECENT_LIMIT = 5
+
+const instance = getInstanceManager().getInstance('default')
+let searchInitialized = false
+
+// Recent visits — opt-out via Do Not Track and quietly no-op when storage is
+// unavailable (private mode, quota, disabled cookies/storage).
+const isDoNotTrackEnabled = () => {
+  if (typeof navigator === 'undefined') {
+    return false
+  }
+
+  return navigator.doNotTrack === '1' ||
+    globalThis.doNotTrack === '1' ||
+    navigator.msDoNotTrack === '1'
+}
+
+const getRecentVisits = () => {
+  if (isDoNotTrackEnabled()) {
+    return []
   }
 
-  const searchElement = document.getElementById('docsearch')
+  try {
+    const raw = localStorage.getItem(RECENT_STORAGE_KEY)
+    const parsed = raw ? JSON.parse(raw) : []
+    return Array.isArray(parsed) ? parsed.filter(item => item && item.url) : []
+  } catch {
+    return []
+  }
+}
 
-  if (!searchElement) {
+const saveRecentVisit = visit => {
+  if (isDoNotTrackEnabled() || !visit?.url) {
     return
   }
 
-  const siteDocsVersion = searchElement.getAttribute('data-bd-docs-version')
-
-  docsearch({
-    apiKey: CONFIG.apiKey,
-    indexName: CONFIG.indexName,
-    appId: CONFIG.appId,
-    container: searchElement,
-    searchParameters: {
-      facetFilters: [`version:${siteDocsVersion}`]
-    },
-    transformItems(items) {
-      return items.map(item => {
-        const liveUrl = 'https://getbootstrap.com/'
-
-        item.url = window.location.origin.startsWith(liveUrl) ?
-          // On production, return the result as is
-          item.url :
-          // On development or Netlify, replace `item.url` with a trailing slash,
-          // so that the result link is relative to the server root
-          item.url.replace(liveUrl, '/')
-
-        // Prevent jumping to first header
-        if (item.anchor === 'content') {
-          item.url = item.url.replace(/#content$/, '')
-          item.anchor = null
+  try {
+    const existing = getRecentVisits().filter(item => item.url !== visit.url)
+    const next = [visit, ...existing].slice(0, RECENT_LIMIT)
+    localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(next))
+  } catch {
+    // Ignore — storage is best-effort.
+  }
+}
+
+const clearRecentVisits = () => {
+  try {
+    localStorage.removeItem(RECENT_STORAGE_KEY)
+  } catch {
+    // Ignore — storage is best-effort.
+  }
+}
+
+const HTML_ESCAPES = {
+  '&': '&amp;',
+  '<': '&lt;',
+  '>': '&gt;',
+  '"': '&quot;',
+  '\'': '&#39;'
+}
+
+const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => HTML_ESCAPES[char])
+
+// Single template for every clickable row in the results list — top-level
+// results, nested subresults, and recently visited items. The two variants
+// only differ by icon and a `-sub` modifier that nudges the font size down.
+// `title` and `excerpt` are inlined as-is so callers can either escape them
+// (recents, plain text) or pass through Pagefind's pre-marked HTML excerpts.
+const renderItem = ({ url, title, excerpt = '', icon, sub = false }) => `
+  <a class="list-group-item list-group-item-action bd-search-item${sub ? ' bd-search-item-sub' : ''}" href="${escapeHtml(url)}">
+    <svg class="bi bd-search-item-icon" width="16" height="16" aria-hidden="true">
+      <use href="#${icon}"></use>
+    </svg>
+    <span class="bd-search-item-title">${title}</span>
+    ${excerpt ? `<span class="bd-search-item-excerpt">${excerpt}</span>` : ''}
+  </a>
+`
+
+class BdSearchInput extends HTMLElement {
+  connectedCallback() {
+    this.input = this.querySelector('input')
+    if (!this.input) {
+      return
+    }
+
+    this.inputEl = this.input
+    instance.registerInput(this, { keyboardNavigation: true })
+
+    this._onInput = this._onInput.bind(this)
+    this._onKeydown = this._onKeydown.bind(this)
+    this.input.addEventListener('input', this._onInput)
+    this.input.addEventListener('keydown', this._onKeydown)
+
+    this.input.addEventListener('focus', () => {
+      instance.triggerLoad()
+    }, { once: true })
+  }
+
+  disconnectedCallback() {
+    this.input?.removeEventListener('input', this._onInput)
+    this.input?.removeEventListener('keydown', this._onKeydown)
+  }
+
+  _onInput(event) {
+    instance.triggerSearch(event.target.value)
+  }
+
+  _onKeydown(event) {
+    // ArrowDown jumps focus to the first link in the next results component.
+    if (event.key === 'ArrowDown' && instance.focusNextResults(this)) {
+      event.preventDefault()
+      return
+    }
+
+    // Enter follows the first result link — this matches the command-palette
+    // pattern where the top result is always the implicit Enter target.
+    // Always preventDefault so the surrounding `<form role="search">` never
+    // implicit-submits and reloads the page when no results are present
+    // (loading / empty / error / zero-results states).
+    if (event.key === 'Enter') {
+      event.preventDefault()
+      const dialogEl = this.closest('dialog')
+      dialogEl?.querySelector('.bd-search-item')?.click()
+    }
+  }
+
+  focus() {
+    this.input?.focus()
+  }
+}
+
+class BdSearchResults extends HTMLElement {
+  connectedCallback() {
+    instance.registerResults(this, { keyboardNavigation: true })
+
+    instance.on('loading', () => this._renderLoading(), this)
+    instance.on('results', result => this._renderResults(result), this)
+    instance.on('error', error => this._renderError(error), this)
+
+    this._onKeydown = this._onKeydown.bind(this)
+    this._onClick = this._onClick.bind(this)
+    this.addEventListener('keydown', this._onKeydown)
+    this.addEventListener('click', this._onClick)
+
+    this._renderEmpty()
+  }
+
+  disconnectedCallback() {
+    this.removeEventListener('keydown', this._onKeydown)
+    this.removeEventListener('click', this._onClick)
+    this._clearLoadingTimer()
+  }
+
+  _onClick(event) {
+    if (event.target.closest('[data-bd-search-clear-recent]')) {
+      event.preventDefault()
+      clearRecentVisits()
+      this._renderEmpty()
+      instance.focusInputAndType(this, '')
+    }
+  }
+
+  _getLinks() {
+    return [...this.querySelectorAll('.bd-search-item')]
+  }
+
+  _onKeydown(event) {
+    const link = event.target.closest('.bd-search-item')
+    if (!link) {
+      return
+    }
+
+    const links = this._getLinks()
+    const index = links.indexOf(link)
+
+    switch (event.key) {
+      case 'ArrowDown': {
+        const next = links[index + 1]
+        if (next) {
+          event.preventDefault()
+          next.focus()
+        }
+
+        break
+      }
+
+      case 'ArrowUp': {
+        event.preventDefault()
+        if (index === 0) {
+          instance.focusPreviousInput(this)
+        } else {
+          links[index - 1].focus()
         }
 
-        return item
-      })
+        break
+      }
+
+      case 'Home': {
+        if (links[0]) {
+          event.preventDefault()
+          links[0].focus()
+        }
+
+        break
+      }
+
+      case 'End': {
+        const last = links.at(-1)
+        if (last) {
+          event.preventDefault()
+          last.focus()
+        }
+
+        break
+      }
+
+      case 'Backspace': {
+        event.preventDefault()
+        instance.focusInputAndDelete(this)
+        break
+      }
+
+      default: {
+        // Single printable character — redirect to the input and keep typing there.
+        if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
+          event.preventDefault()
+          instance.focusInputAndType(this, event.key)
+        }
+      }
+    }
+  }
+
+  _clearLoadingTimer() {
+    if (this._loadingTimer) {
+      clearTimeout(this._loadingTimer)
+      this._loadingTimer = null
+    }
+  }
+
+  _renderEmpty() {
+    this._clearLoadingTimer()
+
+    if (instance.searchTerm) {
+      return
+    }
+
+    const recents = getRecentVisits()
+    if (recents.length === 0) {
+      this.innerHTML = `
+        <div class="bd-search-empty">Start typing to search…</div>
+      `
+      return
+    }
+
+    this.innerHTML = `
+      <div class="bd-search-recent">
+        <div class="bd-search-recent-header">
+          <span class="bd-search-recent-label">Recently visited</span>
+          <button type="button" class="bd-search-recent-clear" data-bd-search-clear-recent>
+            Clear
+          </button>
+        </div>
+        <ol class="list-group list-group-flush bd-search-results-list">
+          ${recents.map(visit => {
+            // URL fragment → in-page link (Pagefind subresult). Render with
+            // the hash icon and smaller title to match the live results UI.
+            const isSubLink = visit.url.includes('#')
+            return `
+              <li class="bd-search-result">
+                ${renderItem({
+                  url: visit.url,
+                  title: escapeHtml(visit.title),
+                  excerpt: escapeHtml(visit.excerpt),
+                  icon: isSubLink ? 'hash' : 'file-earmark-richtext',
+                  sub: isSubLink
+                })}
+              </li>
+            `
+          }).join('')}
+        </ol>
+      </div>
+    `
+  }
+
+  _renderLoading() {
+    // Defer the skeleton paint — most searches finish well before this fires,
+    // so the previous results stay on screen instead of flashing to skeletons
+    // on every keystroke.
+    this._clearLoadingTimer()
+    this._loadingTimer = setTimeout(() => {
+      this._loadingTimer = null
+      this._paintLoading()
+    }, LOADING_SKELETON_DELAY_MS)
+  }
+
+  _paintLoading() {
+    this.innerHTML = `
+      <div class="bd-search-loading placeholder-glow" role="status" aria-live="polite">
+        <span class="visually-hidden">${escapeHtml(instance.translate('searching') || 'Searching…')}</span>
+        ${Array.from({ length: 3 }, () => `
+          <div class="bd-search-skeleton">
+            <span class="placeholder col-4"></span>
+            <span class="placeholder col-12"></span>
+            <span class="placeholder col-10"></span>
+          </div>
+        `).join('')}
+      </div>
+    `
+  }
+
+  _renderError(error) {
+    this._clearLoadingTimer()
+
+    const message = error?.message || instance.translate('error_text') || 'Something went wrong with search.'
+    this.innerHTML = `
+      <div class="bd-search-error" role="alert">
+        ${escapeHtml(message)}
+      </div>
+    `
+  }
+
+  async _renderResults(searchResult) {
+    this._clearLoadingTimer()
+
+    const term = instance.searchTerm
+
+    if (!term) {
+      this._renderEmpty()
+      return
+    }
+
+    const rawResults = searchResult?.results ?? []
+
+    if (rawResults.length === 0) {
+      const zero = instance.translate('zero_results', { SEARCH_TERM: term }) || `No results for "${term}"`
+      this.innerHTML = `
+        <div class="bd-search-empty">${escapeHtml(zero)}</div>
+      `
+      return
+    }
+
+    const top = rawResults.slice(0, 5)
+    const settled = await Promise.all(top.map(raw => raw.data().catch(() => null)))
+    const data = settled.filter(Boolean)
+
+    if (instance.searchTerm !== term) {
+      // A newer search has started while we were resolving — drop these.
+      return
+    }
+
+    this.innerHTML = `
+      <ol class="list-group list-group-flush bd-search-results-list">
+        ${data.map(result => this._renderResult(result)).join('')}
+      </ol>
+    `
+  }
+
+  _renderResult(result) {
+    const title = result.meta?.title || result.url
+    const subResults = instance.getDisplaySubResults(result, SUB_RESULTS_LIMIT)
+
+    const subResultsHtml = subResults.length === 0 ?
+      '' :
+      `
+        <ul class="bd-search-subresults list-unstyled">
+          ${subResults.map(sub => `
+            <li>
+              ${renderItem({
+                url: sub.url,
+                title: escapeHtml(sub.title),
+                excerpt: sub.excerpt,
+                icon: 'hash',
+                sub: true
+              })}
+            </li>
+          `).join('')}
+        </ul>
+      `
+
+    return `
+      <li class="bd-search-result">
+        ${renderItem({
+          url: result.url,
+          title: escapeHtml(title),
+          excerpt: result.excerpt,
+          icon: 'file-earmark-richtext'
+        })}
+        ${subResultsHtml}
+      </li>
+    `
+  }
+}
+
+const defineSearchCustomElements = () => {
+  if (!customElements.get('bd-search-input')) {
+    customElements.define('bd-search-input', BdSearchInput)
+  }
+
+  if (!customElements.get('bd-search-results')) {
+    customElements.define('bd-search-results', BdSearchResults)
+  }
+}
+
+const isMac = () => {
+  if (typeof navigator === 'undefined') {
+    return false
+  }
+
+  const platform = navigator.userAgentData?.platform || navigator.platform || ''
+  return /mac|iphone|ipad|ipod/i.test(platform)
+}
+
+// Populate every `.bd-search-trigger-shortcut` slot with the platform-correct
+// shortcut and reveal it. Rendered empty/hidden by `SearchTrigger.astro` so
+// non-JS visitors never see a misleading hint and JS visitors never see a
+// flash of the wrong key.
+const setupTriggerShortcuts = () => {
+  const mac = isMac()
+  const modifier = mac ? '⌘' : '⌃'
+  const ariaKeyshortcut = mac ? 'Meta+K' : 'Control+K'
+
+  for (const slot of document.querySelectorAll('.bd-search-trigger-shortcut')) {
+    slot.innerHTML = `<kbd class="bd-search-trigger-key">${modifier}K</kbd>`
+    slot.hidden = false
+    slot.closest('.bd-search-trigger')?.setAttribute('aria-keyshortcuts', ariaKeyshortcut)
+  }
+}
+
+const isEditableTarget = target => {
+  if (!target) {
+    return false
+  }
+
+  if (target.isContentEditable) {
+    return true
+  }
+
+  const tag = target.tagName
+  return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'
+}
+
+const openSearchDialog = () => {
+  const dialogEl = document.querySelector(DIALOG_SELECTOR)
+  if (!dialogEl) {
+    return
+  }
+
+  const dialog = Dialog.getOrCreateInstance(dialogEl)
+  dialog.show()
+  // Focus the input on the next frame so the dialog is in the top layer.
+  requestAnimationFrame(() => {
+    dialogEl.querySelector('bd-search-input')?.focus()
+  })
+}
+
+const registerGlobalShortcuts = () => {
+  // Global Cmd/Ctrl + K shortcut to open the search dialog.
+  document.addEventListener('keydown', event => {
+    if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') {
+      event.preventDefault()
+      openSearchDialog()
+      return
+    }
+
+    // `/` opens search when not typing in another field — common docs shortcut.
+    if (event.key === '/' && !event.metaKey && !event.ctrlKey && !event.altKey && !isEditableTarget(event.target)) {
+      event.preventDefault()
+      openSearchDialog()
     }
   })
 }
+
+const setupDialogResetOnClose = () => {
+  const dialogEl = document.querySelector(DIALOG_SELECTOR)
+  if (!dialogEl) {
+    return
+  }
+
+  dialogEl.addEventListener('hidden.bs.dialog', () => {
+    const inputEl = dialogEl.querySelector('bd-search-input input')
+    if (inputEl) {
+      inputEl.value = ''
+    }
+
+    instance.triggerSearch('')
+  })
+
+  // Re-render empty state on open so a freshly-saved visit shows up in the
+  // "Recently visited" list without waiting for the user to type and clear.
+  dialogEl.addEventListener('shown.bs.dialog', () => {
+    if (!instance.searchTerm) {
+      dialogEl.querySelector('bd-search-results')?._renderEmpty?.()
+    }
+  })
+}
+
+const onDomReady = () => {
+  setupTriggerShortcuts()
+  setupDialogResetOnClose()
+}
+
+const registerResultLinkHandler = () => {
+  // Result links should close the dialog when clicked (so the user lands on the
+  // destination page with the modal already gone) and have the visit recorded
+  // so it can resurface in the empty-state "Recently visited" list.
+  document.addEventListener('click', event => {
+    const link = event.target.closest('.bd-search-item')
+    if (!link) {
+      return
+    }
+
+    const dialogEl = link.closest(DIALOG_SELECTOR)
+    if (!dialogEl) {
+      return
+    }
+
+    const titleEl = link.querySelector('.bd-search-item-title')
+    const excerptEl = link.querySelector('.bd-search-item-excerpt')
+    saveRecentVisit({
+      url: link.getAttribute('href') || '',
+      title: titleEl?.textContent.trim() || link.textContent.trim(),
+      excerpt: excerptEl?.textContent.trim() || ''
+    })
+
+    Dialog.getInstance(dialogEl)?.hide()
+  })
+}
+
+const initSearch = () => {
+  if (searchInitialized) {
+    return
+  }
+
+  searchInitialized = true
+  defineSearchCustomElements()
+  registerGlobalShortcuts()
+  registerResultLinkHandler()
+
+  if (document.readyState === 'loading') {
+    document.addEventListener('DOMContentLoaded', onDomReady, { once: true })
+  } else {
+    onDomReady()
+  }
+}
+
+export default initSearch
index 503f175df824453d456fbe4f90f9e04032953989..97991af4e0ab8b5741f5e9d98b8fbf347cb27584 100644 (file)
@@ -5,7 +5,9 @@
 ---
 
 <script>
+  import initSearch from '../assets/search.js'
   import stackblitz from '../assets/stackblitz.js'
 
+  initSearch()
   stackblitz()
 </script>
index 41a61221b7a89897fe016cded034fa92f2041339..a9b716b25f22307607e0dd3747a1a5223d8bfc46 100644 (file)
@@ -6,7 +6,11 @@ interface Props {
 const { version } = Astro.props
 ---
 
-<span class="badge bg-3 ms-auto fg-3 fw-normal me--1" data-bs-toggle="tooltip" data-bs-title={`Added in v${version}`}>
+<span
+  class="badge badge-subtle theme-primary ms-auto fw-normal me--1"
+  data-bs-toggle="tooltip"
+  data-bs-title={`Added in v${version}`}
+>
   New
   <span class="visually-hidden">{` (Added in v${version})`}</span>
 </span>
index faad24c478b59df6542bea7f9a61113f91a6366b..47e8711b76013d043d69460041905a4c02896d6a 100644 (file)
@@ -11,10 +11,8 @@ const { layout } = Astro.props
 
 <script>
   import application from '../assets/application.js'
-  import search from '../assets/search.js'
 
   application()
-  search()
 </script>
 
-{layout === 'docs' && <DocsScripts />}
+<DocsScripts />
index a9f1048caf072f49d9e14dedb6c1a584171cf240..3bcd62fd00d7a804bb30df9c939ad5b1d181ea97 100644 (file)
@@ -35,14 +35,11 @@ const ScssProd = import.meta.env.PROD ? await import('@components/head/ScssProd.
 <meta name="description" content={description} />
 
 <meta name="author" content={getConfig().authors} />
+
 <meta name="generator" content={Astro.generator} />
-<meta name="docsearch:language" content="en" />
-<meta name="docsearch:version" content={getConfig().docs_version} />
 
 <link rel="canonical" href={canonicalUrl} />
 
-<link rel="preconnect" href=`https://${getConfig().algolia.app_id}-dsn.algolia.net` crossorigin />
-
 <link rel="preconnect" href="https://fonts.googleapis.com" />
 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
 <link
index d75b991e211f00f16cf92f948bde37b7a6535da6..92568ce38af07b0d23954afa7e794403abf68c97 100644 (file)
@@ -12,6 +12,8 @@ import XIcon from '@components/icons/XIcon.astro'
 import Versions from '@components/header/Versions.astro'
 import ThemeToggler from '@layouts/partials/ThemeToggler.astro'
 import CloseButton from '@components/shortcodes/CloseButton.astro'
+import SearchTrigger from '@components/header/SearchTrigger.astro'
+import SearchDialog from '@components/header/SearchDialog.astro'
 
 interface Props {
   addedIn?: CollectionEntry<'docs'>['data']['added']
@@ -114,7 +116,9 @@ const activeSection = !layout ? 'Home' : title === 'Examples' ? 'Examples' : 'Do
     </div>
 
     <ul class="nav navbar-nav flex-row ms-auto">
-      <li class="nav-item nav-link px-1" id="docsearch" data-bd-docs-version={getConfig().docs_version}></li>
+      <div class="bd-search">
+        <SearchTrigger placeholder="Search docs…" />
+      </div>
 
       <LinkItem class="px-1 d-none lg:d-flex" href={getConfig().github_org} target="_blank" rel="noopener">
         <GitHubIcon class="navbar-nav-svg" height={16} width={16} />
@@ -144,3 +148,5 @@ const activeSection = !layout ? 'Home' : title === 'Examples' ? 'Examples' : 'Do
     }
   </nav>
 </header>
+
+<SearchDialog placeholder="Search docs…" />
diff --git a/site/src/components/header/SearchDialog.astro b/site/src/components/header/SearchDialog.astro
new file mode 100644 (file)
index 0000000..c267063
--- /dev/null
@@ -0,0 +1,41 @@
+---
+import CloseButton from '@components/shortcodes/CloseButton.astro'
+
+interface Props {
+  id?: string
+  placeholder?: string
+}
+
+const { id = 'bdSearchDialog', placeholder = 'Search docs…' } = Astro.props
+---
+
+<dialog class="dialog dialog-lg dialog-search dialog-scrollable" id={id} aria-labelledby={`${id}-label`}>
+  <form class="dialog-header bd-search-form" role="search" autocomplete="off">
+    <label class="visually-hidden" id={`${id}-label`} for={`${id}-input`}>
+      {placeholder}
+    </label>
+    <div class="form-control form-adorn">
+      <div class="form-adorn-icon">
+        <svg class="bi bd-search-form-icon" width="16" height="16" aria-hidden="true">
+          <use href="#search"></use>
+        </svg>
+      </div>
+      <bd-search-input>
+        <input
+          id={`${id}-input`}
+          type="search"
+          class="form-ghost bd-search-input"
+          placeholder={placeholder}
+          autocomplete="off"
+          spellcheck="false"
+          enterkeyhint="search"
+        />
+      </bd-search-input>
+    </div>
+    <CloseButton dismiss="dialog" class="bd-search-close" />
+  </form>
+
+  <div class="dialog-body bd-search-body">
+    <bd-search-results></bd-search-results>
+  </div>
+</dialog>
diff --git a/site/src/components/header/SearchTrigger.astro b/site/src/components/header/SearchTrigger.astro
new file mode 100644 (file)
index 0000000..7da4613
--- /dev/null
@@ -0,0 +1,29 @@
+---
+interface Props {
+  class?: string
+  placeholder?: string
+  target?: string
+}
+
+const { class: className, placeholder = 'Search docs…', target = '#bdSearchDialog' } = Astro.props
+---
+
+<button
+  type="button"
+  class:list={['bd-search-trigger', className]}
+  data-bs-toggle="dialog"
+  data-bs-target={target}
+  aria-haspopup="dialog"
+  aria-label={placeholder}
+>
+  <svg class="bi bd-search-trigger-icon" width="16" height="16" aria-hidden="true">
+    <use href="#search"></use>
+  </svg>
+  <span class="bd-search-trigger-label">{placeholder}</span>
+  {
+    /* Shortcut hint is empty + hidden until search.js detects the platform and
+      fills in either ⌘K (Mac) or Ctrl K (other). Without JS the shortcut
+      itself doesn't work, so showing nothing is more honest than guessing. */
+  }
+  <span class="bd-search-trigger-shortcut" aria-hidden="true" hidden></span>
+</button>
index 740fe77e013cc861a13b75f6effd39442c83c503..7d669a969ea50ab1e8f4ac3e736988be9f5c2015 100644 (file)
       d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zm8 0A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm-8 8A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm8 0A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5v-3z"
     ></path>
   </symbol>
+  <symbol id="hash" viewBox="0 0 16 16">
+    <path
+      d="M8.39 12.648a1 1 0 0 0-.015.18c0 .305.21.508.5.508.266 0 .492-.172.555-.477l.554-2.703h1.204c.421 0 .617-.234.617-.547 0-.312-.188-.53-.617-.53h-.985l.516-2.524h1.265c.43 0 .618-.227.618-.547 0-.313-.188-.524-.618-.524h-1.046l.476-2.304a1 1 0 0 0 .016-.164.51.51 0 0 0-.516-.516.54.54 0 0 0-.539.43l-.523 2.554H7.617l.477-2.304c.008-.04.015-.118.015-.164a.51.51 0 0 0-.523-.516.54.54 0 0 0-.531.43L6.53 5.484H5.414c-.43 0-.617.22-.617.532s.187.539.617.539h.906l-.515 2.523H4.609c-.421 0-.609.219-.609.531s.188.547.61.547h.976l-.516 2.492c-.008.04-.015.125-.015.18 0 .305.21.508.5.508.265 0 .492-.172.554-.477l.555-2.703h2.242zm-1-6.109h2.266l-.515 2.563H6.859l.532-2.563z"
+    ></path>
+  </symbol>
   <symbol id="lightning-charge-fill" viewBox="0 0 16 16">
     <path
       d="M11.251.068a.5.5 0 0 1 .227.58L9.677 6.5H13a.5.5 0 0 1 .364.843l-8 8.5a.5.5 0 0 1-.842-.49L6.323 9.5H3a.5.5 0 0 1-.364-.843l8-8.5a.5.5 0 0 1 .615-.09z"
index 607c56f495c8250b5faca09dfbc1a75f35c78f25..895d2456b11244f0ba5647306e138ee9e09106b1 100644 (file)
@@ -246,7 +246,7 @@ const highlightedCode =
       )}
     </>
   ) : (
-    <div class:list={['bd-code-snippet', containerClass]}>
+    <div class:list={['bd-code-snippet', containerClass]} data-pagefind-ignore>
       {!noToolbar && (
         <div class="hstack highlight-toolbar align-items-center">
           {highlightedTabs ? (
index b6c94eedec42268a82978653f766f1c791373193..608f5904f3f1eb67920304b315f407dc7d71cf60 100644 (file)
@@ -76,7 +76,7 @@ const simplifiedMarkup = sourceMarkup
   )
 ---
 
-<div class="bd-example-snippet bd-code-snippet not-prose">
+<div class="bd-example-snippet bd-code-snippet not-prose" data-pagefind-ignore>
   {
     showPreview && (
       <div id={id} class:list={['bd-example', className]}>
index b07ce85fa6dad313cf164c73f82574dee0957f39..0dffc70530fdbd083f92ba2a1b7e1b8e1dbd4573 100644 (file)
@@ -53,7 +53,7 @@ const simplifiedMarkup = markup.replace(
 )
 ---
 
-<div class="bd-example-snippet bd-code-snippet">
+<div class="bd-example-snippet bd-code-snippet" data-pagefind-ignore>
   <div class="bd-example bd-example-resizable p-2">
     <div
       class:list={['bd-resizable-container', containerClass]}
index bb9cf87f549984843fb3c8895b3bdf9b434b09fd..ffdde814be73f5cf0751821a7e178ca457f18d2e 100644 (file)
@@ -146,7 +146,7 @@ if (currentPageIndex < allPages.length - 1) {
         <Ads />
       </div>
 
-      <div class="bd-content prose lg:ps-2">
+      <div class="bd-content prose lg:ps-2 lg:pt-4" data-pagefind-body>
         {
           frontmatter.sections && (
             <div class="grid grid-cols-1 lg:grid-cols-3 mb-5">
index 71c60a5c37f3ef53aa408d35b2fec33abe04f43e..7f6644eeb79bdcf19ea5b87cd1efa8f9d23ce35d 100644 (file)
@@ -74,6 +74,7 @@ export function bootstrap(): AstroIntegration[] {
           copyBootstrap()
           copyStatic()
           aliasStatic()
+          copyPagefindIndex()
         },
         'astro:build:done': ({ dir }) => {
           validateVersionedDocsPaths(dir)
@@ -137,6 +138,23 @@ function cleanPublicDirectory() {
   fs.rmSync(getDocsPublicFsPath(), { force: true, recursive: true })
 }
 
+// Copy the previously-generated Pagefind search index from `_site/pagefind/`
+// into `site/public/pagefind/` so `astro dev` can serve it at `/pagefind/`.
+// The index is regenerated by `npm run docs-build`; this step is a no-op if
+// no build has been run yet, in which case dev simply has no search results.
+function copyPagefindIndex() {
+  const source = path.join(process.cwd(), '_site', 'pagefind')
+
+  if (!fs.existsSync(source)) {
+    return
+  }
+
+  const destination = path.join(getDocsPublicFsPath(), 'pagefind')
+
+  fs.mkdirSync(destination, { recursive: true })
+  fs.cpSync(source, destination, { recursive: true })
+}
+
 // Copy the `dist` folder from the root of the repo containing the latest version of Bootstrap to make it available from
 // the `/docs/${docs_version}/dist` URL.
 function copyBootstrap() {
index 2970323c3980638cc4030cc80c45a9a62cb63362..869e8b2559321de1c7e1684ebbd22450bc456b4d 100644 (file)
@@ -5,11 +5,6 @@ import { zPrefixedVersionSemver, zVersionMajorMinor, zVersionSemver } from './va
 
 // The config schema used to validate the config file content and ensure all values required by the site are valid.
 const configSchema = z.object({
-  algolia: z.object({
-    api_key: z.string(),
-    app_id: z.string(),
-    index_name: z.string()
-  }),
   analytics: z.object({
     fathom_site: z.string()
   }),
diff --git a/site/src/plugins/algolia-plugin.js b/site/src/plugins/algolia-plugin.js
deleted file mode 100644 (file)
index 3481140..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-import { getConfig } from '../libs/config.ts'
-
-/**
- * Vite plugin to replace placeholder values in search.js with actual configuration values
- */
-export function algoliaPlugin() {
-  const config = getConfig()
-
-  return {
-    name: 'algolia-config-replacer',
-    transform(code, id) {
-      if (id.includes('search.js')) {
-        return code
-          .replace(/__API_KEY__/g, config.algolia.api_key)
-          .replace(/__INDEX_NAME__/g, config.algolia.index_name)
-          .replace(/__APP_ID__/g, config.algolia.app_id)
-      }
-
-      return code
-    }
-  }
-}
index c4bd4166b162edf8caf5b6efd0ba008fa5977b48..dd380ce2509b19748704cc550d2963aa195fcabd 100644 (file)
@@ -11,7 +11,8 @@
     --link-hover-color: var(--bd-callout-color);
     --code-color: var(--bd-callout-code-color);
 
-    padding: 1.25rem;
+    container-type: inline-size;
+    padding: 1rem;
     font-size: .875rem;
     line-height: 1.5;
     background-color: var(--bd-callout-bg, var(--bs-gray-100));
     + .bd-callout {
       margin-top: -.25rem;
     }
+
+    @container (width >= 768px) {
+      padding: 1.25rem;
+    }
   }
 
   // Variations
index 94559714bd1abf846a772f2681c1b9c0bd72cb53..370ddca48d16b97c23478be22db9639ce6531b23 100644 (file)
@@ -6,7 +6,11 @@
 
 @layer custom {
   .bd-content {
+    --bs-content-font-size: 15px;
+    --bs-content-line-height: calc(22 / 15);
+
     @media (width >= 1024px) {
+      --bs-content-line-height: 1.625;
       font-size: var(--font-size-md);
     }
 
index b3db3168e0523331f295f4fad1a2d5c8eaec1731..ee2b32807b91305596f75ed3970a5a5f941d9994 100644 (file)
@@ -41,6 +41,7 @@
         "content";
       grid-template-rows: auto auto 1fr;
       gap: inherit;
+      padding-top: .75rem;
     }
 
     @include media-breakpoint-up(lg) {
index bb627a83239b933fddf21abf61a78d30eb9f8b52..cdafbeea0e6b0dd7397ff3951d5987cf1d2dddd3 100644 (file)
 @use "../../../scss/layout/breakpoints" as *;
-@use "../../../scss/mixins/color-mode" as *;
 @use "../../../scss/mixins/border-radius" as *;
+@use "../../../scss/mixins/focus-ring" as *;
+@use "../../../scss/mixins/transition" as *;
 
-// stylelint-disable selector-class-pattern
-
-:root {
-  --docsearch-primary-color: var(--bs-indigo-500);
-  --docsearch-logo-color: var(--bs-indigo-500);
-}
-
-@include color-mode(dark, true) {
-  // From here, the values are copied from https://cdn.jsdelivr.net/npm/@docsearch/css@3
-  // in html[data-theme="dark"] selector
-  // and are slightly modified for formatting purpose
-  // --docsearch-text-color: #f5f6f7;
-  --docsearch-container-background: rgba(9, 10, 17, .8);
-  --docsearch-modal-background: #15172a;
-  --docsearch-modal-shadow: inset 1px 1px 0 0 #2c2e40, 0 3px 8px 0 #000309;
-  // --docsearch-searchbox-background: #090a11;
-  // --docsearch-searchbox-focus-background: #000;
-  --docsearch-hit-color: #bec3c9;
-  --docsearch-hit-shadow: none;
-  --docsearch-hit-background: #090a11;
-  --docsearch-key-gradient: linear-gradient(-26.5deg, #565872, #31355b);
-  --docsearch-key-shadow: inset 0 -2px 0 0 #282d55, inset 0 0 1px 1px #51577d, 0 2px 2px 0 rgba(3, 4, 9, .3);
-  --docsearch-footer-background: #1e2136;
-  --docsearch-footer-shadow: inset 0 1px 0 0 rgba(73, 76, 106, .5), 0 -4px 8px 0 rgba(0, 0, 0, .2);
-  // --docsearch-muted-color: #7f8497;
-}
-
-.DocSearch-Button {
-  width: auto;
-  height: auto;
-  padding: var(--bs-nav-link-padding-y) 0;
-  margin-left: 0;
-  color: var(--bs-nav-link-color);
-  background-color: var(--bs-nav-link-bg);
+//
+// Custom Pagefind search UI w/ Bootstrap components
+//
+
+.bd-search {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+
+  // Below `md` the trigger is a borderless icon button
+  @include media-breakpoint-up(md) {
+    position: absolute;
+    top: .875rem;
+    left: 50%;
+    width: 240px;
+    margin-inline-start: -100px;
+  }
+
+  @include media-breakpoint-up(xl) {
+    width: 280px;
+    margin-inline-start: -140px;
+  }
+}
+
+// Trigger button
+//
+// Mobile (< md): icon-only button, no chrome.
+// md+:           expands to a faux input with label + ⌘K shortcut.
+
+.bd-search-trigger {
+  display: inline-flex;
+  gap: .5rem;
+  align-items: center;
+  padding: .375rem;
+  font-size: var(--bs-font-size-sm);
+  color: var(--bs-fg-3, var(--bs-secondary-color));
+  text-align: start;
+  cursor: pointer;
+  background-color: transparent;
+  border: 0;
+  @include border-radius(var(--bs-border-radius));
+  @include transition(color .15s ease-in-out, background-color .15s ease-in-out, border-color .15s ease-in-out);
 
   &:hover {
-    color: var(--bs-nav-link-hover-color);
-    background-color: var(--bs-nav-link-hover-bg);
-    box-shadow: none;
+    color: var(--bs-fg-body);
+  }
+
+  &:focus-visible {
+    @include focus-ring(true);
   }
 
-  @include media-breakpoint-up(lg) {
-    padding-inline: calc(var(--bs-spacer) * .5);
+  @include media-breakpoint-up(md) {
+    width: 100%;
+    padding: .375rem .5rem .375rem .75rem;
+    background-color: var(--bs-bg-1);
+    border: var(--bs-border-width) solid var(--bs-border-color);
+
+    &:hover {
+      background-color: var(--bs-bg-2);
+      border-color: var(--bs-border-color);
+    }
   }
 }
 
-.DocSearch-Search-Icon {
-  width: 16px;
-  height: 16px;
-  color: inherit !important; // stylelint-disable-line declaration-no-important
+.bd-search-trigger-icon {
+  flex-shrink: 0;
+  color: var(--bs-fg-3);
 }
 
-.DocSearch-Button-Placeholder {
+.bd-search-trigger-label,
+.bd-search-trigger-shortcut {
   display: none;
-  font-size: var(--bs-font-size-sm);
-  font-weight: var(--bs-font-weight-normal);
-  color: var(--bs-nav-link-color);
+
+  @include media-breakpoint-up(md) {
+    display: inline-flex;
+  }
 }
 
-.DocSearch-Button-Keys {
-  display: none;
+.bd-search-trigger-label {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.bd-search-trigger-shortcut {
+  flex-shrink: 0;
+  gap: .125rem;
+}
+
+.bd-search-trigger-key {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 1.25rem;
+  padding: 0 .25rem;
+  font-family: var(--bs-font-monospace);
+  font-size: .75rem;
+  color: var(--bs-fg-3, var(--bs-secondary-color));
+  background-color: var(--bs-bg-body);
+  border: var(--bs-border-width) solid var(--bs-border-color);
+  @include border-radius(var(--bs-border-radius-sm));
 }
 
-.DocSearch-Container {
-  --docsearch-muted-color: var(--bs-fg-3);
-  --docsearch-hit-shadow: none;
+// Search dialog shell
+//
+// Top-aligned overlay that grows up to the viewport. Uses the
+// existing `.dialog` token surface; we only override sizing and
+// position so the dialog feels like a docs-style command palette.
+
+.dialog-search {
+  --dialog-margin: 1.5rem;
+  --dialog-padding: .75rem;
+  --dialog-header-padding: .75rem;
+  --dialog-footer-padding: .5rem .75rem;
+  --dialog-border-radius: var(--border-radius-xl);
 
-  position: fixed;
-  z-index: 2000; // Make sure to be over all components showcased in the documentation
-  cursor: auto; // Needed because of [role="button"] in Algolia search modal. Remove once https://github.com/algolia/docsearch/issues/1370 is tackled.
+  align-self: flex-start;
+  width: 640px;
+  max-height: min(80dvh, 720px);
+  margin-block-start: 3rem;
+  overflow: hidden;
 
-  @include media-breakpoint-up(lg) {
-    padding-block-start: 4rem;
+  @include media-breakpoint-down(sm) {
+    max-height: calc(100dvh - 4rem);
   }
 }
 
-.DocSearch-Form {
-  @include border-radius(var(--bs-border-radius));
+//
+// Header / search form row
+//
+
+.bd-search-form {
+  display: flex;
+  flex-wrap: nowrap;
+  gap: .75rem;
+  align-items: center;
+  // padding: var(--dialog-header-padding);
+  padding: var(--spacer-6) var(--spacer-6) var(--spacer-4);
+  border-bottom: 0;
+}
+
+.bd-search-form-icon {
+  flex-shrink: 0;
+  color: var(--bs-fg-3);
+}
+
+bd-search-input {
+  width: 100%;
+}
+
+.bd-search-input {
+  flex: 1;
+  width: 100%;
+  min-width: 0;
+  background-color: transparent;
+  border-color: transparent;
+  box-shadow: none;
+
+  &:focus {
+    background-color: transparent;
+    border-color: transparent;
+    box-shadow: none;
+  }
+}
+
+.bd-search-close {
+  flex-shrink: 0;
+  margin-inline-start: 0;
+}
+
+//
+// Body / results
+//
+
+.bd-search-body {
+  padding: 0 var(--spacer-6) var(--spacer-6);
+}
+
+bd-search-results {
+  display: block;
+  min-height: 6rem;
+}
+
+.bd-search-results-list {
+  --bs-list-group-border-width: 0;
+  --bs-list-group-border-radius: 0;
+
+  gap: var(--spacer-2);
+  padding-block-start: var(--spacer-1);
+  list-style: none;
 }
 
-.DocSearch-Hits {
+.bd-search-result {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacer-1);
+  padding: 0;
+}
+
+// Default-active state: while focus is still in the search input (i.e. the
+// results list isn't `:focus-within`), the first result is visually
+// highlighted as the "Enter target", giving the dialog a command-palette
+// feel. Once the user arrow-keys into the list, `:focus-within` flips on,
+// the highlight drops, and the focus ring on the actually-focused row takes
+// over instead.
+bd-search-results:not(:focus-within) .bd-search-result:first-child > .bd-search-item {
+  @include focus-ring(true, var(--bs-accent-focus-ring));
+  --focus-ring-offset: -2px;
+  background-color: var(--bs-bg-1);
+}
+
+// Single template for every clickable row: top-level results, nested
+// subresults, and recently visited items. Variants only differ by icon
+// (`#file-earmark-richtext` vs `#hash`) and a `-sub` modifier that nudges
+// the title down to `font-size-sm`.
+.bd-search-item {
+  display: grid;
+  grid-template-columns: auto 1fr;
+  column-gap: .5rem;
+  padding: .75rem;
+  text-decoration: none;
+  @include border-radius(var(--bs-border-radius-lg));
+
+  &:hover {
+    background-color: var(--bs-bg-1);
+  }
+
+  &:focus-visible {
+    --focus-ring-offset: -2px;
+
+    z-index: 1;
+    @include focus-ring(true, var(--bs-accent-focus-ring));
+  }
+
+  &:not(.bd-search-item-sub) {
+    border: var(--border-width) solid var(--bs-border-color);
+  }
+
   mark {
     padding: 0;
+    font-weight: 600;
+    color: light-dark(var(--bs-indigo-600), var(--bs-indigo-300));
+    text-decoration: underline;
+    text-decoration-thickness: 1px;
+    text-decoration-color: light-dark(var(--bs-indigo-500), var(--bs-indigo-400));
+    text-underline-offset: 2px;
+    background-color: transparent;
   }
 }
 
-.DocSearch-Hit {
-  padding-block-end: 0;
-  @include border-radius(0);
+.bd-search-item-icon {
+  margin-block-start: .15rem;
+  color: var(--bs-fg-3);
+}
+
+.bd-search-item-title {
+  grid-column: 2;
+  font-weight: 600;
+  color: var(--bs-fg-body);
+}
+
+.bd-search-item-excerpt {
+  display: -webkit-box;
+  grid-column: 2;
+  overflow: hidden;
+  font-size: var(--bs-font-size-sm);
+  line-height: 1.4;
+  color: var(--bs-fg-2);
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+}
+
+.bd-search-item-sub {
+  padding-inline-start: var(--spacer-3);
+  background-color: color-mix(in lab, var(--bs-bg-1), var(--bs-bg-body));
 
-  a {
-    @include border-radius(0);
-    border: solid var(--bs-border-color);
-    border-width: 0 1px 1px;
+  .bd-search-item-title {
+    font-size: var(--bs-font-size-sm);
+    font-weight: 500;
   }
+}
+
+// Sub-results are nested inside their parent result row.
+.bd-search-subresults {
+  display: flex;
+  flex-direction: column;
+  gap: var(--spacer-1);
+  padding-inline-start: var(--spacer-6);
+}
 
-  &:first-child a {
-    @include border-top-radius(var(--bs-border-radius));
-    border-top-width: 1px;
+//
+// Recently visited
+//
+
+.bd-search-recent-header {
+  display: flex;
+  gap: .5rem;
+  align-items: center;
+  justify-content: space-between;
+  padding-block: .5rem;
+}
+
+.bd-search-recent-label {
+  font-size: var(--bs-font-size-xs);
+  font-weight: 600;
+  color: var(--bs-fg-3);
+  text-transform: uppercase;
+  letter-spacing: .04em;
+}
+
+.bd-search-recent-clear {
+  padding: .125rem .375rem;
+  font-size: var(--bs-font-size-xs);
+  color: var(--bs-fg-3);
+  cursor: pointer;
+  background-color: transparent;
+  border: 0;
+  @include border-radius(var(--bs-border-radius-sm));
+  @include transition(color .15s ease-in-out, background-color .15s ease-in-out);
+
+  &:hover {
+    color: var(--bs-fg-body);
+    background-color: var(--bs-bg-1);
   }
-  &:last-child a {
-    @include border-bottom-radius(var(--bs-border-radius));
+
+  &:focus-visible {
+    @include focus-ring(true);
   }
 }
 
-.DocSearch-Hit-icon {
+//
+// Empty / loading / error states
+//
+
+.bd-search-empty,
+.bd-search-error {
+  padding: 2rem 1rem;
+  font-size: var(--bs-font-size-sm);
+  color: var(--bs-fg-3);
+  text-align: center;
+}
+
+.bd-search-error {
+  color: var(--bs-danger-text-emphasis);
+}
+
+.bd-search-loading {
   display: flex;
-  align-items: center;
+  flex-direction: column;
+  gap: .25rem;
+  padding: 0;
 }
 
-// Fix --docsearch-logo-color that doesn't do anything
-.DocSearch-Logo svg .cls-1,
-.DocSearch-Logo svg .cls-2 {
-  fill: var(--docsearch-logo-color);
+.bd-search-skeleton {
+  display: flex;
+  flex-direction: column;
+  gap: .375rem;
+  padding: .75rem 1rem;
+
+  & + & {
+    border-block-start: var(--bs-border-width) solid var(--bs-border-color);
+  }
+
+  .placeholder {
+    display: block;
+    height: .65rem;
+    @include border-radius(var(--bs-border-radius-sm));
+  }
+
+  .placeholder.col-4 {
+    height: .85rem;
+  }
 }
index d724f0bac4ca68885a95d8ebd109a1d8ef769bad..ddead0fc66448b38cc16c97da26aeb8ea472b041 100644 (file)
@@ -7,5 +7,8 @@
  * For details, see https://creativecommons.org/licenses/by/3.0/.
  */
 
-@use "@docsearch/css/dist/style";
+// Custom search UI styling. We no longer ship Pagefind's vendored
+// component CSS — the search experience is built from Bootstrap
+// primitives (.dialog, .form-control, .list-group) and styled in
+// `_search.scss`.
 @use "search";
index d1fe9c1010264ac781e7fb895c6567c176162dcd..cbedd7d8e3711fa4503a340fbe84b05c20549bbe 100644 (file)
@@ -16,7 +16,7 @@
       return storedTheme
     }
 
-    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
+    return 'auto'
   }
 
   const setTheme = theme => {