diff --git a/AGENTS.md b/AGENTS.md index 340333d90c..880494dfcd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,6 +76,26 @@ It is built using HTML5, CSS, TypeScript, and PHP and supports various databases - Use single quotes for strings. - Use arrow functions for callbacks. +# Coding Patterns + +- Prefer guard clauses and early returns over deep conditional nesting +- Name classes and methods after domain concepts, not technical mechanics (e.g. `Subscription.renew()`, not `DataRecord.update()`) +- Isolate external dependencies behind an interface you own, so they can be swapped or mocked at the boundary +- Make illegal states unrepresentable — encode invariants in types and constructors instead of re-validating them everywhere +- Separate decisions (pure logic that computes *what* to do) from actions (side effects that carry it out) +- Keep functions small and single-purpose +- Fail loudly and specifically: errors state what went wrong, where, and what to do next — structured enough for machines to parse, readable enough for humans to act on + +## Making illegal states unrepresentable in PHP + +- Use `enum` for fixed sets instead of string/int constants or magic values (e.g. `enum FaqStatus { case Draft; case Published; case Archived; }`) +- Push validation into the constructor and throw on bad input, so an object cannot exist in an invalid state — never construct first and validate later +- Use named constructors (private `__construct` + static `fromString()`, `create()`) when there are several distinct, valid ways to build an object +- Make value objects `readonly` so invariants checked at construction can't be mutated away afterward (`final readonly class EmailAddress`) +- Prefer required, typed, non-nullable constructor arguments over nullable properties with setters; if a value is optional, model that explicitly rather than leaning on `null` +- Replace pervasive `null` checks with a Null Object or a typed result/option where it removes branching +- Mark classes `final` by default; open them for extension only when you mean to + ## Agent Workflow When implementing changes: diff --git a/phpmyfaq/assets/scss/layout/_autocomplete.scss b/phpmyfaq/assets/scss/layout/_autocomplete.scss index aa4e46c44c..664bb8d4ec 100644 --- a/phpmyfaq/assets/scss/layout/_autocomplete.scss +++ b/phpmyfaq/assets/scss/layout/_autocomplete.scss @@ -25,12 +25,39 @@ right: 5px; height: 50px; } + + .pmf-search-hint { + position: absolute; + top: 16px; + right: 130px; + padding: 2px 8px; + font-size: 12px; + color: var(--bs-secondary-color); + background-color: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: 4px; + pointer-events: none; + } +} + +.pmf-search-modal-body { + .search { + box-shadow: none; + } } .autocomplete { @extend .list-group; z-index: 1000; + + .pmf-search-empty { + cursor: pointer; + } + + .pmf-searched-question strong { + color: var(--bs-primary); + } } .autocomplete > div { diff --git a/phpmyfaq/assets/src/api/index.ts b/phpmyfaq/assets/src/api/index.ts index a69a648fec..70a44ab5b7 100644 --- a/phpmyfaq/assets/src/api/index.ts +++ b/phpmyfaq/assets/src/api/index.ts @@ -3,6 +3,7 @@ export * from './bookmarks'; export * from './comment'; export * from './contact'; export * from './faq'; +export * from './popularSearches'; export * from './question'; export * from './register'; export * from './share'; diff --git a/phpmyfaq/assets/src/api/popularSearches.test.ts b/phpmyfaq/assets/src/api/popularSearches.test.ts new file mode 100644 index 0000000000..8a30954b4f --- /dev/null +++ b/phpmyfaq/assets/src/api/popularSearches.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { fetchPopularSearches } from './popularSearches'; +import createFetchMock, { FetchMock } from 'vitest-fetch-mock'; + +const fetchMocker: FetchMock = createFetchMock(vi); +fetchMocker.enableMocks(); + +describe('fetchPopularSearches', (): void => { + beforeEach((): void => { + fetchMocker.resetMocks(); + }); + + test('returns the parsed list on success and coerces the count to a number', async (): Promise => { + fetchMocker.mockResponseOnce(JSON.stringify([{ id: 1, searchterm: 'mac', number: '18' }])); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([{ id: 1, searchterm: 'mac', number: 18 }]); + expect(fetch).toHaveBeenCalledWith('api/searches/popular', { + method: 'GET', + cache: 'no-cache', + headers: { 'Content-Type': 'application/json' }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + }); + + test('drops malformed entries and keeps only well-formed ones', async (): Promise => { + fetchMocker.mockResponseOnce( + JSON.stringify([ + { id: 1, searchterm: 'mac', number: 18 }, + { id: 2, searchterm: '', number: 5 }, // empty searchterm + { id: 3, number: 7 }, // missing searchterm + { id: 4, searchterm: 'linux', number: 'not-a-number' }, // non-coercible + { id: 5, searchterm: 'php', number: null }, // invalid number type + 'nonsense', // not an object + { id: 6, searchterm: 'sqlite', number: '9' }, // valid, coerced + ]) + ); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([ + { id: 1, searchterm: 'mac', number: 18 }, + { id: 6, searchterm: 'sqlite', number: 9 }, + ]); + }); + + test('returns an empty array when the payload is not an array', async (): Promise => { + fetchMocker.mockResponseOnce(JSON.stringify({ unexpected: 'shape' })); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([]); + }); + + test('returns an empty array when a 200 response has an invalid JSON body', async (): Promise => { + fetchMocker.mockResponseOnce('not valid json', { status: 200 }); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([]); + }); + + test('returns an empty array on a non-ok response', async (): Promise => { + fetchMocker.mockResponseOnce('[]', { status: 404 }); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([]); + }); + + test('returns an empty array when fetch rejects', async (): Promise => { + fetchMocker.mockRejectOnce(new Error('API is down')); + + const data = await fetchPopularSearches(); + + expect(data).toEqual([]); + }); +}); diff --git a/phpmyfaq/assets/src/api/popularSearches.ts b/phpmyfaq/assets/src/api/popularSearches.ts new file mode 100644 index 0000000000..81191ad7e6 --- /dev/null +++ b/phpmyfaq/assets/src/api/popularSearches.ts @@ -0,0 +1,77 @@ +/** + * Popular searches API functionality + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +import { PopularSearch, PopularSearchResponse } from '../interfaces'; + +/** + * Validates the raw JSON payload and returns only well-formed entries. + * The backend is trusted but not guaranteed; malformed items are dropped so they + * never reach the autocomplete rendering. Numeric fields are coerced to numbers. + */ +const normalizePopularSearches = (data: unknown): PopularSearchResponse => { + if (!Array.isArray(data)) { + return []; + } + + const result: PopularSearch[] = []; + + for (const entry of data) { + if (typeof entry !== 'object' || entry === null) { + continue; + } + + const record = entry as Record; + const { searchterm, number } = record; + + if ( + typeof searchterm !== 'string' || + searchterm.trim() === '' || + (typeof number !== 'number' && typeof number !== 'string') + ) { + continue; + } + + const count = Number(number); + if (!Number.isFinite(count)) { + continue; + } + + result.push({ id: Number(record.id) || 0, searchterm, number: count }); + } + + return result; +}; + +export const fetchPopularSearches = async (): Promise => { + try { + const response: Response = await fetch('api/searches/popular', { + method: 'GET', + cache: 'no-cache', + headers: { + 'Content-Type': 'application/json', + }, + redirect: 'follow', + referrerPolicy: 'no-referrer', + }); + + if (!response.ok) { + return []; + } + + return normalizePopularSearches(await response.json()); + } catch { + return []; + } +}; diff --git a/phpmyfaq/assets/src/frontend.test.ts b/phpmyfaq/assets/src/frontend.test.ts index ae130714f8..2b41308f4b 100644 --- a/phpmyfaq/assets/src/frontend.test.ts +++ b/phpmyfaq/assets/src/frontend.test.ts @@ -45,11 +45,15 @@ vi.mock('./faq', () => ({ const mockHandleAutoComplete = vi.fn(); const mockHandleCategorySelection = vi.fn(); const mockHandleQuestion = vi.fn(); +const mockInitSearchShortcut = vi.fn(); +const mockInitSearchShortcutBadge = vi.fn(); vi.mock('./search', () => ({ handleAutoComplete: () => mockHandleAutoComplete(), handleCategorySelection: () => mockHandleCategorySelection(), handleQuestion: () => mockHandleQuestion(), + initSearchShortcut: () => mockInitSearchShortcut(), + initSearchShortcutBadge: () => mockInitSearchShortcutBadge(), })); const mockHandleDeleteBookmarks = vi.fn(); diff --git a/phpmyfaq/assets/src/frontend.ts b/phpmyfaq/assets/src/frontend.ts index 55ce8b4c80..17843fbc39 100644 --- a/phpmyfaq/assets/src/frontend.ts +++ b/phpmyfaq/assets/src/frontend.ts @@ -25,7 +25,13 @@ import { handleUserVoting, renderFaqEditor, } from './faq'; -import { handleAutoComplete, handleCategorySelection, handleQuestion } from './search'; +import { + handleAutoComplete, + handleCategorySelection, + handleQuestion, + initSearchShortcut, + initSearchShortcutBadge, +} from './search'; import { handleDeleteBookmarks, handleRegister, @@ -121,6 +127,8 @@ document.addEventListener('DOMContentLoaded', (): void => { // AutoComplete handleAutoComplete(); handleCategorySelection(); + initSearchShortcut(); + initSearchShortcutBadge(); // Handle Chat handleChat(); diff --git a/phpmyfaq/assets/src/interfaces/PopularSearch.ts b/phpmyfaq/assets/src/interfaces/PopularSearch.ts new file mode 100644 index 0000000000..cdf2dcc4fb --- /dev/null +++ b/phpmyfaq/assets/src/interfaces/PopularSearch.ts @@ -0,0 +1,7 @@ +export interface PopularSearch { + id: number; + searchterm: string; + number: number | string; +} + +export type PopularSearchResponse = PopularSearch[]; diff --git a/phpmyfaq/assets/src/interfaces/index.ts b/phpmyfaq/assets/src/interfaces/index.ts index 3a806039b7..e240e3b0c4 100644 --- a/phpmyfaq/assets/src/interfaces/index.ts +++ b/phpmyfaq/assets/src/interfaces/index.ts @@ -4,3 +4,4 @@ export * from './Bookmark'; export * from './authenticatorResponse'; export * from './Chat'; export * from './suggestionItem'; +export * from './PopularSearch'; diff --git a/phpmyfaq/assets/src/interfaces/suggestionItem.ts b/phpmyfaq/assets/src/interfaces/suggestionItem.ts index 7aaf06a6f5..1113f20075 100644 --- a/phpmyfaq/assets/src/interfaces/suggestionItem.ts +++ b/phpmyfaq/assets/src/interfaces/suggestionItem.ts @@ -1,9 +1,14 @@ import { AutocompleteItem } from 'autocompleter'; +export type SuggestionType = 'result' | 'recent' | 'popular' | 'empty'; + export interface SuggestionItem { - question: string; - category: string; + type?: SuggestionType; url: string; + question?: string; + category?: string; + searchTerm?: string; + count?: number; } export type Suggestion = SuggestionItem & AutocompleteItem; diff --git a/phpmyfaq/assets/src/search/autocomplete.test.ts b/phpmyfaq/assets/src/search/autocomplete.test.ts index 3b4a3c8644..a694fd43a8 100644 --- a/phpmyfaq/assets/src/search/autocomplete.test.ts +++ b/phpmyfaq/assets/src/search/autocomplete.test.ts @@ -6,142 +6,188 @@ vi.mock('autocompleter', () => ({ vi.mock('../api', () => ({ fetchAutoCompleteData: vi.fn(), + fetchPopularSearches: vi.fn(), +})); + +vi.mock('./recentSearches', () => ({ + getRecentSearches: vi.fn(() => []), + addRecentSearch: vi.fn(), })); vi.mock('../utils', () => ({ - addElement: vi.fn((tag: string, props: Record, children: Node[] = []) => { + addElement: vi.fn((tag: string, props: Record = {}, children: Node[] = []) => { const el = document.createElement(tag); if (props.classList) el.className = props.classList; - if (props.innerText) el.innerText = props.innerText; + // jsdom does not reflect innerText into textContent, so treat it as text content. + if (props.innerText) el.textContent = props.innerText; if (props.textContent) el.textContent = props.textContent; children.forEach((child) => el.appendChild(child)); return el; }), + TranslationService: class { + async loadTranslations(): Promise {} + translate(key: string): string { + return key; + } + }, })); -import { handleAutoComplete } from './autocomplete'; -import { fetchAutoCompleteData } from '../api'; -import { Suggestion } from '../interfaces'; +import { attachAutocomplete, handleAutoComplete } from './autocomplete'; +import { fetchAutoCompleteData, fetchPopularSearches } from '../api'; +import { getRecentSearches, addRecentSearch } from './recentSearches'; +import { AutocompleteSearchResponse, Suggestion } from '../interfaces'; import autocomplete from 'autocompleter'; const mockAutocomplete = vi.mocked(autocomplete); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const getConfig = (): any => mockAutocomplete.mock.calls[0][0]; -describe('handleAutoComplete', () => { +describe('attachAutocomplete', () => { beforeEach(() => { vi.clearAllMocks(); document.body.innerHTML = ''; + vi.mocked(getRecentSearches).mockReturnValue([]); + vi.mocked(fetchPopularSearches).mockResolvedValue([]); + vi.mocked(fetchAutoCompleteData).mockResolvedValue([]); }); - it('should do nothing when autocomplete input is missing', () => { - document.body.innerHTML = '
'; - - handleAutoComplete(); - - expect(mockAutocomplete).not.toHaveBeenCalled(); - }); - - it('should initialize autocomplete when input element exists', () => { - document.body.innerHTML = ''; - - handleAutoComplete(); - + it('initialises autocomplete on the given input', () => { + const input = document.createElement('input'); + attachAutocomplete(input); expect(mockAutocomplete).toHaveBeenCalledTimes(1); + expect(getConfig().input).toBe(input); + expect(getConfig().showOnFocus).toBe(true); + expect(getConfig().debounceWaitMs).toBe(200); }); - it('should pass the input element to autocomplete', () => { - document.body.innerHTML = ''; + it('fetches live results mapped to result items when typing', async () => { + vi.mocked(fetchAutoCompleteData).mockResolvedValue([ + { question: 'How to install?', category: 'Setup', url: '/faq/1' }, + ] as AutocompleteSearchResponse); + attachAutocomplete(document.createElement('input')); - handleAutoComplete(); + const update = vi.fn(); + await getConfig().fetch('Install', update); - expect(getConfig().input).toBe(document.getElementById('pmf-search-autocomplete')); + expect(fetchAutoCompleteData).toHaveBeenCalledWith('install'); + const items = update.mock.calls[0][0] as Suggestion[]; + expect(items[0].type).toBe('result'); }); - it('should configure debounce wait time', () => { - document.body.innerHTML = ''; + it('shows a no-results helper when a non-empty query returns nothing', async () => { + vi.mocked(fetchAutoCompleteData).mockResolvedValue([]); + attachAutocomplete(document.createElement('input')); - handleAutoComplete(); + const update = vi.fn(); + await getConfig().fetch('zzz', update); - expect(getConfig().debounceWaitMs).toBe(200); + const items = update.mock.calls[0][0] as Suggestion[]; + expect(items).toHaveLength(1); + expect(items[0].type).toBe('empty'); }); - it('should fetch data and call update in the fetch callback', async () => { - document.body.innerHTML = ''; - - const mockResults: Suggestion[] = [ - { question: 'How to install?', category: 'Setup', url: '/faq/1' } as Suggestion, - { question: 'How to configure?', category: 'Config', url: '/faq/2' } as Suggestion, - ]; - vi.mocked(fetchAutoCompleteData).mockResolvedValue(mockResults); - - handleAutoComplete(); + it('composes recent then popular items on empty input', async () => { + vi.mocked(getRecentSearches).mockReturnValue(['mac']); + vi.mocked(fetchPopularSearches).mockResolvedValue([{ id: 1, searchterm: 'linux', number: '9' }]); + attachAutocomplete(document.createElement('input')); const update = vi.fn(); - await getConfig().fetch('Test Query', update); + await getConfig().fetch('', update); - expect(fetchAutoCompleteData).toHaveBeenCalledWith('test query'); - expect(update).toHaveBeenCalledWith(mockResults); + const items = update.mock.calls[0][0] as Suggestion[]; + expect(items.map((i) => i.type)).toEqual(['recent', 'popular']); + expect(items[0].searchTerm).toBe('mac'); + expect(items[1].searchTerm).toBe('linux'); }); - it('should convert search string to lowercase in fetch callback', async () => { - document.body.innerHTML = ''; + it('returns no empty-state items when there are no recent or popular searches', async () => { + attachAutocomplete(document.createElement('input')); + const update = vi.fn(); + await getConfig().fetch('', update); + expect(update).toHaveBeenCalledWith([]); + }); - vi.mocked(fetchAutoCompleteData).mockResolvedValue([]); + it('clears suggestions when fetchAutoCompleteData rejects', async () => { + vi.mocked(fetchAutoCompleteData).mockRejectedValueOnce(new Error('Network response was not ok.')); + attachAutocomplete(document.createElement('input')); + const update = vi.fn(); + await getConfig().fetch('install', update); + expect(update).toHaveBeenCalledWith([]); + }); - handleAutoComplete(); + it('records the term and navigates on select of a result', () => { + const input = document.createElement('input'); + input.value = 'install'; + const mockLocation = { href: '' }; + Object.defineProperty(window, 'location', { value: mockLocation, writable: true }); + attachAutocomplete(input); - await getConfig().fetch('UPPERCASE Query', vi.fn()); + getConfig().onSelect({ type: 'result', url: '/faq/42' } as Suggestion); - expect(fetchAutoCompleteData).toHaveBeenCalledWith('uppercase query'); + expect(addRecentSearch).toHaveBeenCalledWith('install'); + expect(mockLocation.href).toBe('/faq/42'); }); - it('should navigate to item URL on select', () => { - document.body.innerHTML = ''; - + it('records the search term and navigates on select of a popular item', () => { const mockLocation = { href: '' }; Object.defineProperty(window, 'location', { value: mockLocation, writable: true }); + attachAutocomplete(document.createElement('input')); - handleAutoComplete(); + getConfig().onSelect({ type: 'popular', searchTerm: 'linux', url: 'search.html?search=linux' } as Suggestion); - const item: Suggestion = { question: 'FAQ', category: 'General', url: '/faq/42' } as Suggestion; - getConfig().onSelect(item); + expect(addRecentSearch).toHaveBeenCalledWith('linux'); + expect(mockLocation.href).toBe('search.html?search=linux'); + }); - expect(mockLocation.href).toBe('/faq/42'); + it('handleAutoComplete does nothing without the inline input', () => { + document.body.innerHTML = '
'; + handleAutoComplete(); + expect(mockAutocomplete).not.toHaveBeenCalled(); }); - it('should render suggestion items with category and question', () => { + it('handleAutoComplete attaches to the inline input when present', () => { document.body.innerHTML = ''; - handleAutoComplete(); + expect(mockAutocomplete).toHaveBeenCalledTimes(1); + expect(getConfig().input).toBe(document.getElementById('pmf-search-autocomplete')); + }); - const item: Suggestion = { - question: 'How to install phpMyFAQ?', - category: 'Installation', - url: '/faq/1', - } as Suggestion; - - const rendered = getConfig().render(item) as HTMLElement; - - expect(rendered.tagName).toBe('LI'); - expect(rendered.classList.contains('list-group-item')).toBe(true); - - const categoryEl = rendered.querySelector('.fw-bold') as HTMLElement | null; - expect(categoryEl?.innerText).toBe('Installation'); - - const questionEl = rendered.querySelector('.pmf-searched-question'); - expect(questionEl?.textContent).toBe('How to install phpMyFAQ?'); + it('renders a no-results helper for an empty item', () => { + attachAutocomplete(document.createElement('input')); + const el = getConfig().render({ type: 'empty', url: 'search.html?search=x' } as Suggestion, '') as HTMLElement; + expect(el.classList.contains('pmf-search-empty')).toBe(true); + expect(el.textContent).toContain('msgNoSearchResults'); + expect(el.textContent).toContain('msgAskQuestionInstead'); }); - it('should create a list-group container', () => { - document.body.innerHTML = ''; + it('renders a popular item with a count badge', () => { + attachAutocomplete(document.createElement('input')); + const el = getConfig().render( + { type: 'popular', searchTerm: 'linux', count: 9, url: 'search.html?search=linux' } as Suggestion, + '' + ) as HTMLElement; + expect(el.textContent).toContain('linux'); + expect(el.textContent).toContain('9x'); + }); - handleAutoComplete(); + it('does not render a badge when the popular count is NaN', () => { + attachAutocomplete(document.createElement('input')); + const el = getConfig().render( + { type: 'popular', searchTerm: 'linux', count: Number('abc'), url: 'search.html?search=linux' } as Suggestion, + '' + ) as HTMLElement; + expect(el.textContent).toContain('linux'); + expect(el.textContent).not.toContain('NaN'); + }); - const container = getConfig().container as HTMLElement; - expect(container).toBeDefined(); - expect(container.tagName).toBe('UL'); - expect(container.classList.contains('list-group')).toBe(true); + it('renders a result item and highlights the matched query', () => { + attachAutocomplete(document.createElement('input')); + const el = getConfig().render( + { type: 'result', question: 'Install phpMyFAQ', category: 'Setup', url: '/faq/1' } as Suggestion, + 'install' + ) as HTMLElement; + expect(el.textContent).toContain('Setup'); + const strong = el.querySelector('strong'); + expect(strong?.textContent).toBe('Install'); }); }); diff --git a/phpmyfaq/assets/src/search/autocomplete.ts b/phpmyfaq/assets/src/search/autocomplete.ts index bc24b4749e..15c2963271 100644 --- a/phpmyfaq/assets/src/search/autocomplete.ts +++ b/phpmyfaq/assets/src/search/autocomplete.ts @@ -14,36 +14,137 @@ */ import autocomplete from 'autocompleter'; -import { fetchAutoCompleteData } from '../api'; -import { addElement } from '../utils'; +import { fetchAutoCompleteData, fetchPopularSearches } from '../api'; +import { addElement, TranslationService } from '../utils'; import { Suggestion } from '../interfaces'; +import { addRecentSearch, getRecentSearches } from './recentSearches'; +import { highlightMatch } from './highlight'; -export const handleAutoComplete = (): void => { - const autoCompleteInput = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; +const buildEmptyStateItems = async (translate: (key: string) => string): Promise => { + const recent: Suggestion[] = getRecentSearches().map( + (term): Suggestion => + ({ + type: 'recent', + searchTerm: term, + url: `search.html?search=${encodeURIComponent(term)}`, + group: translate('msgRecentSearches'), + }) as Suggestion + ); - if (autoCompleteInput) { - autocomplete({ - debounceWaitMs: 200, - preventSubmit: undefined, - disableAutoSelect: false, - input: autoCompleteInput, - container: addElement('ul', { classList: 'list-group bg-dark' }) as HTMLDivElement, - fetch: async (searchString: string, update: (items: Suggestion[]) => void): Promise => { - searchString = searchString.toLowerCase(); - const fetchedData: Suggestion[] = await fetchAutoCompleteData(searchString); - update(fetchedData); - }, - onSelect: (item: Suggestion): void => { - window.location.href = item.url; - }, - render: (item: Suggestion): HTMLDivElement => { - return addElement('li', { classList: 'list-group-item d-flex justify-content-between align-items-start' }, [ - addElement('div', { classList: 'ms-2 me-auto' }, [ - addElement('div', { classList: 'fw-bold', innerText: item.category }), - addElement('span', { classList: 'pmf-searched-question', textContent: item.question }), - ]), - ]) as HTMLDivElement; + const popular: Suggestion[] = (await fetchPopularSearches()).map( + (item): Suggestion => + ({ + type: 'popular', + searchTerm: item.searchterm, + count: Number(item.number), + url: `search.html?search=${encodeURIComponent(item.searchterm)}`, + group: translate('msgPopularSearches'), + }) as Suggestion + ); + + return [...recent, ...popular]; +}; + +const renderItem = (item: Suggestion, currentValue: string, translate: (key: string) => string): HTMLDivElement => { + if (item.type === 'empty') { + return addElement('li', { classList: 'list-group-item pmf-search-empty' }, [ + addElement('div', { classList: 'text-muted', innerText: translate('msgNoSearchResults') }), + addElement('div', { classList: 'fw-bold text-primary' }, [ + addElement('i', { classList: 'bi bi-question-circle me-1' }), + document.createTextNode(translate('msgAskQuestionInstead')), + ]), + ]) as HTMLDivElement; + } + + if (item.type === 'recent' || item.type === 'popular') { + const icon = item.type === 'recent' ? 'bi-clock-history' : 'bi-graph-up-arrow'; + const children: Node[] = [ + addElement('span', {}, [ + addElement('i', { classList: `bi ${icon} me-2 text-muted` }), + document.createTextNode(item.searchTerm ?? ''), + ]), + ]; + if (item.type === 'popular' && typeof item.count === 'number' && !Number.isNaN(item.count)) { + children.push(addElement('span', { classList: 'badge bg-info', innerText: `${item.count}x` })); + } + return addElement( + 'li', + { + classList: 'list-group-item d-flex justify-content-between align-items-center', }, - }); + children + ) as HTMLDivElement; + } + + // type === 'result' (or undefined, treated as a result) + const questionEl = addElement('span', { classList: 'pmf-searched-question' }); + questionEl.appendChild(highlightMatch(item.question ?? '', currentValue)); + + return addElement('li', { classList: 'list-group-item d-flex justify-content-between align-items-start' }, [ + addElement('div', { classList: 'ms-2 me-auto' }, [ + addElement('div', { classList: 'fw-bold', innerText: item.category ?? '' }), + questionEl, + ]), + ]) as HTMLDivElement; +}; + +export const attachAutocomplete = (input: HTMLInputElement): void => { + const translator = new TranslationService(); + const translate = (key: string): string => translator.translate(key); + void translator.loadTranslations(document.documentElement.lang); + + autocomplete({ + debounceWaitMs: 200, + preventSubmit: undefined, + disableAutoSelect: false, + showOnFocus: true, + input, + container: addElement('ul', { classList: 'list-group bg-dark' }) as HTMLDivElement, + fetch: async (searchString: string, update: (items: Suggestion[]) => void): Promise => { + const query = searchString.trim().toLowerCase(); + + if (query === '') { + update(await buildEmptyStateItems(translate)); + return; + } + + let results: Suggestion[]; + try { + results = (await fetchAutoCompleteData(query)).map( + (result): Suggestion => + ({ + type: 'result', + url: result.url, + question: result.question, + category: result.category, + }) as Suggestion + ); + } catch { + // A transient network/backend failure must not leave the dropdown stuck: + // recover by clearing the suggestions so the UI stays responsive. + update([]); + return; + } + + if (results.length === 0) { + update([{ type: 'empty', url: `search.html?search=${encodeURIComponent(query)}` } as Suggestion]); + return; + } + + update(results); + }, + onSelect: (item: Suggestion): void => { + const term = item.type === 'result' || item.type === undefined ? input.value.trim() : (item.searchTerm ?? ''); + addRecentSearch(term); + window.location.href = item.url; + }, + render: (item: Suggestion, currentValue: string): HTMLDivElement => renderItem(item, currentValue, translate), + }); +}; + +export const handleAutoComplete = (): void => { + const autoCompleteInput = document.getElementById('pmf-search-autocomplete') as HTMLInputElement | null; + if (autoCompleteInput) { + attachAutocomplete(autoCompleteInput); } }; diff --git a/phpmyfaq/assets/src/search/highlight.test.ts b/phpmyfaq/assets/src/search/highlight.test.ts new file mode 100644 index 0000000000..dbb6ad07a7 --- /dev/null +++ b/phpmyfaq/assets/src/search/highlight.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { highlightMatch } from './highlight'; + +const toHtml = (fragment: DocumentFragment): string => { + const wrapper = document.createElement('div'); + wrapper.appendChild(fragment); + return wrapper.innerHTML; +}; + +describe('highlightMatch', () => { + it('wraps the matched substring in ', () => { + expect(toHtml(highlightMatch('Install phpMyFAQ', 'install'))).toBe('Install phpMyFAQ'); + }); + + it('matches case-insensitively in the middle of the text', () => { + expect(toHtml(highlightMatch('How to MAC setup', 'mac'))).toBe('How to MAC setup'); + }); + + it('returns the plain text when there is no match', () => { + expect(toHtml(highlightMatch('Hello world', 'xyz'))).toBe('Hello world'); + }); + + it('returns the plain text for an empty query', () => { + expect(toHtml(highlightMatch('Hello world', ''))).toBe('Hello world'); + }); + + it('escapes HTML so it cannot inject markup', () => { + expect(toHtml(highlightMatch(' term', 'term'))).toBe('<img src=x> term'); + }); + + it('treats regex metacharacters in the query as literals', () => { + expect(toHtml(highlightMatch('a.b.c', '.'))).toBe('a.b.c'); + }); +}); diff --git a/phpmyfaq/assets/src/search/highlight.ts b/phpmyfaq/assets/src/search/highlight.ts new file mode 100644 index 0000000000..c15e7bf9bc --- /dev/null +++ b/phpmyfaq/assets/src/search/highlight.ts @@ -0,0 +1,45 @@ +/** + * Matched-term highlighting for search suggestions + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +export const highlightMatch = (text: string, query: string): DocumentFragment => { + const fragment = document.createDocumentFragment(); + const trimmedQuery = query.trim(); + + if (trimmedQuery === '') { + fragment.appendChild(document.createTextNode(text)); + return fragment; + } + + const regex = new RegExp(`(${escapeRegExp(trimmedQuery)})`, 'gi'); + const parts = text.split(regex); + + parts.forEach((part, index) => { + if (part === '') { + return; + } + // split() with a capturing group yields matches at odd indices. + if (index % 2 === 1) { + const strong = document.createElement('strong'); + strong.textContent = part; + fragment.appendChild(strong); + } else { + fragment.appendChild(document.createTextNode(part)); + } + }); + + return fragment; +}; diff --git a/phpmyfaq/assets/src/search/index.ts b/phpmyfaq/assets/src/search/index.ts index 9040469c91..0917e58577 100644 --- a/phpmyfaq/assets/src/search/index.ts +++ b/phpmyfaq/assets/src/search/index.ts @@ -1,3 +1,6 @@ export * from './autocomplete'; export * from './category'; export * from './question'; +export * from './searchModal'; +export * from './searchShortcut'; +export * from './searchShortcutBadge'; diff --git a/phpmyfaq/assets/src/search/recentSearches.test.ts b/phpmyfaq/assets/src/search/recentSearches.test.ts new file mode 100644 index 0000000000..c378451ab2 --- /dev/null +++ b/phpmyfaq/assets/src/search/recentSearches.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { addRecentSearch, clearRecentSearches, getRecentSearches } from './recentSearches'; + +describe('recentSearches', () => { + beforeEach(() => { + localStorage.clear(); + }); + + it('returns an empty array when nothing is stored', () => { + expect(getRecentSearches()).toEqual([]); + }); + + it('trims, drops blank/non-string entries, and caps externally modified storage on read', () => { + localStorage.setItem( + 'pmf-recent-searches', + JSON.stringify([' mac ', '', ' ', 42, 'linux', 'a', 'b', 'c', 'd']) + ); + expect(getRecentSearches()).toEqual(['mac', 'linux', 'a', 'b', 'c']); + }); + + it('stores and returns a term', () => { + addRecentSearch('mac'); + expect(getRecentSearches()).toEqual(['mac']); + }); + + it('puts the most recent term first', () => { + addRecentSearch('one'); + addRecentSearch('two'); + expect(getRecentSearches()).toEqual(['two', 'one']); + }); + + it('deduplicates case-insensitively and moves the term to the front', () => { + addRecentSearch('Mac'); + addRecentSearch('linux'); + addRecentSearch('mac'); + expect(getRecentSearches()).toEqual(['mac', 'linux']); + }); + + it('caps the list at five entries', () => { + ['a', 'b', 'c', 'd', 'e', 'f'].forEach(addRecentSearch); + expect(getRecentSearches()).toEqual(['f', 'e', 'd', 'c', 'b']); + }); + + it('ignores blank terms', () => { + addRecentSearch(' '); + addRecentSearch(''); + expect(getRecentSearches()).toEqual([]); + }); + + it('clears stored terms', () => { + addRecentSearch('mac'); + clearRecentSearches(); + expect(getRecentSearches()).toEqual([]); + }); + + it('returns an empty array when localStorage.getItem throws', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new Error('blocked'); + }); + expect(getRecentSearches()).toEqual([]); + vi.restoreAllMocks(); + }); + + it('does not throw when localStorage.setItem throws', () => { + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { + throw new Error('blocked'); + }); + expect(() => addRecentSearch('mac')).not.toThrow(); + vi.restoreAllMocks(); + }); +}); diff --git a/phpmyfaq/assets/src/search/recentSearches.ts b/phpmyfaq/assets/src/search/recentSearches.ts new file mode 100644 index 0000000000..76339a419d --- /dev/null +++ b/phpmyfaq/assets/src/search/recentSearches.ts @@ -0,0 +1,62 @@ +/** + * Recent searches storage (localStorage) + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +const STORAGE_KEY = 'pmf-recent-searches'; +const MAX_ENTRIES = 5; + +export const getRecentSearches = (): string[] => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return []; + } + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + // Normalize defensively: externally modified localStorage could contain + // non-strings, blank entries, or an arbitrarily large array. + return parsed + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter((item) => item !== '') + .slice(0, MAX_ENTRIES); + } catch { + return []; + } +}; + +export const addRecentSearch = (term: string): void => { + const trimmed = term.trim(); + if (trimmed === '') { + return; + } + + try { + const existing = getRecentSearches().filter((item) => item.toLowerCase() !== trimmed.toLowerCase()); + const updated = [trimmed, ...existing].slice(0, MAX_ENTRIES); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch { + // Storage unavailable (e.g. private mode) — silently ignore. + } +}; + +export const clearRecentSearches = (): void => { + try { + localStorage.removeItem(STORAGE_KEY); + } catch { + // Storage unavailable — silently ignore. + } +}; diff --git a/phpmyfaq/assets/src/search/searchModal.test.ts b/phpmyfaq/assets/src/search/searchModal.test.ts new file mode 100644 index 0000000000..a95533fe1b --- /dev/null +++ b/phpmyfaq/assets/src/search/searchModal.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const showSpy = vi.fn(); +const hideSpy = vi.fn(); +vi.mock('bootstrap', () => ({ + Modal: vi.fn(function () { + return { show: showSpy, hide: hideSpy }; + }), +})); + +vi.mock('./autocomplete', () => ({ + attachAutocomplete: vi.fn(), +})); + +describe('openSearchModal', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.resetModules(); + document.body.innerHTML = ''; + }); + + it('creates the modal markup with a search input on first open', async () => { + const { openSearchModal } = await import('./searchModal'); + openSearchModal(); + const input = document.getElementById('pmf-search-modal-input'); + expect(input).not.toBeNull(); + expect(input?.tagName).toBe('INPUT'); + }); + + it('wires the modal input with attachAutocomplete', async () => { + const { openSearchModal } = await import('./searchModal'); + const { attachAutocomplete } = await import('./autocomplete'); + openSearchModal(); + expect(attachAutocomplete).toHaveBeenCalledTimes(1); + }); + + it('shows the Bootstrap modal', async () => { + const { openSearchModal } = await import('./searchModal'); + const { Modal } = await import('bootstrap'); + openSearchModal(); + expect(Modal).toHaveBeenCalled(); + expect(showSpy).toHaveBeenCalled(); + }); + + it('reuses the same modal markup on subsequent opens', async () => { + const { openSearchModal } = await import('./searchModal'); + const { attachAutocomplete } = await import('./autocomplete'); + openSearchModal(); + openSearchModal(); + expect(document.querySelectorAll('#pmf-search-modal').length).toBe(1); + expect(attachAutocomplete).toHaveBeenCalledTimes(1); + }); +}); diff --git a/phpmyfaq/assets/src/search/searchModal.ts b/phpmyfaq/assets/src/search/searchModal.ts new file mode 100644 index 0000000000..f0f740e824 --- /dev/null +++ b/phpmyfaq/assets/src/search/searchModal.ts @@ -0,0 +1,76 @@ +/** + * Search modal palette (fallback when the inline search bar is absent) + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +import { Modal } from 'bootstrap'; +import { addElement, TranslationService } from '../utils'; +import { attachAutocomplete } from './autocomplete'; + +let modalInstance: Modal | null = null; + +const buildModal = (): void => { + const input = addElement('input', { + type: 'text', + id: 'pmf-search-modal-input', + classList: 'form-control form-control-lg', + name: 'search', + autocomplete: 'off', + maxLength: 255, + }) as HTMLInputElement; + + // Localize the placeholder / accessible label via the translation system. + // Loading is async; set the labels once translations are available. + const translator = new TranslationService(); + void translator.loadTranslations(document.documentElement.lang).then((): void => { + const label = translator.translate('msgSearch'); + input.placeholder = `${label} …`; + input.setAttribute('aria-label', label); + }); + + const dialog = addElement('div', { classList: 'modal-dialog modal-lg modal-dialog-scrollable' }, [ + addElement('div', { classList: 'modal-content' }, [ + addElement('div', { classList: 'modal-body pmf-search-modal-body' }, [ + addElement('div', { classList: 'search' }, [addElement('i', { classList: 'bi bi-search' }), input]), + ]), + ]), + ]); + + const modalEl = addElement( + 'div', + { + classList: 'modal fade', + id: 'pmf-search-modal', + tabIndex: -1, + 'aria-hidden': 'true', + }, + [dialog] + ); + + document.body.appendChild(modalEl); + + attachAutocomplete(input); + + modalEl.addEventListener('shown.bs.modal', (): void => { + input.focus(); + }); + + modalInstance = new Modal(modalEl); +}; + +export const openSearchModal = (): void => { + if (modalInstance === null) { + buildModal(); + } + modalInstance?.show(); +}; diff --git a/phpmyfaq/assets/src/search/searchShortcut.test.ts b/phpmyfaq/assets/src/search/searchShortcut.test.ts new file mode 100644 index 0000000000..d0fb0719d1 --- /dev/null +++ b/phpmyfaq/assets/src/search/searchShortcut.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./searchModal', () => ({ + openSearchModal: vi.fn(), +})); + +import { initSearchShortcut } from './searchShortcut'; +import { openSearchModal } from './searchModal'; + +const pressK = (): KeyboardEvent => { + const event = new KeyboardEvent('keydown', { + key: 'k', + metaKey: true, + ctrlKey: true, + cancelable: true, + bubbles: true, + }); + document.dispatchEvent(event); + return event; +}; + +describe('initSearchShortcut', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('focuses the inline input when it exists', () => { + document.body.innerHTML = ''; + const input = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; + const focusSpy = vi.spyOn(input, 'focus'); + + initSearchShortcut(); + const event = pressK(); + + expect(focusSpy).toHaveBeenCalled(); + expect(openSearchModal).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(true); + }); + + it('opens the modal when the inline input is absent', () => { + initSearchShortcut(); + pressK(); + expect(openSearchModal).toHaveBeenCalledTimes(1); + }); + + it('ignores the shortcut when typing in another text field', () => { + document.body.innerHTML = ''; + const other = document.getElementById('other') as HTMLTextAreaElement; + other.focus(); + const focusSpy = vi.spyOn(document.getElementById('pmf-search-autocomplete') as HTMLInputElement, 'focus'); + + initSearchShortcut(); + pressK(); + + expect(focusSpy).not.toHaveBeenCalled(); + expect(openSearchModal).not.toHaveBeenCalled(); + }); + + it('blurs the inline input on Escape', () => { + document.body.innerHTML = ''; + const input = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; + input.focus(); + const blurSpy = vi.spyOn(input, 'blur'); + + initSearchShortcut(); + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + + expect(blurSpy).toHaveBeenCalled(); + }); +}); diff --git a/phpmyfaq/assets/src/search/searchShortcut.ts b/phpmyfaq/assets/src/search/searchShortcut.ts new file mode 100644 index 0000000000..06a9953a4b --- /dev/null +++ b/phpmyfaq/assets/src/search/searchShortcut.ts @@ -0,0 +1,64 @@ +/** + * Global keyboard shortcut for search (Cmd+K / Ctrl+K) + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +import { openSearchModal } from './searchModal'; + +const INLINE_INPUT_ID = 'pmf-search-autocomplete'; + +let initialized = false; + +const isEditableTarget = (target: EventTarget | null): boolean => { + const element = target instanceof HTMLElement ? target : document.activeElement; + if (!(element instanceof HTMLElement)) { + return false; + } + if (element.id === INLINE_INPUT_ID) { + return false; + } + const tag = element.tagName; + return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || element.isContentEditable; +}; + +export const initSearchShortcut = (): void => { + if (initialized) { + return; + } + initialized = true; + + document.addEventListener('keydown', (event: KeyboardEvent): void => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') { + if (isEditableTarget(event.target)) { + return; + } + event.preventDefault(); + + const inlineInput = document.getElementById(INLINE_INPUT_ID) as HTMLInputElement | null; + if (inlineInput) { + inlineInput.scrollIntoView?.({ behavior: 'smooth', block: 'center' }); + inlineInput.focus(); + } else { + openSearchModal(); + } + return; + } + + if (event.key === 'Escape') { + const inlineInput = document.getElementById(INLINE_INPUT_ID) as HTMLInputElement | null; + if (inlineInput && document.activeElement === inlineInput) { + inlineInput.blur(); + } + } + }); +}; diff --git a/phpmyfaq/assets/src/search/searchShortcutBadge.test.ts b/phpmyfaq/assets/src/search/searchShortcutBadge.test.ts new file mode 100644 index 0000000000..489bd91b05 --- /dev/null +++ b/phpmyfaq/assets/src/search/searchShortcutBadge.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../utils', () => ({ + getShortcutHintLabel: vi.fn(() => '⌘ K'), +})); + +import { initSearchShortcutBadge } from './searchShortcutBadge'; + +describe('initSearchShortcutBadge', () => { + beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; + }); + + it('does nothing when the hint element is absent', () => { + expect(() => initSearchShortcutBadge()).not.toThrow(); + }); + + it('sets the hint label from getShortcutHintLabel', () => { + document.body.innerHTML = ''; + initSearchShortcutBadge(); + expect(document.getElementById('pmf-search-hint')?.textContent).toBe('⌘ K'); + }); + + it('hides the hint initially when the input is pre-filled', () => { + document.body.innerHTML = + ''; + initSearchShortcutBadge(); + expect(document.getElementById('pmf-search-hint')?.classList.contains('d-none')).toBe(true); + }); + + it('shows the hint initially when the input is empty and not focused', () => { + document.body.innerHTML = ''; + initSearchShortcutBadge(); + expect(document.getElementById('pmf-search-hint')?.classList.contains('d-none')).toBe(false); + }); + + it('hides the hint on focus and shows it again on blur', () => { + document.body.innerHTML = ''; + const input = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; + const hint = document.getElementById('pmf-search-hint') as HTMLElement; + initSearchShortcutBadge(); + + input.dispatchEvent(new Event('focus')); + // jsdom focus event does not set document.activeElement; call focus() too + input.focus(); + input.dispatchEvent(new Event('focus')); + expect(hint.classList.contains('d-none')).toBe(true); + + input.blur(); + input.dispatchEvent(new Event('blur')); + expect(hint.classList.contains('d-none')).toBe(false); + }); + + it('hides the hint when the user types', () => { + document.body.innerHTML = ''; + const input = document.getElementById('pmf-search-autocomplete') as HTMLInputElement; + const hint = document.getElementById('pmf-search-hint') as HTMLElement; + initSearchShortcutBadge(); + + input.value = 'mac'; + input.dispatchEvent(new Event('input')); + expect(hint.classList.contains('d-none')).toBe(true); + }); +}); diff --git a/phpmyfaq/assets/src/search/searchShortcutBadge.ts b/phpmyfaq/assets/src/search/searchShortcutBadge.ts new file mode 100644 index 0000000000..4f55169d71 --- /dev/null +++ b/phpmyfaq/assets/src/search/searchShortcutBadge.ts @@ -0,0 +1,39 @@ +/** + * Keyboard shortcut hint badge for the inline search bar + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +import { getShortcutHintLabel } from '../utils'; + +export const initSearchShortcutBadge = (): void => { + const searchHint: HTMLElement | null = document.getElementById('pmf-search-hint'); + if (searchHint === null) { + return; + } + + searchHint.textContent = getShortcutHintLabel(); + + const searchInput: HTMLInputElement | null = document.getElementById( + 'pmf-search-autocomplete' + ) as HTMLInputElement | null; + + const toggleHint = (): void => { + const hide = searchInput !== null && (document.activeElement === searchInput || searchInput.value !== ''); + searchHint.classList.toggle('d-none', hide); + }; + + toggleHint(); + searchInput?.addEventListener('focus', toggleHint); + searchInput?.addEventListener('blur', toggleHint); + searchInput?.addEventListener('input', toggleHint); +}; diff --git a/phpmyfaq/assets/src/utils/index.ts b/phpmyfaq/assets/src/utils/index.ts index 4193bd1a77..aa075891e7 100644 --- a/phpmyfaq/assets/src/utils/index.ts +++ b/phpmyfaq/assets/src/utils/index.ts @@ -3,6 +3,7 @@ export * from './helper'; export * from './login'; export * from './notifications'; export * from './password'; +export * from './platform'; export * from './reading-time'; export * from './tooltip'; export * from './TranslationService'; diff --git a/phpmyfaq/assets/src/utils/platform.test.ts b/phpmyfaq/assets/src/utils/platform.test.ts new file mode 100644 index 0000000000..2361de63f9 --- /dev/null +++ b/phpmyfaq/assets/src/utils/platform.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { getShortcutHintLabel, isMacPlatform } from './platform'; + +const setPlatform = (value: string): void => { + Object.defineProperty(window.navigator, 'platform', { value, configurable: true }); +}; + +const setUserAgentData = (platform: string | undefined): void => { + Object.defineProperty(window.navigator, 'userAgentData', { + value: platform === undefined ? undefined : { platform }, + configurable: true, + }); +}; + +describe('platform helpers', () => { + afterEach(() => { + vi.restoreAllMocks(); + setPlatform(''); + setUserAgentData(undefined); + }); + + it('detects macOS from navigator.userAgentData.platform', () => { + setUserAgentData('macOS'); + setPlatform('Win32'); // userAgentData must take precedence + expect(isMacPlatform()).toBe(true); + }); + + it('returns false when userAgentData.platform is not macOS even if navigator.platform is Mac', () => { + setUserAgentData('Windows'); + setPlatform('MacIntel'); // userAgentData must take precedence over the legacy value + expect(isMacPlatform()).toBe(false); + }); + + it('falls back to non-Mac when userAgentData is absent and platform is empty', () => { + setUserAgentData(undefined); + setPlatform(''); + expect(isMacPlatform()).toBe(false); + }); + + it('detects macOS from navigator.platform', () => { + setPlatform('MacIntel'); + expect(isMacPlatform()).toBe(true); + }); + + it('returns false for Windows', () => { + setPlatform('Win32'); + expect(isMacPlatform()).toBe(false); + }); + + it('defaults to non-Mac for an unknown platform', () => { + setPlatform('Linux x86_64'); + expect(isMacPlatform()).toBe(false); + }); + + it('returns the Cmd label on macOS', () => { + setPlatform('MacIntel'); + expect(getShortcutHintLabel()).toBe('⌘ K'); + }); + + it('returns the Ctrl label off macOS', () => { + setPlatform('Win32'); + expect(getShortcutHintLabel()).toBe('Ctrl K'); + }); +}); diff --git a/phpmyfaq/assets/src/utils/platform.ts b/phpmyfaq/assets/src/utils/platform.ts new file mode 100644 index 0000000000..c7ba8b1f49 --- /dev/null +++ b/phpmyfaq/assets/src/utils/platform.ts @@ -0,0 +1,26 @@ +/** + * Platform detection for keyboard shortcuts + * + * This Source Code Form is subject to the terms of the Mozilla Public License, + * v. 2.0. If a copy of the MPL was not distributed with this file, You can + * obtain one at https://mozilla.org/MPL/2.0/. + * + * @package phpMyFAQ + * @author Thorsten Rinne + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +interface UserAgentDataLike { + platform?: string; +} + +export const isMacPlatform = (): boolean => { + const uaData = (navigator as Navigator & { userAgentData?: UserAgentDataLike }).userAgentData; + const platform = uaData?.platform ?? navigator.platform ?? ''; + return /mac/i.test(platform); +}; + +export const getShortcutHintLabel = (): string => (isMacPlatform() ? '⌘ K' : 'Ctrl K'); diff --git a/phpmyfaq/assets/templates/default/index.twig b/phpmyfaq/assets/templates/default/index.twig index 53506f62bb..19c0335613 100644 --- a/phpmyfaq/assets/templates/default/index.twig +++ b/phpmyfaq/assets/templates/default/index.twig @@ -128,6 +128,7 @@ + diff --git a/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PopularSearchesController.php b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PopularSearchesController.php new file mode 100644 index 0000000000..108d4edb44 --- /dev/null +++ b/phpmyfaq/src/phpMyFAQ/Controller/Frontend/Api/PopularSearchesController.php @@ -0,0 +1,57 @@ + + * @copyright 2026 phpMyFAQ Team + * @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0 + * @link https://www.phpmyfaq.de + * @since 2026-06-20 + */ + +declare(strict_types=1); + +namespace phpMyFAQ\Controller\Frontend\Api; + +use phpMyFAQ\Controller\AbstractController; +use phpMyFAQ\Search; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +final class PopularSearchesController extends AbstractController +{ + public function __construct( + private readonly Search $faqSearch, + ) { + parent::__construct(); + } + + /** + * @throws \Exception + */ + #[Route(path: 'searches/popular', name: 'api.private.searches.popular', methods: ['GET'])] + public function popular(): JsonResponse + { + $numberOfResults = (int) $this->configuration->get('search.numberSearchTerms'); + if ($numberOfResults <= 0) { + $numberOfResults = 7; + } + + // Aggregate across all languages (no withLang), consistent with the existing + // frontend "most popular searches" display in SearchController/search.twig. + $result = $this->faqSearch->getMostPopularSearches($numberOfResults); + + if ($result === []) { + return $this->json([], Response::HTTP_NOT_FOUND); + } + + return $this->json($result, Response::HTTP_OK); + } +} diff --git a/phpmyfaq/src/services.php b/phpmyfaq/src/services.php index 6f482225ad..b403ad9bba 100644 --- a/phpmyfaq/src/services.php +++ b/phpmyfaq/src/services.php @@ -137,6 +137,7 @@ use phpMyFAQ\Controller\Frontend\Api\CommentController as FrontendApiCommentController; use phpMyFAQ\Controller\Frontend\Api\ContactController as FrontendApiContactController; use phpMyFAQ\Controller\Frontend\Api\FaqController as FrontendApiFaqController; +use phpMyFAQ\Controller\Frontend\Api\PopularSearchesController as FrontendApiPopularSearchesController; use phpMyFAQ\Controller\Frontend\Api\PushController as FrontendApiPushController; use phpMyFAQ\Controller\Frontend\Api\QuestionController as FrontendApiQuestionController; use phpMyFAQ\Controller\Frontend\Api\UserController as FrontendApiUserController; @@ -809,6 +810,9 @@ service('phpmyfaq.helper.search'), service('phpmyfaq.language.plurals'), ]); + $services->set(FrontendApiPopularSearchesController::class, FrontendApiPopularSearchesController::class)->args([ + service('phpmyfaq.search'), + ]); $services->set(FrontendApiCaptchaController::class, FrontendApiCaptchaController::class)->args([ service('phpmyfaq.captcha'), ]); diff --git a/phpmyfaq/translations/language_de.php b/phpmyfaq/translations/language_de.php index 6ba3bae889..375054776f 100755 --- a/phpmyfaq/translations/language_de.php +++ b/phpmyfaq/translations/language_de.php @@ -807,6 +807,10 @@ // added 2.5.0-alpha2 - 2009-01-24 by Thorsten $PMF_LANG['msgMostPopularSearches'] = "Beliebte Suchbegriffe"; +$PMF_LANG['msgRecentSearches'] = 'Letzte Suchanfragen'; +$PMF_LANG['msgPopularSearches'] = 'Beliebte Suchanfragen'; +$PMF_LANG['msgNoSearchResults'] = 'Keine Ergebnisse gefunden'; +$PMF_LANG['msgAskQuestionInstead'] = 'Stattdessen eine Frage stellen'; $LANG_CONF['main.enableWysiwygEditor'] = ['checkbox', "Aktivierung des WYSIWYG Editors"]; // added 2.5.0-beta - 2009-03-30 by Anatoliy diff --git a/phpmyfaq/translations/language_en.php b/phpmyfaq/translations/language_en.php index 4ba620affc..d82c68d6c9 100644 --- a/phpmyfaq/translations/language_en.php +++ b/phpmyfaq/translations/language_en.php @@ -804,6 +804,9 @@ // added 2.5.0-alpha2 - 2009-01-24 by Thorsten $PMF_LANG['msgMostPopularSearches'] = 'Most popular searches'; +$PMF_LANG['msgRecentSearches'] = 'Recent searches'; +$PMF_LANG['msgNoSearchResults'] = 'No results found'; +$PMF_LANG['msgAskQuestionInstead'] = 'Ask a question instead'; $LANG_CONF['main.enableWysiwygEditor'] = ["checkbox", "Enable bundled WYSIWYG editor"]; // added 2.5.0-beta - 2009-03-30 by Anatoliy diff --git a/tests/bootstrap.php b/tests/bootstrap.php index bb49ce2b78..ffc86086cc 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -93,6 +93,22 @@ function canReusePreparedTestDatabase(string $databasePath, string $databaseConf return false; } + // The recorded server path in database.php must match the current environment's + // database path. A previously generated config may carry a different absolute path + // — e.g. the same checkout accessed under a different case on a case-insensitive + // filesystem, or a moved/shared working copy. Reusing it would leave database.php + // pointing at the wrong path string and break path-sensitive assertions. A mismatch + // forces a rebuild below, which regenerates database.php with the current path. + if (!is_readable($databaseConfigPath)) { + return false; + } + + $DB = []; + include $databaseConfigPath; + if (!isset($DB['server']) || $DB['server'] !== $databasePath) { + return false; + } + try { $pdo = new \PDO('sqlite:' . $databasePath); $statement = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'"); diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerDirectTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerDirectTest.php new file mode 100644 index 0000000000..95f22b1e09 --- /dev/null +++ b/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerDirectTest.php @@ -0,0 +1,92 @@ +createMock(Search::class); + $search + ->expects($this->once()) + ->method('getMostPopularSearches') + ->willReturn([['id' => 1, 'searchterm' => 'mac', 'number' => '18']]); + + $controller = new PopularSearchesController($search); + $currentUser = $this->createAuthenticatedUserMock(); + $this->injectControllerState($controller, $currentUser, $this->createSession()); + + $response = $controller->popular(); + $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('mac', $payload[0]['searchterm']); + self::assertSame('18', $payload[0]['number']); + } + + public function testPopularReturnsNotFoundWhenEmpty(): void + { + $search = $this->createMock(Search::class); + $search->expects($this->once())->method('getMostPopularSearches')->willReturn([]); + + $controller = new PopularSearchesController($search); + $currentUser = $this->createAuthenticatedUserMock(); + $this->injectControllerState($controller, $currentUser, $this->createSession()); + + $response = $controller->popular(); + + self::assertSame(Response::HTTP_NOT_FOUND, $response->getStatusCode()); + self::assertSame('[]', (string) $response->getContent()); + } + + public function testPopularPassesConfiguredCountToSearch(): void + { + $search = $this->createMock(Search::class); + $search + ->expects($this->once()) + ->method('getMostPopularSearches') + ->with(5) + ->willReturn([['id' => 1, 'searchterm' => 'php', 'number' => '3']]); + + $controller = new PopularSearchesController($search); + $this->injectControllerState($controller, $this->createAuthenticatedUserMock(), $this->createSession()); + + // Override after controller construction: the AbstractController constructor reads + // template settings via getAll(), which reloads the whole config from the database. + $this->overrideConfigurationValues(['search.numberSearchTerms' => '5']); + + $response = $controller->popular(); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + public function testPopularFallsBackToSevenWhenConfiguredCountIsZero(): void + { + $search = $this->createMock(Search::class); + $search + ->expects($this->once()) + ->method('getMostPopularSearches') + ->with(7) + ->willReturn([['id' => 1, 'searchterm' => 'php', 'number' => '3']]); + + $controller = new PopularSearchesController($search); + $this->injectControllerState($controller, $this->createAuthenticatedUserMock(), $this->createSession()); + + // Override after controller construction: the AbstractController constructor reads + // template settings via getAll(), which reloads the whole config from the database. + $this->overrideConfigurationValues(['search.numberSearchTerms' => '0']); + + $response = $controller->popular(); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } +} diff --git a/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerWebTest.php b/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerWebTest.php new file mode 100644 index 0000000000..78d0dbc67e --- /dev/null +++ b/tests/phpMyFAQ/Controller/Frontend/Api/PopularSearchesControllerWebTest.php @@ -0,0 +1,34 @@ +requestApi('GET', '/searches/popular'); + + self::assertContains( + $response->getStatusCode(), + [Response::HTTP_OK, Response::HTTP_NOT_FOUND], + 'The popular searches endpoint must resolve through the container and ' + . 'return a valid HTTP status, not a 500 from an unregistered controller.', + ); + self::assertStringContainsString('json', (string) $response->headers->get('Content-Type')); + self::assertJson((string) $response->getContent()); + } +}