From 9f6c1053182ac16b11d35a5966c89c9c0a6f26bc Mon Sep 17 00:00:00 2001 From: Luke Manning Date: Tue, 18 Aug 2026 23:03:41 +0100 Subject: [PATCH 1/3] feat(search): parameterise Fuse keys/threshold, search tagline by default - createFuseSearch/getFuseOptions accept { threshold, keys } options, with bare-number threshold kept working for v1.3 consumers - add tagline to the default key set at the description weight tier (1.5) - align default threshold at 0.3 everywhere (matches useProjectSearch and the documented default; single source in fuse.ts) - add pure searchProjects(projects, query, opts) helper alongside filterByStatus/sortProjects; useProjectSearch now passes keys through and builds its index via createFuseSearch Closes #21 --- packages/core/src/lib/__tests__/fuse.test.ts | 75 ++++++++++++- .../src/lib/__tests__/searchProjects.test.ts | 100 ++++++++++++++++++ .../lib/__tests__/useProjectSearch.test.ts | 22 ++++ packages/core/src/lib/fuse.ts | 44 ++++++-- packages/core/src/lib/index.ts | 3 +- packages/core/src/lib/searchProjects.ts | 27 +++++ packages/core/src/lib/useProjectSearch.ts | 23 ++-- .../docs/src/api/utilities/fuse-search.md | 80 +++++++++----- packages/docs/src/api/utilities/index.md | 6 +- .../docs/src/api/utilities/search-projects.md | 72 +++++++++++++ .../src/api/utilities/use-project-search.md | 7 +- .../docs/src/guides/customizing-search.md | 49 ++++++--- 12 files changed, 433 insertions(+), 75 deletions(-) create mode 100644 packages/core/src/lib/__tests__/searchProjects.test.ts create mode 100644 packages/core/src/lib/searchProjects.ts create mode 100644 packages/docs/src/api/utilities/search-projects.md diff --git a/packages/core/src/lib/__tests__/fuse.test.ts b/packages/core/src/lib/__tests__/fuse.test.ts index a4cf72e..f992425 100644 --- a/packages/core/src/lib/__tests__/fuse.test.ts +++ b/packages/core/src/lib/__tests__/fuse.test.ts @@ -29,24 +29,31 @@ function createProject(overrides: Partial = {}): ProjexProject { } describe('getFuseOptions', () => { - it('should return default threshold of 0.2', () => { + it('should return default threshold of 0.3', () => { const options = getFuseOptions() - expect(options.threshold).toBe(0.2) + expect(options.threshold).toBe(0.3) }) - it('should accept custom threshold', () => { + it('should accept custom threshold via options object', () => { + const options = getFuseOptions({ threshold: 0.5 }) + + expect(options.threshold).toBe(0.5) + }) + + it('should accept a bare threshold number (v1.3 back-compat)', () => { const options = getFuseOptions(0.5) expect(options.threshold).toBe(0.5) }) - it('should include name, description, and stack keys', () => { + it('should include name, tagline, description, and stack keys by default', () => { const options = getFuseOptions() const keyNames = options.keys.map(k => k.name) expect(keyNames).toContain('name') + expect(keyNames).toContain('tagline') expect(keyNames).toContain('description') expect(keyNames).toContain('stack') }) @@ -59,15 +66,25 @@ describe('getFuseOptions', () => { expect(nameKey?.weight).toBe(2) }) - it('should assign higher weight to description than stack', () => { + it('should weight tagline and description equally, above stack', () => { const options = getFuseOptions() + const taglineKey = options.keys.find(k => k.name === 'tagline') const descKey = options.keys.find(k => k.name === 'description') const stackKey = options.keys.find(k => k.name === 'stack') + expect(taglineKey?.weight).toBe(1.5) expect(descKey?.weight).toBe(1.5) expect(stackKey?.weight).toBe(1) }) + + it('should use custom keys when provided via options object', () => { + const customKeys = [{ name: 'name', weight: 1 }] + + const options = getFuseOptions({ keys: customKeys }) + + expect(options.keys).toEqual(customKeys) + }) }) describe('createFuseSearch', () => { @@ -121,6 +138,19 @@ describe('createFuseSearch', () => { expect(results[0].item.id).toBe('1') }) + it('should find projects by tagline', () => { + const projects = [ + createProject({ id: '1', name: 'Project One', tagline: 'Ship your portfolio fast' }), + createProject({ id: '2', name: 'Project Two', tagline: 'A todo app for everyone' }), + ] + + const fuse = createFuseSearch(projects) + const results = fuse.search('portfolio') + + expect(results).toHaveLength(1) + expect(results[0].item.id).toBe('1') + }) + it('should find projects by stack tag', () => { const projects = [ createProject({ id: '1', name: 'Project One', stack: ['react', 'typescript'] }), @@ -161,6 +191,41 @@ describe('createFuseSearch', () => { expect(looseResults.length).toBeGreaterThanOrEqual(strictResults.length) }) + it('should accept custom threshold via options object', () => { + const projects = [ + createProject({ id: '1', name: 'React Dashboard' }), + ] + + const fuse = createFuseSearch(projects, { threshold: 0.4 }) + const results = fuse.search('Ract') + + expect(results.length).toBeGreaterThan(0) + }) + + it('should restrict search to custom keys via options object', () => { + const projects = [ + createProject({ id: '1', name: 'Alpha', description: 'A modern dashboard' }), + createProject({ id: '2', name: 'Dashboard Tool', description: 'A simple tool' }), + ] + + const nameOnly = createFuseSearch(projects, { keys: [{ name: 'name', weight: 1 }] }) + + const results = nameOnly.search('dashboard') + + expect(results).toHaveLength(1) + expect(results[0].item.id).toBe('2') + }) + + it('should accept a bare threshold number (v1.3 back-compat)', () => { + const projects = [ + createProject({ id: '1', name: 'React Dashboard' }), + ] + + const fuse = createFuseSearch(projects, 0.4) + + expect(fuse).toBeDefined() + }) + it('should return empty array for no matches', () => { const projects = [ createProject({ id: '1', name: 'React Dashboard' }), diff --git a/packages/core/src/lib/__tests__/searchProjects.test.ts b/packages/core/src/lib/__tests__/searchProjects.test.ts new file mode 100644 index 0000000..b245f9f --- /dev/null +++ b/packages/core/src/lib/__tests__/searchProjects.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest' +import { searchProjects } from '../searchProjects' +import type { ProjexProject } from '../../types' + +function createProject(overrides: Partial = {}): ProjexProject { + return { + id: 'test-id', + type: 'github', + status: 'active', + featured: false, + name: 'Test Project', + tagline: '', + description: '', + background: null, + why: null, + image: null, + struggles: [], + timeline: [], + posts: [], + stack: [], + links: {}, + stats: null, + language: null, + languageColor: null, + createdAt: null, + updatedAt: null, + ...overrides, + } +} + +describe('searchProjects', () => { + const projects = [ + createProject({ id: '1', name: 'React Dashboard', tagline: 'Ship dashboards fast', description: 'A modern dashboard', stack: ['react', 'typescript'] }), + createProject({ id: '2', name: 'Vue Todo', tagline: 'Todos made simple', description: 'A simple Vue app', stack: ['vue', 'javascript'] }), + ] + + it('should return the input array unchanged for an empty query', () => { + expect(searchProjects(projects, '')).toBe(projects) + }) + + it('should return the input array unchanged for whitespace-only query', () => { + expect(searchProjects(projects, ' ')).toBe(projects) + }) + + it('should return the input array unchanged for null or undefined query', () => { + expect(searchProjects(projects, null)).toBe(projects) + expect(searchProjects(projects, undefined)).toBe(projects) + }) + + it('should find projects by name', () => { + const results = searchProjects(projects, 'vue todo') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('2') + }) + + it('should find projects by tagline', () => { + const results = searchProjects(projects, 'simple') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('2') + }) + + it('should find projects by description', () => { + const results = searchProjects(projects, 'dashboard') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('1') + }) + + it('should find projects by stack tag', () => { + const results = searchProjects(projects, 'typescript') + + expect(results).toHaveLength(1) + expect(results[0].id).toBe('1') + }) + + it('should restrict search to custom keys', () => { + const nameOnly = searchProjects(projects, 'modern', { + keys: [{ name: 'name', weight: 1 }], + }) + + expect(nameOnly).toHaveLength(0) + }) + + it('should accept a custom threshold', () => { + const strict = searchProjects(projects, 'dashbord', { threshold: 0.1 }) + const lenient = searchProjects(projects, 'dashbord', { threshold: 0.4 }) + + expect(lenient.length).toBeGreaterThanOrEqual(strict.length) + }) + + it('should return empty array when no matches found', () => { + expect(searchProjects(projects, 'nonexistent')).toHaveLength(0) + }) + + it('should return empty array for empty projects', () => { + expect(searchProjects([], 'react')).toHaveLength(0) + }) +}) diff --git a/packages/core/src/lib/__tests__/useProjectSearch.test.ts b/packages/core/src/lib/__tests__/useProjectSearch.test.ts index 23bbd1d..475b75d 100644 --- a/packages/core/src/lib/__tests__/useProjectSearch.test.ts +++ b/packages/core/src/lib/__tests__/useProjectSearch.test.ts @@ -137,4 +137,26 @@ describe('useProjectSearch', () => { expect(result.current.length).toBeGreaterThan(0) }) + + it('should find projects by tagline by default', () => { + const projectsWithTaglines = [ + createProject({ id: '1', name: 'Project One', tagline: 'Show everything you ship' }), + createProject({ id: '2', name: 'Project Two', tagline: 'A calm todo list' }), + ] + + const { result } = renderHook(() => useProjectSearch(projectsWithTaglines, 'everything')) + + expect(result.current).toHaveLength(1) + expect(result.current[0].id).toBe('1') + }) + + it('should restrict search to custom keys option', () => { + const { result } = renderHook(() => + useProjectSearch(projects, 'authentication', { + keys: [{ name: 'name', weight: 1 }], + }) + ) + + expect(result.current).toHaveLength(0) + }) }) diff --git a/packages/core/src/lib/fuse.ts b/packages/core/src/lib/fuse.ts index 45c3b5c..0e249ec 100644 --- a/packages/core/src/lib/fuse.ts +++ b/packages/core/src/lib/fuse.ts @@ -1,26 +1,58 @@ import Fuse from 'fuse.js' import type { ProjexProject } from '../types' +/** Full Fuse.js configuration returned by `getFuseOptions`. */ export interface FuseOptions { threshold: number ignoreLocation: boolean keys: Array<{ name: string; weight: number }> } -export function getFuseOptions(threshold: number = 0.2): FuseOptions { +/** + * Consumer-facing search overrides accepted by `getFuseOptions`, + * `createFuseSearch`, `searchProjects` and `useProjectSearch`. + */ +export interface FuseSearchOptions { + /** Match threshold (0.0 = exact match, 1.0 = match anything). */ + threshold?: number + /** Weighted fields to search. Replaces the default key set when provided. */ + keys?: Array<{ name: string; weight: number }> +} + +/** + * Single default threshold shared by every search entry point. + * 0.3 was already the `useProjectSearch` default and what the docs describe, + * so it wins over `createFuseSearch`'s old 0.2 (see issue #21). + */ +const DEFAULT_FUSE_THRESHOLD = 0.3 + +/** + * Build Fuse.js options, optionally overriding the default threshold and keys. + * Accepts a bare threshold number for backwards compatibility with v1.3. + */ +export function getFuseOptions(options: FuseSearchOptions | number = {}): FuseOptions { + const { threshold = DEFAULT_FUSE_THRESHOLD, keys } = + typeof options === 'number' ? { threshold: options } : options + return { threshold, ignoreLocation: true, - keys: [ + keys: keys ?? [ { name: 'name', weight: 2 }, + { name: 'tagline', weight: 1.5 }, { name: 'description', weight: 1.5 }, { name: 'stack', weight: 1 }, ], } } -export function createFuseSearch(projects: ProjexProject[], threshold: number = 0.2): Fuse { - const options = getFuseOptions(threshold) - - return new Fuse(projects, options) +/** + * Create a configured Fuse search instance for fuzzy searching projects. + * Accepts a bare threshold number for backwards compatibility with v1.3. + */ +export function createFuseSearch( + projects: ProjexProject[], + options: FuseSearchOptions | number = {} +): Fuse { + return new Fuse(projects, getFuseOptions(options)) } diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 494cfca..ae458cd 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -30,7 +30,8 @@ export { sortProjects } from './sortProjects' export type { SortValue } from './sortProjects' export type { SortOrder } from './sortByDate' export { getFuseOptions, createFuseSearch } from './fuse' -export type { FuseOptions } from './fuse' +export type { FuseOptions, FuseSearchOptions } from './fuse' +export { searchProjects } from './searchProjects' export { useProjectSearch } from './useProjectSearch' export type { UseProjectSearchOptions } from './useProjectSearch' export { useProjectFilters } from './useProjectFilters' diff --git a/packages/core/src/lib/searchProjects.ts b/packages/core/src/lib/searchProjects.ts new file mode 100644 index 0000000..d802637 --- /dev/null +++ b/packages/core/src/lib/searchProjects.ts @@ -0,0 +1,27 @@ +import type { ProjexProject } from '../types' +import { createFuseSearch } from './fuse' +import type { FuseSearchOptions } from './fuse' + +/** + * Pure fuzzy search over projects — the non-React sibling of `useProjectSearch`, + * fitting the `filterByStatus` / `sortProjects` family of helpers. Usable in + * server components, scripts and tests where a hook is not available. + * + * Returns the input array unchanged when the query is empty or whitespace-only; + * otherwise returns matching projects ranked by Fuse relevance. + */ +export function searchProjects( + projects: ProjexProject[], + query: string | undefined | null, + options: FuseSearchOptions = {} +): ProjexProject[] { + const normalizedQuery = query == null ? '' : String(query).trim() + + if (!normalizedQuery) { + return projects + } + + return createFuseSearch(projects, options) + .search(normalizedQuery) + .map(result => result.item) +} diff --git a/packages/core/src/lib/useProjectSearch.ts b/packages/core/src/lib/useProjectSearch.ts index 5b50cfc..3ad018c 100644 --- a/packages/core/src/lib/useProjectSearch.ts +++ b/packages/core/src/lib/useProjectSearch.ts @@ -1,24 +1,25 @@ 'use client' import { useMemo } from 'react' -import Fuse from 'fuse.js' import type { ProjexProject } from '../types' -import { getFuseOptions } from './fuse' +import { createFuseSearch } from './fuse' +import type { FuseSearchOptions } from './fuse' -export interface UseProjectSearchOptions { - threshold?: number -} +export type UseProjectSearchOptions = FuseSearchOptions export function useProjectSearch( projects: ProjexProject[], query: string | undefined | null, options: UseProjectSearchOptions = {} ): ProjexProject[] { - const { threshold = 0.3 } = options + const { threshold, keys } = options - const fuse = useMemo(() => { - return new Fuse(projects, getFuseOptions(threshold)) - }, [projects, threshold]) + // Index construction is memoised independently of the query so each + // keystroke re-searches the same Fuse instance instead of rebuilding it. + const fuse = useMemo( + () => createFuseSearch(projects, { threshold, keys }), + [projects, threshold, keys] + ) const normalizedQuery = query == null ? '' : String(query).trim() @@ -26,7 +27,5 @@ export function useProjectSearch( return projects } - const results = fuse.search(normalizedQuery) - - return results.map(result => result.item) + return fuse.search(normalizedQuery).map(result => result.item) } diff --git a/packages/docs/src/api/utilities/fuse-search.md b/packages/docs/src/api/utilities/fuse-search.md index 0810f04..aefaee7 100644 --- a/packages/docs/src/api/utilities/fuse-search.md +++ b/packages/docs/src/api/utilities/fuse-search.md @@ -9,14 +9,17 @@ Get Fuse.js search configuration options with default weights for project fields ### Signature ```tsx -function getFuseOptions(threshold?: number): FuseOptions +function getFuseOptions(options?: FuseSearchOptions | number): FuseOptions ``` +Accepts an options object, or a bare threshold number for backwards compatibility with v1.3. + ### Parameters | Parameter | Type | Default | Description | |-----------|------|----------|-------------| -| threshold | `number` | `0.3` | Match threshold (0.0 = perfect match, 1.0 = match anything) | +| options.threshold | `number` | `0.3` | Match threshold (0.0 = perfect match, 1.0 = match anything) | +| options.keys | `Array<{ name: string; weight: number }>` | weighted defaults below | Weighted fields to search (replaces the defaults entirely) | ### Returns @@ -25,6 +28,11 @@ function getFuseOptions(threshold?: number): FuseOptions ### Types ```tsx +interface FuseSearchOptions { + threshold?: number + keys?: Array<{ name: string; weight: number }> +} + interface FuseOptions { threshold: number ignoreLocation: boolean @@ -37,7 +45,8 @@ interface FuseOptions { | Key | Weight | Description | |-----|--------|-------------| | `name` | 2 | Project name (highest priority) | -| `description` | 1.5 | Project description (medium priority) | +| `tagline` | 1.5 | Short, high-signal summary line | +| `description` | 1.5 | Project description | | `stack` | 1 | Technology stack (lowest priority) | ### Default Options @@ -47,6 +56,8 @@ interface FuseOptions { | `threshold` | `number` | `0.3` | Match threshold (lower = stricter) | | `ignoreLocation` | `boolean` | `true` | Find matches anywhere in field content (not just at start) | +The `0.3` default is shared by every search entry point (`getFuseOptions`, `createFuseSearch`, `searchProjects`, and `useProjectSearch`). + ### Example ```tsx @@ -57,8 +68,10 @@ const options = getFuseOptions() // { threshold: 0.3, ignoreLocation: true, keys: [{ name: 'name', weight: 2 }, ...] } // Custom threshold for stricter matching -const strictOptions = getFuseOptions(0.1) -// { threshold: 0.1, ignoreLocation: true, keys: [{ name: 'name', weight: 2 }, ...] } +const strictOptions = getFuseOptions({ threshold: 0.1 }) + +// Bare number still works (v1.3 back-compat) +const legacyOptions = getFuseOptions(0.1) ``` ### Threshold Values @@ -78,15 +91,21 @@ Create a configured Fuse search instance for fuzzy searching projects. ### Signature ```tsx -function createFuseSearch(projects: ProjexProject[], threshold?: number): Fuse +function createFuseSearch( + projects: ProjexProject[], + options?: FuseSearchOptions | number +): Fuse ``` +Accepts an options object, or a bare threshold number for backwards compatibility with v1.3. + ### Parameters | Parameter | Type | Default | Description | |-----------|------|----------|-------------| | projects | `ProjexProject[]` | - | Array of projects to search | -| threshold | `number` | `0.3` | Match threshold (0.0 = perfect, 1.0 = match anything) | +| options.threshold | `number` | `0.3` | Match threshold (0.0 = perfect, 1.0 = match anything) | +| options.keys | `Array<{ name: string; weight: number }>` | weighted defaults | Weighted fields to search | ### Returns @@ -108,9 +127,31 @@ results.forEach(({ item, refIndex }) => { }) ``` +### Custom Keys + +Override the searchable fields without dropping down to `new Fuse(...)` yourself: + +```tsx +import { createFuseSearch } from '@manningworks/projex' + +// Only search names and taglines +const fuse = createFuseSearch(projects, { + keys: [ + { name: 'name', weight: 2 }, + { name: 'tagline', weight: 1 }, + ], +}) + +// Custom keys and threshold together +const strictFuse = createFuseSearch(projects, { + threshold: 0.1, + keys: [{ name: 'stack', weight: 1 }], +}) +``` + ## Usage in useProjectSearch -These utilities are used internally by `useProjectSearch` hook: +These utilities are used internally by `useProjectSearch` and `searchProjects`: ```tsx import { useProjectSearch, ProjectCard } from '@manningworks/projex' @@ -127,27 +168,7 @@ function ProjectSearch({ projects }) { } ``` -## Advanced Usage - -### Custom Weights - -For custom search behavior, create your own options: - -```tsx -import Fuse from 'fuse.js' -import type { ProjexProject } from '@manningworks/projex' - -const fuse = new Fuse(projects, { - threshold: 0.2, - keys: [ - { name: 'name', weight: 3 }, - { name: 'description', weight: 1 }, - { name: 'stack', weight: 0.5 } - ] -}) -``` - -### Multi-term Search +## Multi-term Search Fuse supports searching multiple terms: @@ -160,5 +181,6 @@ const results = fuse.search('react next') ## Related +- `searchProjects` - Pure helper returning matching projects directly - `useProjectSearch` - React hook for fuzzy search with Fuse.js - Fuse.js documentation: https://fusejs.io/ diff --git a/packages/docs/src/api/utilities/index.md b/packages/docs/src/api/utilities/index.md index bc0f499..34d78fb 100644 --- a/packages/docs/src/api/utilities/index.md +++ b/packages/docs/src/api/utilities/index.md @@ -25,7 +25,7 @@ Projex provides utility functions for filtering, sorting, and normalizing projec | Function | Description | |----------|-------------| -| [useProjectSearch](./use-project-search) | Fuzzy search projects by name, description, stack | +| [useProjectSearch](./use-project-search) | Fuzzy search projects by name, tagline, description, stack | | [useProjectFilters](./use-project-filters) | Filter projects by tags | ### Data Normalization @@ -49,10 +49,11 @@ Projex provides utility functions for filtering, sorting, and normalizing projec | [fetchLemonSqueezyStore](./fetch-lemon-squeezy-store) | Fetch Lemon Squeezy store data | | [fetchDevToUser](./fetch-devto-user) | Fetch Dev.to user data | -### Search Configuration +### Search | Function | Description | |----------|-------------| +| [searchProjects](./search-projects) | Pure fuzzy search over projects | | [getFuseOptions](./fuse-search) | Get Fuse.js search configuration | | [createFuseSearch](./fuse-search) | Create Fuse.js search instance | @@ -100,6 +101,7 @@ import { fetchDevToUser, getFuseOptions, createFuseSearch, + searchProjects, generatePersonSchema, generateProjectSchema, generatePortfolioMetadata, diff --git a/packages/docs/src/api/utilities/search-projects.md b/packages/docs/src/api/utilities/search-projects.md new file mode 100644 index 0000000..218d6df --- /dev/null +++ b/packages/docs/src/api/utilities/search-projects.md @@ -0,0 +1,72 @@ +# searchProjects + +Pure fuzzy search over projects — the non-React sibling of `useProjectSearch`, fitting the `filterByStatus` / `sortProjects` family of helpers. Usable in server components, scripts and tests where a hook is not available. + +## Signature + +```tsx +function searchProjects( + projects: ProjexProject[], + query: string | undefined | null, + options?: FuseSearchOptions +): ProjexProject[] +``` + +## Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| projects | `ProjexProject[]` | Array of projects to search | +| query | `string \| undefined \| null` | Search query string | +| options | `FuseSearchOptions` | Optional configuration | + +## Returns + +`ProjexProject[]` - The input array unchanged when the query is empty, null, undefined, or whitespace-only; otherwise matching projects ranked by Fuse relevance. + +## Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| threshold | `number` | `0.3` | Fuse.js fuzzy match threshold (lower = stricter) | +| keys | `Array<{ name: string; weight: number }>` | `name` (2), `tagline` (1.5), `description` (1.5), `stack` (1) | Weighted fields to search | + +## Example + +```tsx +import { searchProjects, filterByStatus, sortProjects } from '@manningworks/projex' + +// Chain it with the other pure helpers +const results = sortProjects( + searchProjects( + filterByStatus(projects, 'active'), + 'dashboard' + ), + 'stars' +) +``` + +### Custom Keys and Threshold + +```tsx +import { searchProjects } from '@manningworks/projex' + +// Only search names, strictly +const nameMatches = searchProjects(projects, query, { + threshold: 0.1, + keys: [{ name: 'name', weight: 1 }], +}) +``` + +## When to use which + +| Situation | Use | +|-----------|-----| +| Client component with a query state | `useProjectSearch` (memoises the Fuse index) | +| Server component, script, or plain data pipeline | `searchProjects` | +| You need the raw Fuse instance (e.g. `remove()`, custom search calls) | `createFuseSearch` | + +## Related + +- [useProjectSearch](./use-project-search) - React hook wrapper +- [Fuse Search Utilities](./fuse-search) - `getFuseOptions` / `createFuseSearch` diff --git a/packages/docs/src/api/utilities/use-project-search.md b/packages/docs/src/api/utilities/use-project-search.md index 63bfc21..bff3fe8 100644 --- a/packages/docs/src/api/utilities/use-project-search.md +++ b/packages/docs/src/api/utilities/use-project-search.md @@ -1,6 +1,6 @@ # useProjectSearch -React hook for fuzzy searching projects by name, description, and stack using Fuse.js. +React hook for fuzzy searching projects by name, tagline, description, and stack using Fuse.js. ## Signature @@ -29,12 +29,13 @@ function useProjectSearch( | Option | Type | Default | Description | |--------|------|---------|-------------| | threshold | `number` | `0.3` | Fuse.js fuzzy match threshold (lower = stricter) | +| keys | `Array<{ name: string; weight: number }>` | `name` (2), `tagline` (1.5), `description` (1.5), `stack` (1) | Weighted fields to search | ## Behavior - Returns all projects if query is empty, null, or undefined -- Searches across name, description, and stack fields -- Name field has highest weight, then description, then stack +- Searches across name, tagline, description, and stack fields +- Name field has highest weight; tagline and description sit equally below it, then stack - Threshold of 0.3 allows typos while remaining accurate - Uses `ignoreLocation: true` for better substring matching - Results maintain original project order (sorted by relevance) diff --git a/packages/docs/src/guides/customizing-search.md b/packages/docs/src/guides/customizing-search.md index 2fba47c..76d3fcb 100644 --- a/packages/docs/src/guides/customizing-search.md +++ b/packages/docs/src/guides/customizing-search.md @@ -11,7 +11,9 @@ The threshold controls how "fuzzy" search matching is. Lower values are stricter Projex uses these defaults: - **Threshold:** `0.3` - Balanced between precision and recall - **ignoreLocation:** `true` - Matches found anywhere in field content -- **Field weights:** `name` (2), `description` (1.5), `stack` (1) +- **Field weights:** `name` (2), `tagline` (1.5), `description` (1.5), `stack` (1) + +The `0.3` threshold default is shared by every search entry point: `useProjectSearch`, `searchProjects`, `createFuseSearch`, and `getFuseOptions`. ### Threshold Values Reference @@ -50,9 +52,9 @@ function SearchablePortfolio() { } ``` -## Using Fuse.js Directly +## Customizing Searchable Fields and Threshold -For maximum control over search behavior, use Fuse.js utilities directly: +Both `createFuseSearch` and the `searchProjects` helper (and `useProjectSearch`) accept a `{ threshold, keys }` options object, so customising the searchable fields no longer requires constructing `new Fuse(...)` yourself: ```tsx import { createFuseSearch } from '@manningworks/projex' @@ -61,8 +63,14 @@ import { useState, useMemo } from 'react' function AdvancedSearch() { const [searchQuery, setSearchQuery] = useState('') - const fuse = useMemo(() => - createFuseSearch(projects, 0.3), // Custom threshold + const fuse = useMemo(() => + createFuseSearch(projects, { + threshold: 0.1, // stricter matching + keys: [ // search only these fields, weighted + { name: 'name', weight: 2 }, + { name: 'tagline', weight: 1 }, + ], + }), [] ) @@ -77,9 +85,13 @@ function AdvancedSearch() { } ``` -### Customizing Fuse.js Options +::: tip Backwards compatibility +`createFuseSearch(projects, 0.3)` and `getFuseOptions(0.3)` still accept a bare threshold number, so v1.3-style calls keep working. +::: + +### Truly Custom Fuse Options -When using `createFuseSearch`, you get full access to Fuse.js options: +For options projex does not expose (such as `ignoreLocation: false` or `minMatchCharLength`), drop down to Fuse.js directly: ```tsx import Fuse from 'fuse.js' @@ -87,15 +99,16 @@ import Fuse from 'fuse.js' const fuse = new Fuse(projects, { keys: [ { name: 'name', weight: 2 }, + { name: 'tagline', weight: 1.5 }, { name: 'description', weight: 1.5 }, { name: 'stack', weight: 1 }, ], - threshold: 0.2, // Match strictness - ignoreLocation: true, // Match anywhere in field - ignoreFieldNorm: false, // Consider field length in scoring - includeScore: false, // Don't include score in results - includeMatches: false, // Don't include match details - minMatchCharLength: 1, // Minimum characters to match + threshold: 0.3, // Match strictness + ignoreLocation: true, // Match anywhere in field + ignoreFieldNorm: false, // Consider field length in scoring + includeScore: false, // Don't include score in results + includeMatches: false, // Don't include match details + minMatchCharLength: 1, // Minimum characters to match }) ``` @@ -144,20 +157,22 @@ Use this when: Search relevance is influenced by field weights. Higher weight = higher relevance score: ```tsx -const fuse = new Fuse(projects, { +import { createFuseSearch } from '@manningworks/projex' + +const fuse = createFuseSearch(projects, { keys: [ { name: 'name', weight: 2 }, // Highest priority + { name: 'tagline', weight: 1.5 }, // Medium priority { name: 'description', weight: 1.5 }, // Medium priority { name: 'stack', weight: 1 }, // Lower priority ], - threshold: 0.2, - ignoreLocation: true, + threshold: 0.3, }) ``` **Effect of weights:** - Matching in `name` is 2x more relevant than matching in `stack` -- Matching in `description` is 1.5x more relevant than matching in `stack` +- Matching in `tagline` or `description` is 1.5x more relevant than matching in `stack` - Results are sorted by combined relevance score **When to adjust weights:** From f244ffd0bef0bc9f2cd0aea7707ee13902d8e016 Mon Sep 17 00:00:00 2001 From: Luke Manning Date: Tue, 18 Aug 2026 23:14:43 +0100 Subject: [PATCH 2/3] fix(search): stabilise inline custom keys so the Fuse index isn't rebuilt per render useProjectSearch now pins keys to a content-compared identity via a ref, so an inline keys array no longer busts the index useMemo on every render. Adds index-rebuild counting tests via a spy around createFuseSearch. Addresses review on PR #31. --- .../lib/__tests__/useProjectSearch.test.ts | 52 ++++++++++++++++++- packages/core/src/lib/useProjectSearch.ts | 29 +++++++++-- .../src/api/utilities/use-project-search.md | 1 + 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/core/src/lib/__tests__/useProjectSearch.test.ts b/packages/core/src/lib/__tests__/useProjectSearch.test.ts index 475b75d..0f30bf1 100644 --- a/packages/core/src/lib/__tests__/useProjectSearch.test.ts +++ b/packages/core/src/lib/__tests__/useProjectSearch.test.ts @@ -1,8 +1,16 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { renderHook } from '@testing-library/react' import { useProjectSearch } from '../useProjectSearch' +import { createFuseSearch } from '../fuse' import type { ProjexProject } from '../../types' +// Wrap createFuseSearch in a spy (delegating to the real implementation) so +// index rebuilds can be counted in the memoisation tests below. +vi.mock('../fuse', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, createFuseSearch: vi.fn(actual.createFuseSearch) } +}) + function createProject(overrides: Partial = {}): ProjexProject { return { id: 'test-id', @@ -159,4 +167,46 @@ describe('useProjectSearch', () => { expect(result.current).toHaveLength(0) }) + + it('should not rebuild the search index when inline keys have stable content', () => { + const spy = vi.mocked(createFuseSearch) + spy.mockClear() + + const { rerender, result } = renderHook( + ({ query }) => + useProjectSearch(projects, query, { + keys: [{ name: 'name', weight: 2 }], + }), + { initialProps: { query: '' } } + ) + + spy.mockClear() + + // Each rerender passes a fresh inline keys array with identical content + rerender({ query: 'dash' }) + rerender({ query: 'dashb' }) + rerender({ query: 'dashboard' }) + + expect(spy).not.toHaveBeenCalled() + expect(result.current).toHaveLength(1) + expect(result.current[0].id).toBe('1') + }) + + it('should rebuild the search index when key content changes', () => { + const spy = vi.mocked(createFuseSearch) + spy.mockClear() + + const { rerender } = renderHook( + ({ keys }) => useProjectSearch(projects, 'authentication', { keys }), + { initialProps: { keys: [{ name: 'name', weight: 1 }] } } + ) + + spy.mockClear() + + rerender({ keys: [{ name: 'name', weight: 1 }] }) + expect(spy).not.toHaveBeenCalled() + + rerender({ keys: [{ name: 'description', weight: 1 }] }) + expect(spy).toHaveBeenCalledTimes(1) + }) }) diff --git a/packages/core/src/lib/useProjectSearch.ts b/packages/core/src/lib/useProjectSearch.ts index 3ad018c..5aeee2c 100644 --- a/packages/core/src/lib/useProjectSearch.ts +++ b/packages/core/src/lib/useProjectSearch.ts @@ -1,12 +1,27 @@ 'use client' -import { useMemo } from 'react' +import { useMemo, useRef } from 'react' import type { ProjexProject } from '../types' import { createFuseSearch } from './fuse' import type { FuseSearchOptions } from './fuse' export type UseProjectSearchOptions = FuseSearchOptions +type FuseKey = NonNullable[number] + +/** Content equality for key sets, so arrays with the same entries are treated as equal. */ +function sameKeys(a: FuseKey[] | undefined, b: FuseKey[] | undefined): boolean { + if (a === b) { + return true + } + + if (!a || !b || a.length !== b.length) { + return false + } + + return a.every((key, i) => key.name === b[i].name && key.weight === b[i].weight) +} + export function useProjectSearch( projects: ProjexProject[], query: string | undefined | null, @@ -14,11 +29,19 @@ export function useProjectSearch( ): ProjexProject[] { const { threshold, keys } = options + // Pin `keys` to a stable identity based on content, so consumers passing an + // inline array don't rebuild the Fuse index on every render. + const keysRef = useRef(keys) + if (!sameKeys(keysRef.current, keys)) { + keysRef.current = keys + } + const stableKeys = keysRef.current + // Index construction is memoised independently of the query so each // keystroke re-searches the same Fuse instance instead of rebuilding it. const fuse = useMemo( - () => createFuseSearch(projects, { threshold, keys }), - [projects, threshold, keys] + () => createFuseSearch(projects, { threshold, keys: stableKeys }), + [projects, threshold, stableKeys] ) const normalizedQuery = query == null ? '' : String(query).trim() diff --git a/packages/docs/src/api/utilities/use-project-search.md b/packages/docs/src/api/utilities/use-project-search.md index bff3fe8..28846d0 100644 --- a/packages/docs/src/api/utilities/use-project-search.md +++ b/packages/docs/src/api/utilities/use-project-search.md @@ -37,6 +37,7 @@ function useProjectSearch( - Searches across name, tagline, description, and stack fields - Name field has highest weight; tagline and description sit equally below it, then stack - Threshold of 0.3 allows typos while remaining accurate +- Custom `keys` are compared by content, so passing an inline array is safe — the search index is only rebuilt when the entries actually change - Uses `ignoreLocation: true` for better substring matching - Results maintain original project order (sorted by relevance) From f3c2fa04b3d0971a3f322d1f16b6ae973afa1724 Mon Sep 17 00:00:00 2001 From: Luke Manning Date: Tue, 18 Aug 2026 23:19:36 +0100 Subject: [PATCH 3/3] test(search): use field-disjoint fixtures and discriminating threshold queries searchProjects fixtures now use vocabulary unique to each field so name/tagline/description assertions can only pass via the intended field. Threshold tests in searchProjects, fuse and useProjectSearch use single-deletion typos whose result sets differ between strict and lenient thresholds, asserting exact sets plus strict inequality instead of >=. Custom-keys test gains a positive control. Addresses review feedback on PR #31. --- packages/core/src/lib/__tests__/fuse.test.ts | 8 ++- .../src/lib/__tests__/searchProjects.test.ts | 59 ++++++++++++++----- .../lib/__tests__/useProjectSearch.test.ts | 13 +++- 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/packages/core/src/lib/__tests__/fuse.test.ts b/packages/core/src/lib/__tests__/fuse.test.ts index f992425..dd37a12 100644 --- a/packages/core/src/lib/__tests__/fuse.test.ts +++ b/packages/core/src/lib/__tests__/fuse.test.ts @@ -180,15 +180,21 @@ describe('createFuseSearch', () => { it('should respect custom threshold', () => { const projects = [ createProject({ id: '1', name: 'React Dashboard' }), + createProject({ id: '2', name: 'Vue Todo' }), ] + // 'Ract' (one deletion from 'React') is within fuzzy tolerance at 0.5 + // but not at 0.1, so the two thresholds must produce different sets. const fuseStrict = createFuseSearch(projects, 0.1) const fuseLoose = createFuseSearch(projects, 0.5) const strictResults = fuseStrict.search('Ract') const looseResults = fuseLoose.search('Ract') - expect(looseResults.length).toBeGreaterThanOrEqual(strictResults.length) + expect(strictResults).toHaveLength(0) + expect(looseResults).toHaveLength(1) + expect(looseResults[0].item.id).toBe('1') + expect(looseResults.length).toBeGreaterThan(strictResults.length) }) it('should accept custom threshold via options object', () => { diff --git a/packages/core/src/lib/__tests__/searchProjects.test.ts b/packages/core/src/lib/__tests__/searchProjects.test.ts index b245f9f..1a01fcb 100644 --- a/packages/core/src/lib/__tests__/searchProjects.test.ts +++ b/packages/core/src/lib/__tests__/searchProjects.test.ts @@ -29,9 +29,23 @@ function createProject(overrides: Partial = {}): ProjexProject { } describe('searchProjects', () => { + // Vocabulary is deliberately disjoint across name, tagline, description and + // stack so each field-specific assertion can only pass via its intended field. const projects = [ - createProject({ id: '1', name: 'React Dashboard', tagline: 'Ship dashboards fast', description: 'A modern dashboard', stack: ['react', 'typescript'] }), - createProject({ id: '2', name: 'Vue Todo', tagline: 'Todos made simple', description: 'A simple Vue app', stack: ['vue', 'javascript'] }), + createProject({ + id: '1', + name: 'Argon Grid', + tagline: 'Orchestrate your containers', + description: 'A control plane for batch scheduling', + stack: ['kafka', 'grpc'], + }), + createProject({ + id: '2', + name: 'Beryl Slate', + tagline: 'Capture fleeting thoughts', + description: 'Markdown journal with backlinks', + stack: ['zig', 'sqlite'], + }), ] it('should return the input array unchanged for an empty query', () => { @@ -48,46 +62,61 @@ describe('searchProjects', () => { }) it('should find projects by name', () => { - const results = searchProjects(projects, 'vue todo') + const results = searchProjects(projects, 'argon') expect(results).toHaveLength(1) - expect(results[0].id).toBe('2') + expect(results[0].id).toBe('1') }) it('should find projects by tagline', () => { - const results = searchProjects(projects, 'simple') + const results = searchProjects(projects, 'orchestrate') expect(results).toHaveLength(1) - expect(results[0].id).toBe('2') + expect(results[0].id).toBe('1') }) it('should find projects by description', () => { - const results = searchProjects(projects, 'dashboard') + const results = searchProjects(projects, 'scheduling') expect(results).toHaveLength(1) expect(results[0].id).toBe('1') }) it('should find projects by stack tag', () => { - const results = searchProjects(projects, 'typescript') + const results = searchProjects(projects, 'kafka') expect(results).toHaveLength(1) expect(results[0].id).toBe('1') }) it('should restrict search to custom keys', () => { - const nameOnly = searchProjects(projects, 'modern', { + // 'orchestrate' lives only in the tagline, so name-only keys exclude it... + const nameOnly = searchProjects(projects, 'orchestrate', { keys: [{ name: 'name', weight: 1 }], }) - - expect(nameOnly).toHaveLength(0) + expect(nameOnly).toEqual([]) + + // ...and including the tagline key finds it. + const withTagline = searchProjects(projects, 'orchestrate', { + keys: [ + { name: 'name', weight: 1 }, + { name: 'tagline', weight: 1 }, + ], + }) + expect(withTagline).toHaveLength(1) + expect(withTagline[0].id).toBe('1') }) it('should accept a custom threshold', () => { - const strict = searchProjects(projects, 'dashbord', { threshold: 0.1 }) - const lenient = searchProjects(projects, 'dashbord', { threshold: 0.4 }) - - expect(lenient.length).toBeGreaterThanOrEqual(strict.length) + // 'schedulng' (one deletion from 'scheduling') is within fuzzy tolerance + // at 0.4 but not at 0.1, so the two thresholds must produce different sets. + const strict = searchProjects(projects, 'schedulng', { threshold: 0.1 }) + const lenient = searchProjects(projects, 'schedulng', { threshold: 0.4 }) + + expect(strict).toEqual([]) + expect(lenient).toHaveLength(1) + expect(lenient[0].id).toBe('1') + expect(lenient.length).toBeGreaterThan(strict.length) }) it('should return empty array when no matches found', () => { diff --git a/packages/core/src/lib/__tests__/useProjectSearch.test.ts b/packages/core/src/lib/__tests__/useProjectSearch.test.ts index 0f30bf1..1a52164 100644 --- a/packages/core/src/lib/__tests__/useProjectSearch.test.ts +++ b/packages/core/src/lib/__tests__/useProjectSearch.test.ts @@ -141,9 +141,18 @@ describe('useProjectSearch', () => { }) it('should use custom threshold option', () => { - const { result } = renderHook(() => useProjectSearch(projects, 'ract', { threshold: 0.4 })) + // 'ract' (one deletion from 'React') is within fuzzy tolerance at 0.4 + // but not at 0.1, so the two thresholds must produce different sets. + const { result: strict } = renderHook(() => + useProjectSearch(projects, 'ract', { threshold: 0.1 }) + ) + const { result: lenient } = renderHook(() => + useProjectSearch(projects, 'ract', { threshold: 0.4 }) + ) - expect(result.current.length).toBeGreaterThan(0) + expect(strict.current).toHaveLength(0) + expect(lenient.current).toHaveLength(1) + expect(lenient.current[0].id).toBe('1') }) it('should find projects by tagline by default', () => {