chore: rename website-astro to website, update gitignore

- Rename website-astro/ to website/
- Add website/dist/ and website/.astro/ to gitignore
- Update generate-website-data.mjs output path
This commit is contained in:
Aaron Powell
2026-01-28 16:42:32 +11:00
parent 4b3bd3d0ad
commit aa42998e29
69 changed files with 17 additions and 16941 deletions
+155
View File
@@ -0,0 +1,155 @@
/**
* Fuzzy search implementation for the Awesome Copilot website
* Simple substring matching on title and description with scoring
*/
import { escapeHtml, fetchData } from './utils';
export interface SearchItem {
title: string;
description?: string;
searchText?: string;
path: string;
type: string;
[key: string]: unknown;
}
export interface SearchOptions {
fields?: string[];
limit?: number;
minScore?: number;
}
export class FuzzySearch {
private items: SearchItem[] = [];
constructor(items: SearchItem[] = []) {
this.items = items;
}
/**
* Update the items to search
*/
setItems(items: SearchItem[]): void {
this.items = items;
}
/**
* Search items with fuzzy matching
*/
search(query: string, options: SearchOptions = {}): SearchItem[] {
const {
fields = ['title', 'description', 'searchText'],
limit = 50,
minScore = 0,
} = options;
if (!query || query.trim().length === 0) {
return this.items.slice(0, limit);
}
const normalizedQuery = query.toLowerCase().trim();
const queryWords = normalizedQuery.split(/\s+/);
const results: Array<{ item: SearchItem; score: number }> = [];
for (const item of this.items) {
const score = this.calculateScore(item, queryWords, fields);
if (score > minScore) {
results.push({ item, score });
}
}
// Sort by score descending
results.sort((a, b) => b.score - a.score);
return results.slice(0, limit).map(r => r.item);
}
/**
* Calculate match score for an item
*/
private calculateScore(item: SearchItem, queryWords: string[], fields: string[]): number {
let totalScore = 0;
for (const word of queryWords) {
let wordScore = 0;
for (const field of fields) {
const value = item[field];
if (!value) continue;
const normalizedValue = String(value).toLowerCase();
// Exact match in title gets highest score
if (field === 'title' && normalizedValue === word) {
wordScore = Math.max(wordScore, 100);
}
// Title starts with word
else if (field === 'title' && normalizedValue.startsWith(word)) {
wordScore = Math.max(wordScore, 80);
}
// Title contains word
else if (field === 'title' && normalizedValue.includes(word)) {
wordScore = Math.max(wordScore, 60);
}
// Description contains word
else if (field === 'description' && normalizedValue.includes(word)) {
wordScore = Math.max(wordScore, 30);
}
// searchText (includes tags, tools, etc) contains word
else if (field === 'searchText' && normalizedValue.includes(word)) {
wordScore = Math.max(wordScore, 20);
}
}
totalScore += wordScore;
}
// Bonus for matching all words
const matchesAllWords = queryWords.every(word =>
fields.some(field => {
const value = item[field];
return value && String(value).toLowerCase().includes(word);
})
);
if (matchesAllWords && queryWords.length > 1) {
totalScore *= 1.5;
}
return totalScore;
}
/**
* Highlight matching text in a string
*/
highlight(text: string, query: string): string {
if (!query || !text) return escapeHtml(text || '');
const normalizedQuery = query.toLowerCase().trim();
const words = normalizedQuery.split(/\s+/);
let result = escapeHtml(text);
for (const word of words) {
if (word.length < 2) continue;
const regex = new RegExp(`(${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
result = result.replace(regex, '<mark>$1</mark>');
}
return result;
}
}
// Global search instance
export const globalSearch = new FuzzySearch();
/**
* Initialize global search with search index
*/
export async function initGlobalSearch(): Promise<FuzzySearch> {
const searchIndex = await fetchData<SearchItem[]>('search-index.json');
if (searchIndex) {
globalSearch.setItems(searchIndex);
}
return globalSearch;
}