diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f05d0dd1..65531075 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,13 +38,11 @@ jobs: curl -sSL https://github.com/mre/gh-stats/releases/download/v0.1.0/gh-stats-v0.1.0-x86_64-unknown-linux-gnu.tar.gz | tar xz --directory=bin sudo install "$(pwd)/bin/gh-stats" /usr/local/bin - - name: ⏬ Install wasm-pack - run: curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh - name: ⏬ Install tinysearch run: | set -e - curl -sSL https://github.com/tinysearch/tinysearch/releases/download/v0.10.0/tinysearch-v0.10.0-x86_64-unknown-linux-gnu.tar.gz | tar xz + curl -sSL https://github.com/tinysearch/tinysearch/releases/download/v0.11.0/tinysearch-v0.11.0-x86_64-unknown-linux-gnu.tar.gz | tar xz sudo install "$(pwd)/tinysearch" /usr/local/bin - name: ⏬ Install cavif @@ -57,8 +55,8 @@ jobs: - name: ⏬ Install binaryen run: | set -e - curl -L https://github.com/WebAssembly/binaryen/releases/download/version_104/binaryen-version_104-x86_64-linux.tar.gz | tar xzf - - sudo install "$(pwd)/binaryen-version_104/bin/wasm-opt" /usr/local/bin + curl -L https://github.com/WebAssembly/binaryen/releases/download/version_131/binaryen-version_131-x86_64-linux.tar.gz | tar xzf - + sudo install "$(pwd)/binaryen-version_131/bin/wasm-opt" /usr/local/bin - name: ⏬ Install terser run: sudo npm install -g terser diff --git a/Makefile b/Makefile index bdb17fec..4d4aa4fa 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,5 @@ SHELL := /bin/bash +TINYSEARCH_VERSION := 0.11.0 .PHONY: help help: ## This help message @@ -12,8 +13,7 @@ clean-img: ## Remove autogenerated image files .PHONY: clean-pub clean-pub: ## Remove build files - rm -rf public/ - rm -f static/tinysearch_engine* + rm -rf public/ tinysearch_out/ .PHONY: clean clean: clean-pub clean-img ## Remove public files and images @@ -25,7 +25,6 @@ versions: ## Show versions of tools tinysearch --version wasm-opt --version terser --version - wasm-opt --version cavif --version convert -version @@ -37,23 +36,24 @@ content: ## Build the content of the static site with zola images: ## Create avif images cargo run --manifest-path ./helpers/img/Cargo.toml -# Creating a temporary directory here because wasm-pack seems to overwrite -# the public output directory. Haven't yet found the reason why. +# Keep a copy in static/ so plain `zola serve` can load search locally. +# Production still gets a freshly generated copy in public/. .PHONY: index index: content ## Build the search index with tinysearch - # Commented out tinysearch build for now - # mkdir -p tinysearch_out - # mv public/tinysearch.json/index.html public/search_index.json - # RUST_LOG=debug tinysearch --release -o -m wasm -p tinysearch_out public/search_index.json - # mv tinysearch_out/* public - # rm -rf tinysearch_out + @tinysearch --version | grep -q "tinysearch $(TINYSEARCH_VERSION)" || (echo "tinysearch $(TINYSEARCH_VERSION) is required"; exit 1) + mkdir -p tinysearch_out + mv public/tinysearch.json/index.html public/search_index.json + tinysearch --release -o -m wasm -p tinysearch_out public/search_index.json + cp tinysearch_out/tinysearch_engine.wasm static/ + mv tinysearch_out/tinysearch_engine.wasm public/ + rm -rf public/tinysearch.json public/search_index.json tinysearch_out .PHONY: minify -minify: ## Compress JavaScript assets - terser --compress --mangle --output public/search_min.js -- static/search.mjs +minify: index ## Compress JavaScript assets + terser --module --compress --mangle --output public/search.mjs -- static/search.mjs .PHONY: build -build: stars content index minify ## Build static site and search index, minify JS +build: stars minify ## Build static site and search index, minify JS .PHONY: build-quick build-quick: content ## Build static site diff --git a/content/static/tinysearch.md b/content/static/tinysearch.md new file mode 100644 index 00000000..3ff17231 --- /dev/null +++ b/content/static/tinysearch.md @@ -0,0 +1,7 @@ ++++ +title = "Search Index" +path = "tinysearch.json" +template = "tinysearch_index.html" +date = 2026-08-14 +in_sitemap = false ++++ diff --git a/static/css/main.css b/static/css/main.css index 99dc0ce5..a6b76720 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -329,8 +329,18 @@ main article { font-size: 0.8rem; } -/* Theme toggle button */ -.theme-toggle { +/* Header controls */ +.header-actions { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-left: 10px; + position: relative; +} + + +.theme-toggle, +.search-toggle { background: none; border: 1px solid var(--border-color); padding: 0.4em; @@ -343,17 +353,22 @@ main article { justify-content: center; color: var(--text-color); opacity: 0.8; - transition: all 0.3s ease; - margin-left: 10px; + transition: + opacity var(--transition) ease, + border-color var(--transition) ease; vertical-align: middle; } -.theme-toggle:hover { +.theme-toggle:hover, +.theme-toggle:focus-visible, +.search-toggle:hover, +.search-toggle:focus-visible { opacity: 1; border-color: var(--accent-color); } -.theme-toggle svg { +.theme-toggle svg, +.search-toggle svg { width: 1.2em; height: 1.2em; stroke: currentColor; @@ -376,6 +391,108 @@ main article { display: block; } +.site-search { + display: inline-flex; + position: relative; +} + +.search-panel { + width: 100%; + margin: 0 auto 3rem; + padding: 0.55rem; + background: var(--background); + border: 1px solid var(--border-color); + border-radius: var(--radius); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.12); + text-align: left; +} + +.search-panel[hidden] { + display: none; +} + +.search-form { + margin: 0; +} + +.search-input { + width: 100%; + padding: 0.6rem 0.7rem; + border: 1px solid var(--border-color); + border-radius: calc(var(--radius) - 3px); + color: var(--text-color); + background: var(--surface-color); + font: inherit; + font-size: 0.9rem; + line-height: 1.4; +} + +.search-input:focus { + border-color: var(--accent-color); + outline: 2px solid color-mix(in srgb, var(--accent-color) 25%, transparent); + outline-offset: 1px; +} + +.search-content { + padding: 0.5rem 0 3rem; +} + +.search-content-status { + margin-bottom: 1.25rem; + color: var(--meta-color); + font-size: 0.8rem; +} + +.search-content-results { + margin: 0; + padding: 0; +} + +.search-content-results li { + list-style: none; + border-bottom: 1px solid var(--border-color); +} + +.search-content-results a { + display: block; + padding: 0.85rem 0.25rem; + color: var(--accent-color); + font-family: var(--heading-font); + font-size: 1.05rem; + line-height: 1.35; + text-decoration: none; +} + +.search-content-results a:hover, +.search-content-results a:focus-visible, +.search-content-results a.is-active { + color: var(--text-color); + background: var(--surface-color); + outline: none; +} + +body.is-search-open .homepage-header, +body.is-search-open .site-header { + padding-bottom: 25px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +body > header, +.article-header { + overflow: visible; +} + /* Media elements */ img, video, @@ -809,7 +926,7 @@ footer .body { position: relative; } - .post-meta { + .post-meta .post-date { display: none; } @@ -860,11 +977,12 @@ footer .body { padding: 40px 20px; } - .theme-toggle { - margin-left: 0; + .homepage-header .header-actions { margin-top: 10px; + margin-left: 0; } + /* Back to normal width on very small screens */ article img, article video, diff --git a/static/search.mjs b/static/search.mjs index 1ceec46a..5ffc407d 100644 --- a/static/search.mjs +++ b/static/search.mjs @@ -1,114 +1,260 @@ -import { search, default as init } from "/tinysearch_engine.js"; -async function lazyLoad() { - await init("/tinysearch_engine_bg.wasm"); -} +const WASM_PATH = "/tinysearch_engine.wasm"; +const RESULT_LIMIT = 10; +const SEARCH_DELAY_MS = 100; +const WASM_PAGE_SIZE = 65_536; + +class TinySearch { + constructor(instance) { + const { memory, search, free_search_result: freeSearchResult } = + instance.exports; -var loaded = false; + if (!memory || !search || !freeSearchResult) { + throw new Error("TinySearch is missing required WebAssembly exports"); + } -function autocomplete(inp) { - var currentFocus; + this.memory = memory; + this.searchFunction = search; + this.freeSearchResult = freeSearchResult; + this.encoder = new TextEncoder(); + this.decoder = new TextDecoder(); + } - inp.addEventListener("click", function (e) { - // There's probably a better way to do lazy loading. - // Then again, I'm not a JavaScript developer ¯\_(ツ)_/¯ - if (!loaded) { - lazyLoad(); - loaded = true; + search(query, limit = RESULT_LIMIT) { + const queryBytes = this.encoder.encode(`${query}\0`); + const pages = Math.max(1, Math.ceil(queryBytes.length / WASM_PAGE_SIZE)); + const queryPointer = this.memory.grow(pages) * WASM_PAGE_SIZE; + new Uint8Array(this.memory.buffer, queryPointer, queryBytes.length).set( + queryBytes, + ); + + const resultPointer = this.searchFunction(queryPointer, limit); + if (resultPointer === 0) { + return []; } - }); - inp.addEventListener("input", function (e) { - var a, - b, - i, - val = this.value; + try { + const memory = new Uint8Array(this.memory.buffer); + let end = resultPointer; + while (end < memory.length && memory[end] !== 0) { + end += 1; + } - /*close any already open lists of autocompleted values*/ - closeAllLists(); - if (!val) { - return false; + return JSON.parse( + this.decoder.decode(memory.subarray(resultPointer, end)), + ); + } finally { + this.freeSearchResult(resultPointer); } - currentFocus = -1; + } +} - /* Create a DIV element that will contain the items (values):*/ - a = document.createElement("DIV"); - a.setAttribute("id", this.id + "autocomplete-list"); - a.setAttribute("class", "autocomplete-items"); +let enginePromise; - /* Append the DIV element as a child of the autocomplete container:*/ - this.parentNode.appendChild(a); +async function loadEngine() { + if (!enginePromise) { + enginePromise = (async () => { + const response = await fetch(WASM_PATH); + if (!response.ok) { + throw new Error(`Could not load TinySearch (${response.status})`); + } - let arr = search(val, 3); + let instance; + try { + ({ instance } = await WebAssembly.instantiateStreaming( + Promise.resolve(response.clone()), + )); + } catch { + ({ instance } = await WebAssembly.instantiate( + await response.arrayBuffer(), + )); + } - for (i = 0; i < arr.length; i++) { - let elem = arr[i]; + return new TinySearch(instance); + })(); + } - b = document.createElement("DIV"); - b.innerHTML = elem[0]; + return enginePromise; +} - b.addEventListener("click", function (e) { - window.location.href = `${elem[1]}?q=${encodeURIComponent(val)}`; - }); - a.appendChild(b); - } - }); +function currentOriginUrl(value) { + const url = new URL(value, window.location.href); + return `${url.pathname}${url.search}${url.hash}`; +} + +function initializeSearch(root) { + const toggle = root.querySelector(".search-toggle"); + const panel = document.querySelector("[data-search-panel]"); + const form = panel.querySelector(".search-form"); + const input = panel.querySelector(".search-input"); + const pageContent = document.querySelector("[data-page-content]"); + const searchContent = document.querySelector("[data-search-content]"); + const status = searchContent.querySelector(".search-content-status"); + const results = searchContent.querySelector(".search-content-results"); + + let activeIndex = -1; + let recommendations = []; + let requestId = 0; + let searchTimer; + + function showSearchContent(show) { + searchContent.hidden = !show; + pageContent.hidden = show; + input.setAttribute("aria-expanded", String(show)); + } + + function resetSearch() { + clearTimeout(searchTimer); + requestId += 1; + activeIndex = -1; + recommendations = []; + input.value = ""; + input.removeAttribute("aria-activedescendant"); + results.replaceChildren(); + status.textContent = ""; + showSearchContent(false); + } - inp.addEventListener("keydown", function (e) { - var x = document.getElementById(this.id + "autocomplete-list"); - if (x) x = x.getElementsByTagName("div"); - if (e.keyCode == 40) { - /* If the arrow DOWN key is pressed, - increase the currentFocus variable:*/ - currentFocus++; - /* and make the current item more visible:*/ - addActive(x); - } else if (e.keyCode == 38) { - //up - /* If the arrow UP key is pressed, - decrease the currentFocus variable:*/ - currentFocus--; - /* and and make the current item more visible:*/ - addActive(x); - } else if (e.keyCode == 13) { - /* If the ENTER key is pressed, prevent the form from being submitted,*/ - e.preventDefault(); - if (currentFocus > -1) { - /* and simulate a click on the "active" item:*/ - if (x) x[currentFocus].click(); + function setOpen(open, returnFocus = false) { + panel.hidden = !open; + toggle.setAttribute("aria-expanded", String(open)); + document.body.classList.toggle("is-search-open", open); + + if (open) { + loadEngine().catch(() => {}); + requestAnimationFrame(() => input.focus()); + } else { + resetSearch(); + if (returnFocus) { + toggle.focus(); } } - }); + } - function addActive(x) { - /* A function to classify an item as "active":*/ - if (!x) return false; - /* Start by removing the "active" class on all items:*/ - removeActive(x); - if (currentFocus >= x.length) currentFocus = 0; - if (currentFocus < 0) currentFocus = x.length - 1; - /* Add class "autocomplete-active":*/ - x[currentFocus].classList.add("autocomplete-active"); + function setActive(index) { + const options = results.querySelectorAll("[role='option']"); + options.forEach((option) => { + option.classList.remove("is-active"); + option.setAttribute("aria-selected", "false"); + }); + + if (options.length === 0) { + activeIndex = -1; + input.removeAttribute("aria-activedescendant"); + return; + } + + activeIndex = (index + options.length) % options.length; + const activeOption = options[activeIndex]; + activeOption.classList.add("is-active"); + activeOption.setAttribute("aria-selected", "true"); + input.setAttribute("aria-activedescendant", activeOption.id); + activeOption.scrollIntoView({ block: "nearest" }); } - function removeActive(x) { - /* A function to remove the "active" class from all autocomplete items:*/ - for (var i = 0; i < x.length; i++) { - x[i].classList.remove("autocomplete-active"); + function renderRecommendations(query, items) { + recommendations = items; + activeIndex = -1; + input.removeAttribute("aria-activedescendant"); + results.replaceChildren(); + + if (items.length === 0) { + status.textContent = `No articles found for “${query}”.`; + return; } + + status.textContent = `${items.length} article${items.length === 1 ? "" : "s"} found for “${query}”.`; + + items.forEach((item, index) => { + const listItem = document.createElement("li"); + const link = document.createElement("a"); + link.id = `search-result-${index}`; + link.href = item.url; + link.textContent = item.title; + link.setAttribute("role", "option"); + link.setAttribute("aria-selected", "false"); + listItem.append(link); + results.append(listItem); + }); } - function closeAllLists(elmnt) { - /* Close all autocomplete lists in the document, - except the one passed as an argument:*/ - var x = document.getElementsByClassName("autocomplete-items"); - for (var i = 0; i < x.length; i++) { - if (elmnt != x[i] && elmnt != inp) { - x[i].parentNode.removeChild(x[i]); + async function updateRecommendations() { + const query = input.value.trim(); + const currentRequest = ++requestId; + + if (!query) { + activeIndex = -1; + recommendations = []; + input.removeAttribute("aria-activedescendant"); + results.replaceChildren(); + status.textContent = ""; + showSearchContent(false); + return; + } + + results.replaceChildren(); + status.textContent = "Searching…"; + showSearchContent(true); + + try { + const engine = await loadEngine(); + const items = engine.search(query, RESULT_LIMIT).map((item) => ({ + ...item, + url: currentOriginUrl(item.url), + })); + if (currentRequest === requestId) { + renderRecommendations(query, items); + } + } catch (error) { + console.error("TinySearch failed:", error); + if (currentRequest === requestId) { + recommendations = []; + results.replaceChildren(); + status.textContent = "Search is temporarily unavailable."; } } } - document.addEventListener("click", function (e) { - closeAllLists(e.target); + + toggle.addEventListener("click", () => { + setOpen(panel.hidden); }); + + form.addEventListener("submit", (event) => { + event.preventDefault(); + if (recommendations.length > 0) { + const index = activeIndex >= 0 ? activeIndex : 0; + window.location.assign(recommendations[index].url); + } + }); + + input.addEventListener("input", () => { + clearTimeout(searchTimer); + searchTimer = window.setTimeout(updateRecommendations, SEARCH_DELAY_MS); + }); + + input.addEventListener("keydown", (event) => { + if (event.key === "ArrowDown") { + event.preventDefault(); + setActive(activeIndex + 1); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActive(activeIndex - 1); + } else if (event.key === "Escape") { + event.preventDefault(); + setOpen(false, true); + } + }); + + document.addEventListener("pointerdown", (event) => { + if ( + !panel.hidden && + !root.contains(event.target) && + !panel.contains(event.target) && + !searchContent.contains(event.target) + ) { + setOpen(false); + } + }); + } -autocomplete(document.getElementById("tinysearch")); + +document.querySelectorAll("[data-search]").forEach(initializeSearch); diff --git a/static/search_min.js b/static/search_min.js deleted file mode 100644 index 643ea78a..00000000 --- a/static/search_min.js +++ /dev/null @@ -1 +0,0 @@ -import{search,default as init}from"/tinysearch_engine.js";async function lazyLoad(){await init("/tinysearch_engine_bg.wasm")}var loaded=!1;function autocomplete(e){var t;function n(e){if(!e)return!1;!function(e){for(var t=0;t=e.length&&(t=0),t<0&&(t=e.length-1),e[t].classList.add("autocomplete-active")}function a(t){for(var n=document.getElementsByClassName("autocomplete-items"),a=0;a-1&&a&&a[t].click())})),document.addEventListener("click",(function(e){a(e.target)}))}autocomplete(document.getElementById("tinysearch")); \ No newline at end of file diff --git a/static/tinysearch_engine.wasm b/static/tinysearch_engine.wasm new file mode 100644 index 00000000..ba012785 Binary files /dev/null and b/static/tinysearch_engine.wasm differ diff --git a/templates/index.html b/templates/index.html index dfe74987..2875af80 100644 --- a/templates/index.html +++ b/templates/index.html @@ -175,68 +175,73 @@

Matthias Endler

- + {% include "snippets/header_actions.html" %} {%- endblock site_header %}
- {% block content %} {% for page in paginator.pages %} {% include - "snippets/article_preview.html" %} {% endfor %} + - - {% endblock content %} + + +
+ {% block content %} {% for page in paginator.pages %} {% include + "snippets/article_preview.html" %} {% endfor %} + + + {% endblock content %} +
diff --git a/templates/page.html b/templates/page.html index fe4162db..204f0142 100644 --- a/templates/page.html +++ b/templates/page.html @@ -62,6 +62,7 @@

Matthias Endler

+ {% include "snippets/header_actions.html" %} {%- endblock site_header %} {%- block content %} @@ -80,41 +81,6 @@

— {{ page.extra.subtitle }}

macros::nicedate(date=page.updated) }} {% else %} {{ macros::nicedate(date=page.date) }} {% endif %} - - diff --git a/templates/snippets/header_actions.html b/templates/snippets/header_actions.html new file mode 100644 index 00000000..5a9cb6ea --- /dev/null +++ b/templates/snippets/header_actions.html @@ -0,0 +1,38 @@ +
+ + {% include "snippets/search.html" %} +
diff --git a/templates/snippets/search.html b/templates/snippets/search.html index 07e7dbb2..6f02c648 100644 --- a/templates/snippets/search.html +++ b/templates/snippets/search.html @@ -1,7 +1,25 @@ -{% set search_min = load_data(path="static/search_min.js") %} - - \ No newline at end of file + + diff --git a/templates/tinysearch_index.html b/templates/tinysearch_index.html index 562b1329..6a73b314 100644 --- a/templates/tinysearch_index.html +++ b/templates/tinysearch_index.html @@ -1,12 +1,15 @@ {% set section = get_section(path="_index.md") %} +{% set_global first = true %} [ {%- for post in section.pages -%} {% if not post.draft %} +{% if not first %},{% endif %} { "title": {{ post.title | striptags | json_encode | safe }}, "url": {{ post.permalink | json_encode | safe }}, -"body": "{{ post.content | striptags | replace(from="{", to=" ") | replace(from="}", to=" ") | replace(from='"', to=" ") | replace(from="'", to="") | replace(from="\", to="") | escape }}" -}{% if not loop.last %},{% endif %} +"body": {{ post.content | striptags | json_encode | safe }} +} +{% set_global first = false %} {% endif %} {%- endfor -%} ] \ No newline at end of file