diff --git a/static/index.js b/static/index.js index c586c88e8..2926653d4 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,64 @@ 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') + 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 +132,7 @@ function setSearchPending(pending) { // Handle initial page load setSearchPending(false) +updateSearchFilterUi(input.value) syncFromUrl({ initialPageLoad: true }) const handleInput = () => { @@ -192,37 +202,22 @@ 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 && target.classList.contains('is-active') && hasFilterValue(oldQuery, clicked.type, clicked.value)) { + newQuery = removeFilterValue(oldQuery, clicked.type, clicked.value) + } else if (clicked && ['author', 'label'].includes(clicked.type)) { + newQuery = clicked.token + } else if (clicked?.type === 'platform') { + if (hasFilterValue(oldQuery, clicked.type, clicked.value)) { + newQuery = removeFilterValue(oldQuery, clicked.type, clicked.value) } else { - newQuery = `${oldQuery} ${newToken}`.trim() + newQuery = appendFilterToken(oldQuery, clicked.token) } - - 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'], + }) + }) +})