diff --git a/package.json b/package.json index 4479e99..16446f4 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "type": "module", "private": true, "scripts": { - "dev": "waku dev", - "build": "waku build --with-netlify-static", + "dev": "VITE_EXPERIMENTAL_WAKU_ROUTER=true waku dev", + "build": "VITE_EXPERIMENTAL_WAKU_ROUTER=true waku build --with-netlify-static", "start": "waku start" }, "dependencies": { @@ -21,6 +21,7 @@ "@react-aria/overlays": "^3.26.0", "@react-aria/searchfield": "^3.8.1", "@tailwindcss/postcss": "4.0.12", + "@tanstack/react-query": "^5.67.2", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", "cmdk": "^1.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81aceef..d2f2ee6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -39,6 +39,9 @@ importers: '@tailwindcss/postcss': specifier: 4.0.12 version: 4.0.12 + '@tanstack/react-query': + specifier: ^5.67.2 + version: 5.67.2(react@19.0.0) '@types/react': specifier: 19.0.10 version: 19.0.10 @@ -915,6 +918,14 @@ packages: '@tailwindcss/postcss@4.0.12': resolution: {integrity: sha512-r59Sdr8djCW4dL3kvc4aWU8PHdUAVM3O3te2nbYzXsWwKLlHPCuUoZAc9FafXb/YyNDZOMI7sTbKTKFmwOrMjw==} + '@tanstack/query-core@5.67.2': + resolution: {integrity: sha512-+iaFJ/pt8TaApCk6LuZ0WHS/ECVfTzrxDOEL9HH9Dayyb5OVuomLzDXeSaI2GlGT/8HN7bDGiRXDts3LV+u6ww==} + + '@tanstack/react-query@5.67.2': + resolution: {integrity: sha512-6Sa+BVNJWhAV4QHvIqM73norNeGRWGC3ftN0Ix87cmMvI215I1wyJ44KUTt/9a0V9YimfGcg25AITaYVel71Og==} + peerDependencies: + react: ^18 || ^19 + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -2362,6 +2373,13 @@ snapshots: postcss: 8.5.3 tailwindcss: 4.0.12 + '@tanstack/query-core@5.67.2': {} + + '@tanstack/react-query@5.67.2(react@19.0.0)': + dependencies: + '@tanstack/query-core': 5.67.2 + react: 19.0.0 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.26.9 diff --git a/src/_data/feeds.ts b/src/_data/feeds.ts index 60d6672..1eb4dd1 100644 --- a/src/_data/feeds.ts +++ b/src/_data/feeds.ts @@ -1,7 +1,37 @@ -export const rssFeedUrls = [ +import Parser from "rss-parser"; +import { RSSFeed } from "../types"; +const parser = new Parser(); + +const rssFeedUrls = [ "https://tylur.blog/api/rss.xml", "https://tkdodo.eu/blog/rss.xml", "https://overreacted.io/rss.xml", "https://cassidoo.co/rss.xml", "https://waku.gg/api/rss.xml", ]; + +export async function getFeedData() { + const feedPromises = rssFeedUrls.map(async (url) => { + try { + const feed = await parser.parseURL(url); + return { + title: feed.title || "", + description: feed.description || "", + link: feed.link || "", + items: + feed.items?.map((item) => ({ + title: item.title || "", + description: item.contentSnippet || item.content || "", + link: item.link || "", + pubDate: item.pubDate || "", + })) || [], + }; + } catch (error) { + console.error(`Error fetching feed from ${url}:`, error); + return null; + } + }); + + const results = await Promise.all(feedPromises); + return results.filter((feed): feed is RSSFeed => feed !== null); +} diff --git a/src/components/providers.tsx b/src/components/providers.tsx new file mode 100644 index 0000000..93b673c --- /dev/null +++ b/src/components/providers.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +export const client = new QueryClient({ + defaultOptions: {}, +}); + +export function Providers({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/src/components/search-dialog.tsx b/src/components/search-dialog.tsx index 51fa575..1298bba 100644 --- a/src/components/search-dialog.tsx +++ b/src/components/search-dialog.tsx @@ -3,8 +3,162 @@ import { useOverlay, useModal, usePreventScroll } from "@react-aria/overlays"; import { FocusScope } from "@react-aria/focus"; import { RSSFeed } from "../types"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import { Command } from "cmdk"; +import { useQuery } from "@tanstack/react-query"; + +// Define types for our search index +interface SearchIndex { + invertedIndex: { + [term: string]: { + [docId: string]: number; + }; + }; + documentMap: { + [docId: string]: { + feedTitle: string; + title: string; + link: string; + pubDate: string; + }; + }; + commonTerms: string[]; + metadata: { + documentCount: number; + termCount: number; + indexSize: number; + generatedAt: string; + }; +} + +interface SearchResult { + id: string; + score: number; + title: string; + link: string; + pubDate: string; + feedTitle: string; +} + +// Client-side search engine +class SearchEngine { + private invertedIndex: SearchIndex["invertedIndex"]; + private documentMap: SearchIndex["documentMap"]; + private commonTerms: Set; + + constructor(searchIndex: SearchIndex) { + this.invertedIndex = searchIndex.invertedIndex; + this.documentMap = searchIndex.documentMap; + this.commonTerms = new Set(searchIndex.commonTerms || []); + } + + search(query: string, limit: number = 10): SearchResult[] { + if (!query || query.trim() === "") { + return []; + } + + // Tokenize the query + const tokens = this._tokenize(query); + if (tokens.length === 0) return []; + + // // Early optimization: If we have an exact match for the full query, prioritize those results + // const exactMatchToken = query + // .toLowerCase() + // .replace(/[^\w\s]/g, " ") + // .trim(); + // if (this.invertedIndex[exactMatchToken]) { + // const exactMatches: SearchResult[] = Object.entries( + // this.invertedIndex[exactMatchToken] + // ) + // .map(([docId, score]) => ({ + // id: docId, + // score: score * 2, // Boost exact matches + // ...this.documentMap[docId]!, + // })) + // .sort((a, b) => b.score - a.score) + // .slice(0, limit); + + // if (exactMatches.length === limit) { + // return exactMatches; // Return early if we have enough exact matches + // } + // } + + // Filter out common terms for more efficient search + // const searchTokens = tokens.filter((token) => !this.commonTerms.has(token)); + let searchTokens = tokens; + + // Only filter common terms if we have enough tokens + if (tokens.length > 1) { + searchTokens = tokens.filter((token) => !this.commonTerms.has(token)); + } + + // If all terms were common, use a few of them anyway + if (searchTokens.length === 0 && tokens.length > 0) { + searchTokens.push(...tokens.slice(0, 2)); + } + + // Calculate scores for each document + const scores: Record = {}; + + // Process each search token + searchTokens.forEach((token) => { + // Look for exact matches + if (this.invertedIndex[token]) { + const matchingDocs = this.invertedIndex[token]; + + for (const docId in matchingDocs) { + const score = matchingDocs[docId]!; + scores[docId] = (scores[docId] || 0) + score; + } + } + + // Look for prefix matches (for partial word search) + Object.keys(this.invertedIndex).forEach((indexedToken) => { + if (indexedToken.startsWith(token) && indexedToken !== token) { + const matchingDocs = this.invertedIndex[indexedToken]; + + for (const docId in matchingDocs) { + // Lower weight for prefix matches + const score = matchingDocs[docId]! * 0.75; + scores[docId] = (scores[docId] || 0) + score; + } + } + }); + }); + + // Convert scores to array and sort + const results: SearchResult[] = Object.entries(scores) + .map(([docId, score]) => ({ + id: docId, + score, + ...this.documentMap[docId]!, + })) + .sort((a, b) => b.score - a.score) + .slice(0, limit); + + return results; + } + + private _tokenize(text: string): string[] { + if (!text) return []; + + // Simple tokenization - split on non-word characters and convert to lowercase + return text + .toLowerCase() + .replace(/[^\w\s]/g, " ") + .split(/\s+/) + .filter((word) => word.length > 1); + } +} + +// Fetch search index using React Query +const fetchSearchIndex = async (): Promise => { + const response = await fetch("/api/search.json"); + if (!response.ok) { + throw new Error("Failed to load search index"); + } + return response.json(); +}; export function SearchDialog({ isOpen, @@ -17,6 +171,8 @@ export function SearchDialog({ defaultValue: string; feeds: RSSFeed[]; }) { + // TODO solve infinite render loop + const ref = useRef(null); const { overlayProps } = useOverlay( { isOpen, onClose, isDismissable: true }, @@ -25,8 +181,51 @@ export function SearchDialog({ const { modalProps } = useModal(); usePreventScroll(); + const [query, setQuery] = useState(defaultValue || ""); + const [results, setResults] = useState([]); + + // Fetch the search index using React Query + const { + data: searchIndex, + isLoading, + error, + } = useQuery({ + queryKey: ["searchIndex"], + queryFn: fetchSearchIndex, + staleTime: Infinity, // The index doesn't change during a session + enabled: isOpen, // Only fetch when dialog is open + }); + + // Create search engine when index is loaded + const searchEngine = searchIndex ? new SearchEngine(searchIndex) : null; + + // Perform search when query changes + const performSearch = useCallback( + (searchQuery: string) => { + if (!searchEngine || !searchQuery.trim()) { + setResults([]); + return; + } + + const searchResults = searchEngine.search(searchQuery, 10); + setResults(searchResults); + }, + [searchEngine] + ); + + // Debounce search input + useEffect(() => { + const timer = setTimeout(() => { + performSearch(query); + }, 200); + + return () => clearTimeout(timer); + }, [query, performSearch]); + + // Set initial value useEffect(() => { if (defaultValue) { + setQuery(defaultValue); const input = document.querySelector("[cmdk-input]") as HTMLInputElement; if (input) { setTimeout(() => { @@ -37,6 +236,7 @@ export function SearchDialog({ } }, [defaultValue]); + // Keyboard shortcut to close useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { @@ -55,25 +255,53 @@ export function SearchDialog({ {...overlayProps} {...modalProps} ref={ref} - className="fixed top-[20%] left-1/2 transform -translate-x-1/2 p-4 z-50" + className="fixed top-[20%] left-1/2 transform -translate-x-1/2 p-4 z-50 w-full max-w-xl" > - - - - No articles found - {feeds.flatMap((feed) => - feed.items.map((item, index) => ( + + + + {isLoading ? ( +
+ Loading search index... +
+ ) : error ? ( +
+ Error loading search index +
+ ) : results.length === 0 ? ( + + {query.trim() ? "No articles found" : "Start typing to search"} + + ) : ( + results.map((result) => ( { - window.open(item.link, "_blank"); + window.open(result.link, "_blank"); onClose(); }} + className="px-4 py-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer" > - - {item.title} + + {result.title} -
{feed.title}
+
+ {result.feedTitle} + {new Date(result.pubDate).toLocaleDateString()} +
)) )} diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index d004b35..42aaaf0 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -4,6 +4,8 @@ import type { ReactNode } from "react"; import { Footer } from "../components/footer"; import { Meta } from "../components/meta"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { Providers } from "../components/providers"; type RootLayoutProps = { children: ReactNode }; @@ -13,7 +15,7 @@ export default async function RootLayout({ children }: RootLayoutProps) {
- {children} + {children}
diff --git a/src/pages/api/search.json.ts b/src/pages/api/search.json.ts new file mode 100644 index 0000000..81b3d15 --- /dev/null +++ b/src/pages/api/search.json.ts @@ -0,0 +1,242 @@ +import { getFeedData } from "../../_data/feeds"; +import { RSSFeed } from "../../types"; + +interface SearchIndex { + invertedIndex: InvertedIndex; + documentMap: DocumentMap; + commonTerms: string[]; + metadata: IndexMetadata; +} + +interface InvertedIndex { + [term: string]: { + [docId: string]: number; + }; +} + +interface DocumentMap { + [docId: string]: { + feedTitle: string; + title: string; + link: string; + pubDate: string; + }; +} + +interface IndexMetadata { + documentCount: number; + termCount: number; + indexSize: number; + generatedAt: string; +} + +interface DocLocation { + feedIndex: number; + itemIndex: number; +} + +// Function to tokenize and normalize text +function tokenize(text: string): string[] { + if (!text) return []; + + const STOP_WORDS = new Set([ + "a", + "an", + "the", + "and", + "or", + "but", + "in", + "on", + "at", + "to", + "for", + "with", + "by", + "about", + "as", + "of", + "from", + "is", + "are", + "was", + "were", + "be", + "been", + "being", + "have", + "has", + "had", + "do", + "does", + "did", + "will", + "would", + "shall", + "should", + "can", + "could", + "may", + "might", + "must", + "that", + "this", + "these", + "those", + "it", + "its", + "they", + "them", + "their", + "we", + "us", + "our", + "you", + "your", + ]); + + return text + .toLowerCase() + .replace(/[^\w\s]/g, " ") // Replace punctuation with spaces + .split(/\s+/) // Split on whitespace + .filter( + (word) => + word.length > 1 && // Skip single characters + !STOP_WORDS.has(word) // Skip stop words + ); +} + +// Function to build the optimized inverted index +function buildSearchIndex(feeds: RSSFeed[]): SearchIndex { + // Create a document ID mapping system + const docIdMap = new Map(); + let nextDocId = 0; + + // First pass: assign IDs and count term frequencies + const termFrequency: Record = {}; + + feeds.forEach((feed, feedIndex) => { + feed.items.forEach((item, itemIndex) => { + // Assign a unique ID to this item + const docId = nextDocId++; + + // Store a reference to locate this item later + docIdMap.set(docId, { + feedIndex, + itemIndex, + }); + + // Process title and description to count term frequencies + const titleTokens = tokenize(item.title); + const descriptionTokens = tokenize(item.description); + + // Count unique terms in this document + const uniqueTerms = new Set([...titleTokens, ...descriptionTokens]); + uniqueTerms.forEach((term) => { + termFrequency[term] = (termFrequency[term] || 0) + 1; + }); + }); + }); + + // Identify extremely common terms (appearing in >50% of documents) + const totalDocuments = docIdMap.size; + const commonTermThreshold = totalDocuments * 0.5; + const commonTerms = new Set( + Object.entries(termFrequency) + .filter(([_, count]) => count > commonTermThreshold) + .map(([term, _]) => term) + ); + + // Build the inverted index + const invertedIndex: InvertedIndex = {}; + + docIdMap.forEach((location, docId) => { + const feed = feeds[location.feedIndex]!; + const item = feed.items[location.itemIndex]!; + + // Process title (with higher weight) + const titleTokens = tokenize(item.title); + titleTokens.forEach((token) => { + // Skip extremely common terms + if (commonTerms.has(token)) { + return; + } + + if (!invertedIndex[token]) { + invertedIndex[token] = {}; + } + invertedIndex[token][docId] = (invertedIndex[token][docId] || 0) + 3; // Higher weight for title + }); + + // Process description + const descriptionTokens = tokenize(item.description); + descriptionTokens.forEach((token) => { + // Skip extremely common terms + if (commonTerms.has(token)) { + return; + } + + if (!invertedIndex[token]) { + invertedIndex[token] = {}; + } + invertedIndex[token][docId] = (invertedIndex[token][docId] || 0) + 1; + }); + }); + + // Create a lookup table for document retrieval + const documentMap: DocumentMap = {}; + docIdMap.forEach((location, docId) => { + const feed = feeds[location.feedIndex]!; + const item = feed.items[location.itemIndex]!; + + documentMap[docId] = { + feedTitle: feed.title, + title: item.title, + link: item.link, + pubDate: item.pubDate, + }; + }); + + // Create the final index structure + return { + invertedIndex, + documentMap, + commonTerms: Array.from(commonTerms), + metadata: { + documentCount: totalDocuments, + termCount: Object.keys(invertedIndex).length, + indexSize: JSON.stringify(invertedIndex).length, + generatedAt: new Date().toISOString(), + }, + }; +} + +export const GET = async () => { + try { + // Get the feed data + const feeds = await getFeedData(); + + // Build the search index + const searchIndex = buildSearchIndex(feeds); + + // Return the index as JSON + return Response.json(searchIndex); + } catch (error) { + console.error("Error generating search index:", error); + return new Response( + JSON.stringify({ error: "Failed to generate search index" }), + { + status: 500, + headers: { + "Content-Type": "application/json", + }, + } + ); + } +}; + +export const getConfig = () => { + return { + render: "static", + }; +}; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 21446bc..90b5193 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,37 +1,7 @@ import { Compass, Newspaper } from "lucide-react"; -import { rssFeedUrls } from "../_data/feeds"; -import type { RSSFeed } from "../types"; -import Parser from "rss-parser"; import { SearchTrigger } from "../components/search-trigger"; - -const parser = new Parser(); - -async function getFeedData() { - const feedPromises = rssFeedUrls.map(async (url) => { - try { - const feed = await parser.parseURL(url); - return { - title: feed.title || "", - description: feed.description || "", - link: feed.link || "", - items: - feed.items?.map((item) => ({ - title: item.title || "", - description: item.contentSnippet || item.content || "", - link: item.link || "", - pubDate: item.pubDate || "", - })) || [], - }; - } catch (error) { - console.error(`Error fetching feed from ${url}:`, error); - return null; - } - }); - - const results = await Promise.all(feedPromises); - return results.filter((feed): feed is RSSFeed => feed !== null); -} +import { getFeedData } from "../_data/feeds"; export default async function App() { const feeds = await getFeedData();