diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 09e02743b4..fda9515e47 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,7 +8,7 @@ on: workflow_dispatch: jobs: - build_and_deploy: + build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -44,13 +44,27 @@ jobs: - name: "Sync Donor Info" run: cd content/donate && ./sync_donors.sh - - name: "Build and deploy website" - if: github.repository_owner == 'bevyengine' + - name: "Build Website" uses: shalzz/zola-deploy-action@v0.19.2 env: - PAGES_BRANCH: gh-pages + BUILD_ONLY: true BUILD_DIR: . - TOKEN: ${{ secrets.CART_PAT }} + + - name: "Search (1/3): Setup Node.js" + uses: actions/setup-node@v4 + + # See: https://github.com/CloudCannon/pagefind/issues/675#issuecomment-2267210534 + - name: "Search (2/3): Change `public` Permissions" + run: echo "$(whoami):$(id -gn)" | xargs -I {} sudo chown -R {} public + + - name: "Search (3/3): Build Index" + run: npx --yes pagefind --output-path public/pagefind + + - name: Upload GitHub Pages Artifact + id: deployment + uses: actions/upload-pages-artifact@v3 + with: + path: public/ # Caches output of generate-assets for use in ci.yml - name: Update generate-assets cache @@ -58,3 +72,19 @@ jobs: with: path: content/assets key: assets-${{ steps.date.outputs.date }}-${{ hashFiles('generate-assets/**/*.rs', 'generate-assets/Cargo.toml', 'generate-assets/generate_assets.sh') }} + + deploy: + if: github.repository_owner == 'bevyengine' + runs-on: ubuntu-latest + needs: build + permissions: + pages: write + id-token: write + environment: + name: github-pages + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 + with: + token: ${{ secrets.CART_PAT }} diff --git a/.gitignore b/.gitignore index b428d603e0..f8d13c1f6c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ content/community/people/ content/community/donate/ static/assets/examples/ static/processed_images/ +static/pagefind/ # Tools target/ diff --git a/README.md b/README.md index 1bcbd06620..472e7ced78 100644 --- a/README.md +++ b/README.md @@ -21,3 +21,29 @@ A local server should start and you should be able to access a local version of ### Assets, Errors, and Examples pages These pages need to be generated in a separate step by running the shell scripts in the `generate-assets`, `generate-errors`, and `generate-wasm-examples` directories. On Windows, you can use [WSL](https://learn.microsoft.com/en-us/windows/wsl/install) or [git bash](https://gitforwindows.org/). + +## Search Index + +We use [Pagefind](https://pagefind.app) for the search functionality. + +### Configuration + +Which pages to index, category assignment and the weight is configured in `/templates/macros/pagefind.html`. +Note that the category names must be reflected when instancing `SearchCategories` in `/static/search.mjs`. + +Pagefind can be tweaked (ignore content, change weights…) at page and element level by using `data-pagefind-*` HTML attributes. + +The default configuration for the Pagefind CLI is defined in `/pagefind.yml`. + +### Local Development + +To generate the index for local development, download the [Pagefind binary](https://github.com/CloudCannon/pagefind/releases) in the project root and the run: + +```sh +rm -rf public +zola build +./pagefind --write-playground +``` + +This will create the `/static/pagefind` folder which contains the index and JS library. +You can access `http://127.0.0.1:1111/pagefind/playground/` to debug search issues. diff --git a/pagefind.yml b/pagefind.yml new file mode 100644 index 0000000000..fb309bc7cb --- /dev/null +++ b/pagefind.yml @@ -0,0 +1,6 @@ +site: public +output_path: static/pagefind +exclude_selectors: + - ".tree-menu" + - ".example__back" + - ".example__github" diff --git a/sass/_mixins.scss b/sass/_mixins.scss index 484bab4396..368fa4f16e 100644 --- a/sass/_mixins.scss +++ b/sass/_mixins.scss @@ -1,3 +1,19 @@ +@mixin backdrop($z-index) { + visibility: hidden; + position: fixed; + inset: 0px; + z-index: $z-index; + background-color: var(--backdrop-color); + cursor: pointer; + opacity: 0.0; + transition: opacity $duration * 2; +} + +@mixin backdrop-visible() { + visibility: visible; + opacity: 1.0; +} + @mixin card() { display: block; overflow: hidden; diff --git a/sass/_utils.scss b/sass/_utils.scss index bc423240f4..4876fedf4e 100644 --- a/sass/_utils.scss +++ b/sass/_utils.scss @@ -1,5 +1,5 @@ .hidden { - display: none; + display: none !important; } // Visual outline on focused elements, for accessibility diff --git a/sass/_vars.scss b/sass/_vars.scss index 90cebe881a..5f5007ed01 100644 --- a/sass/_vars.scss +++ b/sass/_vars.scss @@ -1,3 +1,5 @@ +@use "sass:color"; + // Typography $size-body: 16px; // Usual browser default size $size-body-mobile: 14px; // Size for mobile @@ -25,6 +27,8 @@ $duration: 250ms; $z-layout-header: 800; $z-main-menu-backdrop: 900; $z-main-menu: 1000; +$z-search-backdrop: 1100; +$z-search: 1200; // General $max-width: 1200px; @@ -42,13 +46,45 @@ $border-radius: 10px; // the `sass:color` package to derive new colors. If a color is not going to be // used to derive a new color, then CSS vars should be used instead. // -// See PR: https://github.com/bevyengine/bevy-website/pull/1953 // See Relative Colors support: https://caniuse.com/css-relative-colors +// +// To revert: +// - Original PR: https://github.com/bevyengine/bevy-website/pull/1953 +// - `.search-category`: https://github.com/bevyengine/bevy-website/pull/1935/commits/30d94f6e9316b03d6741fbdfb4ea493f06485482 +// - `.search-result`: https://github.com/bevyengine/bevy-website/pull/1935/commits/0e0069784f93ab27273384ced13833657c73459e $color-neutral-17: #2c2c2d; +$color-neutral-40: #666; $color-neutral-93: #ececec; $color-black: #000; $color-white: #fff; +$color-dark-search-category-default-bg: $color-neutral-40; +$color-dark-search-category-book-bg: #537134; +$color-dark-search-category-examples-bg: #885d26; +$color-dark-search-category-migrations-bg: #2b5e77; +$color-dark-search-category-errors-bg: #953562; +$color-dark-search-category-news-bg: #2c5580; +$color-dark-search-category-contribute-bg: #78527e; + +$color-light-search-category-book-bg: #5b8b2c; +$color-light-search-category-examples-bg: #b96f0e; +$color-light-search-category-migrations-bg: #455f6b; +$color-light-search-category-news-bg: #2c65a2; + +// TODO: Remove when CSS Relative Colors are available +@mixin hack-search-category-computed-colors($category, $color) { + --search-category-#{$category}-bg-hover-color: #{color.adjust($color, $lightness: 4%)}; + --search-category-#{$category}-border-color: #{color.adjust($color, $lightness: 10%)}; + --search-category-#{$category}-text-color: #{color.change($color, $lightness: 90%)}; +} + +// TODO: Remove when CSS Relative Colors are available, check: `_search-category.scss` +@mixin hack-search-category-modifier-vars($category) { + --bg-hover-color: var(--search-category-#{$category}-bg-hover-color); + --border-color: var(--search-category-#{$category}-border-color); + --text-color: var(--search-category-#{$category}-text-color); +} + // CSS Vars :root { // Typography @@ -67,7 +103,7 @@ $color-white: #fff; --color-neutral-33: #555; --color-neutral-33b: #535353; --color-neutral-36: #59595e; - --color-neutral-40: #666; + --color-neutral-40: #{$color-neutral-40}; --color-neutral-42: #6b6b6b; --color-neutral-45: #737373; --color-neutral-50: #808080; @@ -141,6 +177,8 @@ $color-white: #fff; --asset-version-select-border-color: var(--color-neutral-22); --asset-version-select-border-hover-color: var(--color-neutral-42); + --backdrop-color: #{rgba($color-black, 0.2)}; + --bevy-instance-canvas-color: var(--color-neutral-18); --bevy-instance-text-shadow-color: var(--color-black); --bevy-instance-progress-track-color: var(--color-neutral-33); @@ -165,6 +203,8 @@ $color-white: #fff; --button-pink-bg-hover-color: #954c72; --button-pink-border-color: #ba789b; --button-pink-text-color: var(--color-neutral-93); + + --button-square-color: var(--color-neutral-93); --button-square-bg-color: #{rgba($color-neutral-93, 0.05)}; --callout-caution-accent-color: #e82f5a; @@ -329,6 +369,44 @@ $color-white: #fff; --people-role-maintainer-color: rgb(242, 103, 255); --people-role-sme-color: rgb(80, 200, 50); + --search-bg-color: var(--color-neutral-13); + --search-clear-icon-color: var(--color-neutral-93); + --search-border-color: var(--color-neutral-17); + --search-input-bg-color: var(--color-neutral-18); + --search-input-border-color: var(--color-neutral-22); + --search-input-color: var(--color-neutral-93); + --search-no-results-color: var(--color-neutral-75); + --search-tip-color: var(--color-neutral-67); + + --search-category-default-bg-color: #{$color-dark-search-category-default-bg}; + --search-category-default-bg-hover-color: #{rgba($color-white, 0.05)}; + --search-category-border-color: var(--color-neutral-27); + --search-category-text-color: var(--color-neutral-67); + --search-category-text-hover-color: var(--color-white); + --search-category-book-bg-color: #{$color-dark-search-category-book-bg}; + --search-category-examples-bg-color: #{$color-dark-search-category-examples-bg}; + --search-category-migrations-bg-color: #{$color-dark-search-category-migrations-bg}; + --search-category-errors-bg-color: #{$color-dark-search-category-errors-bg}; + --search-category-news-bg-color: #{$color-dark-search-category-news-bg}; + --search-category-contribute-bg-color: #{$color-dark-search-category-contribute-bg}; + + --search-category-default-border-color: #{color.adjust($color-dark-search-category-default-bg, $lightness: 10%)}; + --search-category-default-text-color: #{color.change($color-dark-search-category-default-bg, $lightness: 90%)}; + @include hack-search-category-computed-colors(book, $color-dark-search-category-book-bg); + @include hack-search-category-computed-colors(examples, $color-dark-search-category-examples-bg); + @include hack-search-category-computed-colors(migrations, $color-dark-search-category-migrations-bg); + @include hack-search-category-computed-colors(errors, $color-dark-search-category-errors-bg); + @include hack-search-category-computed-colors(news, $color-dark-search-category-news-bg); + @include hack-search-category-computed-colors(contribute, $color-dark-search-category-contribute-bg); + + --search-result-bg-color: #{rgba($color-black, 0.15)}; + --search-result-bg-hover-color: #{rgba($color-white, 0.04)}; + --search-result-title-color: var(--color-white); + --search-result-hash-sign-color: var(--color-neutral-75); + --search-result-excerpt-color: var(--color-neutral-53); + --search-result-excerpt-term-bg-color: var(--color-white); + --search-result-excerpt-term-color: var(--color-neutral-82); + --scrollbar-thumb-color: #{rgba($color-white, 0.2)}; --sponsors-name-color: var(--color-neutral-59); @@ -470,6 +548,36 @@ $color-white: #fff; --scrollbar-thumb-color: #{rgba($color-black, 0.2)}; + --search-bg-color: var(--color-neutral-93); + --search-clear-icon-color: var(--color-neutral-13); + --search-border-color: var(--color-neutral-69); + --search-input-bg-color: var(--color-neutral-97); + --search-input-border-color: var(--color-neutral-82); + --search-input-color: var(--color-neutral-13); + --search-no-results-color: var(--color-neutral-40); + --search-tip-color: var(--color-neutral-40); + + --search-category-default-bg-hover-color: #{rgba($color-black, 0.05)}; + --search-category-border-color: var(--color-neutral-67); + --search-category-text-color: var(--color-neutral-33); + --search-category-text-hover-color: var(--color-black); + --search-category-book-bg-color: #{$color-light-search-category-book-bg}; + --search-category-examples-bg-color: #{$color-light-search-category-examples-bg}; + --search-category-migrations-bg-color: #{$color-light-search-category-migrations-bg}; + --search-category-news-bg-color: #{$color-light-search-category-news-bg}; + + @include hack-search-category-computed-colors(book, $color-light-search-category-book-bg); + @include hack-search-category-computed-colors(examples, $color-light-search-category-examples-bg); + @include hack-search-category-computed-colors(migrations, $color-light-search-category-migrations-bg); + @include hack-search-category-computed-colors(news, $color-light-search-category-news-bg); + + --search-result-bg-color: #{rgba($color-white, 0.15)}; + --search-result-bg-hover-color: #{rgba($color-black, 0.04)}; + --search-result-title-color: var(--color-black); + --search-result-hash-sign-color: var(--color-neutral-50); + --search-result-excerpt-term-bg-color: var(--color-black); + --search-result-excerpt-term-color: var(--color-neutral-33); + --table-border-color: var(--color-neutral-75); --table-header-bg-color: var(--color-neutral-82); --table-header-color: var(--color-neutral-22); diff --git a/sass/components/_button-square.scss b/sass/components/_button-square.scss index 1120662fd7..042bb52537 100644 --- a/sass/components/_button-square.scss +++ b/sass/components/_button-square.scss @@ -3,6 +3,12 @@ position: relative; cursor: pointer; + color: var(--button-square-color); + + * { + // Don't capture events on sub-elements + pointer-events: none; + } &:hover { &:before { diff --git a/sass/components/_header.scss b/sass/components/_header.scss index 041293e572..23b5eb6760 100644 --- a/sass/components/_header.scss +++ b/sass/components/_header.scss @@ -64,13 +64,17 @@ &__cta { flex-shrink: 0; - &:not(:last-child) { - margin-right: 8px; + &--search { + @media #{$bp-phone-landscape-down} { + display: none; + } } &--github { @include flex-center; + margin-left: 12px; + img { height: 30px; width: auto; diff --git a/sass/components/_icon.scss b/sass/components/_icon.scss index a1be364d98..296c34eb0f 100644 --- a/sass/components/_icon.scss +++ b/sass/components/_icon.scss @@ -20,6 +20,7 @@ ("chevron-right", 9), ("github", 24), ("pencil", 19), + ("search", 24), ("times", 16), ); diff --git a/sass/components/_main-menu-backdrop.scss b/sass/components/_main-menu-backdrop.scss index 7bb7f89f2b..b0797b13ea 100644 --- a/sass/components/_main-menu-backdrop.scss +++ b/sass/components/_main-menu-backdrop.scss @@ -1,20 +1,9 @@ .main-menu-backdrop { - visibility: hidden; - position: fixed; - top: 0px; - left: 0px; - width: 100%; - height: 100%; - z-index: $z-main-menu-backdrop; - background-color: var(--main-menu-backdrop-color); - cursor: pointer; - opacity: 0; - transition: opacity $duration * 2; + @include backdrop($z-main-menu-backdrop); } @include state-checked("mobile-menu") { .main-menu-backdrop { - visibility: visible; - opacity: 1; + @include backdrop-visible(); } } diff --git a/sass/components/_main-menu.scss b/sass/components/_main-menu.scss index e425775fbb..615ab9aece 100644 --- a/sass/components/_main-menu.scss +++ b/sass/components/_main-menu.scss @@ -82,7 +82,6 @@ &__header { display: flex; align-items: center; - justify-content: space-between; padding-left: $padding; height: var(--layout-header-height); background-color: var(--layout-header-bg-color); @@ -94,6 +93,10 @@ } } + &__logo { + margin-right: auto; + } + &__page-menu-switch { display: none; padding: $padding $padding 0 $padding; diff --git a/sass/components/_search-category.scss b/sass/components/_search-category.scss new file mode 100644 index 0000000000..8b220a1bea --- /dev/null +++ b/sass/components/_search-category.scss @@ -0,0 +1,80 @@ +.search-category { + --search-category-bg-color: var(--search-category-default-bg-color); + @include hack-search-category-modifier-vars(default); + + background-color: transparent; + border: 1px solid var(--search-category-border-color); + font-size: calc-rem(18px); + padding: 4px 8px; + border-radius: 8px; + cursor: pointer; + user-select: none; + color: var(--search-category-text-color); + + &:hover { + color: var(--search-category-text-hover-color); + background-color: var(--search-category-default-bg-hover-color); + // background-color: #{unquote('rgb(from var(--search-category-default-bg-hover-color) r g b / 0.05)')}; + } + + &--quickstart, + &--book { + --search-category-bg-color: var(--search-category-book-bg-color); + @include hack-search-category-modifier-vars(book); + } + + &--examples { + --search-category-bg-color: var(--search-category-examples-bg-color); + @include hack-search-category-modifier-vars(examples); + } + + &--migrations { + --search-category-bg-color: var(--search-category-migrations-bg-color); + @include hack-search-category-modifier-vars(migrations); + } + + &--errors { + --search-category-bg-color: var(--search-category-errors-bg-color); + @include hack-search-category-modifier-vars(errors); + } + + &--news { + --search-category-bg-color: var(--search-category-news-bg-color); + @include hack-search-category-modifier-vars(news); + } + + &--contribute { + --search-category-bg-color: var(--search-category-contribute-bg-color); + @include hack-search-category-modifier-vars(contribute); + } + + &--active { + // --text-color: #{unquote("hsl(from var(--search-category-bg-color) h s 90)")}; + + background-color: var(--search-category-bg-color); + color: var(--text-color); + border-color: var(--border-color); + // border-color: unquote( + // "hsl(from var(--search-category-bg-color) h s calc(l + 10))" + // ); + + &:hover { + color: var(--text-color); + background-color: var(--bg-hover-color); + // background-color: unquote( + // "hsl(from var(--search-category-bg-color) h s calc(l + 4))" + // ); + } + } + + &--compact { + padding: 2px 4px; + font-size: calc-rem(14px); + cursor: default; + border-radius: 4px; + + &:hover { + background-color: var(--search-category-bg-color); + } + } +} diff --git a/sass/components/_search-result.scss b/sass/components/_search-result.scss new file mode 100644 index 0000000000..f87ca87829 --- /dev/null +++ b/sass/components/_search-result.scss @@ -0,0 +1,108 @@ +.search-result { + display: block; + background-color: var(--search-result-bg-color); + border-radius: 8px; + padding: 8px; + + &--compact { + text-decoration: none; + + &:focus { + outline: var(--focus-outline); + } + + &:hover { + background-color: var(--search-result-bg-hover-color); + } + } + + &__header { + display: flex; + align-items: start; + justify-content: space-between; + gap: 8px; + margin-bottom: 4px; + } + + &__title, + &__sub-title { + @include override-anchor { + color: var(--search-result-title-color); + } + + display: block; + margin: 0; + font-weight: 500; + word-break: break-word; + } + + &__title { + font-size: calc-rem(24px); + } + + &__sub-item { + border-radius: 8px; + text-decoration: none; + padding: 4px 6px; + position: relative; + + &:focus { + outline: var(--focus-outline); + } + + &:before { + content: "#"; + position: absolute; + top: 4px; + left: -16px; + font-size: calc-rem(20px); + font-weight: 400; + color: var(--search-result-hash-sign-color); + } + + &:hover { + background-color: var(--search-result-bg-hover-color); + } + } + + &__sub-title { + font-size: calc-rem(20px); + font-weight: 400; + } + + &__category { + flex-shrink: 0; + } + + &__sub-results { + display: flex; + flex-direction: column; + margin: 8px 0 0; + padding-left: 24px; + + li { + list-style: disclosure-closed; + } + } + + &__excerpt { + @include text-ellipsis(2); + margin: 4px 0 0; + color: var(--search-result-excerpt-color); + font-size: calc-rem(16px); + line-height: 1.4; + + mark { + padding: 2px 4px; + background-color: #{unquote('rgb(from var(--search-result-excerpt-term-bg-color) r g b / 0.1)')}; + color: var(--search-result-excerpt-term-color); + border-radius: 4px; + font-size: 0.95em; + } + } + + &__more { + font-size: calc-rem(18px); + padding: 16px 28px 8px; + } +} diff --git a/sass/components/_search.scss b/sass/components/_search.scss new file mode 100644 index 0000000000..4795aae562 --- /dev/null +++ b/sass/components/_search.scss @@ -0,0 +1,136 @@ +.search { + &--searching { + .search__header, + .search__content { + opacity: 0.5; + pointer-events: none; + } + } + + &--visible { + .search__dialog { + display: flex; + } + + .search__backdrop { + @include backdrop-visible(); + } + } + + &__backdrop { + @include backdrop($z-search-backdrop); + backdrop-filter: blur(8px); + } + + &__dialog { + --search-dialog-v-padding: 80px; + + display: none; + flex-direction: column; + width: calc(100% - 24px); + max-width: 800px; + max-height: calc(100% - var(--search-dialog-v-padding) * 2); + overflow: hidden; + border-radius: 8px; + border: 2px solid var(--search-border-color); + position: fixed; + top: var(--search-dialog-v-padding); + left: 50%; + transform: translateX(-50%); + z-index: $z-search; + background-color: var(--search-bg-color); + box-shadow: 0px 16px 40px rgba(#000, 0.35); + + @media #{$bp-tablet-landscape-up} { + --search-dialog-v-padding: 160px; + } + } + + &__header { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border-bottom: 1px solid var(--search-border-color); + } + + &__header-top { + display: flex; + gap: 8px; + } + + &__categories { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + } + + &__input-wrapper { + position: relative; + display: flex; + width: 100%; + } + + &__input { + width: 100%; + display: block; + border-radius: 8px; + border: 2px solid var(--search-input-border-color); + background-color: var(--search-input-bg-color); + text-decoration: none; + font-size: 1.2rem; + padding: 0.4rem; + color: var(--search-input-color); + } + + &__clear-filter { + @include flex-center; + + position: absolute; + right: 0; + top: 0; + bottom: 0; + width: 40px; + cursor: pointer; + font-size: 16px; + color: var(--search-clear-icon-color); + background-color: transparent; + border-radius: 8px; + border: 0; + } + + &__close { + cursor: pointer; + } + + &__content { + overflow-x: hidden; + overflow-y: auto; + padding: 12px; + } + + &__results { + display: flex; + flex-direction: column; + gap: 8px; + } + + &__no-results { + @include flex-center; + + min-height: 100px; + text-align: center; + word-break: break-word; + font-size: calc-rem(20px); + color: var(--search-no-results-color); + } + + &__tip { + color: var(--search-tip-color); + border-top: 1px solid var(--search-border-color); + padding: 6px 12px; + font-size: calc-rem(14px); + font-weight: 300; + } +} diff --git a/sass/elements/_kbd.scss b/sass/elements/_kbd.scss index 8e93b2658f..5150db6d30 100644 --- a/sass/elements/_kbd.scss +++ b/sass/elements/_kbd.scss @@ -1,9 +1,12 @@ kbd { - font-size: 0.8rem; - padding: 0.1rem; + font-family: var(--font-family-mono); + font-size: 0.9em; + padding: 0.2em; + margin-inline: 0.1em; line-height: 1; - border-color: var(--kbd-border-color); - border-radius: 0.3rem; - border-style: solid; - border-width: 1px; + border-radius: 0.3em; + border: 1px solid var(--kbd-border-color); + min-width: 2.5ch; + display: inline-block; + text-align: center; } diff --git a/sass/site.scss b/sass/site.scss index e4cd932ae8..b919da7cc0 100644 --- a/sass/site.scss +++ b/sass/site.scss @@ -48,6 +48,9 @@ @import "components/on-this-page"; @import "components/page-with-menu"; @import "components/pr-list"; +@import "components/search"; +@import "components/search-category"; +@import "components/search-result"; @import "components/sponsors"; @import "components/syntax-theme"; @import "components/themed-picture"; diff --git a/static/assets/icon-search.svg b/static/assets/icon-search.svg new file mode 100644 index 0000000000..2db09706b3 --- /dev/null +++ b/static/assets/icon-search.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/optional-helpers.js b/static/optional-helpers.js index b39b0a2a25..df88512ec2 100644 --- a/static/optional-helpers.js +++ b/static/optional-helpers.js @@ -1,16 +1,23 @@ // these helpers should be totally optional. if someone disables this javascript in their browser, // core functionality should not be affected. +import { isSearchOpen } from "./search.mjs"; + +function navigateDocsNext(/** @type {KeyboardEvent} */ e) { + if (isSearchOpen()) { + return; + } -function navigateDocsNext(e) { if (e.key == 'ArrowLeft') { - var previous = document.querySelector("[data-docs-nav-previous]"); - if (previous) { + const previous = document.querySelector("[data-docs-nav-previous]"); + + if (previous instanceof HTMLAnchorElement) { previous.click(); } } else if (e.key == 'ArrowRight') { - var next = document.querySelector("[data-docs-nav-next]"); - if (next) { + const next = document.querySelector("[data-docs-nav-next]"); + + if (next instanceof HTMLAnchorElement) { next.click(); } } diff --git a/static/search-categories.mjs b/static/search-categories.mjs new file mode 100644 index 0000000000..9724ab6b46 --- /dev/null +++ b/static/search-categories.mjs @@ -0,0 +1,93 @@ +// @ts-check +/** @import {Category} from "./search.mjs" */ + +/** + * @returns string + */ +function getCategoryId(/** @type {string} */ category) { + return category.toLowerCase().replace(/\s+/g, ""); +} + +class SearchCategories { + /** @private @readonly */ + STORAGE_KEY = "bevy-search-categories"; + + constructor( + /** @type {string[]} */ pagefindCategories, + /** @type {string[]} */ categoriesOrder, + /** @type {string[]} */ defaultCheckedCategories + ) { + // Log warnings if order/checked categories don't match with the Pagefind categories + [...categoriesOrder, ...defaultCheckedCategories].forEach((category) => { + if (!pagefindCategories.includes(category)) { + console.warn( + `Category "${category}" not found in Pagefind search index.` + ); + } + }); + + /** @private @readonly @property {string[]}*/ + this.order = categoriesOrder; + + const checkedCategories = this.getInitialCheckedCategories( + defaultCheckedCategories + ); + + /** @private @readonly @property {Record}*/ + this.categories = Object.fromEntries( + pagefindCategories.map((name) => { + const id = getCategoryId(name); + return [id, { id, name, checked: checkedCategories.includes(name) }]; + }) + ); + } + + /** + * @returns {Category[]} + */ + getSorted() { + // Sort by `order` or by `category` name ASC if not found + return Object.values(this.categories).sort((a, b) => { + const aIndex = this.order.indexOf(a.name); + const bIndex = this.order.indexOf(b.name); + + return ( + (aIndex === -1 ? Infinity : aIndex) - + (bIndex === -1 ? Infinity : bIndex) || a.name.localeCompare(b.name) + ); + }); + } + + /** + * @private + * @returns {string[]} + */ + getInitialCheckedCategories(/** @type {string[]} */ fallback) { + try { + // Load categories status from localStorage + const savedCategoriesRaw = localStorage.getItem(this.STORAGE_KEY); + + if (savedCategoriesRaw) { + const savedCategories = JSON.parse(savedCategoriesRaw); + return Object.values(savedCategories) + .filter(({ checked }) => checked) + .map(({ name }) => name); + } + } catch {} + + return fallback; + } + + /** + * @returns {boolean} + */ + toggle(/** @type {string} */ id) { + const category = this.categories[id]; + category.checked = !category.checked; + localStorage.setItem(this.STORAGE_KEY, JSON.stringify(this.categories)); + + return category.checked; + } +} + +export { getCategoryId, SearchCategories }; diff --git a/static/search-dialog.mjs b/static/search-dialog.mjs new file mode 100644 index 0000000000..4892fe5606 --- /dev/null +++ b/static/search-dialog.mjs @@ -0,0 +1,334 @@ +// @ts-check +/** @import {Pagefind} from "./search.mjs" */ +/** @import {SearchCategories} from "./search-categories.mjs" */ +/** @import {SearchTpl} from "./search-tpl.mjs" */ +import { debounce } from "/tools.js"; + +class SearchDialog { + /** @private @readonly */ + CLASS_SEARCHING = "search--searching"; + /** @private @readonly */ + CLASS_VISIBLE = "search--visible"; + + // Up/down arrows navigation + /** @private @type {HTMLElement[] | undefined} */ + results = undefined; + /** @private @type {HTMLElement | undefined} */ + activeResult = undefined; + + constructor( + /** @type {Pagefind} */ pagefind, + /** @type {SearchCategories} */ categories, + /** @type {HTMLElement} */ searchEl, + /** @type {HTMLElement} */ backdropEl, + /** @type {HTMLElement} */ dialogEl, + /** @type {HTMLElement} */ categoriesEl, + /** @type {HTMLElement} */ contentEl, + /** @type {HTMLElement} */ resultsEl, + /** @type {HTMLElement} */ noResultsEl, + /** @type {HTMLElement} */ closeEl, + /** @type {HTMLInputElement} */ inputEl, + /** @type {HTMLElement} */ clearFilterEl, + /** @type {SearchTpl} */ searchTpl + ) { + /** @private @readonly @property {Pagefind} */ + this.pagefind = pagefind; + /** @private @readonly @property {SearchCategories} */ + this.categories = categories; + /** @private @readonly @property {HTMLElement} */ + this.searchEl = searchEl; + /** @private @readonly @property {HTMLElement} */ + this.backdropEl = backdropEl; + /** @private @readonly @property {HTMLElement} */ + this.dialogEl = dialogEl; + /** @private @readonly @property {HTMLElement} */ + this.categoriesEl = categoriesEl; + /** @private @readonly @property {HTMLElement} */ + this.contentEl = contentEl; + /** @private @readonly @property {HTMLElement} */ + this.resultsEl = resultsEl; + /** @private @readonly @property {HTMLElement} */ + this.noResultsEl = noResultsEl; + /** @private @readonly @property {HTMLElement} */ + this.closeEl = closeEl; + /** @private @readonly @property {HTMLInputElement} */ + this.inputEl = inputEl; + /** @private @readonly @property {HTMLElement} */ + this.clearFilterEl = clearFilterEl; + /** @private @readonly @property {SearchTpl} */ + this.searchTpl = searchTpl; + + // Init Categories + this.categories.getSorted().forEach((category) => { + this.categoriesEl.appendChild(this.searchTpl.createCategoryEl(category)); + }); + + this.categoriesEl.addEventListener("click", (event) => { + if (event.target instanceof HTMLElement) { + const categoryId = event.target.dataset.category; + + if (categoryId) { + const checked = this.categories.toggle(categoryId); + event.target.classList.toggle("search-category--active", checked); + this.search(); + } + } + }); + + // Setup global event listeners + window.addEventListener("keydown", (event) => { + // Close with `Escape` + if (event.code === "Escape" && this.isOpen()) { + event.stopPropagation(); + event.preventDefault(); + this.hide(); + } + + // Results keyboard navigations + if (event.code === "ArrowDown" && this.results) { + this.focusNext(); + event.preventDefault(); + } + + if (event.code === "ArrowUp" && this.results) { + this.focusPrevious(); + event.preventDefault(); + } + + // Open with `S` + const isSomeTextInputFocused = + document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement; + + if (event.code === "KeyS" && !isSomeTextInputFocused) { + event.stopPropagation(); + event.preventDefault(); + + if (!this.isOpen()) { + this.show(); + } else { + this.inputEl.focus(); + this.inputEl.select(); + } + } + }); + + // Same page navigation should close the dialog + this.dialogEl.addEventListener("click", (event) => { + if ( + event.target instanceof HTMLAnchorElement && + event.target.hasAttribute("data-search-result") + ) { + const maybeUrl = event.target.getAttribute("href"); + + if (maybeUrl) { + const newPath = maybeUrl.split("#")[0]; + + if (window.location.pathname === newPath) { + this.hide(); + } + } + } + }); + + this.inputEl.addEventListener( + "input", + debounce(() => this.search(), 500) + ); + + this.clearFilterEl.addEventListener("click", () => this.clearInput()); + this.closeEl.addEventListener("click", () => this.hide()); + this.backdropEl.addEventListener("click", () => this.hide()); + + // Open the dialog when any "trigger" element is clicked + window.addEventListener("click", (event) => { + if ( + event.target instanceof HTMLElement && + event.target.hasAttribute("data-search-open") + ) { + this.show(); + } + }); + } + + /** + * @private + * @returns {void} + */ + clearInput() { + this.inputEl.value = ""; + this.inputEl.focus(); + this.clearFilterEl.classList.add("hidden"); + this.resetContentState(true); + } + + /** + * @private + * @returns {void} + */ + focusNext() { + if (!this.results) { + return; + } + + if (!this.activeResult) { + this.activeResult = this.results[0]; + } else { + const idx = this.results.indexOf(this.activeResult); + const next = this.results[idx + 1]; + + if (next) { + this.activeResult = next; + } else { + this.activeResult = this.results[0]; + } + } + + this.activeResult.focus(); + } + + /** + * @private + * @returns {void} + */ + focusPrevious() { + if (!this.results) { + return; + } + + if (!this.activeResult) { + this.activeResult = this.results[this.results.length - 1]; + } else { + const idx = this.results.indexOf(this.activeResult); + const prev = this.results[idx - 1]; + + if (prev) { + this.activeResult = prev; + } else { + this.activeResult = this.results[this.results.length - 1]; + } + } + + this.activeResult.focus(); + } + + /** + * @returns {void} + */ + hide() { + this.searchEl.classList.remove(this.CLASS_VISIBLE); + } + + /** + * @returns {boolean} + */ + isOpen() { + return this.searchEl.classList.contains(this.CLASS_VISIBLE); + } + + /** + * @private + * @returns {Promise} + */ + async search() { + const term = this.inputEl.value.trim(); + + this.clearFilterEl.classList.toggle("hidden", term === ""); + + if (term === "") { + this.resetContentState(true); + return; + } + + let caretPosition = this.inputEl.selectionStart; + + try { + this.searchEl.classList.add(this.CLASS_SEARCHING); + this.inputEl.disabled = true; + + const { results: allResults } = await this.pagefind.search(term, { + filters: { + any: this.categories + .getSorted() + .filter((category) => category.checked) + .map(({ name }) => ({ category: name })), + }, + }); + + // Reduce results set + const maxResults = 20; + const results = allResults.slice(0, maxResults); + + if (results.length > 0) { + await this.showResults(results); + } else { + this.showNoResults(term); + } + } catch (err) { + console.error(`Failed to search for term: "${term}"`, err); + } finally { + this.searchEl.classList.remove(this.CLASS_SEARCHING); + this.inputEl.disabled = false; + this.inputEl.focus(); + this.inputEl.setSelectionRange(caretPosition, caretPosition); + } + } + + /** + * @private + * @returns {void} + */ + resetContentState(/** @type {boolean} */ hideContentWrapper = false) { + this.contentEl.classList.toggle("hidden", hideContentWrapper); + this.contentEl.scrollTop = 0; + this.resultsEl.innerHTML = ""; + this.resultsEl.classList.add("hidden"); + this.noResultsEl.classList.add("hidden"); + } + + /** + * @returns {void} + */ + show() { + this.searchEl.classList.add(this.CLASS_VISIBLE); + this.inputEl.focus(); + this.inputEl.value = ""; + this.clearFilterEl.classList.add("hidden"); + this.resetContentState(true); + } + + /** + * @private + * @returns {Promise} + */ + async showResults(/** @type any[] */ results) { + const data = await Promise.all(results.map((result) => result.data())); + + this.resetContentState(); + this.resultsEl.classList.remove("hidden"); + + data.forEach((item) => { + this.resultsEl.appendChild(this.searchTpl.createResultEl(item)); + }); + + this.results = Array.from( + this.resultsEl.querySelectorAll("[data-search-result]") + ); + } + + /** + * @private + * @returns {void} + */ + showNoResults(/** @type {string} */ term) { + this.resetContentState(); + const termEl = this.noResultsEl.querySelector("[data-search-term]"); + this.noResultsEl.classList.remove("hidden"); + + if (termEl) { + termEl.innerHTML = term; + } + } +} + +export { SearchDialog }; diff --git a/static/search-tpl.mjs b/static/search-tpl.mjs new file mode 100644 index 0000000000..a969e2d849 --- /dev/null +++ b/static/search-tpl.mjs @@ -0,0 +1,139 @@ +// @ts-check +/** @import {Category} from "./search.mjs" */ +import { getCategoryId } from "./search-categories.mjs"; + +class SearchTpl { + constructor( + /** @type {HTMLTemplateElement} */ categoryTplEl, + /** @type {HTMLTemplateElement} */ resultCompactTplEl, + /** @type {HTMLTemplateElement} */ resultTplEl, + /** @type {HTMLTemplateElement} */ subResultTplEl + ) { + /** @private @readonly @property {HTMLTemplateElement} */ + this.categoryTplEl = categoryTplEl; + /** @private @readonly @property {HTMLTemplateElement} */ + this.resultCompactTplEl = resultCompactTplEl; + /** @private @readonly @property {HTMLTemplateElement} */ + this.resultTplEl = resultTplEl; + /** @private @readonly @property {HTMLTemplateElement} */ + this.subResultTplEl = subResultTplEl; + } + + /** + * @returns {DocumentFragment} + */ + createResultEl(/** @type {any} */ item) { + const isCompact = + item.sub_results.length === 1 && item.sub_results[0].url === item.url; + + const resultEl = /** @type {DocumentFragment} */ ( + isCompact + ? this.resultCompactTplEl.content.cloneNode(true) + : this.resultTplEl.content.cloneNode(true) + ); + + const titleEl = resultEl.querySelector("[data-title]"); + const categoryEl = resultEl.querySelector("[data-category]"); + + if (titleEl) { + titleEl.innerHTML = item.meta.title; + } + + if (categoryEl) { + if (item.filters.category?.length > 0) { + const category = item.filters.category[0]; + categoryEl.innerHTML = category; + categoryEl.classList.add(`search-category--${getCategoryId(category)}`); + } else { + categoryEl.classList.add("hidden"); + } + } + + if (isCompact) { + const wrapperEl = resultEl.querySelector("[data-wrapper]"); + const excerptEl = resultEl.querySelector("[data-excerpt]"); + + wrapperEl?.setAttribute("href", item.url); + + if (excerptEl) { + excerptEl.innerHTML = item.sub_results[0].excerpt; + } + } else { + const subResultsEl = resultEl.querySelector("[data-sub-results]"); + const moreEl = resultEl.querySelector("[data-more]"); + const maxSubResults = 5; + + titleEl?.setAttribute("href", item.url); + + if (subResultsEl) { + item.sub_results.slice(0, maxSubResults).forEach((sub) => { + subResultsEl.appendChild( + this.createSubResultEl(sub.title, sub.url, sub.excerpt) + ); + }); + } + + if (moreEl && item.sub_results.length > maxSubResults) { + moreEl.innerHTML = `+${item.sub_results.length - maxSubResults} more`; + moreEl.classList.remove("hidden"); + } + } + + return resultEl; + } + + /** + * @private + * @returns {DocumentFragment} + */ + createSubResultEl( + /** @type {string} */ title, + /** @type {string} */ url, + /** @type {string} */ excerpt + ) { + const subResultEl = /** @type {DocumentFragment} */ ( + this.subResultTplEl.content.cloneNode(true) + ); + const linkEl = subResultEl.querySelector("[data-link]"); + const titleEl = subResultEl.querySelector("[data-title]"); + const subResultsEl = subResultEl.querySelector("[data-excerpt]"); + + if (linkEl) { + linkEl.setAttribute("href", url); + } + + if (titleEl) { + titleEl.innerHTML = title.replace(/[#\s]+$/, ""); + } + + if (subResultsEl) { + subResultsEl.innerHTML = excerpt; + } + + return subResultEl; + } + + /** + * @returns {DocumentFragment} + */ + createCategoryEl(/** @type {Category} */ category) { + const el = /** @type {DocumentFragment} */ ( + this.categoryTplEl.content.cloneNode(true) + ); + const categoryEl = el.querySelector("[data-category]"); + + if (categoryEl instanceof HTMLElement) { + categoryEl.innerHTML = category.name; + categoryEl.dataset.category = category.id; + categoryEl.classList.add(`search-category--${category.id}`); + + if (category.checked) { + categoryEl.classList.add("search-category--active"); + } + } + + return el; + } +} + +export { SearchTpl }; diff --git a/static/search.mjs b/static/search.mjs new file mode 100644 index 0000000000..15475fd303 --- /dev/null +++ b/static/search.mjs @@ -0,0 +1,106 @@ +// @ts-check +import { SearchCategories } from "./search-categories.mjs"; +import { SearchDialog } from "./search-dialog.mjs"; +import { SearchTpl } from "./search-tpl.mjs"; + +/** + * @typedef Pagefind + * @prop {(term: string, options: any) => Promise} search + */ +/** + * @typedef Category + * @prop {string} id + * @prop {string} name + * @prop {boolean} checked + */ + +/** @type {SearchDialog | undefined} */ +let searchDialog; + +/** @returns boolean */ +function isSearchOpen() { + return searchDialog?.isOpen() ?? false; +} + +window.addEventListener("load", async () => { + const getEl = (/** @type {string} */ id) => + document.querySelector(`[data-search-${id}]`); + const searchEl = document.querySelector(`[data-search]`); + const backdropEl = getEl("backdrop"); + const dialogEl = getEl("dialog"); + const categoriesEl = getEl("categories"); + const contentEl = getEl("content"); + const resultsEl = getEl("results"); + const noResultsEl = getEl("no-results"); + const closeEl = getEl("close"); + const inputEl = getEl("input"); + const clearFilterEl = getEl("clear-filter"); + const categoryTplEl = getEl("category-tpl"); + const resultCompactTplEl = getEl("result-compact-tpl"); + const resultTplEl = getEl("result-tpl"); + const subResultTplEl = getEl("sub-result-tpl"); + + if ( + searchEl instanceof HTMLElement && + backdropEl instanceof HTMLElement && + dialogEl instanceof HTMLElement && + categoriesEl instanceof HTMLElement && + contentEl instanceof HTMLElement && + resultsEl instanceof HTMLElement && + noResultsEl instanceof HTMLElement && + closeEl instanceof HTMLElement && + inputEl instanceof HTMLInputElement && + clearFilterEl instanceof HTMLElement && + categoryTplEl instanceof HTMLTemplateElement && + resultCompactTplEl instanceof HTMLTemplateElement && + resultTplEl instanceof HTMLTemplateElement && + subResultTplEl instanceof HTMLTemplateElement + ) { + try { + // @ts-ignore + const pagefind = await import("/pagefind/pagefind.js"); + const filters = await pagefind.filters(); + const categories = new SearchCategories( + Object.keys(filters.category), + [ + "Quick Start", + "Examples", + "Migrations", + "News", + "Contribute", + "Errors", + ], + ["Quick Start", "Examples"] + ); + + searchDialog = new SearchDialog( + pagefind, + categories, + searchEl, + backdropEl, + dialogEl, + categoriesEl, + contentEl, + resultsEl, + noResultsEl, + closeEl, + inputEl, + clearFilterEl, + new SearchTpl( + categoryTplEl, + resultCompactTplEl, + resultTplEl, + subResultTplEl + ) + ); + } catch (err) { + console.error("Failed to initialize Pagefind.", err); + } + } else { + console.error( + "Not all the elements needed to build the Search dialog were found." + ); + } +}); + +export { isSearchOpen }; diff --git a/static/tools.js b/static/tools.js index ed806d905f..d5681364f6 100644 --- a/static/tools.js +++ b/static/tools.js @@ -1,3 +1,12 @@ +function debounce(callback, wait) { + let timeoutId; + + return (...args) => { + clearTimeout(timeoutId); + timeoutId = setTimeout(() => callback(...args), wait); + }; +} + function getFilename(resource) { const pathname = (typeof resource === 'string') ? resource @@ -56,4 +65,4 @@ async function progressiveFetch(resource, callbacks={}) { return new Response(response.body.pipeThrough(transform), response); } -export { progressiveFetch }; +export { debounce, progressiveFetch }; diff --git a/templates/docs.html b/templates/docs.html index 361dbcef60..ab4299c8b9 100644 --- a/templates/docs.html +++ b/templates/docs.html @@ -14,7 +14,7 @@ {% endif %} {% endfor %} - + {% endblock head_extensions %} diff --git a/templates/layouts/base.html b/templates/layouts/base.html index 093491b90f..055cada9b3 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -1,6 +1,7 @@ {% import "macros/header.html" as header_macros %} {% import "macros/public_draft.html" as public_draft %} {% import "macros/base.html" as base_macros %} +{% import "macros/pagefind.html" as pagefind_macros %} {# Shortcut to get the section/page. This variable will have a value except in some special pages like `404.html`. #} {% set_global section_or_page = false %} @@ -88,6 +89,7 @@ + {% if ancestor_is_public_draft or section_or_page and section_or_page.extra and section_or_page.extra.public_draft %} @@ -95,6 +97,8 @@ {% endif %} {% if page.extra.public_draft or section.extra.public_draft %}[DRAFT] {% endif -%}{{ page_title }} + + {% block head_extensions %}{% endblock head_extensions %} @@ -130,7 +134,7 @@ role="navigation" data-page-menu-switch-state-container> + {% include "search.html" %}