diff --git a/static/index.js b/static/index.js index c586c88e8..4f2f0211a 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 allLabelRecords = buildLabelRecords(packages) window.__LABEL_ICON_ALIASES__ = rawIndex.label_icon_aliases ?? {} window.__LABEL_ICON_TINTS__ = rawIndex.label_icon_tints ?? {} @@ -27,75 +47,71 @@ 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, featuredPackages) { + renderFeaturedLabels(query, featuredPackages) + updateFilterButtonStates(query) } -function updateFilterButtonStates(query) { - if (!filterButtons.length) { +function renderFeaturedLabels(query, featuredPackages) { + 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 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, + }) + + 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 +139,7 @@ function setSearchPending(pending) { // Handle initial page load setSearchPending(false) +updateSearchFilterUi(input.value) syncFromUrl({ initialPageLoad: true }) const handleInput = () => { @@ -192,37 +209,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) } } @@ -238,10 +243,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) 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 new file mode 100644 index 000000000..67771e689 --- /dev/null +++ b/static/module/search-query.js @@ -0,0 +1,407 @@ +/** + * @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} 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. + * @property {string} token Complete filter token from the query. + * @property {string} value Filter value without the field prefix/quotes. + */ + +export function buildFeaturedLabels( + rawQuery, + labelRecords, + { + defaults = [], + maxTotal = 6, + excludedLabels = [], + } = {}, +) { + const activeLabels = extractActiveLabelValues(rawQuery) + const hasQuery = normalizeQueryWhitespace(rawQuery).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 + ? 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, + } +} + +/** + * 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) + /** @type {Set} */ + const seenNormalizedLabels = new Set() + /** @type {LabelRecord[]} */ + const entries = [] + + for (const label of labels) { + const normalizedLabel = label.toLowerCase() + if (seenNormalizedLabels.has(normalizedLabel)) { + continue + } + seenNormalizedLabels.add(normalizedLabel) + entries.push({ label, normalizedLabel }) + } + + return entries + }) +} + +/** + * 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. + * + * 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 parts = parseQueryParts(rawQuery) + if (parts.length !== 1 || parts[0].kind !== 'filter') { + return null + } + + const [part] = parts + return { + type: part.type, + token: part.token, + value: part.value, + } +} + +/** + * 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 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(part.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) { + return parseQueryParts(rawQuery) + .filter(part => part.kind === 'filter' && part.type === field) + .map(({ token, value, start, end }) => ({ token, value, start, end })) +} + +/** + * 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 omittedLabels = new Set([ + ...activeLabels.map(normalizeValue), + ...excludedLabels.map(normalizeValue), + ...excludedQueryTerms.map(normalizeValue), + ]) + /** @type {Map} */ + const counts = new Map() + + for (const record of labelRecords ?? []) { + for (const { label, normalizedLabel } of record) { + if (omittedLabels.has(normalizedLabel)) { + continue + } + + const existing = counts.get(normalizedLabel) + if (existing) { + existing.count += 1 + } else { + counts.set(normalizedLabel, { label, count: 1 }) + } + } + } + + return [...counts.values()] + .sort((a, b) => { + if (b.count !== a.count) { + return b.count - a.count + } + return a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + }) + .map(({ label }) => label) +} + +function extractFreeTextTerms(rawQuery) { + return tokenizeTerms( + parseQueryParts(rawQuery) + .filter(part => part.kind === 'text') + .map(part => part.value) + .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 + + 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 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[]} + */ +function parsePackageLabels(value) { + return value + .split(',') + .map(label => label.trim()) + .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 new file mode 100644 index 000000000..a88f92b1a --- /dev/null +++ b/static/module/search-query.test.js @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest' + +import { + appendFilterToken, + buildFeaturedLabels, + 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({ + 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 allRecords = buildLabelRecords([ + { labels: 'python,snippets,linting,theme,FAILING' }, + { labels: 'python,snippets,color scheme' }, + { labels: 'python,linting,theme' }, + { labels: 'python,snippets,language syntax' }, + { labels: 'go,snippets' }, + ]) + + 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('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('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, allRecords, { defaults, maxTotal: 6, excludedLabels: excluded })).toEqual({ + labels: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + activeLabels: ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + }) + }) +}) 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 = { 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; + } } }