From 8f3dd2a348abbe561dc3b0b53db8af63e23cf204 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 25 Mar 2026 02:00:43 +0300 Subject: [PATCH 01/12] Update testing actions for move of env vars to secrets --- .github/workflows/cron.yml | 4 ++-- .github/workflows/migrations-preview.yml | 4 ++-- .github/workflows/migrations-production.yml | 4 ++-- .github/workflows/playwright.yml | 4 ++-- .github/workflows/test.yml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index b4f38936a..c4e2efbad 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -35,7 +35,7 @@ jobs: uses: './.github/actions/keep-alive' with: service: turso - url: ${{ vars.ASTRO_DB_REMOTE_URL }} + url: ${{ secrets.ASTRO_DB_REMOTE_URL }} token: ${{ secrets.ASTRO_DB_APP_TOKEN }} ping-preview: @@ -60,7 +60,7 @@ jobs: uses: './.github/actions/keep-alive' with: service: turso - url: ${{ vars.ASTRO_DB_REMOTE_URL }} + url: ${{ secrets.ASTRO_DB_REMOTE_URL }} token: ${{ secrets.ASTRO_DB_APP_TOKEN }} ping-upstash-search: diff --git a/.github/workflows/migrations-preview.yml b/.github/workflows/migrations-preview.yml index 43168f5b9..b976330d2 100644 --- a/.github/workflows/migrations-preview.yml +++ b/.github/workflows/migrations-preview.yml @@ -110,7 +110,7 @@ jobs: id: verify if: steps.gate.outputs.should_migrate == 'true' env: - ASTRO_DB_REMOTE_URL: ${{ vars.ASTRO_DB_REMOTE_URL }} + ASTRO_DB_REMOTE_URL: ${{ secrets.ASTRO_DB_REMOTE_URL }} ASTRO_DB_APP_TOKEN: ${{ secrets.ASTRO_DB_APP_TOKEN }} run: | set -uo pipefail @@ -141,6 +141,6 @@ jobs: - name: Push database migrations (preview) if: steps.gate.outputs.should_migrate == 'true' && steps.verify.outputs.needs_push == 'true' env: - ASTRO_DB_REMOTE_URL: ${{ vars.ASTRO_DB_REMOTE_URL }} + ASTRO_DB_REMOTE_URL: ${{ secrets.ASTRO_DB_REMOTE_URL }} ASTRO_DB_APP_TOKEN: ${{ secrets.ASTRO_DB_APP_TOKEN }} run: npx astro db push --remote diff --git a/.github/workflows/migrations-production.yml b/.github/workflows/migrations-production.yml index 875487873..9522535d0 100644 --- a/.github/workflows/migrations-production.yml +++ b/.github/workflows/migrations-production.yml @@ -89,7 +89,7 @@ jobs: id: verify if: steps.gate.outputs.should_migrate == 'true' env: - ASTRO_DB_REMOTE_URL: ${{ vars.ASTRO_DB_REMOTE_URL }} + ASTRO_DB_REMOTE_URL: ${{ secrets.ASTRO_DB_REMOTE_URL }} ASTRO_DB_APP_TOKEN: ${{ secrets.ASTRO_DB_APP_TOKEN }} run: | set -uo pipefail @@ -120,6 +120,6 @@ jobs: - name: Push database migrations (production) if: steps.gate.outputs.should_migrate == 'true' && steps.verify.outputs.needs_push == 'true' env: - ASTRO_DB_REMOTE_URL: ${{ vars.ASTRO_DB_REMOTE_URL }} + ASTRO_DB_REMOTE_URL: ${{ secrets.ASTRO_DB_REMOTE_URL }} ASTRO_DB_APP_TOKEN: ${{ secrets.ASTRO_DB_APP_TOKEN }} run: npx astro db push --remote diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 1089eeb22..88fd9f3f3 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -24,8 +24,8 @@ jobs: contents: read env: - ASTRO_DB_APP_TOKEN: ${{ vars.ASTRO_DB_APP_TOKEN }} - ASTRO_DB_REMOTE_URL: ${{ vars.ASTRO_DB_REMOTE_URL }} + ASTRO_DB_APP_TOKEN: ${{ secrets.ASTRO_DB_APP_TOKEN }} + ASTRO_DB_REMOTE_URL: ${{ secrets.ASTRO_DB_REMOTE_URL }} COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME }} CONVERTKIT_API_KEY: ${{ vars.CONVERTKIT_API_KEY }} CONVERTKIT_HTTP_PORT: ${{ vars.CONVERTKIT_HTTP_PORT }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5857601ed..b1287760c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,8 +26,8 @@ jobs: pull-requests: write env: - ASTRO_DB_APP_TOKEN: ${{ vars.ASTRO_DB_APP_TOKEN }} - ASTRO_DB_REMOTE_URL: ${{ vars.ASTRO_DB_REMOTE_URL }} + ASTRO_DB_APP_TOKEN: ${{ secrets.ASTRO_DB_APP_TOKEN }} + ASTRO_DB_REMOTE_URL: ${{ secrets.ASTRO_DB_REMOTE_URL }} COMPOSE_PROJECT_NAME: ${{ vars.COMPOSE_PROJECT_NAME }} CONVERTKIT_API_KEY: ${{ vars.CONVERTKIT_API_KEY }} CONVERTKIT_HTTP_PORT: ${{ vars.CONVERTKIT_HTTP_PORT }} From 842773e213727fe4ae921142aa1a29c11fe4541a Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 25 Mar 2026 03:27:07 +0300 Subject: [PATCH 02/12] Improve search modal styling --- _TODO.md | 4 +- .../SearchBar/client/__tests__/index.spec.ts | 63 +++++++++-- .../client/__tests__/results.spec.ts | 37 +++++++ .../Search/SearchBar/client/index.ts | 70 +++++++++++- .../Search/SearchBar/client/results.ts | 69 ++++++++++++ src/pages/testing/comps/scratchpad.astro | 102 +++++++++--------- 6 files changed, 279 insertions(+), 66 deletions(-) create mode 100644 src/components/Search/SearchBar/client/__tests__/results.spec.ts create mode 100644 src/components/Search/SearchBar/client/results.ts diff --git a/_TODO.md b/_TODO.md index 1470ab615..59ef9f628 100644 --- a/_TODO.md +++ b/_TODO.md @@ -226,7 +226,7 @@ All code samples in this article are licensed under the MIT License. Feel free t - Search box on the search page is not working ```json -{ +[{ "id": "unique-id-from-crawler", "score": 0.985, "content": { @@ -237,5 +237,5 @@ All code samples in this article are licensed under the MIT License. Feel free t "title": "Introduction to Vector Search", "description": "Learn the basics of how vector databases work." } -} +}] ``` diff --git a/src/components/Search/SearchBar/client/__tests__/index.spec.ts b/src/components/Search/SearchBar/client/__tests__/index.spec.ts index ee679978e..e22ac4d5b 100644 --- a/src/components/Search/SearchBar/client/__tests__/index.spec.ts +++ b/src/components/Search/SearchBar/client/__tests__/index.spec.ts @@ -4,6 +4,7 @@ import SearchBarFixture from '@components/Search/SearchBar/client/__fixtures__/i import SearchBarHeaderFixture from '@components/Search/SearchBar/client/__fixtures__/header.fixture.astro' import type { SearchBarElement as SearchBarElementInstance } from '../index' import type { WebComponentModule } from '@components/scripts/@types/webComponentModule' +import type { SearchHit } from '@actions/search/@types' import { executeRender } from '@test/unit/helpers/litRuntime' import { __resetHeaderSearchForTests } from '@components/scripts/store/search' @@ -16,7 +17,7 @@ const searchQueryMock = (_input: { q: string limit?: number - }) => Promise> + }) => Promise> >() vi.mock('astro:actions', () => ({ @@ -129,7 +130,11 @@ describe('SearchBar web component', () => { searchQueryMock.mockResolvedValue({ data: { hits: [ - { title: 'About', url: '/about' }, + { + title: 'Introduction to Vector Search', + url: 'https://www.webstackbuilders.com/deep-dive/introduction-to-vector-search', + snippet: 'Learn the basics of how vector databases work.', + }, { title: 'Services', url: '/services' }, ], }, @@ -147,11 +152,57 @@ describe('SearchBar web component', () => { expect(searchQueryMock).toHaveBeenCalledWith({ q: 'ab', limit: 8 }) expect(resultsContainer.classList.contains('hidden')).toBe(false) - const links = Array.from( - element.querySelectorAll('[data-search-results-list] a') - ) as HTMLAnchorElement[] - expect(links.some(link => link.getAttribute('href') === '/about')).toBe(true) + const links = Array.from(element.querySelectorAll('[data-search-results-list] a')) as HTMLAnchorElement[] + expect( + links.some( + link => + link.getAttribute('href') === + 'https://www.webstackbuilders.com/deep-dive/introduction-to-vector-search' + ) + ).toBe(true) expect(links.some(link => link.getAttribute('href') === '/search?q=ab')).toBe(true) + + const firstLink = links[0] + expect(firstLink?.className).toContain('no-underline') + expect(firstLink?.querySelector('svg')).toBeTruthy() + expect(firstLink?.textContent).toContain('/deep-dive/introduction-to-vector-search') + }) + + vi.useRealTimers() + }) + + it('highlights matching query terms in result titles and snippets, but not the path', async () => { + vi.useFakeTimers() + + await runComponentRender(async ({ element, window }) => { + searchQueryMock.mockResolvedValue({ + data: { + hits: [ + { + title: 'Introduction to Vector Search', + url: 'https://www.webstackbuilders.com/deep-dive/introduction-to-vector-search', + snippet: 'Learn the basics of how vector databases work.', + }, + ], + }, + }) + + const input = element.querySelector('[data-search-input]') as HTMLInputElement + + input.value = 'vector' + input.dispatchEvent(new window.Event('input', { bubbles: true })) + + await vi.advanceTimersByTimeAsync(260) + await flushMicrotasks() + + const firstLink = element.querySelector('[data-search-results-list] a') as HTMLAnchorElement + const marks = Array.from(firstLink.querySelectorAll('mark')) + + expect(marks.length).toBeGreaterThan(0) + expect(marks.every(mark => mark.textContent?.toLowerCase() === 'vector')).toBe(true) + + const pathRow = firstLink.querySelector('.font-mono') + expect(pathRow?.querySelector('mark')).toBeNull() }) vi.useRealTimers() diff --git a/src/components/Search/SearchBar/client/__tests__/results.spec.ts b/src/components/Search/SearchBar/client/__tests__/results.spec.ts new file mode 100644 index 000000000..9077b56e3 --- /dev/null +++ b/src/components/Search/SearchBar/client/__tests__/results.spec.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { getSearchResultDisplayPath, highlightSearchText } from '../results' + +describe('SearchBar result helpers', () => { + it('extracts the relative path from an absolute url', () => { + expect( + getSearchResultDisplayPath( + 'https://www.webstackbuilders.com/articles/kubernetes-decision-framework#when-kubernetes-fits' + ) + ).toBe('/articles/kubernetes-decision-framework#when-kubernetes-fits') + }) + + it('returns the same value for an existing relative url', () => { + expect(getSearchResultDisplayPath('/deep-dive/introduction-to-vector-search')).toBe( + '/deep-dive/introduction-to-vector-search' + ) + }) + + it('falls back to the search page when the url is not usable', () => { + expect(getSearchResultDisplayPath('not a valid url')).toBe('/search') + }) + + it('wraps matching query terms in highlight markup', () => { + const highlighted = highlightSearchText('Introduction to Vector Search', 'vector') + + expect(Array.isArray(highlighted)).toBe(true) + + const highlightedParts = highlighted as Array + expect(highlightedParts.some(part => typeof part === 'string' && part.includes('Introduction to '))).toBe(true) + expect(highlightedParts.some(part => typeof part !== 'string')).toBe(true) + }) + + it('returns plain text when there is no highlightable query term', () => { + expect(highlightSearchText('Introduction to Vector Search', 'v')).toBe('Introduction to Vector Search') + }) +}) \ No newline at end of file diff --git a/src/components/Search/SearchBar/client/index.ts b/src/components/Search/SearchBar/client/index.ts index d2a423094..cbf13f1ca 100644 --- a/src/components/Search/SearchBar/client/index.ts +++ b/src/components/Search/SearchBar/client/index.ts @@ -8,6 +8,7 @@ import { addScriptBreadcrumb } from '@components/scripts/errors' import { addButtonEventListeners, addWrapperEventListeners } from '@components/scripts/elementListeners' import { getSearchBarElements, getSearchBarOptionalElements } from './selectors' import type { SearchHit } from '@actions/search/@types' +import { getSearchResultDisplayPath, highlightSearchText } from './results' import { closeHeaderSearch, getHeaderSearchExpanded, @@ -597,18 +598,77 @@ export class SearchBarElement extends LitElement { const items = hits.map(hit => { const url = hit.url || `/search?q=${encodeURIComponent(query)}` const title = hit.title || query + const snippet = hit.snippet?.trim() ?? '' + const displayPath = getSearchResultDisplayPath(url) return html` -
  • - ${title} +
  • + +
    +
    + ${displayPath} +
    +
    + ${highlightSearchText(title, query)} +
    + ${ + snippet + ? html` +

    + ${highlightSearchText(snippet, query)} +

    + ` + : nothing + } +
    + + + + +
  • ` }) const searchFor = html` -
  • - - Search for "${query}" +
  • + +
    +
    + /search?q=${query} +
    +
    + Search for "${highlightSearchText(query, query)}" +
    +
    + + + +
  • ` diff --git a/src/components/Search/SearchBar/client/results.ts b/src/components/Search/SearchBar/client/results.ts new file mode 100644 index 000000000..e571b8732 --- /dev/null +++ b/src/components/Search/SearchBar/client/results.ts @@ -0,0 +1,69 @@ +import { html, type TemplateResult } from 'lit' + +const MIN_HIGHLIGHT_TERM_LENGTH = 2 +const SEARCH_RESULT_FALLBACK_PATH = '/search' + +type HighlightChunk = string | TemplateResult + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +const getHighlightTerms = (query: string): string[] => { + return [...new Set(query.trim().split(/\s+/).filter(term => term.length >= MIN_HIGHLIGHT_TERM_LENGTH))].sort( + (left, right) => right.length - left.length + ) +} + +export const getSearchResultDisplayPath = (url: string): string => { + if (!url) { + return SEARCH_RESULT_FALLBACK_PATH + } + + if (url.startsWith('/')) { + return url + } + + try { + const parsedUrl = new URL(url) + return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}` || SEARCH_RESULT_FALLBACK_PATH + } catch { + return SEARCH_RESULT_FALLBACK_PATH + } +} + +export const highlightSearchText = (text: string, query: string): HighlightChunk[] | string => { + const highlightTerms = getHighlightTerms(query) + + if (!text || highlightTerms.length === 0) { + return text + } + + const pattern = new RegExp(`(${highlightTerms.map(term => escapeRegExp(term)).join('|')})`, 'gi') + const parts: HighlightChunk[] = [] + let cursor = 0 + + for (const match of text.matchAll(pattern)) { + const matchText = match[0] + const matchIndex = match.index ?? -1 + + if (!matchText || matchIndex < 0) { + continue + } + + if (matchIndex > cursor) { + parts.push(text.slice(cursor, matchIndex)) + } + + parts.push(html`${matchText}`) + cursor = matchIndex + matchText.length + } + + if (parts.length === 0) { + return text + } + + if (cursor < text.length) { + parts.push(text.slice(cursor)) + } + + return parts +} \ No newline at end of file diff --git a/src/pages/testing/comps/scratchpad.astro b/src/pages/testing/comps/scratchpad.astro index 6bf54b203..c18315566 100644 --- a/src/pages/testing/comps/scratchpad.astro +++ b/src/pages/testing/comps/scratchpad.astro @@ -8,59 +8,55 @@ const path = '/testing/comps/scratchpad' --- - + From d550b7c6b6c1af7b297b73940bf9a5701d17127e Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Wed, 25 Mar 2026 04:47:08 +0300 Subject: [PATCH 03/12] Fix hover on search modal --- src/components/Header/index.astro | 4 +- src/components/Header/index.css | 8 +-- .../SearchBar/client/__tests__/index.spec.ts | 32 +++++++++++- .../client/__tests__/results.spec.ts | 11 ++++ .../Search/SearchBar/client/index.ts | 51 ++++--------------- .../Search/SearchBar/client/results.ts | 13 ++++- src/components/Search/SearchBar/index.astro | 8 +-- src/styles/index.css | 2 + 8 files changed, 72 insertions(+), 57 deletions(-) diff --git a/src/components/Header/index.astro b/src/components/Header/index.astro index 63e101ae2..40824e481 100644 --- a/src/components/Header/index.astro +++ b/src/components/Header/index.astro @@ -25,7 +25,7 @@ import './index.css'