From faa209b547b441fe7d5a61f796beb9ac9158ef2e Mon Sep 17 00:00:00 2001 From: herr kaste Date: Tue, 21 Apr 2026 18:10:51 +0200 Subject: [PATCH 1/6] Avoid duplicate search run on filter clicks Clicking homepage filter links already calls goSearch directly. Previously, the click handler also dispatched synthetic input/change signals, which triggered a second debounced search pass. That second pass caused visible UI flicker (active label state blips and card label icon re-renders). Remove the synthetic form events in that path and explicitly clear any pending debounce state before running goSearch. --- static/index.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/static/index.js b/static/index.js index c586c88e8..71f48bbb0 100644 --- a/static/index.js +++ b/static/index.js @@ -238,10 +238,9 @@ document.addEventListener('click', (event) => { newSort = newSort ?? currentSearch.get('sort') ?? 'relevance' sortSelect.value = newSort - const inputEvent = new Event('input', { bubbles: true }) - const changeEvent = new Event('change', { bubbles: true }) - input.dispatchEvent(inputEvent) - input.dispatchEvent(changeEvent) + clearTimeout(debounceTimeout) + setSearchPending(false) + window.dispatchEvent(new Event('search:expand')) list.scrollUp() list.goSearch(newQuery, newSort, 1) From 90e507e1b13803eac7545af5a77896ba4bf9c24f Mon Sep 17 00:00:00 2001 From: herr kaste Date: Mon, 20 Apr 2026 16:00:42 +0200 Subject: [PATCH 2/6] Add dynamic featured label shortcuts Build dynamic featured labels under search using active label filters and co-occurring label usage. When no label filter is active, keep curated defaults. When labels are active, show all active labels first, then suggest up to the remaining slots (max 6 total suggested+active cap logic), and exclude synthetic status labels. Also update home-page link handling so label and platform links toggle filters in-place, while author links replace the query. --- static/index.js | 167 ++++++++++++----------- static/module/search-query.js | 206 +++++++++++++++++++++++++++++ static/module/search-query.test.js | 101 ++++++++++++++ 3 files changed, 390 insertions(+), 84 deletions(-) create mode 100644 static/module/search-query.js create mode 100644 static/module/search-query.test.js diff --git a/static/index.js b/static/index.js index 71f48bbb0..f9a34a805 100644 --- a/static/index.js +++ b/static/index.js @@ -1,6 +1,25 @@ import { List } from './module/list.js' import MiniSearch from 'https://cdn.jsdelivr.net/npm/minisearch@7.1.2/+esm' import { createMinisearch } from './module/minisearch.js' +import { + appendFilterToken, + buildFeaturedLabels, + buildLabelRecords, + hasFilterValue, + parseSingleFilterQuery, + removeFilterValue, +} from './module/search-query.js' + +const CURATED_FEATURED_LABELS = [ + 'language syntax', + 'snippets', + 'linting', + 'auto-complete', + 'color scheme', + 'theme', +] +const DYNAMIC_LABEL_EXCLUSIONS = ['ST2', 'ST3', 'MIA', 'RIP', 'FAILING'] +const MAX_FEATURED_LABELS = 6 // Fetches and returns the search data from the index async function fetchSearchData() { @@ -12,6 +31,7 @@ async function fetchSearchData() { const rawIndex = await fetchSearchData() const packages = Array.isArray(rawIndex) ? rawIndex : (rawIndex.packages || []) +const labelRecords = buildLabelRecords(packages) window.__LABEL_ICON_ALIASES__ = rawIndex.label_icon_aliases ?? {} window.__LABEL_ICON_TINTS__ = rawIndex.label_icon_tints ?? {} @@ -27,75 +47,65 @@ window.dispatchEvent(new CustomEvent('search:index-loaded', { detail: { data: pa const list = new List() list.setMinisearch(minisrch) -list.setFilterStateUpdater(updateFilterButtonStates) +list.setFilterStateUpdater(updateSearchFilterUi) const form = document.forms.search const input = form.elements['q'] const sortSelect = form.elements['sort'] +const featuredLabelsWrap = form.querySelector('.search-shortcuts .button-group.labels') -/** - * @typedef {Object} FilterButtonDescriptor - * @property {HTMLAnchorElement} element The original filter button element. - * @property {string} type The token prefix (e.g. label, platform, author). - * @property {string} token Full token string extracted from the button href. - */ - -/** - * All filter buttons that can toggle search tokens, converted to metadata objects. - * @type {FilterButtonDescriptor[]} - */ -const filterButtons = Array - .from(form.querySelectorAll('.button-group.labels a.button[href*="?q="]')) - .map(parseFilterButton) - .filter(Boolean) - -/** - * Extract token metadata from a filter button anchor. - * @param {HTMLAnchorElement} element - * @returns {FilterButtonDescriptor | null} - */ -function parseFilterButton(element) { - const url = new URL(element.href, window.location.origin) - const token = url.searchParams.get('q') - if (!token) { - return null - } - const delimiterIndex = token.indexOf(':') - if (delimiterIndex === -1) { - return null - } - const type = token.slice(0, delimiterIndex) - if (!type) { - return null - } - - return { element, type, token } +function updateSearchFilterUi(query) { + renderFeaturedLabels(query) + updateFilterButtonStates(query) } -function updateFilterButtonStates(query) { - if (!filterButtons.length) { +function renderFeaturedLabels(query) { + if (!featuredLabelsWrap) { return } - const normalizedQuery = query.trim() - const tokenCache = new Map() - - const getActiveTokens = (type) => { - if (!tokenCache.has(type)) { - const regex = new RegExp(`${type}:("[^"]+"|\\S+)`, 'g') - const matches = normalizedQuery.match(regex) ?? [] - tokenCache.set(type, new Set(matches)) + const { labels } = buildFeaturedLabels(query, labelRecords, { + defaults: CURATED_FEATURED_LABELS, + maxTotal: MAX_FEATURED_LABELS, + excludedLabels: DYNAMIC_LABEL_EXCLUSIONS, + }) + + featuredLabelsWrap.innerHTML = '' + for (const label of labels) { + const token = `label:"${label}"` + const isActive = hasFilterValue(query, 'label', label) + + const li = document.createElement('li') + const a = document.createElement('a') + a.classList.add('button', 'label') + a.dataset.filterBehavior = 'toggle' + if (isActive) { + a.classList.add('is-active') } - return tokenCache.get(type) + a.href = '/?q=' + encodeURIComponent(token) + a.appendChild(document.createTextNode(label)) + li.appendChild(a) + featuredLabelsWrap.appendChild(li) } - for (const { element, type, token } of filterButtons) { - const activeTokens = getActiveTokens(type) - if (activeTokens.has(token)) { - element.classList.add('is-active') - } else { - element.classList.remove('is-active') - } + const li = document.createElement('li') + const moreLink = document.createElement('a') + moreLink.classList.add('button', 'label-more') + moreLink.href = '/labels' + moreLink.title = 'More labels' + moreLink.appendChild(document.createTextNode('…')) + li.appendChild(moreLink) + featuredLabelsWrap.appendChild(li) +} + +function updateFilterButtonStates(query) { + const buttons = featuredLabelsWrap?.querySelectorAll('a.button[href*="?q="]') ?? [] + + for (const button of buttons) { + const token = new URL(button.href, window.location.origin).searchParams.get('q') + const parsed = parseSingleFilterQuery(token) + const isActive = parsed && hasFilterValue(query, parsed.type, parsed.value) + button.classList.toggle('is-active', Boolean(isActive)) } } @@ -123,6 +133,7 @@ function setSearchPending(pending) { // Handle initial page load setSearchPending(false) +updateSearchFilterUi(input.value) syncFromUrl({ initialPageLoad: true }) const handleInput = () => { @@ -192,37 +203,25 @@ document.addEventListener('click', (event) => { event.preventDefault() event.stopPropagation() - if (newQuery !== null && target.closest('form')) { - // the shortcuts in the form act as filters, toggling off when clicked twice + if (newQuery !== null) { const oldQuery = input.value - const clickedQuery = newQuery - const applyToggle = (type) => { - if (!clickedQuery.includes(`${type}:`)) { - return false - } - - const tokenRegex = new RegExp(`${type}:("[^"]+"|\\S+)`) - const newTokenMatch = clickedQuery.match(tokenRegex) - if (!newTokenMatch) { - return false - } - - const newToken = newTokenMatch[0] - const oldTokenMatch = oldQuery.match(tokenRegex) - - if (oldTokenMatch && oldTokenMatch[0] === newToken) { - newQuery = oldQuery.replace(tokenRegex, '').replace(/\s{2,}/g, ' ').trim() - } else if (oldTokenMatch) { - newQuery = oldQuery.replace(tokenRegex, newToken) + const clicked = parseSingleFilterQuery(newQuery) + + if (clicked?.type === 'author') { + newQuery = clicked.token + } else if (clicked && ['label', 'platform'].includes(clicked.type)) { + const isActive = hasFilterValue(oldQuery, clicked.type, clicked.value) + const shouldToggle = target.dataset.filterBehavior === 'toggle' + + if (shouldToggle && isActive) { + newQuery = removeFilterValue(oldQuery, clicked.type, clicked.value) + } else if (!isActive) { + newQuery = appendFilterToken(oldQuery, clicked.token) } else { - newQuery = `${oldQuery} ${newToken}`.trim() + newQuery = oldQuery } - - return true - } - - if (!applyToggle('label') && !applyToggle('platform') && !applyToggle('author')) { - newQuery = `${oldQuery} ${clickedQuery}`.trim() + } else if (target.closest('form')) { + newQuery = appendFilterToken(oldQuery, newQuery) } } diff --git a/static/module/search-query.js b/static/module/search-query.js new file mode 100644 index 000000000..bb67923db --- /dev/null +++ b/static/module/search-query.js @@ -0,0 +1,206 @@ +const SUPPORTED_FILTER_TYPES = ['label', 'platform', 'author'] + +export function buildFeaturedLabels( + rawQuery, + labelRecords, + { + defaults = [], + maxTotal = 6, + excludedLabels = [], + } = {}, +) { + const activeLabels = extractActiveLabelValues(rawQuery) + + if (activeLabels.length === 0) { + return { + labels: [...defaults].slice(0, maxTotal), + activeLabels, + } + } + + const suggestionLimit = Math.max(0, maxTotal - activeLabels.length) + const suggested = suggestionLimit > 0 + ? suggestLabelsForActive(activeLabels, labelRecords, excludedLabels) + : [] + + return { + labels: [...activeLabels, ...suggested.slice(0, suggestionLimit)], + activeLabels, + } +} + +export function buildLabelRecords(packages) { + return (packages ?? []).map((pkg) => { + const labels = parsePackageLabels(pkg?.labels) + const normalized = new Set() + const entries = [] + + for (const label of labels) { + const normalizedLabel = normalizeValue(label) + if (!normalizedLabel || normalized.has(normalizedLabel)) { + continue + } + normalized.add(normalizedLabel) + entries.push({ label, normalizedLabel }) + } + + return { + entries, + normalized, + } + }) +} + +export function parseSingleFilterQuery(rawQuery) { + const query = normalizeQueryWhitespace(rawQuery) + if (!query) { + return null + } + + for (const type of SUPPORTED_FILTER_TYPES) { + const matches = parseFilterMatches(query, type) + if (matches.length !== 1) { + continue + } + const [match] = matches + if (match.start === 0 && match.end === query.length) { + return { + type, + token: match.token, + value: match.value, + } + } + } + + return null +} + +export function extractActiveLabelValues(rawQuery) { + const seen = new Set() + const active = [] + + for (const { value } of parseFilterMatches(rawQuery, 'label')) { + const normalized = normalizeValue(value) + if (!normalized || seen.has(normalized)) { + continue + } + seen.add(normalized) + active.push(value) + } + + return active +} + +export function hasFilterValue(rawQuery, field, value) { + const expected = normalizeValue(value) + if (!expected) { + return false + } + + return parseFilterMatches(rawQuery, field).some( + ({ value: current }) => normalizeValue(current) === expected, + ) +} + +export function removeFilterValue(rawQuery, field, value) { + const expected = normalizeValue(value) + const matches = parseFilterMatches(rawQuery, field) + .filter(({ value: current }) => normalizeValue(current) === expected) + + if (matches.length === 0) { + return normalizeQueryWhitespace(rawQuery) + } + + let next = String(rawQuery ?? '') + for (const match of [...matches].reverse()) { + next = `${next.slice(0, match.start)} ${next.slice(match.end)}` + } + + return normalizeQueryWhitespace(next) +} + +export function appendFilterToken(rawQuery, token) { + const normalizedToken = String(token ?? '').trim() + if (!normalizedToken) { + return normalizeQueryWhitespace(rawQuery) + } + return normalizeQueryWhitespace(`${String(rawQuery ?? '')} ${normalizedToken}`) +} + +export function normalizeQueryWhitespace(rawQuery) { + return String(rawQuery ?? '').replace(/\s+/g, ' ').trim() +} + +export function parseFilterMatches(rawQuery, field) { + const query = String(rawQuery ?? '') + const pattern = new RegExp(`${field}:"([^"]+)"|${field}:"([^"]*)$|${field}:([^\\s]+)`, 'gi') + const matches = [] + + let match + while ((match = pattern.exec(query)) !== null) { + const value = (match[1] ?? match[2] ?? match[3] ?? '').trim() + if (!value) { + continue + } + matches.push({ + token: match[0], + value, + start: match.index, + end: match.index + match[0].length, + }) + } + + return matches +} + +function suggestLabelsForActive(activeLabels, labelRecords, excludedLabels) { + const activeSet = new Set(activeLabels.map(normalizeValue)) + const excludedSet = new Set((excludedLabels ?? []).map(normalizeValue)) + const counts = new Map() + + for (const record of labelRecords ?? []) { + if (!record || !record.normalized || !record.entries) { + continue + } + + const matchesAll = [...activeSet].every(label => record.normalized.has(label)) + if (!matchesAll) { + continue + } + + for (const { label, normalizedLabel } of record.entries) { + if (!normalizedLabel || activeSet.has(normalizedLabel) || excludedSet.has(normalizedLabel)) { + continue + } + counts.set(label, (counts.get(label) ?? 0) + 1) + } + } + + return [...counts.entries()] + .sort((a, b) => { + if (b[1] !== a[1]) { + return b[1] - a[1] + } + return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }) + }) + .map(([label]) => label) +} + +function parsePackageLabels(value) { + if (Array.isArray(value)) { + return value + .map(label => String(label ?? '').trim()) + .filter(Boolean) + } + if (typeof value !== 'string') { + return [] + } + return value + .split(',') + .map(label => label.trim()) + .filter(Boolean) +} + +function normalizeValue(value) { + return String(value ?? '').trim().toLowerCase() +} diff --git a/static/module/search-query.test.js b/static/module/search-query.test.js new file mode 100644 index 000000000..f958d9908 --- /dev/null +++ b/static/module/search-query.test.js @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +import { + appendFilterToken, + buildFeaturedLabels, + buildLabelRecords, + extractActiveLabelValues, + hasFilterValue, + parseSingleFilterQuery, + removeFilterValue, +} from './search-query.js' + +describe('parseSingleFilterQuery', () => { + it('parses a single filter token', () => { + expect(parseSingleFilterQuery('label:"python"')).toEqual({ + type: 'label', + token: 'label:"python"', + value: 'python', + }) + expect(parseSingleFilterQuery('platform:windows')).toEqual({ + type: 'platform', + token: 'platform:windows', + value: 'windows', + }) + }) + + it('returns null when query contains extra text', () => { + expect(parseSingleFilterQuery('label:"python" snippets')).toBeNull() + }) +}) + +describe('filter token toggling helpers', () => { + it('detects, removes and appends tokens', () => { + const current = 'foo label:"python" label:"linting"' + expect(hasFilterValue(current, 'label', 'python')).toBe(true) + + const removed = removeFilterValue(current, 'label', 'python') + expect(removed).toBe('foo label:"linting"') + expect(hasFilterValue(removed, 'label', 'python')).toBe(false) + + const appended = appendFilterToken(removed, 'label:"theme"') + expect(appended).toBe('foo label:"linting" label:"theme"') + }) +}) + +describe('extractActiveLabelValues', () => { + it('dedupes labels while preserving first-seen order', () => { + expect(extractActiveLabelValues('label:"Python" label:"python" label:"Linting"')).toEqual([ + 'Python', + 'Linting', + ]) + }) +}) + +describe('buildFeaturedLabels', () => { + const defaults = ['language syntax', 'snippets', 'linting', 'auto-complete', 'color scheme', 'theme'] + const excluded = ['ST2', 'ST3', 'MIA', 'RIP', 'FAILING'] + + const records = buildLabelRecords([ + { labels: 'python,snippets,linting,theme,FAILING' }, + { labels: 'python,snippets,color scheme' }, + { labels: 'python,linting,theme' }, + { labels: 'python,snippets,language syntax' }, + { labels: 'go,snippets' }, + ]) + + it('returns curated defaults when there are no active labels', () => { + expect(buildFeaturedLabels('', records, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + labels: defaults, + activeLabels: [], + }) + }) + + it('returns active labels plus most-used co-occurring suggestions', () => { + expect(buildFeaturedLabels('label:"python"', records, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + labels: ['python', 'snippets', 'linting', 'theme', 'color scheme', 'language syntax'], + activeLabels: ['python'], + }) + }) + + it('uses AND semantics for multiple active labels', () => { + expect( + buildFeaturedLabels('label:"python" label:"snippets"', records, { + defaults, + maxTotal: 6, + excludedLabels: excluded, + }), + ).toEqual({ + labels: ['python', 'snippets', 'color scheme', 'language syntax', 'linting', 'theme'], + activeLabels: ['python', 'snippets'], + }) + }) + + it('shows all active labels even when they exceed maxTotal', () => { + const query = 'label:"a" label:"b" label:"c" label:"d" label:"e" label:"f" label:"g"' + expect(buildFeaturedLabels(query, records, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + labels: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + activeLabels: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + }) + }) +}) From 29fe38ced486a6c9243ab7019f9f0a360ac03cd1 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Wed, 22 Apr 2026 11:30:16 +0200 Subject: [PATCH 3/6] Scope featured labels to current results Compute featured labels from the current query result set instead of the global package set. This makes the six shortcut labels adapt to any search term, not only explicit label filters. Also exclude free-text query terms from suggestions so typing a label word like "git" or "lsp" does not echo that same label in the featured row. Active label filters remain pinned so users can still toggle them. --- static/index.js | 16 +++-- static/module/list.js | 4 +- static/module/search-query.js | 106 ++++++++++++++++++++++++----- static/module/search-query.test.js | 42 +++++++----- 4 files changed, 128 insertions(+), 40 deletions(-) diff --git a/static/index.js b/static/index.js index f9a34a805..4f2f0211a 100644 --- a/static/index.js +++ b/static/index.js @@ -31,7 +31,7 @@ async function fetchSearchData() { const rawIndex = await fetchSearchData() const packages = Array.isArray(rawIndex) ? rawIndex : (rawIndex.packages || []) -const labelRecords = buildLabelRecords(packages) +const allLabelRecords = buildLabelRecords(packages) window.__LABEL_ICON_ALIASES__ = rawIndex.label_icon_aliases ?? {} window.__LABEL_ICON_TINTS__ = rawIndex.label_icon_tints ?? {} @@ -54,17 +54,23 @@ const input = form.elements['q'] const sortSelect = form.elements['sort'] const featuredLabelsWrap = form.querySelector('.search-shortcuts .button-group.labels') -function updateSearchFilterUi(query) { - renderFeaturedLabels(query) +function updateSearchFilterUi(query, featuredPackages) { + renderFeaturedLabels(query, featuredPackages) updateFilterButtonStates(query) } -function renderFeaturedLabels(query) { +function renderFeaturedLabels(query, featuredPackages) { if (!featuredLabelsWrap) { return } - const { labels } = buildFeaturedLabels(query, labelRecords, { + const normalizedQuery = String(query ?? '') + const hasQuery = normalizedQuery.trim().length > 0 + const scopedRecords = hasQuery && Array.isArray(featuredPackages) + ? buildLabelRecords(featuredPackages) + : allLabelRecords + + const { labels } = buildFeaturedLabels(normalizedQuery, scopedRecords, { defaults: CURATED_FEATURED_LABELS, maxTotal: MAX_FEATURED_LABELS, excludedLabels: DYNAMIC_LABEL_EXCLUSIONS, diff --git a/static/module/list.js b/static/module/list.js index bd260aa95..332d5940e 100644 --- a/static/module/list.js +++ b/static/module/list.js @@ -211,7 +211,6 @@ export class List { throw new Error('minisearch is not initialized') } - this.filterStateUpdater?.(value) this.activeSortSelection = sortBy const query = value.trim() @@ -261,6 +260,7 @@ export class List { } if (isReverting) { + this.filterStateUpdater?.(query) this.updateHeading() this.revertToNormal() return @@ -270,6 +270,8 @@ export class List { ? this.search.search(query) : this.search.all() + this.filterStateUpdater?.(query, searchResults) + let effectiveSort = sortBy if (usingWildcard && effectiveSort.startsWith('author')) { effectiveSort = 'list-' + effectiveSort diff --git a/static/module/search-query.js b/static/module/search-query.js index bb67923db..43ac74898 100644 --- a/static/module/search-query.js +++ b/static/module/search-query.js @@ -10,21 +10,32 @@ export function buildFeaturedLabels( } = {}, ) { const activeLabels = extractActiveLabelValues(rawQuery) + const hasQuery = normalizeQueryWhitespace(rawQuery).length > 0 - if (activeLabels.length === 0) { + if (!hasQuery && activeLabels.length === 0) { return { labels: [...defaults].slice(0, maxTotal), activeLabels, } } + const excludedQueryTerms = hasQuery ? extractFreeTextTerms(rawQuery) : [] const suggestionLimit = Math.max(0, maxTotal - activeLabels.length) const suggested = suggestionLimit > 0 - ? suggestLabelsForActive(activeLabels, labelRecords, excludedLabels) + ? suggestLabels(activeLabels, labelRecords, excludedLabels, excludedQueryTerms) : [] + const labels = [...activeLabels, ...suggested.slice(0, suggestionLimit)] + + if (labels.length === 0 && activeLabels.length === 0) { + return { + labels: [...defaults].slice(0, maxTotal), + activeLabels, + } + } + return { - labels: [...activeLabels, ...suggested.slice(0, suggestionLimit)], + labels, activeLabels, } } @@ -153,37 +164,98 @@ export function parseFilterMatches(rawQuery, field) { return matches } -function suggestLabelsForActive(activeLabels, labelRecords, excludedLabels) { +function suggestLabels(activeLabels, labelRecords, excludedLabels, excludedQueryTerms) { const activeSet = new Set(activeLabels.map(normalizeValue)) const excludedSet = new Set((excludedLabels ?? []).map(normalizeValue)) + const queryTermSet = new Set((excludedQueryTerms ?? []).map(normalizeValue)) const counts = new Map() for (const record of labelRecords ?? []) { - if (!record || !record.normalized || !record.entries) { - continue - } - - const matchesAll = [...activeSet].every(label => record.normalized.has(label)) - if (!matchesAll) { + if (!record || !record.entries) { continue } for (const { label, normalizedLabel } of record.entries) { - if (!normalizedLabel || activeSet.has(normalizedLabel) || excludedSet.has(normalizedLabel)) { + if ( + !normalizedLabel + || activeSet.has(normalizedLabel) + || excludedSet.has(normalizedLabel) + || queryTermSet.has(normalizedLabel) + ) { continue } - counts.set(label, (counts.get(label) ?? 0) + 1) + + const existing = counts.get(normalizedLabel) + if (existing) { + existing.count += 1 + } else { + counts.set(normalizedLabel, { label, count: 1 }) + } } } - return [...counts.entries()] + return [...counts.values()] .sort((a, b) => { - if (b[1] !== a[1]) { - return b[1] - a[1] + if (b.count !== a.count) { + return b.count - a.count } - return a[0].localeCompare(b[0], undefined, { sensitivity: 'base' }) + return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) }) - .map(([label]) => label) + .map(({ label }) => label) +} + +function extractFreeTextTerms(rawQuery) { + const query = String(rawQuery ?? '') + if (!query.trim()) { + return [] + } + + const filterSpans = [] + for (const type of SUPPORTED_FILTER_TYPES) { + for (const match of parseFilterMatches(query, type)) { + filterSpans.push({ start: match.start, end: match.end }) + } + } + + if (filterSpans.length === 0) { + return tokenizeTerms(query) + } + + filterSpans.sort((a, b) => a.start - b.start) + + const remainder = [] + let cursor = 0 + + for (const span of filterSpans) { + if (span.start > cursor) { + remainder.push(query.slice(cursor, span.start)) + } + cursor = Math.max(cursor, span.end) + } + + if (cursor < query.length) { + remainder.push(query.slice(cursor)) + } + + return tokenizeTerms(remainder.join(' ')) +} + +function tokenizeTerms(value) { + const terms = [] + const seen = new Set() + const pattern = /"([^"]+)"|(\S+)/g + + let match + while ((match = pattern.exec(value)) !== null) { + const term = normalizeValue(match[1] ?? match[2] ?? '') + if (!term || seen.has(term)) { + continue + } + seen.add(term) + terms.push(term) + } + + return terms } function parsePackageLabels(value) { diff --git a/static/module/search-query.test.js b/static/module/search-query.test.js index f958d9908..a993961fd 100644 --- a/static/module/search-query.test.js +++ b/static/module/search-query.test.js @@ -56,7 +56,7 @@ describe('buildFeaturedLabels', () => { const defaults = ['language syntax', 'snippets', 'linting', 'auto-complete', 'color scheme', 'theme'] const excluded = ['ST2', 'ST3', 'MIA', 'RIP', 'FAILING'] - const records = buildLabelRecords([ + const allRecords = buildLabelRecords([ { labels: 'python,snippets,linting,theme,FAILING' }, { labels: 'python,snippets,color scheme' }, { labels: 'python,linting,theme' }, @@ -64,36 +64,44 @@ describe('buildFeaturedLabels', () => { { labels: 'go,snippets' }, ]) - it('returns curated defaults when there are no active labels', () => { - expect(buildFeaturedLabels('', records, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + const pythonScopedRecords = buildLabelRecords([ + { labels: 'python,snippets,linting,theme,FAILING' }, + { labels: 'python,snippets,color scheme' }, + { labels: 'python,linting,theme' }, + { labels: 'python,snippets,language syntax' }, + ]) + + it('returns curated defaults when there is no query', () => { + expect(buildFeaturedLabels('', allRecords, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ labels: defaults, activeLabels: [], }) }) - it('returns active labels plus most-used co-occurring suggestions', () => { - expect(buildFeaturedLabels('label:"python"', records, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ - labels: ['python', 'snippets', 'linting', 'theme', 'color scheme', 'language syntax'], + it('omits free-text query terms from featured labels', () => { + expect(buildFeaturedLabels('python', pythonScopedRecords, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + labels: ['snippets', 'linting', 'theme', 'color scheme', 'language syntax'], + activeLabels: [], + }) + }) + + it('keeps active labels while omitting unrelated free-text terms', () => { + expect(buildFeaturedLabels('label:"python" snippets', pythonScopedRecords, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + labels: ['python', 'linting', 'theme', 'color scheme', 'language syntax'], activeLabels: ['python'], }) }) - it('uses AND semantics for multiple active labels', () => { - expect( - buildFeaturedLabels('label:"python" label:"snippets"', records, { - defaults, - maxTotal: 6, - excludedLabels: excluded, - }), - ).toEqual({ - labels: ['python', 'snippets', 'color scheme', 'language syntax', 'linting', 'theme'], - activeLabels: ['python', 'snippets'], + it('falls back to defaults when a non-empty query has no suggestions', () => { + expect(buildFeaturedLabels('nope', [], { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + labels: defaults, + activeLabels: [], }) }) it('shows all active labels even when they exceed maxTotal', () => { const query = 'label:"a" label:"b" label:"c" label:"d" label:"e" label:"f" label:"g"' - expect(buildFeaturedLabels(query, records, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + expect(buildFeaturedLabels(query, allRecords, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ labels: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], activeLabels: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], }) From c16043c26e73db3725e23a418d978f5fe8e69410 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 26 Apr 2026 16:36:43 +0200 Subject: [PATCH 4/6] Refactor label suggestion records Document the search-query label helpers with JSDoc annotations and simplify the per-package label record shape. The records now return the label arrays directly instead of wrapping them in an entries property. Use direct lower-casing where inputs are already trimmed by the parser, and combine omitted label checks into a single set for clearer suggestion filtering. --- static/module/search-query.js | 133 +++++++++++++++++++++++++--------- 1 file changed, 99 insertions(+), 34 deletions(-) diff --git a/static/module/search-query.js b/static/module/search-query.js index 43ac74898..e64001599 100644 --- a/static/module/search-query.js +++ b/static/module/search-query.js @@ -1,5 +1,28 @@ +/** + * @typedef {'label' | 'platform' | 'author'} FilterType + */ + +/** @type {FilterType[]} */ const SUPPORTED_FILTER_TYPES = ['label', 'platform', 'author'] +/** + * @typedef {Object} SearchIndexPackage + * @property {string} labels Comma-joined labels from search/index.json.njk. + */ + +/** + * @typedef {Object} LabelRecord + * @property {string} label Display label as provided by the search index. + * @property {string} normalizedLabel Case-insensitive form used for matching. + */ + +/** + * @typedef {Object} SingleFilterQuery + * @property {FilterType} type Filter field name. + * @property {string} token Complete filter token from the query. + * @property {string} value Filter value without the field prefix/quotes. + */ + export function buildFeaturedLabels( rawQuery, labelRecords, @@ -40,28 +63,46 @@ export function buildFeaturedLabels( } } +/** + * Build per-package label records from search-index package objects. + * + * The index template emits `labels` as an always-present, comma-joined + * string. We keep the original label for display, and store a normalized + * copy next to it for case-insensitive de-duping, matching, and counting. + * + * @param {SearchIndexPackage[]} packages + * @returns {LabelRecord[][]} + */ export function buildLabelRecords(packages) { - return (packages ?? []).map((pkg) => { - const labels = parsePackageLabels(pkg?.labels) - const normalized = new Set() + return packages.map((pkg) => { + const labels = parsePackageLabels(pkg.labels) + /** @type {Set} */ + const seenNormalizedLabels = new Set() + /** @type {LabelRecord[]} */ const entries = [] for (const label of labels) { - const normalizedLabel = normalizeValue(label) - if (!normalizedLabel || normalized.has(normalizedLabel)) { + const normalizedLabel = label.toLowerCase() + if (seenNormalizedLabels.has(normalizedLabel)) { continue } - normalized.add(normalizedLabel) + seenNormalizedLabels.add(normalizedLabel) entries.push({ label, normalizedLabel }) } - return { - entries, - normalized, - } + return entries }) } +/** + * Parse queries that contain exactly one supported filter token. + * + * Used for shortcut links whose `q` value should map to one toggleable + * filter. Queries with extra free text or multiple filters return null. + * + * @param {string | null | undefined} rawQuery + * @returns {SingleFilterQuery | null} + */ export function parseSingleFilterQuery(rawQuery) { const query = normalizeQueryWhitespace(rawQuery) if (!query) { @@ -86,13 +127,24 @@ export function parseSingleFilterQuery(rawQuery) { return null } +/** + * Extract label filter values from a query in first-seen order. + * + * Values are de-duped case-insensitively, but returned with their original + * query casing so active shortcut labels can preserve what the user typed. + * + * @param {string | null | undefined} rawQuery + * @returns {string[]} + */ export function extractActiveLabelValues(rawQuery) { + /** @type {Set} */ const seen = new Set() + /** @type {string[]} */ const active = [] for (const { value } of parseFilterMatches(rawQuery, 'label')) { - const normalized = normalizeValue(value) - if (!normalized || seen.has(normalized)) { + const normalized = value.toLowerCase() + if (seen.has(normalized)) { continue } seen.add(normalized) @@ -164,24 +216,30 @@ export function parseFilterMatches(rawQuery, field) { return matches } +/** + * Suggest labels by counting their package frequency across label records. + * + * Active labels, configured exclusions, and free-text query terms are omitted. + * The returned string list is sorted by descending frequency, then label name. + * + * @param {string[]} activeLabels + * @param {LabelRecord[][]} labelRecords + * @param {string[]} excludedLabels + * @param {string[]} excludedQueryTerms + * @returns {string[]} + */ function suggestLabels(activeLabels, labelRecords, excludedLabels, excludedQueryTerms) { - const activeSet = new Set(activeLabels.map(normalizeValue)) - const excludedSet = new Set((excludedLabels ?? []).map(normalizeValue)) - const queryTermSet = new Set((excludedQueryTerms ?? []).map(normalizeValue)) + const omittedLabels = new Set([ + ...activeLabels.map(normalizeValue), + ...excludedLabels.map(normalizeValue), + ...excludedQueryTerms.map(normalizeValue), + ]) + /** @type {Map} */ const counts = new Map() for (const record of labelRecords ?? []) { - if (!record || !record.entries) { - continue - } - - for (const { label, normalizedLabel } of record.entries) { - if ( - !normalizedLabel - || activeSet.has(normalizedLabel) - || excludedSet.has(normalizedLabel) - || queryTermSet.has(normalizedLabel) - ) { + for (const { label, normalizedLabel } of record) { + if (omittedLabels.has(normalizedLabel)) { continue } @@ -240,8 +298,19 @@ function extractFreeTextTerms(rawQuery) { return tokenizeTerms(remainder.join(' ')) } +/** + * Split free-text query fragments into normalized, unique terms. + * + * Quoted phrases are kept as one term. Unquoted text is split on whitespace. + * Returned terms are lower-cased for comparison with normalized labels. + * + * @param {string} value + * @returns {string[]} + */ function tokenizeTerms(value) { + /** @type {string[]} */ const terms = [] + /** @type {Set} */ const seen = new Set() const pattern = /"([^"]+)"|(\S+)/g @@ -258,15 +327,11 @@ function tokenizeTerms(value) { return terms } +/** + * @param {string} value Comma-joined labels from search/index.json.njk. + * @returns {string[]} + */ function parsePackageLabels(value) { - if (Array.isArray(value)) { - return value - .map(label => String(label ?? '').trim()) - .filter(Boolean) - } - if (typeof value !== 'string') { - return [] - } return value .split(',') .map(label => label.trim()) From 158e8998833733b4f395d3946962104ee21cd487 Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 26 Apr 2026 17:46:54 +0200 Subject: [PATCH 5/6] Share search query parsing Introduce a shared parseQueryParts helper that returns filter and text spans without rendering the full query. This keeps shortcut UI helpers and MiniSearch query processing on the same filter syntax while preserving the original query text for span-based edits. Refactor processQueryString to consume the shared parsed parts and add focused parser tests for filter spans, unsupported filters, and quoted prefix searches. --- static/module/search-query.js | 208 +++++++++++++++++++---------- static/module/search-query.test.js | 90 +++++++++++++ static/module/search.js | 137 ++++++++++--------- 3 files changed, 301 insertions(+), 134 deletions(-) diff --git a/static/module/search-query.js b/static/module/search-query.js index e64001599..67771e689 100644 --- a/static/module/search-query.js +++ b/static/module/search-query.js @@ -16,6 +16,29 @@ const SUPPORTED_FILTER_TYPES = ['label', 'platform', 'author'] * @property {string} normalizedLabel Case-insensitive form used for matching. */ +/** + * @typedef {Object} QueryFilterPart + * @property {'filter'} kind + * @property {FilterType} type Filter field name. + * @property {string} token Complete filter token from the query. + * @property {string} value Filter value without the field prefix/quotes. + * @property {boolean} quoted Whether the filter used a closed quoted value. + * @property {number} start Start offset in the original query. + * @property {number} end End offset in the original query. + */ + +/** + * @typedef {Object} QueryTextPart + * @property {'text'} kind + * @property {string} value Raw free-text fragment between filters. + * @property {number} start Start offset in the original query. + * @property {number} end End offset in the original query. + */ + +/** + * @typedef {QueryFilterPart | QueryTextPart} QueryPart + */ + /** * @typedef {Object} SingleFilterQuery * @property {FilterType} type Filter field name. @@ -94,6 +117,24 @@ export function buildLabelRecords(packages) { }) } +/** + * Parse a raw query into filter tokens and remaining free-text spans. + * + * The parser only recognizes the shared filter syntax. It preserves source + * spans so callers can remove tokens without rendering the full query and + * accidentally overwriting the user's formatting. + * + * @param {string | null | undefined} rawQuery + * @returns {QueryPart[]} + */ +export function parseQueryParts(rawQuery) { + const query = String(rawQuery ?? '') + const filters = parseFilterParts(query) + const text = parseTextParts(query, filters) + + return [...filters, ...text].sort((a, b) => a.start - b.start) +} + /** * Parse queries that contain exactly one supported filter token. * @@ -104,27 +145,17 @@ export function buildLabelRecords(packages) { * @returns {SingleFilterQuery | null} */ export function parseSingleFilterQuery(rawQuery) { - const query = normalizeQueryWhitespace(rawQuery) - if (!query) { + const parts = parseQueryParts(rawQuery) + if (parts.length !== 1 || parts[0].kind !== 'filter') { return null } - for (const type of SUPPORTED_FILTER_TYPES) { - const matches = parseFilterMatches(query, type) - if (matches.length !== 1) { - continue - } - const [match] = matches - if (match.start === 0 && match.end === query.length) { - return { - type, - token: match.token, - value: match.value, - } - } + const [part] = parts + return { + type: part.type, + token: part.token, + value: part.value, } - - return null } /** @@ -142,13 +173,17 @@ export function extractActiveLabelValues(rawQuery) { /** @type {string[]} */ const active = [] - for (const { value } of parseFilterMatches(rawQuery, 'label')) { - const normalized = value.toLowerCase() + for (const part of parseQueryParts(rawQuery)) { + if (part.kind !== 'filter' || part.type !== 'label') { + continue + } + + const normalized = part.value.toLowerCase() if (seen.has(normalized)) { continue } seen.add(normalized) - active.push(value) + active.push(part.value) } return active @@ -195,25 +230,9 @@ export function normalizeQueryWhitespace(rawQuery) { } export function parseFilterMatches(rawQuery, field) { - const query = String(rawQuery ?? '') - const pattern = new RegExp(`${field}:"([^"]+)"|${field}:"([^"]*)$|${field}:([^\\s]+)`, 'gi') - const matches = [] - - let match - while ((match = pattern.exec(query)) !== null) { - const value = (match[1] ?? match[2] ?? match[3] ?? '').trim() - if (!value) { - continue - } - matches.push({ - token: match[0], - value, - start: match.index, - end: match.index + match[0].length, - }) - } - - return matches + return parseQueryParts(rawQuery) + .filter(part => part.kind === 'filter' && part.type === field) + .map(({ token, value, start, end }) => ({ token, value, start, end })) } /** @@ -263,39 +282,12 @@ function suggestLabels(activeLabels, labelRecords, excludedLabels, excludedQuery } function extractFreeTextTerms(rawQuery) { - const query = String(rawQuery ?? '') - if (!query.trim()) { - return [] - } - - const filterSpans = [] - for (const type of SUPPORTED_FILTER_TYPES) { - for (const match of parseFilterMatches(query, type)) { - filterSpans.push({ start: match.start, end: match.end }) - } - } - - if (filterSpans.length === 0) { - return tokenizeTerms(query) - } - - filterSpans.sort((a, b) => a.start - b.start) - - const remainder = [] - let cursor = 0 - - for (const span of filterSpans) { - if (span.start > cursor) { - remainder.push(query.slice(cursor, span.start)) - } - cursor = Math.max(cursor, span.end) - } - - if (cursor < query.length) { - remainder.push(query.slice(cursor)) - } - - return tokenizeTerms(remainder.join(' ')) + return tokenizeTerms( + parseQueryParts(rawQuery) + .filter(part => part.kind === 'text') + .map(part => part.value) + .join(' '), + ) } /** @@ -327,6 +319,70 @@ function tokenizeTerms(value) { return terms } +function parseFilterParts(query) { + /** @type {QueryFilterPart[]} */ + const parts = [] + const filters = SUPPORTED_FILTER_TYPES.join('|') + const pattern = new RegExp( + `(${filters}):"([^"]+)"|(${filters}):"([^"]*)$|(${filters}):([^\\s]+)`, + 'gi', + ) + + let match + while ((match = pattern.exec(query)) !== null) { + const rawType = match[1] ?? match[3] ?? match[5] + const value = (match[2] ?? match[4] ?? match[6] ?? '').trim() + if (!value) { + continue + } + + parts.push({ + kind: 'filter', + type: normalizeFilterType(rawType), + token: match[0], + value, + quoted: match[1] !== undefined, + start: match.index, + end: match.index + match[0].length, + }) + } + + return parts +} + +function parseTextParts(query, filterParts) { + /** @type {QueryTextPart[]} */ + const parts = [] + let cursor = 0 + + for (const part of filterParts) { + if (part.start > cursor) { + pushTextPart(parts, query, cursor, part.start) + } + cursor = Math.max(cursor, part.end) + } + + if (cursor < query.length) { + pushTextPart(parts, query, cursor, query.length) + } + + return parts +} + +function pushTextPart(parts, query, start, end) { + const value = query.slice(start, end) + if (!value.trim()) { + return + } + + parts.push({ + kind: 'text', + value, + start, + end, + }) +} + /** * @param {string} value Comma-joined labels from search/index.json.njk. * @returns {string[]} @@ -338,6 +394,14 @@ function parsePackageLabels(value) { .filter(Boolean) } +/** + * @param {string} value + * @returns {FilterType} + */ +function normalizeFilterType(value) { + return /** @type {FilterType} */ (value.toLowerCase()) +} + function normalizeValue(value) { return String(value ?? '').trim().toLowerCase() } diff --git a/static/module/search-query.test.js b/static/module/search-query.test.js index a993961fd..a88f92b1a 100644 --- a/static/module/search-query.test.js +++ b/static/module/search-query.test.js @@ -6,10 +6,100 @@ import { buildLabelRecords, extractActiveLabelValues, hasFilterValue, + parseQueryParts, parseSingleFilterQuery, removeFilterValue, } from './search-query.js' +describe('parseQueryParts', () => { + it('parses supported filters and free-text spans', () => { + const query = 'react author:dan label:"starter kit" platform:web' + + expect(parseQueryParts(query)).toEqual([ + { + kind: 'text', + value: 'react ', + start: 0, + end: query.indexOf('author:dan'), + }, + { + kind: 'filter', + type: 'author', + token: 'author:dan', + value: 'dan', + quoted: false, + start: query.indexOf('author:dan'), + end: query.indexOf('author:dan') + 'author:dan'.length, + }, + { + kind: 'filter', + type: 'label', + token: 'label:"starter kit"', + value: 'starter kit', + quoted: true, + start: query.indexOf('label:"starter kit"'), + end: query.indexOf('label:"starter kit"') + 'label:"starter kit"'.length, + }, + { + kind: 'filter', + type: 'platform', + token: 'platform:web', + value: 'web', + quoted: false, + start: query.indexOf('platform:web'), + end: query.indexOf('platform:web') + 'platform:web'.length, + }, + ]) + }) + + it('keeps unsupported filters in free text', () => { + const query = 'status:active label:theme' + + expect(parseQueryParts(query)).toEqual([ + { + kind: 'text', + value: 'status:active ', + start: 0, + end: query.indexOf('label:theme'), + }, + { + kind: 'filter', + type: 'label', + token: 'label:theme', + value: 'theme', + quoted: false, + start: query.indexOf('label:theme'), + end: query.length, + }, + ]) + }) + + it('distinguishes exact quoted filters from prefix quoted filters', () => { + const query = 'label:"full phrase" label:"prefix' + + expect(parseQueryParts(query)).toEqual([ + { + kind: 'filter', + type: 'label', + token: 'label:"full phrase"', + value: 'full phrase', + quoted: true, + start: 0, + end: 'label:"full phrase"'.length, + }, + { + kind: 'filter', + type: 'label', + token: 'label:"prefix', + value: 'prefix', + quoted: false, + start: query.indexOf('label:"prefix'), + end: query.length, + }, + ]) + }) +}) + describe('parseSingleFilterQuery', () => { it('parses a single filter token', () => { expect(parseSingleFilterQuery('label:"python"')).toEqual({ diff --git a/static/module/search.js b/static/module/search.js index b775ce743..c68e37ea3 100644 --- a/static/module/search.js +++ b/static/module/search.js @@ -1,3 +1,5 @@ +import { parseQueryParts } from './search-query.js' + export class Search { minisearch = null options = { @@ -43,37 +45,12 @@ export class Search { } export function processQueryString(rawValue = '', filterFlags = {}) { + /** @type {Array} */ const queries = [] + /** @type {{ field: string, value: string }[]} */ const exactMatches = [] - let hasFreeText = false - - let value = typeof rawValue === 'string' ? rawValue : String(rawValue ?? '') - value = rewriteSyntheticLabelAliases(value) - - const extractFilter = (field, regex, buildQuery = () => {}) => { - const matches = [] - let match - while ((match = regex.exec(value)) !== null) { - matches.push(match) - } - regex.lastIndex = 0 - - matches.forEach((currentMatch) => { - const [, quoted, prefixQuoted, unquoted] = currentMatch - const filterValue = (quoted ?? prefixQuoted ?? unquoted)?.trim() - if (!filterValue) { - return - } - - queries.push({ fields: [field], queries: [filterValue], ...buildQuery(filterValue) }) - if (quoted) { - exactMatches.push({ field, value: filterValue }) - } - - value = value.replace(currentMatch[0], ' ') - }) - } - + const value = rewriteSyntheticLabelAliases(String(rawValue ?? '')) + const parts = parseQueryParts(value) const filters = { author: true, label: true, @@ -81,46 +58,29 @@ export function processQueryString(rawValue = '', filterFlags = {}) { ...filterFlags, } - const regexFor = field => - new RegExp(`${field}:"([^"]+)"|${field}:"([^"]*)$|${field}:([^\\s]+)`, 'gi') - - if (filters.author) { - extractFilter('author', regexFor('author')) - } + for (const type of ['author', 'label', 'platform']) { + if (!filters[type]) { + continue + } - if (filters.label) { - extractFilter('labels', regexFor('label')) - } + for (const part of parts) { + if (part.kind !== 'filter' || part.type !== type) { + continue + } - if (filters.platform) { - extractFilter('platforms', regexFor('platform'), (platformValue) => { - // The lexer splits at "-" but we want "windows-x32" to *not* match - // "linux-x32". - const parts = platformValue.split(/[-\s]+/).filter(Boolean) - const query - = parts.length > 1 - ? { - fields: ['platforms'], - combineWith: 'AND', - queries: parts, - } - : platformValue - - return { - fields: ['platforms'], - combineWith: 'OR', - queries: [query, 'any'], + queries.push(buildFilterQuery(part)) + if (part.quoted) { + exactMatches.push({ field: searchFieldForFilterType(part.type), value: part.value }) } - }) + } } - const trimmed = value.trim() - if (trimmed.length > 0) { + const freeText = extractSearchableText(parts, filters) + if (freeText.length > 0) { queries.push({ - queries: trimmed.split(/\s+/), + queries: freeText.split(/\s+/), fields: ['name', 'description', 'author', 'labels'], }) - hasFreeText = true } const filter = result => exactMatches.every(({ field, value: expected }) => { @@ -134,7 +94,60 @@ export function processQueryString(rawValue = '', filterFlags = {}) { return value.toLowerCase().split(',').map(x => x.trim()).includes(expectedL) }) - return { queries, hasFreeText, filter } + return { queries, hasFreeText: freeText.length > 0, filter } +} + +function buildFilterQuery(part) { + const field = searchFieldForFilterType(part.type) + if (part.type !== 'platform') { + return { fields: [field], queries: [part.value] } + } + + return { + fields: [field], + combineWith: 'OR', + queries: [buildPlatformQuery(part.value), 'any'], + } +} + +function buildPlatformQuery(value) { + // The lexer splits at "-" but we want "windows-x32" to *not* match + // "linux-x32". + const parts = value.split(/[-\s]+/).filter(Boolean) + if (parts.length <= 1) { + return value + } + + return { + fields: ['platforms'], + combineWith: 'AND', + queries: parts, + } +} + +function extractSearchableText(parts, filters) { + return parts + .flatMap((part) => { + if (part.kind === 'text') { + return [part.value] + } + if (!filters[part.type]) { + return [part.token] + } + return [] + }) + .join(' ') + .trim() +} + +function searchFieldForFilterType(type) { + if (type === 'label') { + return 'labels' + } + if (type === 'platform') { + return 'platforms' + } + return 'author' } const SYNTHETIC_LABEL_ALIASES = { From 9300f145af00f0df397d4bb3f6daa30e509b556d Mon Sep 17 00:00:00 2001 From: herr kaste Date: Sun, 26 Apr 2026 18:08:32 +0200 Subject: [PATCH 6/6] Hide dynamic labels while initializing Hide the default featured label shortcuts during search initialization so the page does not briefly show the static labels before the dynamic list is rendered. --- static/styles.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/static/styles.css b/static/styles.css index d1cc91912..55f40e490 100644 --- a/static/styles.css +++ b/static/styles.css @@ -36,6 +36,9 @@ html { section[name="labels"] { display: none; } + form[name='search'] .search-shortcuts .button-group.labels { + visibility: hidden; + } } }