Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions phpmyfaq/assets/scss/layout/_autocomplete.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions phpmyfaq/assets/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
80 changes: 80 additions & 0 deletions phpmyfaq/assets/src/api/popularSearches.test.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
fetchMocker.mockResponseOnce('[]', { status: 404 });

const data = await fetchPopularSearches();

expect(data).toEqual([]);
});

test('returns an empty array when fetch rejects', async (): Promise<void> => {
fetchMocker.mockRejectOnce(new Error('API is down'));

const data = await fetchPopularSearches();

expect(data).toEqual([]);
});
});
77 changes: 77 additions & 0 deletions phpmyfaq/assets/src/api/popularSearches.ts
Original file line number Diff line number Diff line change
@@ -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 <thorsten@phpmyfaq.de>
* @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<string, unknown>;
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<PopularSearchResponse> => {
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 [];
}
};
4 changes: 4 additions & 0 deletions phpmyfaq/assets/src/frontend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 9 additions & 1 deletion phpmyfaq/assets/src/frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -121,6 +127,8 @@ document.addEventListener('DOMContentLoaded', (): void => {
// AutoComplete
handleAutoComplete();
handleCategorySelection();
initSearchShortcut();
initSearchShortcutBadge();

// Handle Chat
handleChat();
Expand Down
7 changes: 7 additions & 0 deletions phpmyfaq/assets/src/interfaces/PopularSearch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface PopularSearch {
id: number;
searchterm: string;
number: number | string;
}

export type PopularSearchResponse = PopularSearch[];
1 change: 1 addition & 0 deletions phpmyfaq/assets/src/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './Bookmark';
export * from './authenticatorResponse';
export * from './Chat';
export * from './suggestionItem';
export * from './PopularSearch';
9 changes: 7 additions & 2 deletions phpmyfaq/assets/src/interfaces/suggestionItem.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading