From 43538a0fff279cc59c43ef9c0e623c8f02705e9f Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 6 Feb 2026 17:36:32 +0100 Subject: [PATCH 1/3] Phase 1: Data Layer Modernization This commit introduces build-time static data generation, replacing runtime GitHub API calls for tools data. Key changes: ## New Build System - Added scripts/build-data.ts: Fetches and consolidates tools data from static-analysis and dynamic-analysis repos at build time - Added prebuild hook in package.json to run build-data before next build - Data is stored in data/tools.json, data/tags.json, data/tool-stats.json, and data/tag-stats.json (all gitignored as they're generated) ## New Static Data Utilities - utils/static-data.ts: Core utility for reading pre-built static data - utils/tools.ts: Simplified tools API using static data - utils/tools-with-votes.ts: Merges tools with votes data - utils/firebase-votes.ts: Simplified Firebase votes fetching - utils/tags.ts: Tags utility using static data - utils/filters.ts: Filtering utility (moved from utils-api) - utils/stats.ts: View statistics utility ## Updated Pages - pages/index.tsx: Uses new static data utilities - pages/tool/[slug].tsx: Uses new static data utilities - pages/tag/[slug].tsx: Uses new static data utilities ## Build/Deploy Updates - Dockerfile: Removed manual tools.json download (now handled by build-data) - deploy.yml: Updated to hash generated data files for cache busting ## Benefits - No runtime GitHub API calls for tools data - Faster page generation (data is pre-fetched) - Simplified data flow - Old utils-api code preserved for backwards compatibility Note: API routes and old utils-api code are preserved for now. The /tools page still uses API routes which will be addressed in Phase 2 with InstantSearch integration. --- .github/workflows/deploy.yml | 23 ++- .gitignore | 5 + Dockerfile | 8 +- MODERNIZATION_TODO.md | 297 +++++++++++++++++++++++++++++++++++ package.json | 2 + pages/index.tsx | 12 +- pages/tag/[slug].tsx | 28 ++-- pages/tool/[slug].tsx | 23 +-- scripts/build-data.ts | 293 ++++++++++++++++++++++++++++++++++ utils/filters.ts | 253 +++++++++++++++++++++++++++++ utils/firebase-votes.ts | 132 ++++++++++++++++ utils/static-data.ts | 187 ++++++++++++++++++++++ utils/stats.ts | 185 ++++++++++++++++++++++ utils/tags.ts | 192 ++++++++++++++++++++++ utils/tools-with-votes.ts | 113 +++++++++++++ utils/tools.ts | 124 +++++++++++++++ utils/types.ts | 2 +- 17 files changed, 1835 insertions(+), 44 deletions(-) create mode 100644 MODERNIZATION_TODO.md create mode 100644 scripts/build-data.ts create mode 100644 utils/filters.ts create mode 100644 utils/firebase-votes.ts create mode 100644 utils/static-data.ts create mode 100644 utils/stats.ts create mode 100644 utils/tags.ts create mode 100644 utils/tools-with-votes.ts create mode 100644 utils/tools.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7ec2448..373eaf4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -87,29 +87,24 @@ jobs: app_id: ${{ secrets.GH_APP_ID }} private_key: ${{ secrets.GH_APP_PRIVATE_KEY }} - # We want to redeploy, whenever the tools.json file changes - # so make sure to hash the file and use it as a tag for the Docker image - - name: 'Download tools.json File' - run: curl -sL https://github.com/analysis-tools-dev/static-analysis/raw/master/data/api/tools.json -o ./tools.json - - - name: 'Generate Hash of tools.json File' - id: tools_json_hash - run: echo "tools_json_hash=$(sha256sum tools.json | cut -c1-7)" >> $GITHUB_ENV + # Hash the generated tools.json from the build (created by npm run build-data) + # to ensure we redeploy when tools data changes + - name: 'Generate Hash of built tools data' + run: | + echo "tools_hash=$(sha256sum data/tools.json | cut -c1-7)" >> $GITHUB_ENV - # Also take screenshots.json into account, which is at - # https://github.com/analysis-tools-dev/assets/blob/master/screenshots.json + # Also take screenshots.json into account for cache busting - name: 'Download screenshots.json File' run: curl -sL https://github.com/analysis-tools-dev/assets/raw/master/screenshots.json -o ./screenshots.json - name: 'Generate Hash of screenshots.json File' - id: screenshots_json_hash - run: echo "screenshots_json_hash=$(sha256sum screenshots.json | cut -c1-7)" >> $GITHUB_ENV + run: echo "screenshots_hash=$(sha256sum screenshots.json | cut -c1-7)" >> $GITHUB_ENV - # Image hash is a combination of the hashes + # Image hash is a combination of commit + data hashes - name: 'Set IMAGE_NAME hash' run: | short_hash=$(echo "${{ github.sha }}" | cut -c1-7) - echo "IMAGE_NAME=${{ env.IMAGE_NAME }}:$short_hash-${{ env.tools_json_hash }}-${{ env.screenshots_json_hash }}" >> $GITHUB_ENV + echo "IMAGE_NAME=${{ env.IMAGE_NAME }}:$short_hash-${{ env.tools_hash }}-${{ env.screenshots_hash }}" >> $GITHUB_ENV - name: 'Build Docker Image' env: diff --git a/.gitignore b/.gitignore index 3173cbb..b24666d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,5 +46,10 @@ algolia-index.js credentials.json +# Generated data files (created by npm run build-data) +/data/tools.json +/data/tags.json +/data/tool-stats.json +/data/tag-stats.json .vscode diff --git a/Dockerfile b/Dockerfile index 9a69ebe..6b10381 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,12 +11,10 @@ ARG PROJECT_ID COPY . /src -# Download tools.json directly in Dockerfile and detect changes -# This is done to ensure that we redeploy the app whenever the tools.json changes -ADD https://raw.githubusercontent.com/analysis-tools-dev/static-analysis/master/data/api/tools.json /src/data/api/tools.json - +# Build runs npm run build-data (prebuild hook) which fetches tools data +# from GitHub repos and generates static JSON files, then runs next build RUN npm run build -RUN rm /src/credentials.json /src/data/api/tools.json +RUN rm /src/credentials.json FROM node:20 WORKDIR /src diff --git a/MODERNIZATION_TODO.md b/MODERNIZATION_TODO.md new file mode 100644 index 0000000..d553917 --- /dev/null +++ b/MODERNIZATION_TODO.md @@ -0,0 +1,297 @@ +# Modernization TODO List + +This document outlines the steps needed to modernize the analysis-tools.dev website by simplifying the backend and leveraging modern tools: **Algolia + InstantSearch + Headless UI + Static JSON/CMS**. + +## Current Architecture Overview + +The current setup has several layers of complexity: +- Next.js with Server-Side Rendering (SSR) and API routes +- Firebase/Firestore for votes storage +- GitHub API for fetching tools data from external repos +- File-based caching (`cache-manager-fs-hash`) +- Custom search context and filtering logic +- React Query for client-side data fetching +- Docker deployment on Google Cloud Run via Pulumi + +--- + +## Phase 1: Data Layer Modernization + +### 1.1 Static JSON Data Source +- [x] Create a build-time script to fetch and consolidate `tools.json` from both `static-analysis` and `dynamic-analysis` repos +- [x] Store consolidated data in `data/tools.json` at build time +- [x] Remove runtime GitHub API calls (`utils-api/tools.ts` → `getTools()`) - New utilities in `utils/tools.ts`, `utils/static-data.ts` +- [ ] Remove Octokit dependency (`@octokit/core`) - Keep for now, old code still present +- [ ] Remove file-based cache (`cache-manager`, `cache-manager-fs-hash`) - Keep for now, old code still present +- [ ] Delete `utils-api/cache.ts` - Keep for now, will remove in cleanup phase + +### 1.2 Votes Data Migration +- [ ] **Option A**: Migrate votes to Algolia as a field in the tools index +- [ ] **Option B**: Use a lightweight service (e.g., Supabase, PlanetScale) for votes +- [x] **Option C**: Keep Firebase but simplify to client-side only voting - Implemented `utils/firebase-votes.ts` +- [ ] Remove server-side Firebase Admin SDK (`firebase-admin`) - Still needed for votes +- [ ] Delete `firebase-key.json` and related credentials handling - Still needed +- [ ] Remove `utils-api/firebase.ts` - Keep for now, will remove in cleanup phase +- [ ] Remove `utils-api/votes.ts` server-side logic - Keep for now, API routes still use it +- [x] Simplify or remove `utils-api/toolsWithVotes.ts` - New `utils/tools-with-votes.ts` created + +### 1.3 Remove API Routes +- [ ] Delete `pages/api/tools.ts` - Keep for now, tools page still uses it +- [ ] Delete `pages/api/paginated-tools.ts` - Keep for now, tools page still uses it +- [ ] Delete `pages/api/tags/*` - Keep for now +- [ ] Delete `pages/api/vote/*` - Keep for now, voting still works via API +- [ ] Delete `pages/api/votes/*` - Keep for now +- [ ] Delete `pages/api/mostViewed.ts` - Keep for now +- [ ] Delete `pages/api/popularLanguages.ts` - Keep for now +- [ ] Delete `pages/api/articles.ts` (if not needed) - Keep for now +- [ ] Remove entire `utils-api/` directory (migrate necessary utils to `utils/`) - Partially done + +### 1.4 New Static Data Utilities (Phase 1 Additions) +- [x] Created `scripts/build-data.ts` - Build-time data fetching script +- [x] Created `utils/static-data.ts` - Static data reader utility +- [x] Created `utils/tools.ts` - Simplified tools utility +- [x] Created `utils/tools-with-votes.ts` - Tools with votes merger +- [x] Created `utils/firebase-votes.ts` - Simplified Firebase votes utility +- [x] Created `utils/tags.ts` - Simplified tags utility +- [x] Created `utils/filters.ts` - Filtering utility (moved from utils-api) +- [x] Created `utils/stats.ts` - Stats utility for views data +- [x] Updated `package.json` with `build-data` script and `prebuild` hook +- [x] Updated `.gitignore` to exclude generated data files +- [x] Updated `Dockerfile` to use new build process +- [x] Updated `.github/workflows/deploy.yml` to use generated data for hashing +- [x] Updated `pages/index.tsx` to use new static data utilities +- [x] Updated `pages/tool/[slug].tsx` to use new static data utilities +- [x] Updated `pages/tag/[slug].tsx` to use new static data utilities + +--- + +## Phase 2: Search & Filtering with Algolia + InstantSearch + +### 2.1 Algolia Index Enhancement +- [ ] Enhance `algolia-index.ts` to run at build time (not from deployed API) +- [ ] Add all filterable attributes to Algolia index: + - `languages` (facet) + - `categories` (facet) + - `types` (facet) + - `licenses` (facet) + - `pricing`/`plans` (facet) + - `votes` (for sorting) + - `deprecated` (filter) +- [ ] Configure Algolia dashboard: + - Set up facets for filtering + - Configure searchable attributes ranking + - Set up sorting indices (by votes, by name, etc.) + +### 2.2 InstantSearch Integration +- [ ] Upgrade `react-instantsearch` to latest version +- [ ] Replace custom `SearchProvider` (`context/SearchProvider.tsx`) with InstantSearch provider +- [ ] Delete `context/SearchProvider.tsx` +- [ ] Replace `utils-api/filters.ts` logic with Algolia facets +- [ ] Create new InstantSearch widgets: + - [ ] `SearchBox` component + - [ ] `RefinementList` for language filters + - [ ] `RefinementList` for category filters + - [ ] `RefinementList` for type filters + - [ ] `RefinementList` for license filters + - [ ] `RefinementList` for pricing filters + - [ ] `SortBy` component for sorting + - [ ] `Hits` component for results + - [ ] `Pagination` or `InfiniteHits` component + +### 2.3 Replace Custom Data Fetching +- [ ] Remove React Query (`@tanstack/react-query`) for tools fetching +- [ ] Delete `components/tools/queries/tools.ts` +- [ ] Delete `components/tools/queries/languages.ts` +- [ ] Delete `components/tools/queries/others.ts` +- [ ] Delete `components/tools/queries/index.ts` +- [ ] Keep React Query only if needed for non-search data (blog posts, etc.) + +--- + +## Phase 3: UI Modernization with Headless UI + +### 3.1 Replace Custom UI Components +- [ ] Install `@headlessui/react` +- [ ] Replace custom dropdown (`components/elements/Dropdown`) with Headless UI `Listbox` +- [ ] Replace mobile filters drawer with Headless UI `Dialog` +- [ ] Create accessible filter components using Headless UI: + - [ ] `Disclosure` for collapsible filter sections + - [ ] `Combobox` for searchable language selector + - [ ] `Switch` for toggle filters (e.g., "Show deprecated") + - [ ] `Popover` for filter tooltips/info + +### 3.2 Component Cleanup +- [ ] Audit and simplify `components/elements/` +- [ ] Remove unused components +- [ ] Standardize component patterns with Headless UI + +--- + +## Phase 4: Page Architecture Simplification + +### 4.1 Convert SSR Pages to Static (SSG) +- [ ] Convert `pages/tools/index.tsx` from `getServerSideProps` to `getStaticProps` +- [ ] All filtering/search handled client-side via InstantSearch +- [ ] Keep `pages/tool/[slug].tsx` as static (`getStaticProps` + `getStaticPaths`) āœ“ (already done) +- [ ] Keep `pages/tag/[slug].tsx` as static āœ“ (already done) +- [ ] Consider Incremental Static Regeneration (ISR) for data freshness + +### 4.2 Simplify Page Components +- [ ] Refactor `ListPageComponent.tsx` to use InstantSearch +- [ ] Remove infinite scroll complexity (use Algolia pagination) +- [ ] Remove `useRouterPush` hook for search state (InstantSearch handles URL) +- [ ] Delete `hooks/` directory if no longer needed + +### 4.3 Data Flow Cleanup +- [ ] Remove `utils/query.ts` (objectToQueryString) +- [ ] Remove `utils/urls.ts` if only used for API URLs +- [ ] Simplify `utils/constants.ts` + +--- + +## Phase 5: Build & Deployment Simplification + +### 5.1 Build Process +- [ ] Create `scripts/build-data.ts`: + - Fetch tools from GitHub repos + - Fetch star history (optional, can be removed) + - Merge and validate data + - Output to `data/tools.json` + - Update Algolia index +- [ ] Update `package.json` scripts: + ```json + { + "prebuild": "npm run build-data", + "build-data": "ts-node scripts/build-data.ts", + "build": "next build" + } + ``` +- [ ] Remove runtime data fetching from build process + +### 5.2 Environment Variables Cleanup +- [ ] Remove `GOOGLE_APPLICATION_CREDENTIALS` +- [ ] Remove `GH_TOKEN` (if data is fetched at build time from public repos) +- [ ] Keep only: + - `ALGOLIA_APP_ID` + - `ALGOLIA_API_KEY` (search-only key for client) + - `ALGOLIA_ADMIN_KEY` (for indexing, build-time only) + - `PUBLIC_HOST` + +### 5.3 Docker & Deployment +- [ ] Simplify `Dockerfile` (no credentials needed at runtime) +- [ ] Consider static export (`next export`) if no server features needed +- [ ] Evaluate moving from Cloud Run to static hosting (Vercel, Netlify, Cloudflare Pages) +- [ ] Simplify or remove Pulumi infrastructure if using static hosting +- [ ] Update `.github/workflows/deploy.yml` + +--- + +## Phase 6: CMS Integration (Optional) + +### 6.1 Content Management +- [ ] Evaluate CMS options for non-tool content: + - Blog posts (currently markdown in `data/blog/`) + - FAQ content (`data/faq.json`) + - Sponsors (`data/sponsors.json`) + - Homepage content (`data/homepage.json`) +- [ ] Consider headless CMS options: + - Contentlayer (for markdown) + - Sanity + - Strapi + - Directus +- [ ] Or keep as static JSON/Markdown if editorial workflow is simple + +--- + +## Phase 7: Dependency Cleanup + +### 7.1 Remove Unused Dependencies +```json +{ + "remove": [ + "@octokit/core", + "cache-manager", + "cache-manager-fs-hash", + "firebase-admin", + "algoliasearch-helper", + "@tanstack/react-query" + ], + "keep": [ + "algoliasearch", + "react-instantsearch", + "next", + "react", + "react-dom" + ], + "add": [ + "@headlessui/react" + ] +} +``` + +### 7.2 DevDependencies Cleanup +- [ ] Remove unused type definitions +- [ ] Update ESLint config for simplified codebase +- [ ] Consider switching to Biome or similar for faster linting + +--- + +## Phase 8: Code Quality & Maintenance + +### 8.1 TypeScript Improvements +- [ ] Remove all `@ts-nocheck` and `@ts-ignore` comments +- [ ] Fix type errors in `context/SearchProvider.tsx` (before deletion) +- [ ] Ensure strict TypeScript throughout + +### 8.2 Testing +- [ ] Add unit tests for data transformation utilities +- [ ] Add integration tests for search functionality +- [ ] Add E2E tests for critical user flows + +### 8.3 Documentation +- [ ] Update `README.md` with new architecture +- [ ] Document new build process +- [ ] Document Algolia configuration +- [ ] Create contributing guide for the simplified codebase + +--- + +## Migration Checklist Summary + +### Files to Delete +- [ ] `utils-api/` (entire directory) +- [ ] `pages/api/` (entire directory) +- [ ] `context/SearchProvider.tsx` +- [ ] `components/tools/queries/` (entire directory) +- [ ] `firebase-key.json` +- [ ] `algolia-index.js` (keep only `.ts` version) + +### Files to Create +- [ ] `scripts/build-data.ts` +- [ ] `components/search/SearchProvider.tsx` (InstantSearch wrapper) +- [ ] `components/search/SearchBox.tsx` +- [ ] `components/search/Filters.tsx` +- [ ] `components/search/Results.tsx` +- [ ] `components/ui/` (Headless UI wrappers) + +### Files to Significantly Modify +- [ ] `pages/tools/index.tsx` +- [ ] `pages/index.tsx` +- [ ] `components/tools/listPage/ListPageComponent/ListPageComponent.tsx` +- [ ] `next.config.js` +- [ ] `package.json` +- [ ] `.github/workflows/deploy.yml` +- [ ] `Dockerfile` + +--- + +## Benefits After Modernization + +1. **Simpler Architecture**: No server-side API routes, no runtime data fetching +2. **Better Performance**: Static pages with client-side search (instant) +3. **Lower Costs**: Static hosting is cheaper than Cloud Run +4. **Better DX**: Less code to maintain, clearer data flow +5. **Better Search UX**: Algolia InstantSearch provides superior search experience +6. **Accessibility**: Headless UI components are accessible by default +7. **Scalability**: Algolia handles search at any scale +8. **Reliability**: Fewer moving parts = fewer things to break \ No newline at end of file diff --git a/package.json b/package.json index 624a156..bb68db1 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "private": true, "scripts": { "dev": "next dev", + "build-data": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' ./scripts/build-data.ts", + "prebuild": "npm run build-data", "build": "next build", "search-index": "ts-node -r tsconfig-paths/register --compiler-options '{\"module\":\"CommonJS\"}' ./algolia-index.ts", "start": "next start", diff --git a/pages/index.tsx b/pages/index.tsx index 7f75cc7..99a0f75 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -14,17 +14,21 @@ import homepageData from '@appdata/homepage.json'; import { ArticlePreview, Faq, SponsorData } from 'utils/types'; import { Tool, ToolsByLanguage } from '@components/tools'; import { getArticlesPreviews } from 'utils-api/blog'; -import { getPopularLanguageStats } from 'utils-api/popularLanguageStats'; -import { getMostViewedTools } from 'utils-api/mostViewedTools'; import { getSponsors } from 'utils-api/sponsors'; import { getFaq } from 'utils-api/faq'; +// New static data utilities (Phase 1) +import { getPopularLanguageStats, getMostViewedTools } from 'utils/stats'; +import { fetchVotes } from 'utils/firebase-votes'; export const getStaticProps: GetStaticProps = async () => { const sponsors = getSponsors(); const faq = getFaq(); const previews = await getArticlesPreviews(); - const popularLanguages = await getPopularLanguageStats(); - const mostViewed = await getMostViewedTools(); + + // Fetch votes from Firebase and use with static data utilities + const votes = await fetchVotes(); + const popularLanguages = getPopularLanguageStats(votes); + const mostViewed = getMostViewedTools(votes); return { props: { diff --git a/pages/tag/[slug].tsx b/pages/tag/[slug].tsx index bcf67d0..e46d94d 100644 --- a/pages/tag/[slug].tsx +++ b/pages/tag/[slug].tsx @@ -12,26 +12,28 @@ import { SponsorData, } from 'utils/types'; import { getArticlesPreviews } from 'utils-api/blog'; -import { getLanguageData, getSimilarTags, getTags } from 'utils-api/tags'; -import { filterByTags } from 'utils-api/filters'; import { getSponsors } from 'utils-api/sponsors'; -import { getToolsWithVotes } from 'utils-api/toolsWithVotes'; import { RelatedTagsList } from '@components/tools/listPage/RelatedTagsList'; import { LanguageFilterOption } from '@components/tools/listPage/ToolsSidebar/FilterCard/LanguageFilterCard'; import { Tool } from '@components/tools'; import { TagsSidebar } from '@components/tags'; import { getRandomAffiliate } from 'utils-api/affiliates'; +// New static data utilities (Phase 1) +import { getTags, getLanguageData, getSimilarTags } from 'utils/tags'; +import { filterByTags } from 'utils/filters'; +import { getToolsWithVotes } from 'utils/tools-with-votes'; +import { fetchVotes } from 'utils/firebase-votes'; // This function gets called at build time export const getStaticPaths: GetStaticPaths = async () => { - // Call an external API endpoint to get tools - const data = await getTags('all'); + // Get tags from static data (no network call) + const data = getTags('all'); - if (!data) { + if (!data || data.length === 0) { return { paths: [], fallback: false }; } - // Get the paths we want to pre-render based on the tags API response + // Get the paths we want to pre-render based on the tags data const paths = data.map((tag) => { return { params: { slug: tag.value }, @@ -51,7 +53,8 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { }; } - const tagData = await getLanguageData(slug); + // Get tag data from static files (no network call) + const tagData = getLanguageData(slug); // Capitalize the first letter of the tag let tagName = slug.charAt(0).toUpperCase() + slug.slice(1); @@ -59,10 +62,15 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { // We can use a more descriptive name if it exists tagName = tagData.name; } - const tools = await getToolsWithVotes(); + + // Fetch votes from Firebase and merge with static tools data + const votes = await fetchVotes(); + const tools = getToolsWithVotes(votes); + const previews = await getArticlesPreviews(); const sponsors = getSponsors(); - const languages = await getTags('languages'); + // Get languages from static data (no network call) + const languages = getTags('languages'); const affiliate = getRandomAffiliate([slug]); const relatedTags = getSimilarTags(slug); diff --git a/pages/tool/[slug].tsx b/pages/tool/[slug].tsx index e3bd1f3..67f34f7 100644 --- a/pages/tool/[slug].tsx +++ b/pages/tool/[slug].tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { GetStaticPaths, GetStaticProps } from 'next'; import { MainHead, Footer, Navbar, SponsorBanner } from '@components/core'; import { Main, Panel, Wrapper } from '@components/layout'; -import { getTool, getToolIcon } from 'utils-api/tools'; import { AlternativeToolsList, Tool, @@ -11,26 +10,27 @@ import { } from '@components/tools'; import { SearchProvider } from 'context/SearchProvider'; import { getScreenshots } from 'utils-api/screenshot'; -import { getAllTools } from 'utils-api/tools'; import { ArticlePreview, SponsorData, StarHistory } from 'utils/types'; import { containsArray } from 'utils/arrays'; -import { getVotes } from 'utils-api/votes'; import { getArticlesPreviews } from 'utils-api/blog'; import { getSponsors } from 'utils-api/sponsors'; import { ToolGallery } from '@components/tools/toolPage/ToolGallery'; import { Comments } from '@components/core/Comments'; import { calculateUpvotePercentage } from 'utils/votes'; +// New static data utilities (Phase 1) +import { getAllTools, getTool, getToolIcon } from 'utils/tools'; +import { fetchVotes } from 'utils/firebase-votes'; // This function gets called at build time export const getStaticPaths: GetStaticPaths = async () => { - // Call an external API endpoint to get tools - const data = await getAllTools(); + // Get tools from static data (no network call) + const data = getAllTools(); - if (!data) { + if (!data || Object.keys(data).length === 0) { return { paths: [], fallback: false }; } - // Get the paths we want to pre-render based on the tools API response + // Get the paths we want to pre-render based on the tools data const paths = Object.keys(data).map((id) => ({ params: { slug: id }, })); @@ -50,7 +50,8 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { }; } - const apiTool = await getTool(slug); + // Get tool from static data (no network call) + const apiTool = getTool(slug); if (!apiTool) { console.error(`Tool ${slug} not found. Cannot build slug page.`); return { @@ -59,7 +60,8 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { } const sponsors = getSponsors(); - const votes = await getVotes(); + // Fetch votes from Firebase (still needed for now) + const votes = await fetchVotes(); const previews = await getArticlesPreviews(); const icon = getToolIcon(slug); @@ -77,7 +79,8 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { id: slug, icon: icon, }; - const alternativeTools = await getAllTools(); + // Get all tools from static data for alternatives + const alternativeTools = getAllTools(); let alternatives: Tool[] = []; let allAlternatives: Tool[] = []; if (alternativeTools) { diff --git a/scripts/build-data.ts b/scripts/build-data.ts new file mode 100644 index 0000000..c4f9477 --- /dev/null +++ b/scripts/build-data.ts @@ -0,0 +1,293 @@ +/** + * Build-time data fetching script + * + * This script fetches tools data from the analysis-tools-dev GitHub repositories + * and consolidates them into a single static JSON file for use at runtime. + * + * Run with: npm run build-data + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as https from 'https'; + +// Types +interface ToolData { + name: string; + categories: string[]; + languages: string[]; + other: string[]; + licenses: string[]; + types: string[]; + homepage: string; + source: string | null; + pricing: string | null; + plans: { free?: boolean; oss?: boolean } | null; + description: string | null; + discussion: string | null; + deprecated: boolean | null; + resources: { title: string; url: string }[] | null; + reviews: unknown | null; + demos: unknown | null; + wrapper: string | null; +} + +interface ToolsData { + [key: string]: ToolData; +} + +interface StatsData { + [key: string]: number; +} + +interface BuildOutput { + tools: ToolsData; + meta: { + buildTime: string; + staticAnalysisCount: number; + dynamicAnalysisCount: number; + totalCount: number; + }; +} + +// GitHub raw content URLs +const STATIC_ANALYSIS_URL = + 'https://raw.githubusercontent.com/analysis-tools-dev/static-analysis/master/data/api/tools.json'; +const DYNAMIC_ANALYSIS_URL = + 'https://raw.githubusercontent.com/analysis-tools-dev/dynamic-analysis/master/data/api/tools.json'; +const TOOL_STATS_URL = + 'https://raw.githubusercontent.com/analysis-tools-dev/static-analysis/master/data/api/stats/tools.json'; +const TAG_STATS_URL = + 'https://raw.githubusercontent.com/analysis-tools-dev/static-analysis/master/data/api/stats/tags.json'; + +// Output paths +const OUTPUT_DIR = path.join(process.cwd(), 'data'); +const OUTPUT_FILE = path.join(OUTPUT_DIR, 'tools.json'); +const TOOL_STATS_FILE = path.join(OUTPUT_DIR, 'tool-stats.json'); +const TAG_STATS_FILE = path.join(OUTPUT_DIR, 'tag-stats.json'); + +/** + * Fetch JSON data from a URL + */ +function fetchJSON(url: string): Promise { + return new Promise((resolve, reject) => { + console.log(`Fetching: ${url}`); + + https + .get(url, (res) => { + if (res.statusCode !== 200) { + reject( + new Error( + `Failed to fetch ${url}: HTTP ${res.statusCode}`, + ), + ); + return; + } + + let data = ''; + res.on('data', (chunk) => { + data += chunk; + }); + res.on('end', () => { + try { + const parsed = JSON.parse(data) as T; + resolve(parsed); + } catch (e) { + reject(new Error(`Failed to parse JSON from ${url}`)); + } + }); + }) + .on('error', (e) => { + reject(new Error(`Failed to fetch ${url}: ${e.message}`)); + }); + }); +} + +/** + * Merge tools from multiple sources, handling duplicates + */ +function mergeTools( + staticTools: ToolsData, + dynamicTools: ToolsData, +): ToolsData { + const merged: ToolsData = { ...staticTools }; + + for (const [id, tool] of Object.entries(dynamicTools)) { + if (merged[id]) { + // Tool exists in both - merge categories and types + console.log(`Merging duplicate tool: ${id}`); + merged[id] = { + ...merged[id], + categories: [ + ...new Set([...merged[id].categories, ...tool.categories]), + ], + types: [...new Set([...merged[id].types, ...tool.types])], + }; + } else { + merged[id] = tool; + } + } + + return merged; +} + +/** + * Validate tool data + */ +function validateTools(tools: ToolsData): void { + const errors: string[] = []; + + for (const [id, tool] of Object.entries(tools)) { + if (!tool.name) { + errors.push(`Tool "${id}" is missing a name`); + } + if (!Array.isArray(tool.categories)) { + errors.push(`Tool "${id}" has invalid categories`); + } + if (!Array.isArray(tool.languages)) { + errors.push(`Tool "${id}" has invalid languages`); + } + if (!Array.isArray(tool.types)) { + errors.push(`Tool "${id}" has invalid types`); + } + } + + if (errors.length > 0) { + console.warn('Validation warnings:'); + errors.forEach((e) => console.warn(` - ${e}`)); + } +} + +/** + * Extract unique tags (languages and others) from tools + */ +function extractTags(tools: ToolsData): { + languages: string[]; + others: string[]; +} { + const languages = new Set(); + const others = new Set(); + + for (const tool of Object.values(tools)) { + tool.languages?.forEach((lang) => languages.add(lang)); + tool.other?.forEach((other) => others.add(other)); + } + + return { + languages: Array.from(languages).sort(), + others: Array.from(others).sort(), + }; +} + +/** + * Fetch stats data (tool views, tag views) + */ +async function fetchStats(): Promise<{ + toolStats: StatsData; + tagStats: StatsData; +}> { + try { + const [toolStats, tagStats] = await Promise.all([ + fetchJSON(TOOL_STATS_URL), + fetchJSON(TAG_STATS_URL), + ]); + + // Normalize tag stats keys (remove /tag/ prefix) + const normalizedTagStats: StatsData = {}; + for (const [key, value] of Object.entries(tagStats)) { + const normalizedKey = key.replace('/tag/', ''); + normalizedTagStats[normalizedKey] = value; + } + + return { toolStats, tagStats: normalizedTagStats }; + } catch (error) { + console.warn('Warning: Could not fetch stats data:', error); + return { toolStats: {}, tagStats: {} }; + } +} + +/** + * Main build function + */ +async function main(): Promise { + console.log('šŸ”Ø Building tools data...\n'); + + try { + // Fetch data from both repositories and stats + const [staticTools, dynamicTools, stats] = await Promise.all([ + fetchJSON(STATIC_ANALYSIS_URL), + fetchJSON(DYNAMIC_ANALYSIS_URL), + fetchStats(), + ]); + + const staticCount = Object.keys(staticTools).length; + const dynamicCount = Object.keys(dynamicTools).length; + + console.log(`\nšŸ“Š Fetched ${staticCount} static analysis tools`); + console.log(`šŸ“Š Fetched ${dynamicCount} dynamic analysis tools`); + + // Merge tools + const mergedTools = mergeTools(staticTools, dynamicTools); + const totalCount = Object.keys(mergedTools).length; + console.log(`šŸ“Š Total unique tools: ${totalCount}`); + + // Validate + validateTools(mergedTools); + + // Extract tags for reference + const tags = extractTags(mergedTools); + console.log(`\nšŸ·ļø Found ${tags.languages.length} unique languages`); + console.log(`šŸ·ļø Found ${tags.others.length} unique other tags`); + + // Stats info + const toolStatsCount = Object.keys(stats.toolStats).length; + const tagStatsCount = Object.keys(stats.tagStats).length; + console.log(`\nšŸ“ˆ Fetched stats for ${toolStatsCount} tools`); + console.log(`šŸ“ˆ Fetched stats for ${tagStatsCount} tags`); + + // Prepare output + const output: BuildOutput = { + tools: mergedTools, + meta: { + buildTime: new Date().toISOString(), + staticAnalysisCount: staticCount, + dynamicAnalysisCount: dynamicCount, + totalCount, + }, + }; + + // Ensure output directory exists + if (!fs.existsSync(OUTPUT_DIR)) { + fs.mkdirSync(OUTPUT_DIR, { recursive: true }); + } + + // Write output file + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2)); + console.log(`\nāœ… Written to ${OUTPUT_FILE}`); + + // Also write tags for reference (useful for filters) + const tagsFile = path.join(OUTPUT_DIR, 'tags.json'); + fs.writeFileSync(tagsFile, JSON.stringify(tags, null, 2)); + console.log(`āœ… Written tags to ${tagsFile}`); + + // Write stats files + fs.writeFileSync( + TOOL_STATS_FILE, + JSON.stringify(stats.toolStats, null, 2), + ); + console.log(`āœ… Written tool stats to ${TOOL_STATS_FILE}`); + + fs.writeFileSync( + TAG_STATS_FILE, + JSON.stringify(stats.tagStats, null, 2), + ); + console.log(`āœ… Written tag stats to ${TAG_STATS_FILE}`); + + console.log('\nšŸŽ‰ Build complete!'); + } catch (error) { + console.error('\nāŒ Build failed:', error); + process.exit(1); + } +} + +main(); diff --git a/utils/filters.ts b/utils/filters.ts new file mode 100644 index 0000000..e798225 --- /dev/null +++ b/utils/filters.ts @@ -0,0 +1,253 @@ +/** + * Filters Utility Module + * + * This module provides utilities for filtering tools based on various criteria. + * It works with the static tools data and can be used both at build time + * and runtime (client-side). + * + * This replaces the old utils-api/filters.ts + */ + +import { Tool } from '@components/tools/types'; +import { ParsedUrlQuery } from 'querystring'; +import { containsArray } from './arrays'; +import type { ApiTool, ToolsApiData } from './types'; + +/** + * Filter tools based on URL query parameters + * + * @param tools - Tools data object (keyed by tool ID) + * @param query - URL query parameters + * @returns Array of filtered tools + */ +export function filterResults( + tools: ToolsApiData | null, + query: ParsedUrlQuery, +): Tool[] { + if (!tools) { + return []; + } + + const { languages, others, categories, types, licenses, pricing } = query; + const result: Tool[] = []; + + for (const [key, tool] of Object.entries(tools)) { + // Filter by languages + if (languages) { + if (!matchesFilter(tool.languages, languages, tool)) { + continue; + } + } + + // Filter by other tags + if (others) { + if (!matchesFilter(tool.other, others, tool)) { + continue; + } + } + + // Filter by categories + if (categories) { + if (!matchesArrayFilter(tool.categories, categories)) { + continue; + } + } + + // Filter by types + if (types) { + if (!matchesArrayFilter(tool.types, types)) { + continue; + } + } + + // Filter by licenses + if (licenses) { + if (!matchesArrayFilter(tool.licenses, licenses)) { + continue; + } + } + + // Filter by pricing + if (pricing) { + if (!matchesPricingFilter(tool, pricing)) { + continue; + } + } + + // All filters passed + result.push({ id: key, ...tool } as Tool); + } + + return result; +} + +/** + * Filter tools by tags (languages or others) + * + * @param tools - Tools data object + * @param tags - Tag(s) to filter by + * @returns Array of tools matching the tags + */ +export function filterByTags( + tools: ToolsApiData | null, + tags: string | string[], +): Tool[] { + if (!tools) { + return []; + } + + const result: Tool[] = []; + const tagArray = Array.isArray(tags) ? tags : [tags]; + + for (const [key, tool] of Object.entries(tools)) { + const matchesLanguage = tagArray.some((tag) => + tool.languages.includes(tag), + ); + const matchesOther = tagArray.some((tag) => tool.other.includes(tag)); + + if (matchesLanguage || matchesOther) { + result.push({ id: key, ...tool } as Tool); + } + } + + return result; +} + +/** + * Filter tools by a single language + */ +export function filterByLanguage( + tools: ToolsApiData | null, + language: string, +): Tool[] { + return filterByTags(tools, language); +} + +/** + * Filter tools by category + */ +export function filterByCategory( + tools: ToolsApiData | null, + category: string, +): Tool[] { + if (!tools) { + return []; + } + + return Object.entries(tools) + .filter(([, tool]) => tool.categories.includes(category)) + .map(([id, tool]) => ({ id, ...tool } as Tool)); +} + +/** + * Filter tools by type + */ +export function filterByType(tools: ToolsApiData | null, type: string): Tool[] { + if (!tools) { + return []; + } + + return Object.entries(tools) + .filter(([, tool]) => tool.types.includes(type)) + .map(([id, tool]) => ({ id, ...tool } as Tool)); +} + +/** + * Check if a tool is language-specific (supports only 1-2 languages) + */ +export function isSingleLanguageTool(tool: Tool | ApiTool): boolean { + return tool.languages.length <= 2; +} + +/** + * Check if a tool is specific to a given language + */ +export function isToolLanguageSpecific( + tool: Tool | ApiTool, + language: string, +): boolean { + return isSingleLanguageTool(tool) && tool.languages.includes(language); +} + +/** + * Helper: Check if tool field matches filter (for languages/others) + * Multi-language tools need to match all filter values + */ +function matchesFilter( + toolValues: string[], + filterValue: string | string[], + tool: ApiTool, +): boolean { + if (Array.isArray(filterValue)) { + const isMultiLanguage = !isSingleLanguageTool(tool); + const matches = containsArray(toolValues, filterValue); + return isMultiLanguage && matches; + } + return toolValues.includes(filterValue); +} + +/** + * Helper: Check if tool field matches array filter + */ +function matchesArrayFilter( + toolValues: string[], + filterValue: string | string[], +): boolean { + if (Array.isArray(filterValue)) { + return containsArray(toolValues, filterValue); + } + return toolValues.includes(filterValue); +} + +/** + * Helper: Check if tool matches pricing filter + */ +function matchesPricingFilter( + tool: ApiTool, + pricing: string | string[], +): boolean { + const pricingArray = Array.isArray(pricing) ? pricing : [pricing]; + + for (const filter of pricingArray) { + if (filter === 'plans' && !tool.plans) { + return false; + } + if (filter === 'oss' && !tool.plans?.oss) { + return false; + } + if (filter === 'free' && !tool.plans?.free) { + return false; + } + } + + return true; +} + +/** + * Sort tools by votes (descending) + */ +export function sortByVotes(tools: Tool[]): Tool[] { + return [...tools].sort((a, b) => (b.votes || 0) - (a.votes || 0)); +} + +/** + * Sort tools by name (ascending) + */ +export function sortByName(tools: Tool[]): Tool[] { + return [...tools].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * Paginate tools array + */ +export function paginateTools( + tools: Tool[], + offset: number, + limit: number, +): { data: Tool[]; nextCursor?: number; total: number } { + const total = tools.length; + const data = tools.slice(offset, offset + limit); + const nextCursor = offset + limit < total ? offset + limit : undefined; + + return { data, nextCursor, total }; +} diff --git a/utils/firebase-votes.ts b/utils/firebase-votes.ts new file mode 100644 index 0000000..2b5e98a --- /dev/null +++ b/utils/firebase-votes.ts @@ -0,0 +1,132 @@ +/** + * Firebase Votes Utility + * + * This module provides a simplified interface for reading votes from Firebase. + * It's designed to be used at build time (getStaticProps) to fetch votes. + * + * For Phase 1, we keep Firebase for votes but simplify the interface. + * In a future phase, votes could be migrated to Algolia or another service. + */ + +import type { VotesApiData } from './types'; + +// Singleton promise for Firebase initialization +let firebaseInitPromise: Promise | null = null; + +/** + * Initialize Firebase Admin SDK (singleton pattern) + * This ensures Firebase is only initialized once, even with concurrent calls + */ +function initFirebase(): Promise { + if (firebaseInitPromise) { + return firebaseInitPromise; + } + + firebaseInitPromise = (async () => { + // Dynamic import to avoid loading firebase-admin when not needed + const { apps, credential } = await import('firebase-admin'); + const { initializeApp } = await import('firebase-admin/app'); + + // Only initialize if no apps exist + if (!apps.length) { + initializeApp({ + credential: credential.applicationDefault(), + databaseURL: 'https://analysis-tools-dev.firebaseio.com', + }); + } + })(); + + return firebaseInitPromise; +} + +/** + * Fetch all votes from Firebase + * + * This function fetches all vote records from Firestore. + * It should be called at build time (in getStaticProps) to get votes data. + * + * @returns VotesApiData object with vote counts per tool, or null on error + */ +export async function fetchVotes(): Promise { + // Skip Firebase in environments without credentials + if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { + console.warn( + 'Firebase credentials not configured. Skipping votes fetch.', + ); + return null; + } + + try { + await initFirebase(); + + const { getFirestore } = await import('firebase-admin/firestore'); + const db = getFirestore(); + + const votesCol = db.collection('tags'); + const voteSnapshot = await votesCol.get(); + + const votes: VotesApiData = {}; + voteSnapshot.docs.forEach((doc) => { + const data = doc.data(); + votes[doc.id] = { + sum: data.sum || 0, + upVotes: data.upVotes || 0, + downVotes: data.downVotes || 0, + }; + }); + + return votes; + } catch (error) { + console.error('Error fetching votes from Firebase:', error); + return null; + } +} + +/** + * Fetch votes for a single tool + * + * @param toolId - The tool ID to fetch votes for + * @returns Vote data for the tool, or default values on error + */ +export async function fetchToolVotes(toolId: string): Promise<{ + votes: number; + upVotes: number; + downVotes: number; +}> { + const defaultVotes = { votes: 0, upVotes: 0, downVotes: 0 }; + + if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { + return defaultVotes; + } + + try { + await initFirebase(); + + const { getFirestore } = await import('firebase-admin/firestore'); + const db = getFirestore(); + + const key = `toolsyaml${toolId}`; + const doc = await db.collection('tags').doc(key).get(); + + if (!doc.exists) { + return defaultVotes; + } + + const data = doc.data(); + return { + votes: data?.sum || 0, + upVotes: data?.upVotes || 0, + downVotes: data?.downVotes || 0, + }; + } catch (error) { + console.error(`Error fetching votes for tool ${toolId}:`, error); + return defaultVotes; + } +} + +/** + * Check if Firebase is configured + */ +export function isFirebaseConfigured(): boolean { + return !!process.env.GOOGLE_APPLICATION_CREDENTIALS; +} diff --git a/utils/static-data.ts b/utils/static-data.ts new file mode 100644 index 0000000..68d9901 --- /dev/null +++ b/utils/static-data.ts @@ -0,0 +1,187 @@ +/** + * Static Data Reader + * + * This module provides utilities for reading the pre-built static tools data. + * Data is fetched at build time by scripts/build-data.ts and stored in data/tools.json. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { ToolsApiData, ApiTool, StatsApiData } from './types'; + +// Types for the build output format +export interface BuildMeta { + buildTime: string; + staticAnalysisCount: number; + dynamicAnalysisCount: number; + totalCount: number; +} + +export interface StaticToolsData { + tools: ToolsApiData; + meta: BuildMeta; +} + +export interface StaticTagsData { + languages: string[]; + others: string[]; +} + +// Cache for loaded data (within the same build/request) +let toolsCache: StaticToolsData | null = null; +let tagsCache: StaticTagsData | null = null; +let toolStatsCache: StatsApiData | null = null; +let tagStatsCache: StatsApiData | null = null; + +/** + * Get the path to a data file + */ +function getDataPath(filename: string): string { + return path.join(process.cwd(), 'data', filename); +} + +/** + * Load and parse a JSON file + */ +function loadJSON(filepath: string): T { + const content = fs.readFileSync(filepath, 'utf-8'); + return JSON.parse(content) as T; +} + +/** + * Get all tools from the static data file + * This is the primary method to use instead of the old API-based getTools() + */ +export function getStaticTools(): ToolsApiData { + if (!toolsCache) { + const dataPath = getDataPath('tools.json'); + + if (!fs.existsSync(dataPath)) { + console.warn( + 'Static tools data not found. Run `npm run build-data` first.', + ); + return {}; + } + + toolsCache = loadJSON(dataPath); + } + + return toolsCache.tools; +} + +/** + * Get build metadata + */ +export function getStaticToolsMeta(): BuildMeta | null { + if (!toolsCache) { + getStaticTools(); // This will populate the cache + } + return toolsCache?.meta || null; +} + +/** + * Get a single tool by ID + */ +export function getStaticTool(toolId: string): ApiTool | null { + const tools = getStaticTools(); + return tools[toolId] || null; +} + +/** + * Get all tool IDs + */ +export function getStaticToolIds(): string[] { + const tools = getStaticTools(); + return Object.keys(tools); +} + +/** + * Get static tags (languages and others) + */ +export function getStaticTags(): StaticTagsData { + if (!tagsCache) { + const dataPath = getDataPath('tags.json'); + + if (!fs.existsSync(dataPath)) { + console.warn( + 'Static tags data not found. Run `npm run build-data` first.', + ); + return { languages: [], others: [] }; + } + + tagsCache = loadJSON(dataPath); + } + + return tagsCache; +} + +/** + * Get all unique languages from tools + */ +export function getStaticLanguages(): string[] { + return getStaticTags().languages; +} + +/** + * Get all unique "other" tags from tools + */ +export function getStaticOthers(): string[] { + return getStaticTags().others; +} + +/** + * Check if static data exists + */ +export function hasStaticData(): boolean { + return fs.existsSync(getDataPath('tools.json')); +} + +/** + * Get tool stats (view counts) + */ +export function getStaticToolStats(): StatsApiData { + if (!toolStatsCache) { + const dataPath = getDataPath('tool-stats.json'); + + if (!fs.existsSync(dataPath)) { + console.warn( + 'Static tool stats not found. Run `npm run build-data` first.', + ); + return {}; + } + + toolStatsCache = loadJSON(dataPath); + } + + return toolStatsCache; +} + +/** + * Get tag stats (view counts) + */ +export function getStaticTagStats(): StatsApiData { + if (!tagStatsCache) { + const dataPath = getDataPath('tag-stats.json'); + + if (!fs.existsSync(dataPath)) { + console.warn( + 'Static tag stats not found. Run `npm run build-data` first.', + ); + return {}; + } + + tagStatsCache = loadJSON(dataPath); + } + + return tagStatsCache; +} + +/** + * Clear the cache (useful for testing or hot reloading) + */ +export function clearStaticDataCache(): void { + toolsCache = null; + tagsCache = null; + toolStatsCache = null; + tagStatsCache = null; +} diff --git a/utils/stats.ts b/utils/stats.ts new file mode 100644 index 0000000..e66a87e --- /dev/null +++ b/utils/stats.ts @@ -0,0 +1,185 @@ +/** + * Stats Utility Module + * + * This module provides utilities for working with tool and tag statistics. + * It uses the pre-built static data from data/tool-stats.json and data/tag-stats.json + * (generated by scripts/build-data.ts). + * + * This replaces the old utils-api/toolStats.ts which fetched from GitHub at runtime. + */ + +import { ToolsByLanguage, Tool } from '@components/tools/types'; +import { + getStaticToolStats, + getStaticTagStats, + getStaticTools, +} from './static-data'; +import { isSingleLanguageTool } from './filters'; +import { sortByVote } from './votes'; +import type { StatsApiData, VotesApiData } from './types'; +import { mergeToolsWithVotes } from './tools-with-votes'; + +/** + * Get tool stats (view counts) + * + * @returns Object mapping tool IDs to view counts + */ +export function getToolStats(): StatsApiData { + return getStaticToolStats(); +} + +/** + * Get tag/language stats (view counts) + * + * @returns Object mapping tag names to view counts + */ +export function getTagStats(): StatsApiData { + return getStaticTagStats(); +} + +/** + * Get language stats formatted for the homepage + * Returns languages sorted by views with empty formatter/linter arrays + * + * @returns ToolsByLanguage object + */ +export function getLanguageStats(): ToolsByLanguage { + const tagStats = getStaticTagStats(); + + const sortedLanguageStats: ToolsByLanguage = Object.entries(tagStats) + .sort(([, a], [, b]) => b - a) + .reduce( + (r, [key, value]) => ({ + ...r, + [key]: { + views: value, + formatters: [], + linters: [], + }, + }), + {}, + ); + + return sortedLanguageStats; +} + +/** + * Get popular languages with their top tools + * This is used on the homepage to show popular languages and their best tools + * + * @param votes - Optional votes data to merge with tools + * @returns ToolsByLanguage with formatters and linters populated + */ +export function getPopularLanguageStats( + votes?: VotesApiData | null, +): ToolsByLanguage { + const tools = getStaticTools(); + const languageStats = getLanguageStats(); + + // Merge votes if provided + const toolsWithVotes = votes ? mergeToolsWithVotes(tools, votes) : tools; + + // Populate formatters and linters for each language + Object.keys(toolsWithVotes).forEach((toolId) => { + const tool = toolsWithVotes[toolId]; + + if (isSingleLanguageTool(tool)) { + const language = tool.languages[0]; + + if (languageStats[language]) { + const toolObj: Tool = { + id: toolId, + ...tool, + votes: tool.votes || 0, + }; + + if (tool.categories.includes('formatter')) { + languageStats[language].formatters.push(toolObj); + } + if (tool.categories.includes('linter')) { + languageStats[language].linters.push(toolObj); + } + + // Sort by votes after pushing tools + languageStats[language].formatters.sort(sortByVote); + languageStats[language].linters.sort(sortByVote); + + // Keep top 3 + if (languageStats[language].formatters.length > 3) { + languageStats[language].formatters.pop(); + } + if (languageStats[language].linters.length > 3) { + languageStats[language].linters.pop(); + } + } + } + }); + + // Filter out languages with no tools + Object.keys(languageStats).forEach((language) => { + if ( + languageStats[language].formatters.length === 0 && + languageStats[language].linters.length === 0 + ) { + delete languageStats[language]; + } + }); + + return languageStats; +} + +/** + * Get most viewed tools + * + * @param votes - Optional votes data to merge with tools + * @returns Array of tools sorted by views + */ +export function getMostViewedTools(votes?: VotesApiData | null): Tool[] { + const tools = getStaticTools(); + const toolStats = getStaticToolStats(); + + // Merge votes if provided + const toolsWithVotes = votes ? mergeToolsWithVotes(tools, votes) : tools; + + const mostViewedToolIds = Object.keys(toolStats); + + const mostViewedTools = mostViewedToolIds + .map((id) => { + const tool = toolsWithVotes[id]; + if (!tool) { + return null; + } + + return { + id, + ...tool, + votes: tool.votes || 0, + views: toolStats[id], + } as Tool & { views: number }; + }) + .filter((tool): tool is Tool & { views: number } => tool !== null); + + return mostViewedTools; +} + +/** + * Get view count for a specific tool + * + * @param toolId - The tool ID + * @returns View count or 0 if not found + */ +export function getToolViewCount(toolId: string): number { + const stats = getStaticToolStats(); + return stats[toolId] || 0; +} + +/** + * Get view count for a specific tag + * + * @param tag - The tag name + * @returns View count or 0 if not found + */ +export function getTagViewCount(tag: string): number { + const stats = getStaticTagStats(); + return stats[tag] || 0; +} diff --git a/utils/tags.ts b/utils/tags.ts new file mode 100644 index 0000000..b66a32e --- /dev/null +++ b/utils/tags.ts @@ -0,0 +1,192 @@ +/** + * Tags Utility Module + * + * This module provides utilities for working with tags (languages and others). + * It uses the pre-built static data from data/tags.json (generated by scripts/build-data.ts) + * and local data files for descriptions. + * + * This replaces the old utils-api/tags.ts which fetched from GitHub at runtime. + */ + +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { getStaticTags, getStaticTools } from './static-data'; +import { isLanguageData } from './type-guards'; +import type { TagsType, LanguageData, ApiTag } from './types'; + +/** + * Local file path for language/tag description data + */ +const DESCRIPTIONS_PATH = join(process.cwd(), 'data', 'descriptions.json'); +const RELATED_TAGS_PATH = join(process.cwd(), 'data', 'relatedTags.json'); + +/** + * Get tags from static data + * + * @param type - 'languages', 'other', or 'all' + * @returns Array of tag objects + */ +export function getTags(type: TagsType): ApiTag[] { + const staticTags = getStaticTags(); + + // Build tag objects from the static tags arrays + const languageTags: ApiTag[] = staticTags.languages.map((lang) => ({ + name: capitalizeFirstLetter(lang), + value: lang, + tag_type: 'languages', + })); + + const otherTags: ApiTag[] = staticTags.others.map((other) => ({ + name: capitalizeFirstLetter(other), + value: other, + tag_type: 'other', + })); + + switch (type) { + case 'languages': + return languageTags; + case 'other': + return otherTags; + case 'all': + return [...languageTags, ...otherTags]; + default: + console.error(`Unknown tag type: ${type}`); + return []; + } +} + +/** + * Get a single tag by type and ID + */ +export function getTag(type: TagsType, tagId: string): ApiTag | null { + const tags = getTags(type); + return ( + tags.find((t) => t.value.toLowerCase() === tagId.toLowerCase()) || null + ); +} + +/** + * Get all unique tags from tools data + * This is useful when you need fresh tag data directly from tools + */ +export function getTagsFromTools(): { languages: string[]; others: string[] } { + const tools = getStaticTools(); + const languages = new Set(); + const others = new Set(); + + for (const tool of Object.values(tools)) { + tool.languages?.forEach((lang) => languages.add(lang)); + tool.other?.forEach((other) => others.add(other)); + } + + return { + languages: Array.from(languages).sort(), + others: Array.from(others).sort(), + }; +} + +/** + * Get language/tag data (description, website, etc.) + * + * @param tagId - The tag identifier (e.g., 'javascript', 'python') + * @returns Language data object with name, website, and description + */ +export function getLanguageData(tagId: string): LanguageData { + const defaultTagData: LanguageData = { + name: capitalizeFirstLetter(tagId), + website: '', + description: '', + }; + + try { + if (!existsSync(DESCRIPTIONS_PATH)) { + return defaultTagData; + } + + const fileContents = readFileSync(DESCRIPTIONS_PATH, 'utf-8'); + const data = JSON.parse(fileContents); + + if (!data || !data[tagId] || !isLanguageData(data[tagId])) { + return defaultTagData; + } + + return data[tagId]; + } catch (error) { + console.error('Error loading language data:', error); + return defaultTagData; + } +} + +/** + * Get similar/related tags for a given tag + * + * @param tag - The tag to find related tags for + * @returns Array of related tag strings + */ +export function getSimilarTags(tag: string): string[] { + try { + if (!existsSync(RELATED_TAGS_PATH)) { + return []; + } + + const data = readFileSync(RELATED_TAGS_PATH, 'utf-8'); + const relatedTags: string[][] = JSON.parse(data) || []; + + // Find the array that contains the tag + const relatedTagsArray = relatedTags.find((tags) => + tags.includes(tag.toLowerCase()), + ); + + if (!relatedTagsArray) { + return []; + } + + // Remove the current tag from the array + return relatedTagsArray.filter((t) => t !== tag.toLowerCase()); + } catch (error) { + console.error('Error loading related tags:', error); + return []; + } +} + +/** + * Get count of tools for each tag + * + * @param type - 'languages' or 'other' + * @returns Map of tag to tool count + */ +export function getTagCounts(type: 'languages' | 'other'): Map { + const tools = getStaticTools(); + const counts = new Map(); + + for (const tool of Object.values(tools)) { + const tags = type === 'languages' ? tool.languages : tool.other; + tags?.forEach((tag) => { + counts.set(tag, (counts.get(tag) || 0) + 1); + }); + } + + return counts; +} + +/** + * Get tags sorted by tool count (most popular first) + */ +export function getPopularTags( + type: 'languages' | 'other', + limit?: number, +): Array<{ tag: string; count: number }> { + const counts = getTagCounts(type); + const sorted = Array.from(counts.entries()) + .map(([tag, count]) => ({ tag, count })) + .sort((a, b) => b.count - a.count); + + return limit ? sorted.slice(0, limit) : sorted; +} + +/** + * Helper to capitalize first letter of a string + */ +function capitalizeFirstLetter(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/utils/tools-with-votes.ts b/utils/tools-with-votes.ts new file mode 100644 index 0000000..12ddde2 --- /dev/null +++ b/utils/tools-with-votes.ts @@ -0,0 +1,113 @@ +/** + * Tools With Votes Utility + * + * This module combines static tools data with votes from Firebase. + * It provides a simplified interface for getting tools with their vote counts. + * + * This replaces the old utils-api/toolsWithVotes.ts + */ + +import { getStaticTools } from './static-data'; +import { calculateUpvotePercentage } from './votes'; +import { isToolsApiData, isVotesApiData } from './type-guards'; +import type { ToolsApiData, VotesApiData } from './types'; +import { Tool } from '@components/tools/types'; + +/** + * Merge tools data with votes data + * This is a pure function that takes tools and votes and returns enriched tools + */ +export function mergeToolsWithVotes( + tools: ToolsApiData, + votes: VotesApiData | null, +): ToolsApiData { + if (!votes) { + return tools; + } + + const result = { ...tools }; + + Object.keys(result).forEach((toolId) => { + const key = `toolsyaml${toolId}`; + const v = votes[key]; + + const sum = v?.sum || 0; + const upVotes = v?.upVotes || 0; + const downVotes = v?.downVotes || 0; + + result[toolId] = { + ...result[toolId], + votes: sum, + upVotes, + downVotes, + upvotePercentage: calculateUpvotePercentage(upVotes, downVotes), + }; + }); + + return result; +} + +/** + * Get tools with votes as an array + */ +export function toolsToArray(tools: ToolsApiData): Tool[] { + return Object.entries(tools).map(([id, tool]) => ({ + ...tool, + id, + votes: tool.votes || 0, + })) as Tool[]; +} + +/** + * Get all tools with votes from static data + * + * This is the main function to use. It: + * 1. Gets tools from static JSON (no network call) + * 2. Optionally merges with votes if provided + * + * For server-side use, pass votes fetched from Firebase. + * For client-side use without votes, just call with no arguments. + */ +export function getToolsWithVotes(votes?: VotesApiData | null): ToolsApiData { + const tools = getStaticTools(); + + if (!isToolsApiData(tools)) { + console.error('Error loading tools data'); + return {}; + } + + if (votes && isVotesApiData(votes)) { + return mergeToolsWithVotes(tools, votes); + } + + return tools; +} + +/** + * Get tools with votes as an array + */ +export function getToolsWithVotesArray(votes?: VotesApiData | null): Tool[] { + const tools = getToolsWithVotes(votes); + return toolsToArray(tools); +} + +/** + * Get a single tool with votes + */ +export function getToolWithVotes( + toolId: string, + votes?: VotesApiData | null, +): Tool | null { + const tools = getToolsWithVotes(votes); + const tool = tools[toolId]; + + if (!tool) { + return null; + } + + return { + ...tool, + id: toolId, + votes: tool.votes || 0, + } as Tool; +} diff --git a/utils/tools.ts b/utils/tools.ts new file mode 100644 index 0000000..fadd664 --- /dev/null +++ b/utils/tools.ts @@ -0,0 +1,124 @@ +/** + * Tools Utility Module + * + * This module provides utilities for working with tools data. + * It uses the pre-built static data from data/tools.json (generated by scripts/build-data.ts) + * and optionally enriches it with votes from Firebase. + * + * This replaces the old utils-api/tools.ts which fetched from GitHub at runtime. + */ + +import { getStaticTools, getStaticTool, getStaticToolIds } from './static-data'; +import type { ToolsApiData, ApiTool } from './types'; +import { Tool } from '@components/tools/types'; +import * as fs from 'fs'; + +/** + * Get all tools from static data + * This is the replacement for the old getAllTools() from utils-api/tools.ts + */ +export function getAllTools(): ToolsApiData { + return getStaticTools(); +} + +/** + * Get a single tool by ID + * This is the replacement for the old getTool() from utils-api/tools.ts + * + * Note: This returns the basic tool data without GitHub stats or star history. + * Those enrichments can be added separately if needed. + */ +export function getTool(toolId: string): Tool | null { + const tool = getStaticTool(toolId); + if (!tool) { + return null; + } + + // Return tool with id added (matching the expected Tool interface) + return { + ...tool, + id: toolId, + votes: tool.votes || 0, + } as Tool; +} + +/** + * Get all tool IDs + * Useful for generating static paths + */ +export function getAllToolIds(): string[] { + return getStaticToolIds(); +} + +/** + * Check if there is an icon for the tool + */ +export function getToolIcon(toolId: string): string | null { + // Get the absolute path to the icon from project root + const iconPath = `${process.cwd()}/public/assets/images/tools/${toolId}.png`; + if (fs.existsSync(iconPath)) { + // Return web-accessible path + return `/assets/images/tools/${toolId}.png`; + } + return null; +} + +/** + * Get tools as an array (with IDs included in each tool object) + */ +export function getToolsArray(): Tool[] { + const tools = getStaticTools(); + return Object.entries(tools).map(([id, tool]) => ({ + ...tool, + id, + votes: tool.votes || 0, + })) as Tool[]; +} + +/** + * Get tools filtered by a predicate function + */ +export function getToolsWhere( + predicate: (tool: ApiTool, id: string) => boolean, +): Tool[] { + const tools = getStaticTools(); + return Object.entries(tools) + .filter(([id, tool]) => predicate(tool, id)) + .map(([id, tool]) => ({ + ...tool, + id, + votes: tool.votes || 0, + })) as Tool[]; +} + +/** + * Get tools for a specific language + */ +export function getToolsByLanguage(language: string): Tool[] { + return getToolsWhere((tool) => + tool.languages.includes(language.toLowerCase()), + ); +} + +/** + * Get tools for a specific category + */ +export function getToolsByCategory(category: string): Tool[] { + return getToolsWhere((tool) => + tool.categories.includes(category.toLowerCase()), + ); +} + +/** + * Get tools for a specific type + */ +export function getToolsByType(type: string): Tool[] { + return getToolsWhere((tool) => tool.types.includes(type.toLowerCase())); +} + +/** + * Get total count of tools + */ +export function getToolsCount(): number { + return Object.keys(getStaticTools()).length; +} diff --git a/utils/types.ts b/utils/types.ts index eea4b01..71af431 100644 --- a/utils/types.ts +++ b/utils/types.ts @@ -101,7 +101,7 @@ export interface LanguageTag { } export interface StatsApiData { - [key: string]: string; + [key: string]: number; } export interface VotesApiData { From 90d4335b14f4514023b1b06e068212738c4ab6c0 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 6 Feb 2026 18:07:29 +0100 Subject: [PATCH 2/3] Phase 1: Data Layer Modernization This commit introduces build-time static data generation, replacing runtime GitHub API calls for tools data. Key changes: ## New Build System - Added scripts/build-data.ts: Fetches and consolidates tools data from static-analysis and dynamic-analysis repos at build time - Added prebuild hook in package.json to run build-data before next build - Data is stored in data/tools.json, data/tags.json, data/tool-stats.json, and data/tag-stats.json (all gitignored as they're generated) ## New Static Data Utilities - utils/static-data.ts: Core utility for reading pre-built static data - utils/tools.ts: Simplified tools API using static data - utils/tools-with-votes.ts: Merges tools with votes data - utils/firebase-votes.ts: Simplified Firebase votes fetching - utils/tags.ts: Tags utility using static data - utils/filters.ts: Filtering utility (moved from utils-api) - utils/stats.ts: View statistics utility ## Updated Pages - pages/index.tsx: Uses new static data utilities - pages/tool/[slug].tsx: Uses new static data utilities - pages/tag/[slug].tsx: Uses new static data utilities ## Build/Deploy Updates - Dockerfile: Removed manual tools.json download (now handled by build-data) - deploy.yml: Updated to hash generated data files for cache busting ## Benefits - No runtime GitHub API calls for tools data - Faster page generation (data is pre-fetched) - Simplified data flow - Old utils-api code preserved for backwards compatibility Note: API routes and old utils-api code are preserved for now. The /tools page still uses API routes which will be addressed in Phase 2 with InstantSearch integration. --- .gitignore | 2 + MODERNIZATION_TODO.md | 297 ------------------------------------------ pages/index.tsx | 2 +- pages/tag/[slug].tsx | 2 +- pages/tool/[slug].tsx | 2 +- scripts/build-data.ts | 28 ++-- 6 files changed, 19 insertions(+), 314 deletions(-) delete mode 100644 MODERNIZATION_TODO.md diff --git a/.gitignore b/.gitignore index b24666d..c4b8f9f 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,5 @@ credentials.json /data/tag-stats.json .vscode + +TODO.md \ No newline at end of file diff --git a/MODERNIZATION_TODO.md b/MODERNIZATION_TODO.md deleted file mode 100644 index d553917..0000000 --- a/MODERNIZATION_TODO.md +++ /dev/null @@ -1,297 +0,0 @@ -# Modernization TODO List - -This document outlines the steps needed to modernize the analysis-tools.dev website by simplifying the backend and leveraging modern tools: **Algolia + InstantSearch + Headless UI + Static JSON/CMS**. - -## Current Architecture Overview - -The current setup has several layers of complexity: -- Next.js with Server-Side Rendering (SSR) and API routes -- Firebase/Firestore for votes storage -- GitHub API for fetching tools data from external repos -- File-based caching (`cache-manager-fs-hash`) -- Custom search context and filtering logic -- React Query for client-side data fetching -- Docker deployment on Google Cloud Run via Pulumi - ---- - -## Phase 1: Data Layer Modernization - -### 1.1 Static JSON Data Source -- [x] Create a build-time script to fetch and consolidate `tools.json` from both `static-analysis` and `dynamic-analysis` repos -- [x] Store consolidated data in `data/tools.json` at build time -- [x] Remove runtime GitHub API calls (`utils-api/tools.ts` → `getTools()`) - New utilities in `utils/tools.ts`, `utils/static-data.ts` -- [ ] Remove Octokit dependency (`@octokit/core`) - Keep for now, old code still present -- [ ] Remove file-based cache (`cache-manager`, `cache-manager-fs-hash`) - Keep for now, old code still present -- [ ] Delete `utils-api/cache.ts` - Keep for now, will remove in cleanup phase - -### 1.2 Votes Data Migration -- [ ] **Option A**: Migrate votes to Algolia as a field in the tools index -- [ ] **Option B**: Use a lightweight service (e.g., Supabase, PlanetScale) for votes -- [x] **Option C**: Keep Firebase but simplify to client-side only voting - Implemented `utils/firebase-votes.ts` -- [ ] Remove server-side Firebase Admin SDK (`firebase-admin`) - Still needed for votes -- [ ] Delete `firebase-key.json` and related credentials handling - Still needed -- [ ] Remove `utils-api/firebase.ts` - Keep for now, will remove in cleanup phase -- [ ] Remove `utils-api/votes.ts` server-side logic - Keep for now, API routes still use it -- [x] Simplify or remove `utils-api/toolsWithVotes.ts` - New `utils/tools-with-votes.ts` created - -### 1.3 Remove API Routes -- [ ] Delete `pages/api/tools.ts` - Keep for now, tools page still uses it -- [ ] Delete `pages/api/paginated-tools.ts` - Keep for now, tools page still uses it -- [ ] Delete `pages/api/tags/*` - Keep for now -- [ ] Delete `pages/api/vote/*` - Keep for now, voting still works via API -- [ ] Delete `pages/api/votes/*` - Keep for now -- [ ] Delete `pages/api/mostViewed.ts` - Keep for now -- [ ] Delete `pages/api/popularLanguages.ts` - Keep for now -- [ ] Delete `pages/api/articles.ts` (if not needed) - Keep for now -- [ ] Remove entire `utils-api/` directory (migrate necessary utils to `utils/`) - Partially done - -### 1.4 New Static Data Utilities (Phase 1 Additions) -- [x] Created `scripts/build-data.ts` - Build-time data fetching script -- [x] Created `utils/static-data.ts` - Static data reader utility -- [x] Created `utils/tools.ts` - Simplified tools utility -- [x] Created `utils/tools-with-votes.ts` - Tools with votes merger -- [x] Created `utils/firebase-votes.ts` - Simplified Firebase votes utility -- [x] Created `utils/tags.ts` - Simplified tags utility -- [x] Created `utils/filters.ts` - Filtering utility (moved from utils-api) -- [x] Created `utils/stats.ts` - Stats utility for views data -- [x] Updated `package.json` with `build-data` script and `prebuild` hook -- [x] Updated `.gitignore` to exclude generated data files -- [x] Updated `Dockerfile` to use new build process -- [x] Updated `.github/workflows/deploy.yml` to use generated data for hashing -- [x] Updated `pages/index.tsx` to use new static data utilities -- [x] Updated `pages/tool/[slug].tsx` to use new static data utilities -- [x] Updated `pages/tag/[slug].tsx` to use new static data utilities - ---- - -## Phase 2: Search & Filtering with Algolia + InstantSearch - -### 2.1 Algolia Index Enhancement -- [ ] Enhance `algolia-index.ts` to run at build time (not from deployed API) -- [ ] Add all filterable attributes to Algolia index: - - `languages` (facet) - - `categories` (facet) - - `types` (facet) - - `licenses` (facet) - - `pricing`/`plans` (facet) - - `votes` (for sorting) - - `deprecated` (filter) -- [ ] Configure Algolia dashboard: - - Set up facets for filtering - - Configure searchable attributes ranking - - Set up sorting indices (by votes, by name, etc.) - -### 2.2 InstantSearch Integration -- [ ] Upgrade `react-instantsearch` to latest version -- [ ] Replace custom `SearchProvider` (`context/SearchProvider.tsx`) with InstantSearch provider -- [ ] Delete `context/SearchProvider.tsx` -- [ ] Replace `utils-api/filters.ts` logic with Algolia facets -- [ ] Create new InstantSearch widgets: - - [ ] `SearchBox` component - - [ ] `RefinementList` for language filters - - [ ] `RefinementList` for category filters - - [ ] `RefinementList` for type filters - - [ ] `RefinementList` for license filters - - [ ] `RefinementList` for pricing filters - - [ ] `SortBy` component for sorting - - [ ] `Hits` component for results - - [ ] `Pagination` or `InfiniteHits` component - -### 2.3 Replace Custom Data Fetching -- [ ] Remove React Query (`@tanstack/react-query`) for tools fetching -- [ ] Delete `components/tools/queries/tools.ts` -- [ ] Delete `components/tools/queries/languages.ts` -- [ ] Delete `components/tools/queries/others.ts` -- [ ] Delete `components/tools/queries/index.ts` -- [ ] Keep React Query only if needed for non-search data (blog posts, etc.) - ---- - -## Phase 3: UI Modernization with Headless UI - -### 3.1 Replace Custom UI Components -- [ ] Install `@headlessui/react` -- [ ] Replace custom dropdown (`components/elements/Dropdown`) with Headless UI `Listbox` -- [ ] Replace mobile filters drawer with Headless UI `Dialog` -- [ ] Create accessible filter components using Headless UI: - - [ ] `Disclosure` for collapsible filter sections - - [ ] `Combobox` for searchable language selector - - [ ] `Switch` for toggle filters (e.g., "Show deprecated") - - [ ] `Popover` for filter tooltips/info - -### 3.2 Component Cleanup -- [ ] Audit and simplify `components/elements/` -- [ ] Remove unused components -- [ ] Standardize component patterns with Headless UI - ---- - -## Phase 4: Page Architecture Simplification - -### 4.1 Convert SSR Pages to Static (SSG) -- [ ] Convert `pages/tools/index.tsx` from `getServerSideProps` to `getStaticProps` -- [ ] All filtering/search handled client-side via InstantSearch -- [ ] Keep `pages/tool/[slug].tsx` as static (`getStaticProps` + `getStaticPaths`) āœ“ (already done) -- [ ] Keep `pages/tag/[slug].tsx` as static āœ“ (already done) -- [ ] Consider Incremental Static Regeneration (ISR) for data freshness - -### 4.2 Simplify Page Components -- [ ] Refactor `ListPageComponent.tsx` to use InstantSearch -- [ ] Remove infinite scroll complexity (use Algolia pagination) -- [ ] Remove `useRouterPush` hook for search state (InstantSearch handles URL) -- [ ] Delete `hooks/` directory if no longer needed - -### 4.3 Data Flow Cleanup -- [ ] Remove `utils/query.ts` (objectToQueryString) -- [ ] Remove `utils/urls.ts` if only used for API URLs -- [ ] Simplify `utils/constants.ts` - ---- - -## Phase 5: Build & Deployment Simplification - -### 5.1 Build Process -- [ ] Create `scripts/build-data.ts`: - - Fetch tools from GitHub repos - - Fetch star history (optional, can be removed) - - Merge and validate data - - Output to `data/tools.json` - - Update Algolia index -- [ ] Update `package.json` scripts: - ```json - { - "prebuild": "npm run build-data", - "build-data": "ts-node scripts/build-data.ts", - "build": "next build" - } - ``` -- [ ] Remove runtime data fetching from build process - -### 5.2 Environment Variables Cleanup -- [ ] Remove `GOOGLE_APPLICATION_CREDENTIALS` -- [ ] Remove `GH_TOKEN` (if data is fetched at build time from public repos) -- [ ] Keep only: - - `ALGOLIA_APP_ID` - - `ALGOLIA_API_KEY` (search-only key for client) - - `ALGOLIA_ADMIN_KEY` (for indexing, build-time only) - - `PUBLIC_HOST` - -### 5.3 Docker & Deployment -- [ ] Simplify `Dockerfile` (no credentials needed at runtime) -- [ ] Consider static export (`next export`) if no server features needed -- [ ] Evaluate moving from Cloud Run to static hosting (Vercel, Netlify, Cloudflare Pages) -- [ ] Simplify or remove Pulumi infrastructure if using static hosting -- [ ] Update `.github/workflows/deploy.yml` - ---- - -## Phase 6: CMS Integration (Optional) - -### 6.1 Content Management -- [ ] Evaluate CMS options for non-tool content: - - Blog posts (currently markdown in `data/blog/`) - - FAQ content (`data/faq.json`) - - Sponsors (`data/sponsors.json`) - - Homepage content (`data/homepage.json`) -- [ ] Consider headless CMS options: - - Contentlayer (for markdown) - - Sanity - - Strapi - - Directus -- [ ] Or keep as static JSON/Markdown if editorial workflow is simple - ---- - -## Phase 7: Dependency Cleanup - -### 7.1 Remove Unused Dependencies -```json -{ - "remove": [ - "@octokit/core", - "cache-manager", - "cache-manager-fs-hash", - "firebase-admin", - "algoliasearch-helper", - "@tanstack/react-query" - ], - "keep": [ - "algoliasearch", - "react-instantsearch", - "next", - "react", - "react-dom" - ], - "add": [ - "@headlessui/react" - ] -} -``` - -### 7.2 DevDependencies Cleanup -- [ ] Remove unused type definitions -- [ ] Update ESLint config for simplified codebase -- [ ] Consider switching to Biome or similar for faster linting - ---- - -## Phase 8: Code Quality & Maintenance - -### 8.1 TypeScript Improvements -- [ ] Remove all `@ts-nocheck` and `@ts-ignore` comments -- [ ] Fix type errors in `context/SearchProvider.tsx` (before deletion) -- [ ] Ensure strict TypeScript throughout - -### 8.2 Testing -- [ ] Add unit tests for data transformation utilities -- [ ] Add integration tests for search functionality -- [ ] Add E2E tests for critical user flows - -### 8.3 Documentation -- [ ] Update `README.md` with new architecture -- [ ] Document new build process -- [ ] Document Algolia configuration -- [ ] Create contributing guide for the simplified codebase - ---- - -## Migration Checklist Summary - -### Files to Delete -- [ ] `utils-api/` (entire directory) -- [ ] `pages/api/` (entire directory) -- [ ] `context/SearchProvider.tsx` -- [ ] `components/tools/queries/` (entire directory) -- [ ] `firebase-key.json` -- [ ] `algolia-index.js` (keep only `.ts` version) - -### Files to Create -- [ ] `scripts/build-data.ts` -- [ ] `components/search/SearchProvider.tsx` (InstantSearch wrapper) -- [ ] `components/search/SearchBox.tsx` -- [ ] `components/search/Filters.tsx` -- [ ] `components/search/Results.tsx` -- [ ] `components/ui/` (Headless UI wrappers) - -### Files to Significantly Modify -- [ ] `pages/tools/index.tsx` -- [ ] `pages/index.tsx` -- [ ] `components/tools/listPage/ListPageComponent/ListPageComponent.tsx` -- [ ] `next.config.js` -- [ ] `package.json` -- [ ] `.github/workflows/deploy.yml` -- [ ] `Dockerfile` - ---- - -## Benefits After Modernization - -1. **Simpler Architecture**: No server-side API routes, no runtime data fetching -2. **Better Performance**: Static pages with client-side search (instant) -3. **Lower Costs**: Static hosting is cheaper than Cloud Run -4. **Better DX**: Less code to maintain, clearer data flow -5. **Better Search UX**: Algolia InstantSearch provides superior search experience -6. **Accessibility**: Headless UI components are accessible by default -7. **Scalability**: Algolia handles search at any scale -8. **Reliability**: Fewer moving parts = fewer things to break \ No newline at end of file diff --git a/pages/index.tsx b/pages/index.tsx index 99a0f75..c3cf7c7 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -16,7 +16,7 @@ import { Tool, ToolsByLanguage } from '@components/tools'; import { getArticlesPreviews } from 'utils-api/blog'; import { getSponsors } from 'utils-api/sponsors'; import { getFaq } from 'utils-api/faq'; -// New static data utilities (Phase 1) +// New static data utilities import { getPopularLanguageStats, getMostViewedTools } from 'utils/stats'; import { fetchVotes } from 'utils/firebase-votes'; diff --git a/pages/tag/[slug].tsx b/pages/tag/[slug].tsx index e46d94d..548b13d 100644 --- a/pages/tag/[slug].tsx +++ b/pages/tag/[slug].tsx @@ -18,7 +18,7 @@ import { LanguageFilterOption } from '@components/tools/listPage/ToolsSidebar/Fi import { Tool } from '@components/tools'; import { TagsSidebar } from '@components/tags'; import { getRandomAffiliate } from 'utils-api/affiliates'; -// New static data utilities (Phase 1) +// New static data utilities import { getTags, getLanguageData, getSimilarTags } from 'utils/tags'; import { filterByTags } from 'utils/filters'; import { getToolsWithVotes } from 'utils/tools-with-votes'; diff --git a/pages/tool/[slug].tsx b/pages/tool/[slug].tsx index 67f34f7..9d774d7 100644 --- a/pages/tool/[slug].tsx +++ b/pages/tool/[slug].tsx @@ -17,7 +17,7 @@ import { getSponsors } from 'utils-api/sponsors'; import { ToolGallery } from '@components/tools/toolPage/ToolGallery'; import { Comments } from '@components/core/Comments'; import { calculateUpvotePercentage } from 'utils/votes'; -// New static data utilities (Phase 1) +// New static data utilities import { getAllTools, getTool, getToolIcon } from 'utils/tools'; import { fetchVotes } from 'utils/firebase-votes'; diff --git a/scripts/build-data.ts b/scripts/build-data.ts index c4f9477..7c1422f 100644 --- a/scripts/build-data.ts +++ b/scripts/build-data.ts @@ -210,7 +210,7 @@ async function fetchStats(): Promise<{ * Main build function */ async function main(): Promise { - console.log('šŸ”Ø Building tools data...\n'); + console.log('Building tools data...\n'); try { // Fetch data from both repositories and stats @@ -223,27 +223,27 @@ async function main(): Promise { const staticCount = Object.keys(staticTools).length; const dynamicCount = Object.keys(dynamicTools).length; - console.log(`\nšŸ“Š Fetched ${staticCount} static analysis tools`); - console.log(`šŸ“Š Fetched ${dynamicCount} dynamic analysis tools`); + console.log(`\nFetched ${staticCount} static analysis tools`); + console.log(`Fetched ${dynamicCount} dynamic analysis tools`); // Merge tools const mergedTools = mergeTools(staticTools, dynamicTools); const totalCount = Object.keys(mergedTools).length; - console.log(`šŸ“Š Total unique tools: ${totalCount}`); + console.log(`Total unique tools: ${totalCount}`); // Validate validateTools(mergedTools); // Extract tags for reference const tags = extractTags(mergedTools); - console.log(`\nšŸ·ļø Found ${tags.languages.length} unique languages`); - console.log(`šŸ·ļø Found ${tags.others.length} unique other tags`); + console.log(`\nFound ${tags.languages.length} unique languages`); + console.log(`Found ${tags.others.length} unique other tags`); // Stats info const toolStatsCount = Object.keys(stats.toolStats).length; const tagStatsCount = Object.keys(stats.tagStats).length; - console.log(`\nšŸ“ˆ Fetched stats for ${toolStatsCount} tools`); - console.log(`šŸ“ˆ Fetched stats for ${tagStatsCount} tags`); + console.log(`\nFetched stats for ${toolStatsCount} tools`); + console.log(`Fetched stats for ${tagStatsCount} tags`); // Prepare output const output: BuildOutput = { @@ -263,29 +263,29 @@ async function main(): Promise { // Write output file fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2)); - console.log(`\nāœ… Written to ${OUTPUT_FILE}`); + console.log(`\nWritten to ${OUTPUT_FILE}`); // Also write tags for reference (useful for filters) const tagsFile = path.join(OUTPUT_DIR, 'tags.json'); fs.writeFileSync(tagsFile, JSON.stringify(tags, null, 2)); - console.log(`āœ… Written tags to ${tagsFile}`); + console.log(`Written tags to ${tagsFile}`); // Write stats files fs.writeFileSync( TOOL_STATS_FILE, JSON.stringify(stats.toolStats, null, 2), ); - console.log(`āœ… Written tool stats to ${TOOL_STATS_FILE}`); + console.log(`Written tool stats to ${TOOL_STATS_FILE}`); fs.writeFileSync( TAG_STATS_FILE, JSON.stringify(stats.tagStats, null, 2), ); - console.log(`āœ… Written tag stats to ${TAG_STATS_FILE}`); + console.log(`Written tag stats to ${TAG_STATS_FILE}`); - console.log('\nšŸŽ‰ Build complete!'); + console.log('\nBuild complete!'); } catch (error) { - console.error('\nāŒ Build failed:', error); + console.error('\nBuild failed:', error); process.exit(1); } } From 145991d972f6490cb2a105ff6cb95cc56e17d166 Mon Sep 17 00:00:00 2001 From: Matthias Date: Fri, 6 Feb 2026 18:11:39 +0100 Subject: [PATCH 3/3] Phase 1: Data Layer Modernization This commit introduces build-time static data generation, replacing runtime GitHub API calls for tools data. Key changes: ## New Build System - Added scripts/build-data.ts: Fetches and consolidates tools data from static-analysis and dynamic-analysis repos at build time - Added prebuild hook in package.json to run build-data before next build - Data is stored in data/tools.json, data/tags.json, data/tool-stats.json, and data/tag-stats.json (all gitignored as they're generated) ## New Repository Classes (lib/repositories/) - ToolsRepository: Singleton for accessing tools data with caching - TagsRepository: Singleton for accessing tags and descriptions - StatsRepository: Singleton for tool/tag view statistics - VotesRepository: Singleton for Firebase votes fetching - ToolsFilter: Fluent filter class for querying tools by various criteria ## Updated Pages - pages/index.tsx: Uses StatsRepository and VotesRepository - pages/tool/[slug].tsx: Uses ToolsRepository and VotesRepository - pages/tag/[slug].tsx: Uses TagsRepository, ToolsRepository, and ToolsFilter ## Build/Deploy Updates - Dockerfile: Removed manual tools.json download (now handled by build-data) - deploy.yml: Updated to hash generated data files for cache busting - tsconfig.json: Added @lib/* path alias ## Benefits - No runtime GitHub API calls for tools data - Faster page generation (data is pre-fetched) - Idiomatic TypeScript with proper class-based repositories - Singleton pattern ensures data is loaded once and cached - Old utils-api code preserved for backwards compatibility Note: API routes and old utils-api code are preserved for now. The /tools page still uses API routes which will be addressed in Phase 2 with InstantSearch integration. --- lib/repositories/StatsRepository.ts | 185 ++++++++++++++++++++ lib/repositories/TagsRepository.ts | 171 +++++++++++++++++++ lib/repositories/ToolsFilter.ts | 217 ++++++++++++++++++++++++ lib/repositories/ToolsRepository.ts | 191 +++++++++++++++++++++ lib/repositories/VotesRepository.ts | 128 ++++++++++++++ lib/repositories/index.ts | 5 + pages/index.tsx | 14 +- pages/tag/[slug].tsx | 51 +++--- pages/tool/[slug].tsx | 123 +++++--------- tsconfig.json | 3 +- utils/filters.ts | 253 ---------------------------- utils/firebase-votes.ts | 132 --------------- utils/static-data.ts | 187 -------------------- utils/stats.ts | 185 -------------------- utils/tags.ts | 192 --------------------- utils/tools-with-votes.ts | 113 ------------- utils/tools.ts | 124 -------------- 17 files changed, 974 insertions(+), 1300 deletions(-) create mode 100644 lib/repositories/StatsRepository.ts create mode 100644 lib/repositories/TagsRepository.ts create mode 100644 lib/repositories/ToolsFilter.ts create mode 100644 lib/repositories/ToolsRepository.ts create mode 100644 lib/repositories/VotesRepository.ts create mode 100644 lib/repositories/index.ts delete mode 100644 utils/filters.ts delete mode 100644 utils/firebase-votes.ts delete mode 100644 utils/static-data.ts delete mode 100644 utils/stats.ts delete mode 100644 utils/tags.ts delete mode 100644 utils/tools-with-votes.ts delete mode 100644 utils/tools.ts diff --git a/lib/repositories/StatsRepository.ts b/lib/repositories/StatsRepository.ts new file mode 100644 index 0000000..be6cc63 --- /dev/null +++ b/lib/repositories/StatsRepository.ts @@ -0,0 +1,185 @@ +/** + * StatsRepository + * + * Repository class for accessing tool and tag statistics from static JSON files. + * Data is pre-built at build time by scripts/build-data.ts. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { StatsApiData, VotesApiData } from 'utils/types'; +import type { Tool, ToolsByLanguage } from '@components/tools/types'; +import { ToolsRepository } from './ToolsRepository'; +import { sortByVote } from 'utils/votes'; + +export class StatsRepository { + private static instance: StatsRepository | null = null; + private toolStatsData: StatsApiData | null = null; + private tagStatsData: StatsApiData | null = null; + + private readonly toolStatsPath: string; + private readonly tagStatsPath: string; + + private constructor() { + const dataDir = path.join(process.cwd(), 'data'); + this.toolStatsPath = path.join(dataDir, 'tool-stats.json'); + this.tagStatsPath = path.join(dataDir, 'tag-stats.json'); + } + + static getInstance(): StatsRepository { + if (!StatsRepository.instance) { + StatsRepository.instance = new StatsRepository(); + } + return StatsRepository.instance; + } + + private loadToolStats(): StatsApiData { + if (this.toolStatsData) { + return this.toolStatsData; + } + + if (!fs.existsSync(this.toolStatsPath)) { + console.warn( + 'Static tool stats not found. Run `npm run build-data` first.', + ); + return {}; + } + + const content = fs.readFileSync(this.toolStatsPath, 'utf-8'); + this.toolStatsData = JSON.parse(content) as StatsApiData; + return this.toolStatsData; + } + + private loadTagStats(): StatsApiData { + if (this.tagStatsData) { + return this.tagStatsData; + } + + if (!fs.existsSync(this.tagStatsPath)) { + console.warn( + 'Static tag stats not found. Run `npm run build-data` first.', + ); + return {}; + } + + const content = fs.readFileSync(this.tagStatsPath, 'utf-8'); + this.tagStatsData = JSON.parse(content) as StatsApiData; + return this.tagStatsData; + } + + getToolStats(): StatsApiData { + return this.loadToolStats(); + } + + getTagStats(): StatsApiData { + return this.loadTagStats(); + } + + getToolViewCount(toolId: string): number { + const stats = this.loadToolStats(); + return stats[toolId] || 0; + } + + getTagViewCount(tag: string): number { + const stats = this.loadTagStats(); + return stats[tag] || 0; + } + + getLanguageStats(): ToolsByLanguage { + const tagStats = this.loadTagStats(); + + return Object.entries(tagStats) + .sort(([, a], [, b]) => b - a) + .reduce( + (result, [key, value]) => ({ + ...result, + [key]: { + views: value, + formatters: [], + linters: [], + }, + }), + {} as ToolsByLanguage, + ); + } + + getPopularLanguageStats(votes?: VotesApiData | null): ToolsByLanguage { + const toolsRepo = ToolsRepository.getInstance(); + const tools = toolsRepo.withVotes(votes || null); + const languageStats = this.getLanguageStats(); + + for (const [toolId, tool] of Object.entries(tools)) { + const isSingleLanguage = tool.languages.length <= 2; + + if (isSingleLanguage && tool.languages.length > 0) { + const language = tool.languages[0]; + + if (languageStats[language]) { + const toolObj: Tool = { + id: toolId, + ...tool, + votes: tool.votes || 0, + } as Tool; + + if (tool.categories.includes('formatter')) { + languageStats[language].formatters.push(toolObj); + } + if (tool.categories.includes('linter')) { + languageStats[language].linters.push(toolObj); + } + + languageStats[language].formatters.sort(sortByVote); + languageStats[language].linters.sort(sortByVote); + + if (languageStats[language].formatters.length > 3) { + languageStats[language].formatters.pop(); + } + if (languageStats[language].linters.length > 3) { + languageStats[language].linters.pop(); + } + } + } + } + + // Filter out languages with no tools + for (const language of Object.keys(languageStats)) { + if ( + languageStats[language].formatters.length === 0 && + languageStats[language].linters.length === 0 + ) { + delete languageStats[language]; + } + } + + return languageStats; + } + + getMostViewedTools(votes?: VotesApiData | null): Tool[] { + const toolsRepo = ToolsRepository.getInstance(); + const tools = toolsRepo.withVotes(votes || null); + const toolStats = this.loadToolStats(); + + const mostViewedToolIds = Object.keys(toolStats); + + return mostViewedToolIds + .map((id) => { + const tool = tools[id]; + if (!tool) { + return null; + } + + return { + id, + ...tool, + votes: tool.votes || 0, + views: toolStats[id], + } as Tool & { views: number }; + }) + .filter((tool): tool is Tool & { views: number } => tool !== null); + } + + clearCache(): void { + this.toolStatsData = null; + this.tagStatsData = null; + } +} diff --git a/lib/repositories/TagsRepository.ts b/lib/repositories/TagsRepository.ts new file mode 100644 index 0000000..4424cd2 --- /dev/null +++ b/lib/repositories/TagsRepository.ts @@ -0,0 +1,171 @@ +/** + * TagsRepository + * + * Repository class for accessing tags (languages and others) from static JSON files. + * Data is pre-built at build time by scripts/build-data.ts. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { ApiTag, LanguageData, TagsType } from 'utils/types'; +import { isLanguageData } from 'utils/type-guards'; + +interface StaticTagsData { + languages: string[]; + others: string[]; +} + +export class TagsRepository { + private static instance: TagsRepository | null = null; + private tagsData: StaticTagsData | null = null; + private descriptionsData: Record | null = null; + private relatedTagsData: string[][] | null = null; + + private readonly tagsPath: string; + private readonly descriptionsPath: string; + private readonly relatedTagsPath: string; + + private constructor() { + const dataDir = path.join(process.cwd(), 'data'); + this.tagsPath = path.join(dataDir, 'tags.json'); + this.descriptionsPath = path.join(dataDir, 'descriptions.json'); + this.relatedTagsPath = path.join(dataDir, 'relatedTags.json'); + } + + static getInstance(): TagsRepository { + if (!TagsRepository.instance) { + TagsRepository.instance = new TagsRepository(); + } + return TagsRepository.instance; + } + + private loadTags(): StaticTagsData { + if (this.tagsData) { + return this.tagsData; + } + + if (!fs.existsSync(this.tagsPath)) { + console.warn( + 'Static tags data not found. Run `npm run build-data` first.', + ); + return { languages: [], others: [] }; + } + + const content = fs.readFileSync(this.tagsPath, 'utf-8'); + this.tagsData = JSON.parse(content) as StaticTagsData; + return this.tagsData; + } + + private loadDescriptions(): Record { + if (this.descriptionsData) { + return this.descriptionsData; + } + + if (!fs.existsSync(this.descriptionsPath)) { + return {}; + } + + const content = fs.readFileSync(this.descriptionsPath, 'utf-8'); + this.descriptionsData = JSON.parse(content); + return this.descriptionsData || {}; + } + + private loadRelatedTags(): string[][] { + if (this.relatedTagsData) { + return this.relatedTagsData; + } + + if (!fs.existsSync(this.relatedTagsPath)) { + return []; + } + + const content = fs.readFileSync(this.relatedTagsPath, 'utf-8'); + this.relatedTagsData = JSON.parse(content) || []; + return this.relatedTagsData || []; + } + + private capitalizeFirstLetter(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); + } + + getAll(type: TagsType): ApiTag[] { + const tags = this.loadTags(); + + const languageTags: ApiTag[] = tags.languages.map((lang) => ({ + name: this.capitalizeFirstLetter(lang), + value: lang, + tag_type: 'languages', + })); + + const otherTags: ApiTag[] = tags.others.map((other) => ({ + name: this.capitalizeFirstLetter(other), + value: other, + tag_type: 'other', + })); + + switch (type) { + case 'languages': + return languageTags; + case 'other': + return otherTags; + case 'all': + return [...languageTags, ...otherTags]; + default: + console.error(`Unknown tag type: ${type}`); + return []; + } + } + + getLanguages(): string[] { + return this.loadTags().languages; + } + + getOthers(): string[] { + return this.loadTags().others; + } + + getById(type: TagsType, tagId: string): ApiTag | null { + const tags = this.getAll(type); + return ( + tags.find((t) => t.value.toLowerCase() === tagId.toLowerCase()) || + null + ); + } + + getDescription(tagId: string): LanguageData { + const defaultData: LanguageData = { + name: this.capitalizeFirstLetter(tagId), + website: '', + description: '', + }; + + const descriptions = this.loadDescriptions(); + const data = descriptions[tagId]; + + if (!data || !isLanguageData(data)) { + return defaultData; + } + + return data; + } + + getRelated(tag: string): string[] { + const relatedTags = this.loadRelatedTags(); + + const relatedGroup = relatedTags.find((tags) => + tags.includes(tag.toLowerCase()), + ); + + if (!relatedGroup) { + return []; + } + + return relatedGroup.filter((t) => t !== tag.toLowerCase()); + } + + clearCache(): void { + this.tagsData = null; + this.descriptionsData = null; + this.relatedTagsData = null; + } +} diff --git a/lib/repositories/ToolsFilter.ts b/lib/repositories/ToolsFilter.ts new file mode 100644 index 0000000..8fe789e --- /dev/null +++ b/lib/repositories/ToolsFilter.ts @@ -0,0 +1,217 @@ +/** + * ToolsFilter + * + * Filter class for filtering tools based on various criteria. + * Works with tools data from ToolsRepository. + */ + +import type { Tool } from '@components/tools/types'; +import type { ApiTool, ToolsApiData } from 'utils/types'; +import type { ParsedUrlQuery } from 'querystring'; + +export class ToolsFilter { + private tools: ToolsApiData; + + constructor(tools: ToolsApiData) { + this.tools = tools; + } + + static from(tools: ToolsApiData): ToolsFilter { + return new ToolsFilter(tools); + } + + private toToolArray(entries: [string, ApiTool][]): Tool[] { + return entries.map(([id, tool]) => ({ + ...tool, + id, + votes: tool.votes || 0, + })) as Tool[]; + } + + private isSingleLanguageTool(tool: ApiTool): boolean { + return tool.languages.length <= 2; + } + + private containsArray(arr: string[], values: string[]): boolean { + return values.every((value) => arr.includes(value)); + } + + byLanguage(language: string): Tool[] { + const entries = Object.entries(this.tools).filter(([, tool]) => + tool.languages.includes(language.toLowerCase()), + ); + return this.toToolArray(entries); + } + + byLanguages(languages: string[]): Tool[] { + const entries = Object.entries(this.tools).filter(([, tool]) => { + const isMultiLanguage = !this.isSingleLanguageTool(tool); + return ( + isMultiLanguage && this.containsArray(tool.languages, languages) + ); + }); + return this.toToolArray(entries); + } + + byCategory(category: string): Tool[] { + const entries = Object.entries(this.tools).filter(([, tool]) => + tool.categories.includes(category.toLowerCase()), + ); + return this.toToolArray(entries); + } + + byType(type: string): Tool[] { + const entries = Object.entries(this.tools).filter(([, tool]) => + tool.types.includes(type.toLowerCase()), + ); + return this.toToolArray(entries); + } + + byLicense(license: string): Tool[] { + const entries = Object.entries(this.tools).filter(([, tool]) => + tool.licenses.includes(license), + ); + return this.toToolArray(entries); + } + + byTag(tag: string): Tool[] { + const entries = Object.entries(this.tools).filter( + ([, tool]) => + tool.languages.includes(tag) || tool.other.includes(tag), + ); + return this.toToolArray(entries); + } + + byTags(tags: string[]): Tool[] { + const entries = Object.entries(this.tools).filter(([, tool]) => { + const matchesLanguage = tags.some((t) => + tool.languages.includes(t), + ); + const matchesOther = tags.some((t) => tool.other.includes(t)); + return matchesLanguage || matchesOther; + }); + return this.toToolArray(entries); + } + + byQuery(query: ParsedUrlQuery): Tool[] { + const { languages, others, categories, types, licenses, pricing } = + query; + const result: Tool[] = []; + + for (const [key, tool] of Object.entries(this.tools)) { + if (languages && !this.matchesLanguageFilter(tool, languages)) { + continue; + } + + if (others && !this.matchesOthersFilter(tool, others)) { + continue; + } + + if ( + categories && + !this.matchesArrayFilter(tool.categories, categories) + ) { + continue; + } + + if (types && !this.matchesArrayFilter(tool.types, types)) { + continue; + } + + if (licenses && !this.matchesArrayFilter(tool.licenses, licenses)) { + continue; + } + + if (pricing && !this.matchesPricingFilter(tool, pricing)) { + continue; + } + + result.push({ + ...tool, + id: key, + votes: tool.votes || 0, + } as Tool); + } + + return result; + } + + private matchesLanguageFilter( + tool: ApiTool, + filter: string | string[], + ): boolean { + if (Array.isArray(filter)) { + const isMultiLanguage = !this.isSingleLanguageTool(tool); + return ( + isMultiLanguage && this.containsArray(tool.languages, filter) + ); + } + return tool.languages.includes(filter); + } + + private matchesOthersFilter( + tool: ApiTool, + filter: string | string[], + ): boolean { + if (Array.isArray(filter)) { + const isMultiLanguage = !this.isSingleLanguageTool(tool); + return isMultiLanguage && this.containsArray(tool.other, filter); + } + return tool.other.includes(filter); + } + + private matchesArrayFilter( + toolValues: string[], + filter: string | string[], + ): boolean { + if (Array.isArray(filter)) { + return this.containsArray(toolValues, filter); + } + return toolValues.includes(filter); + } + + private matchesPricingFilter( + tool: ApiTool, + pricing: string | string[], + ): boolean { + const pricingArray = Array.isArray(pricing) ? pricing : [pricing]; + + for (const filter of pricingArray) { + if (filter === 'plans' && !tool.plans) { + return false; + } + if (filter === 'oss' && !tool.plans?.oss) { + return false; + } + if (filter === 'free' && !tool.plans?.free) { + return false; + } + } + + return true; + } + + all(): Tool[] { + return this.toToolArray(Object.entries(this.tools)); + } + + sortByVotes(tools: Tool[]): Tool[] { + return [...tools].sort((a, b) => (b.votes || 0) - (a.votes || 0)); + } + + sortByName(tools: Tool[]): Tool[] { + return [...tools].sort((a, b) => a.name.localeCompare(b.name)); + } + + paginate( + tools: Tool[], + offset: number, + limit: number, + ): { data: Tool[]; nextCursor?: number; total: number } { + const total = tools.length; + const data = tools.slice(offset, offset + limit); + const nextCursor = offset + limit < total ? offset + limit : undefined; + + return { data, nextCursor, total }; + } +} diff --git a/lib/repositories/ToolsRepository.ts b/lib/repositories/ToolsRepository.ts new file mode 100644 index 0000000..b6d1a8c --- /dev/null +++ b/lib/repositories/ToolsRepository.ts @@ -0,0 +1,191 @@ +/** + * ToolsRepository + * + * Repository class for accessing tools data from static JSON files. + * Data is pre-built at build time by scripts/build-data.ts. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import type { ToolsApiData, ApiTool, VotesApiData } from 'utils/types'; +import type { Tool } from '@components/tools/types'; +import { calculateUpvotePercentage } from 'utils/votes'; + +interface BuildMeta { + buildTime: string; + staticAnalysisCount: number; + dynamicAnalysisCount: number; + totalCount: number; +} + +interface StaticToolsData { + tools: ToolsApiData; + meta: BuildMeta; +} + +export class ToolsRepository { + private static instance: ToolsRepository | null = null; + private toolsData: StaticToolsData | null = null; + private readonly dataPath: string; + + private constructor() { + this.dataPath = path.join(process.cwd(), 'data', 'tools.json'); + } + + static getInstance(): ToolsRepository { + if (!ToolsRepository.instance) { + ToolsRepository.instance = new ToolsRepository(); + } + return ToolsRepository.instance; + } + + private loadData(): StaticToolsData { + if (this.toolsData) { + return this.toolsData; + } + + if (!fs.existsSync(this.dataPath)) { + console.warn( + 'Static tools data not found. Run `npm run build-data` first.', + ); + return { tools: {}, meta: {} as BuildMeta }; + } + + const content = fs.readFileSync(this.dataPath, 'utf-8'); + this.toolsData = JSON.parse(content) as StaticToolsData; + return this.toolsData; + } + + getAll(): ToolsApiData { + return this.loadData().tools; + } + + getMeta(): BuildMeta | null { + return this.loadData().meta || null; + } + + getById(toolId: string): Tool | null { + const tools = this.getAll(); + const tool = tools[toolId]; + + if (!tool) { + return null; + } + + return { + ...tool, + id: toolId, + votes: tool.votes || 0, + } as Tool; + } + + getAllIds(): string[] { + return Object.keys(this.getAll()); + } + + toArray(): Tool[] { + const tools = this.getAll(); + return Object.entries(tools).map(([id, tool]) => ({ + ...tool, + id, + votes: tool.votes || 0, + })) as Tool[]; + } + + findWhere(predicate: (tool: ApiTool, id: string) => boolean): Tool[] { + const tools = this.getAll(); + return Object.entries(tools) + .filter(([id, tool]) => predicate(tool, id)) + .map(([id, tool]) => ({ + ...tool, + id, + votes: tool.votes || 0, + })) as Tool[]; + } + + findByLanguage(language: string): Tool[] { + return this.findWhere((tool) => + tool.languages.includes(language.toLowerCase()), + ); + } + + findByCategory(category: string): Tool[] { + return this.findWhere((tool) => + tool.categories.includes(category.toLowerCase()), + ); + } + + findByType(type: string): Tool[] { + return this.findWhere((tool) => + tool.types.includes(type.toLowerCase()), + ); + } + + findByTag(tag: string): Tool[] { + return this.findWhere( + (tool) => tool.languages.includes(tag) || tool.other.includes(tag), + ); + } + + count(): number { + return Object.keys(this.getAll()).length; + } + + getIcon(toolId: string): string | null { + const iconPath = path.join( + process.cwd(), + 'public', + 'assets', + 'images', + 'tools', + `${toolId}.png`, + ); + + if (fs.existsSync(iconPath)) { + return `/assets/images/tools/${toolId}.png`; + } + return null; + } + + withVotes(votes: VotesApiData | null): ToolsApiData { + const tools = this.getAll(); + + if (!votes) { + return tools; + } + + const result: ToolsApiData = {}; + + for (const [toolId, tool] of Object.entries(tools)) { + const key = `toolsyaml${toolId}`; + const v = votes[key]; + + const sum = v?.sum || 0; + const upVotes = v?.upVotes || 0; + const downVotes = v?.downVotes || 0; + + result[toolId] = { + ...tool, + votes: sum, + upVotes, + downVotes, + upvotePercentage: calculateUpvotePercentage(upVotes, downVotes), + }; + } + + return result; + } + + withVotesAsArray(votes: VotesApiData | null): Tool[] { + const tools = this.withVotes(votes); + return Object.entries(tools).map(([id, tool]) => ({ + ...tool, + id, + votes: tool.votes || 0, + })) as Tool[]; + } + + clearCache(): void { + this.toolsData = null; + } +} diff --git a/lib/repositories/VotesRepository.ts b/lib/repositories/VotesRepository.ts new file mode 100644 index 0000000..9ae3df3 --- /dev/null +++ b/lib/repositories/VotesRepository.ts @@ -0,0 +1,128 @@ +/** + * VotesRepository + * + * Repository class for accessing votes data from Firebase. + * Provides a simplified interface for fetching votes at build time. + */ + +import type { VotesApiData } from 'utils/types'; + +export class VotesRepository { + private static instance: VotesRepository | null = null; + private initPromise: Promise | null = null; + private votesCache: VotesApiData | null = null; + + // Private constructor for singleton pattern + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + static getInstance(): VotesRepository { + if (!VotesRepository.instance) { + VotesRepository.instance = new VotesRepository(); + } + return VotesRepository.instance; + } + + private async initFirebase(): Promise { + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = (async () => { + const { apps, credential } = await import('firebase-admin'); + const { initializeApp } = await import('firebase-admin/app'); + + if (!apps.length) { + initializeApp({ + credential: credential.applicationDefault(), + databaseURL: 'https://analysis-tools-dev.firebaseio.com', + }); + } + })(); + + return this.initPromise; + } + + isConfigured(): boolean { + return !!process.env.GOOGLE_APPLICATION_CREDENTIALS; + } + + async fetchAll(): Promise { + if (!this.isConfigured()) { + console.warn( + 'Firebase credentials not configured. Skipping votes fetch.', + ); + return null; + } + + if (this.votesCache) { + return this.votesCache; + } + + try { + await this.initFirebase(); + + const { getFirestore } = await import('firebase-admin/firestore'); + const db = getFirestore(); + + const votesCol = db.collection('tags'); + const voteSnapshot = await votesCol.get(); + + const votes: VotesApiData = {}; + voteSnapshot.docs.forEach((doc) => { + const data = doc.data(); + votes[doc.id] = { + sum: data.sum || 0, + upVotes: data.upVotes || 0, + downVotes: data.downVotes || 0, + }; + }); + + this.votesCache = votes; + return votes; + } catch (error) { + console.error('Error fetching votes from Firebase:', error); + return null; + } + } + + async fetchForTool(toolId: string): Promise<{ + votes: number; + upVotes: number; + downVotes: number; + }> { + const defaultVotes = { votes: 0, upVotes: 0, downVotes: 0 }; + + if (!this.isConfigured()) { + return defaultVotes; + } + + try { + await this.initFirebase(); + + const { getFirestore } = await import('firebase-admin/firestore'); + const db = getFirestore(); + + const key = `toolsyaml${toolId}`; + const doc = await db.collection('tags').doc(key).get(); + + if (!doc.exists) { + return defaultVotes; + } + + const data = doc.data(); + return { + votes: data?.sum || 0, + upVotes: data?.upVotes || 0, + downVotes: data?.downVotes || 0, + }; + } catch (error) { + console.error(`Error fetching votes for tool ${toolId}:`, error); + return defaultVotes; + } + } + + clearCache(): void { + this.votesCache = null; + } +} diff --git a/lib/repositories/index.ts b/lib/repositories/index.ts new file mode 100644 index 0000000..3ec60ed --- /dev/null +++ b/lib/repositories/index.ts @@ -0,0 +1,5 @@ +export { ToolsRepository } from './ToolsRepository'; +export { TagsRepository } from './TagsRepository'; +export { StatsRepository } from './StatsRepository'; +export { VotesRepository } from './VotesRepository'; +export { ToolsFilter } from './ToolsFilter'; diff --git a/pages/index.tsx b/pages/index.tsx index c3cf7c7..384a8e5 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -16,19 +16,19 @@ import { Tool, ToolsByLanguage } from '@components/tools'; import { getArticlesPreviews } from 'utils-api/blog'; import { getSponsors } from 'utils-api/sponsors'; import { getFaq } from 'utils-api/faq'; -// New static data utilities -import { getPopularLanguageStats, getMostViewedTools } from 'utils/stats'; -import { fetchVotes } from 'utils/firebase-votes'; +import { StatsRepository, VotesRepository } from '@lib/repositories'; export const getStaticProps: GetStaticProps = async () => { const sponsors = getSponsors(); const faq = getFaq(); const previews = await getArticlesPreviews(); - // Fetch votes from Firebase and use with static data utilities - const votes = await fetchVotes(); - const popularLanguages = getPopularLanguageStats(votes); - const mostViewed = getMostViewedTools(votes); + const votesRepo = VotesRepository.getInstance(); + const statsRepo = StatsRepository.getInstance(); + + const votes = await votesRepo.fetchAll(); + const popularLanguages = statsRepo.getPopularLanguageStats(votes); + const mostViewed = statsRepo.getMostViewedTools(votes); return { props: { diff --git a/pages/tag/[slug].tsx b/pages/tag/[slug].tsx index 548b13d..367edc5 100644 --- a/pages/tag/[slug].tsx +++ b/pages/tag/[slug].tsx @@ -18,30 +18,25 @@ import { LanguageFilterOption } from '@components/tools/listPage/ToolsSidebar/Fi import { Tool } from '@components/tools'; import { TagsSidebar } from '@components/tags'; import { getRandomAffiliate } from 'utils-api/affiliates'; -// New static data utilities -import { getTags, getLanguageData, getSimilarTags } from 'utils/tags'; -import { filterByTags } from 'utils/filters'; -import { getToolsWithVotes } from 'utils/tools-with-votes'; -import { fetchVotes } from 'utils/firebase-votes'; +import { + TagsRepository, + ToolsRepository, + ToolsFilter, + VotesRepository, +} from '@lib/repositories'; -// This function gets called at build time export const getStaticPaths: GetStaticPaths = async () => { - // Get tags from static data (no network call) - const data = getTags('all'); + const tagsRepo = TagsRepository.getInstance(); + const tags = tagsRepo.getAll('all'); - if (!data || data.length === 0) { + if (tags.length === 0) { return { paths: [], fallback: false }; } - // Get the paths we want to pre-render based on the tags data - const paths = data.map((tag) => { - return { - params: { slug: tag.value }, - }; - }); + const paths = tags.map((tag) => ({ + params: { slug: tag.value }, + })); - // We'll pre-render only these paths at build time. - // { fallback: false } means other routes should 404. return { paths, fallback: false }; }; @@ -53,28 +48,28 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { }; } - // Get tag data from static files (no network call) - const tagData = getLanguageData(slug); + const tagsRepo = TagsRepository.getInstance(); + const toolsRepo = ToolsRepository.getInstance(); + const votesRepo = VotesRepository.getInstance(); + + const tagData = tagsRepo.getDescription(slug); - // Capitalize the first letter of the tag let tagName = slug.charAt(0).toUpperCase() + slug.slice(1); if ('name' in tagData && tagData.name) { - // We can use a more descriptive name if it exists tagName = tagData.name; } - // Fetch votes from Firebase and merge with static tools data - const votes = await fetchVotes(); - const tools = getToolsWithVotes(votes); + const votes = await votesRepo.fetchAll(); + const toolsWithVotes = toolsRepo.withVotes(votes); + const filter = ToolsFilter.from(toolsWithVotes); const previews = await getArticlesPreviews(); const sponsors = getSponsors(); - // Get languages from static data (no network call) - const languages = getTags('languages'); + const languages = tagsRepo.getAll('languages'); const affiliate = getRandomAffiliate([slug]); - const relatedTags = getSimilarTags(slug); - const filteredTools = filterByTags(tools, slug); + const relatedTags = tagsRepo.getRelated(slug); + const filteredTools = filter.byTag(slug); // best tools by percent upvoted (single language only) const bestTools = filteredTools diff --git a/pages/tool/[slug].tsx b/pages/tool/[slug].tsx index 9d774d7..223a333 100644 --- a/pages/tool/[slug].tsx +++ b/pages/tool/[slug].tsx @@ -17,26 +17,21 @@ import { getSponsors } from 'utils-api/sponsors'; import { ToolGallery } from '@components/tools/toolPage/ToolGallery'; import { Comments } from '@components/core/Comments'; import { calculateUpvotePercentage } from 'utils/votes'; -// New static data utilities -import { getAllTools, getTool, getToolIcon } from 'utils/tools'; -import { fetchVotes } from 'utils/firebase-votes'; +import { ToolsRepository, VotesRepository } from '@lib/repositories'; // This function gets called at build time export const getStaticPaths: GetStaticPaths = async () => { - // Get tools from static data (no network call) - const data = getAllTools(); + const toolsRepo = ToolsRepository.getInstance(); + const toolIds = toolsRepo.getAllIds(); - if (!data || Object.keys(data).length === 0) { + if (toolIds.length === 0) { return { paths: [], fallback: false }; } - // Get the paths we want to pre-render based on the tools data - const paths = Object.keys(data).map((id) => ({ + const paths = toolIds.map((id) => ({ params: { slug: id }, })); - // We'll pre-render only these paths at build time. - // { fallback: false } means other routes should 404. return { paths, fallback: false }; }; @@ -50,8 +45,10 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { }; } - // Get tool from static data (no network call) - const apiTool = getTool(slug); + const toolsRepo = ToolsRepository.getInstance(); + const votesRepo = VotesRepository.getInstance(); + + const apiTool = toolsRepo.getById(slug); if (!apiTool) { console.error(`Tool ${slug} not found. Cannot build slug page.`); return { @@ -60,13 +57,12 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { } const sponsors = getSponsors(); - // Fetch votes from Firebase (still needed for now) - const votes = await fetchVotes(); + const votes = await votesRepo.fetchAll(); const previews = await getArticlesPreviews(); - const icon = getToolIcon(slug); + const icon = toolsRepo.getIcon(slug); - // calculate the upvote percentage based on the votes - const voteKey = `toolsyaml${slug.toString()}`; + // Calculate the upvote percentage based on the votes + const voteKey = `toolsyaml${slug}`; const voteData = votes ? votes[voteKey] : null; const upvotePercentage = calculateUpvotePercentage( voteData?.upVotes, @@ -79,71 +75,42 @@ export const getStaticProps: GetStaticProps = async ({ params }) => { id: slug, icon: icon, }; - // Get all tools from static data for alternatives - const alternativeTools = getAllTools(); + + // Get all tools with votes for alternatives + const allToolsWithVotes = toolsRepo.withVotesAsArray(votes); let alternatives: Tool[] = []; - let allAlternatives: Tool[] = []; - if (alternativeTools) { - allAlternatives = Object.entries(alternativeTools).reduce( - (acc, [id, tool]) => { - // if key is not equal to slug - if (id !== slug) { - // push value to acc - // add id and votes to value - const voteKey = `toolsyaml${id.toString()}`; - - // check if we have votes for this tool - // otherwise set to 0 - const voteData = votes - ? votes[voteKey] - ? votes[voteKey].sum - : 0 - : 0; - - acc.push({ id, ...tool, votes: voteData }); - } - return acc; - }, - [] as Tool[], + const allAlternatives = allToolsWithVotes.filter((t) => t.id !== slug); + + // Show only tools with the same type, languages, and categories + alternatives = allAlternatives.filter((alt) => { + return ( + containsArray(alt.types, tool.types || []) && + containsArray(alt.languages, tool.languages || []) && + containsArray(alt.categories, tool.categories || []) ); + }); - // if in currentTool view, show only tools with the same type - if (tool) { - alternatives = allAlternatives.filter((alt) => { - return ( - containsArray(alt.types, tool.types || []) && - containsArray(alt.languages, tool.languages || []) && - containsArray(alt.categories, tool.categories || []) - ); + // If the list is empty, show tools with most matched languages + if (alternatives.length === 0) { + alternatives = allAlternatives + .sort((a, b) => { + const aMatches = + a.languages?.filter((lang) => + tool.languages?.includes(lang), + ).length || 0; + const bMatches = + b.languages?.filter((lang) => + tool.languages?.includes(lang), + ).length || 0; + return bMatches - aMatches; + }) + .filter((alt) => { + const matches = + alt.languages?.filter((lang) => + tool.languages?.includes(lang), + ).length || 0; + return matches >= 5; }); - - // if the list is empty, show the tools with the same type and the most - // matched languages - if (alternatives.length === 0) { - alternatives = allAlternatives; - - // sort the list by the number of matched languages - alternatives.sort((a, b) => { - return ( - b.languages?.filter((lang) => - tool.languages?.includes(lang), - ).length - - a.languages?.filter((lang) => - tool.languages?.includes(lang), - ).length - ); - }); - - // take the tools with at least 5 matched languages - alternatives = alternatives.filter((alt) => { - return ( - alt.languages?.filter((lang) => - tool.languages?.includes(lang), - ).length >= 5 - ); - }); - } - } } return { diff --git a/tsconfig.json b/tsconfig.json index 1257693..d417562 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,7 +18,8 @@ "baseUrl": ".", "paths": { "@components/*": ["components/*"], - "@appdata/*": ["data/*"] + "@appdata/*": ["data/*"], + "@lib/*": ["lib/*"] } }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], diff --git a/utils/filters.ts b/utils/filters.ts deleted file mode 100644 index e798225..0000000 --- a/utils/filters.ts +++ /dev/null @@ -1,253 +0,0 @@ -/** - * Filters Utility Module - * - * This module provides utilities for filtering tools based on various criteria. - * It works with the static tools data and can be used both at build time - * and runtime (client-side). - * - * This replaces the old utils-api/filters.ts - */ - -import { Tool } from '@components/tools/types'; -import { ParsedUrlQuery } from 'querystring'; -import { containsArray } from './arrays'; -import type { ApiTool, ToolsApiData } from './types'; - -/** - * Filter tools based on URL query parameters - * - * @param tools - Tools data object (keyed by tool ID) - * @param query - URL query parameters - * @returns Array of filtered tools - */ -export function filterResults( - tools: ToolsApiData | null, - query: ParsedUrlQuery, -): Tool[] { - if (!tools) { - return []; - } - - const { languages, others, categories, types, licenses, pricing } = query; - const result: Tool[] = []; - - for (const [key, tool] of Object.entries(tools)) { - // Filter by languages - if (languages) { - if (!matchesFilter(tool.languages, languages, tool)) { - continue; - } - } - - // Filter by other tags - if (others) { - if (!matchesFilter(tool.other, others, tool)) { - continue; - } - } - - // Filter by categories - if (categories) { - if (!matchesArrayFilter(tool.categories, categories)) { - continue; - } - } - - // Filter by types - if (types) { - if (!matchesArrayFilter(tool.types, types)) { - continue; - } - } - - // Filter by licenses - if (licenses) { - if (!matchesArrayFilter(tool.licenses, licenses)) { - continue; - } - } - - // Filter by pricing - if (pricing) { - if (!matchesPricingFilter(tool, pricing)) { - continue; - } - } - - // All filters passed - result.push({ id: key, ...tool } as Tool); - } - - return result; -} - -/** - * Filter tools by tags (languages or others) - * - * @param tools - Tools data object - * @param tags - Tag(s) to filter by - * @returns Array of tools matching the tags - */ -export function filterByTags( - tools: ToolsApiData | null, - tags: string | string[], -): Tool[] { - if (!tools) { - return []; - } - - const result: Tool[] = []; - const tagArray = Array.isArray(tags) ? tags : [tags]; - - for (const [key, tool] of Object.entries(tools)) { - const matchesLanguage = tagArray.some((tag) => - tool.languages.includes(tag), - ); - const matchesOther = tagArray.some((tag) => tool.other.includes(tag)); - - if (matchesLanguage || matchesOther) { - result.push({ id: key, ...tool } as Tool); - } - } - - return result; -} - -/** - * Filter tools by a single language - */ -export function filterByLanguage( - tools: ToolsApiData | null, - language: string, -): Tool[] { - return filterByTags(tools, language); -} - -/** - * Filter tools by category - */ -export function filterByCategory( - tools: ToolsApiData | null, - category: string, -): Tool[] { - if (!tools) { - return []; - } - - return Object.entries(tools) - .filter(([, tool]) => tool.categories.includes(category)) - .map(([id, tool]) => ({ id, ...tool } as Tool)); -} - -/** - * Filter tools by type - */ -export function filterByType(tools: ToolsApiData | null, type: string): Tool[] { - if (!tools) { - return []; - } - - return Object.entries(tools) - .filter(([, tool]) => tool.types.includes(type)) - .map(([id, tool]) => ({ id, ...tool } as Tool)); -} - -/** - * Check if a tool is language-specific (supports only 1-2 languages) - */ -export function isSingleLanguageTool(tool: Tool | ApiTool): boolean { - return tool.languages.length <= 2; -} - -/** - * Check if a tool is specific to a given language - */ -export function isToolLanguageSpecific( - tool: Tool | ApiTool, - language: string, -): boolean { - return isSingleLanguageTool(tool) && tool.languages.includes(language); -} - -/** - * Helper: Check if tool field matches filter (for languages/others) - * Multi-language tools need to match all filter values - */ -function matchesFilter( - toolValues: string[], - filterValue: string | string[], - tool: ApiTool, -): boolean { - if (Array.isArray(filterValue)) { - const isMultiLanguage = !isSingleLanguageTool(tool); - const matches = containsArray(toolValues, filterValue); - return isMultiLanguage && matches; - } - return toolValues.includes(filterValue); -} - -/** - * Helper: Check if tool field matches array filter - */ -function matchesArrayFilter( - toolValues: string[], - filterValue: string | string[], -): boolean { - if (Array.isArray(filterValue)) { - return containsArray(toolValues, filterValue); - } - return toolValues.includes(filterValue); -} - -/** - * Helper: Check if tool matches pricing filter - */ -function matchesPricingFilter( - tool: ApiTool, - pricing: string | string[], -): boolean { - const pricingArray = Array.isArray(pricing) ? pricing : [pricing]; - - for (const filter of pricingArray) { - if (filter === 'plans' && !tool.plans) { - return false; - } - if (filter === 'oss' && !tool.plans?.oss) { - return false; - } - if (filter === 'free' && !tool.plans?.free) { - return false; - } - } - - return true; -} - -/** - * Sort tools by votes (descending) - */ -export function sortByVotes(tools: Tool[]): Tool[] { - return [...tools].sort((a, b) => (b.votes || 0) - (a.votes || 0)); -} - -/** - * Sort tools by name (ascending) - */ -export function sortByName(tools: Tool[]): Tool[] { - return [...tools].sort((a, b) => a.name.localeCompare(b.name)); -} - -/** - * Paginate tools array - */ -export function paginateTools( - tools: Tool[], - offset: number, - limit: number, -): { data: Tool[]; nextCursor?: number; total: number } { - const total = tools.length; - const data = tools.slice(offset, offset + limit); - const nextCursor = offset + limit < total ? offset + limit : undefined; - - return { data, nextCursor, total }; -} diff --git a/utils/firebase-votes.ts b/utils/firebase-votes.ts deleted file mode 100644 index 2b5e98a..0000000 --- a/utils/firebase-votes.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * Firebase Votes Utility - * - * This module provides a simplified interface for reading votes from Firebase. - * It's designed to be used at build time (getStaticProps) to fetch votes. - * - * For Phase 1, we keep Firebase for votes but simplify the interface. - * In a future phase, votes could be migrated to Algolia or another service. - */ - -import type { VotesApiData } from './types'; - -// Singleton promise for Firebase initialization -let firebaseInitPromise: Promise | null = null; - -/** - * Initialize Firebase Admin SDK (singleton pattern) - * This ensures Firebase is only initialized once, even with concurrent calls - */ -function initFirebase(): Promise { - if (firebaseInitPromise) { - return firebaseInitPromise; - } - - firebaseInitPromise = (async () => { - // Dynamic import to avoid loading firebase-admin when not needed - const { apps, credential } = await import('firebase-admin'); - const { initializeApp } = await import('firebase-admin/app'); - - // Only initialize if no apps exist - if (!apps.length) { - initializeApp({ - credential: credential.applicationDefault(), - databaseURL: 'https://analysis-tools-dev.firebaseio.com', - }); - } - })(); - - return firebaseInitPromise; -} - -/** - * Fetch all votes from Firebase - * - * This function fetches all vote records from Firestore. - * It should be called at build time (in getStaticProps) to get votes data. - * - * @returns VotesApiData object with vote counts per tool, or null on error - */ -export async function fetchVotes(): Promise { - // Skip Firebase in environments without credentials - if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { - console.warn( - 'Firebase credentials not configured. Skipping votes fetch.', - ); - return null; - } - - try { - await initFirebase(); - - const { getFirestore } = await import('firebase-admin/firestore'); - const db = getFirestore(); - - const votesCol = db.collection('tags'); - const voteSnapshot = await votesCol.get(); - - const votes: VotesApiData = {}; - voteSnapshot.docs.forEach((doc) => { - const data = doc.data(); - votes[doc.id] = { - sum: data.sum || 0, - upVotes: data.upVotes || 0, - downVotes: data.downVotes || 0, - }; - }); - - return votes; - } catch (error) { - console.error('Error fetching votes from Firebase:', error); - return null; - } -} - -/** - * Fetch votes for a single tool - * - * @param toolId - The tool ID to fetch votes for - * @returns Vote data for the tool, or default values on error - */ -export async function fetchToolVotes(toolId: string): Promise<{ - votes: number; - upVotes: number; - downVotes: number; -}> { - const defaultVotes = { votes: 0, upVotes: 0, downVotes: 0 }; - - if (!process.env.GOOGLE_APPLICATION_CREDENTIALS) { - return defaultVotes; - } - - try { - await initFirebase(); - - const { getFirestore } = await import('firebase-admin/firestore'); - const db = getFirestore(); - - const key = `toolsyaml${toolId}`; - const doc = await db.collection('tags').doc(key).get(); - - if (!doc.exists) { - return defaultVotes; - } - - const data = doc.data(); - return { - votes: data?.sum || 0, - upVotes: data?.upVotes || 0, - downVotes: data?.downVotes || 0, - }; - } catch (error) { - console.error(`Error fetching votes for tool ${toolId}:`, error); - return defaultVotes; - } -} - -/** - * Check if Firebase is configured - */ -export function isFirebaseConfigured(): boolean { - return !!process.env.GOOGLE_APPLICATION_CREDENTIALS; -} diff --git a/utils/static-data.ts b/utils/static-data.ts deleted file mode 100644 index 68d9901..0000000 --- a/utils/static-data.ts +++ /dev/null @@ -1,187 +0,0 @@ -/** - * Static Data Reader - * - * This module provides utilities for reading the pre-built static tools data. - * Data is fetched at build time by scripts/build-data.ts and stored in data/tools.json. - */ - -import * as fs from 'fs'; -import * as path from 'path'; -import type { ToolsApiData, ApiTool, StatsApiData } from './types'; - -// Types for the build output format -export interface BuildMeta { - buildTime: string; - staticAnalysisCount: number; - dynamicAnalysisCount: number; - totalCount: number; -} - -export interface StaticToolsData { - tools: ToolsApiData; - meta: BuildMeta; -} - -export interface StaticTagsData { - languages: string[]; - others: string[]; -} - -// Cache for loaded data (within the same build/request) -let toolsCache: StaticToolsData | null = null; -let tagsCache: StaticTagsData | null = null; -let toolStatsCache: StatsApiData | null = null; -let tagStatsCache: StatsApiData | null = null; - -/** - * Get the path to a data file - */ -function getDataPath(filename: string): string { - return path.join(process.cwd(), 'data', filename); -} - -/** - * Load and parse a JSON file - */ -function loadJSON(filepath: string): T { - const content = fs.readFileSync(filepath, 'utf-8'); - return JSON.parse(content) as T; -} - -/** - * Get all tools from the static data file - * This is the primary method to use instead of the old API-based getTools() - */ -export function getStaticTools(): ToolsApiData { - if (!toolsCache) { - const dataPath = getDataPath('tools.json'); - - if (!fs.existsSync(dataPath)) { - console.warn( - 'Static tools data not found. Run `npm run build-data` first.', - ); - return {}; - } - - toolsCache = loadJSON(dataPath); - } - - return toolsCache.tools; -} - -/** - * Get build metadata - */ -export function getStaticToolsMeta(): BuildMeta | null { - if (!toolsCache) { - getStaticTools(); // This will populate the cache - } - return toolsCache?.meta || null; -} - -/** - * Get a single tool by ID - */ -export function getStaticTool(toolId: string): ApiTool | null { - const tools = getStaticTools(); - return tools[toolId] || null; -} - -/** - * Get all tool IDs - */ -export function getStaticToolIds(): string[] { - const tools = getStaticTools(); - return Object.keys(tools); -} - -/** - * Get static tags (languages and others) - */ -export function getStaticTags(): StaticTagsData { - if (!tagsCache) { - const dataPath = getDataPath('tags.json'); - - if (!fs.existsSync(dataPath)) { - console.warn( - 'Static tags data not found. Run `npm run build-data` first.', - ); - return { languages: [], others: [] }; - } - - tagsCache = loadJSON(dataPath); - } - - return tagsCache; -} - -/** - * Get all unique languages from tools - */ -export function getStaticLanguages(): string[] { - return getStaticTags().languages; -} - -/** - * Get all unique "other" tags from tools - */ -export function getStaticOthers(): string[] { - return getStaticTags().others; -} - -/** - * Check if static data exists - */ -export function hasStaticData(): boolean { - return fs.existsSync(getDataPath('tools.json')); -} - -/** - * Get tool stats (view counts) - */ -export function getStaticToolStats(): StatsApiData { - if (!toolStatsCache) { - const dataPath = getDataPath('tool-stats.json'); - - if (!fs.existsSync(dataPath)) { - console.warn( - 'Static tool stats not found. Run `npm run build-data` first.', - ); - return {}; - } - - toolStatsCache = loadJSON(dataPath); - } - - return toolStatsCache; -} - -/** - * Get tag stats (view counts) - */ -export function getStaticTagStats(): StatsApiData { - if (!tagStatsCache) { - const dataPath = getDataPath('tag-stats.json'); - - if (!fs.existsSync(dataPath)) { - console.warn( - 'Static tag stats not found. Run `npm run build-data` first.', - ); - return {}; - } - - tagStatsCache = loadJSON(dataPath); - } - - return tagStatsCache; -} - -/** - * Clear the cache (useful for testing or hot reloading) - */ -export function clearStaticDataCache(): void { - toolsCache = null; - tagsCache = null; - toolStatsCache = null; - tagStatsCache = null; -} diff --git a/utils/stats.ts b/utils/stats.ts deleted file mode 100644 index e66a87e..0000000 --- a/utils/stats.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Stats Utility Module - * - * This module provides utilities for working with tool and tag statistics. - * It uses the pre-built static data from data/tool-stats.json and data/tag-stats.json - * (generated by scripts/build-data.ts). - * - * This replaces the old utils-api/toolStats.ts which fetched from GitHub at runtime. - */ - -import { ToolsByLanguage, Tool } from '@components/tools/types'; -import { - getStaticToolStats, - getStaticTagStats, - getStaticTools, -} from './static-data'; -import { isSingleLanguageTool } from './filters'; -import { sortByVote } from './votes'; -import type { StatsApiData, VotesApiData } from './types'; -import { mergeToolsWithVotes } from './tools-with-votes'; - -/** - * Get tool stats (view counts) - * - * @returns Object mapping tool IDs to view counts - */ -export function getToolStats(): StatsApiData { - return getStaticToolStats(); -} - -/** - * Get tag/language stats (view counts) - * - * @returns Object mapping tag names to view counts - */ -export function getTagStats(): StatsApiData { - return getStaticTagStats(); -} - -/** - * Get language stats formatted for the homepage - * Returns languages sorted by views with empty formatter/linter arrays - * - * @returns ToolsByLanguage object - */ -export function getLanguageStats(): ToolsByLanguage { - const tagStats = getStaticTagStats(); - - const sortedLanguageStats: ToolsByLanguage = Object.entries(tagStats) - .sort(([, a], [, b]) => b - a) - .reduce( - (r, [key, value]) => ({ - ...r, - [key]: { - views: value, - formatters: [], - linters: [], - }, - }), - {}, - ); - - return sortedLanguageStats; -} - -/** - * Get popular languages with their top tools - * This is used on the homepage to show popular languages and their best tools - * - * @param votes - Optional votes data to merge with tools - * @returns ToolsByLanguage with formatters and linters populated - */ -export function getPopularLanguageStats( - votes?: VotesApiData | null, -): ToolsByLanguage { - const tools = getStaticTools(); - const languageStats = getLanguageStats(); - - // Merge votes if provided - const toolsWithVotes = votes ? mergeToolsWithVotes(tools, votes) : tools; - - // Populate formatters and linters for each language - Object.keys(toolsWithVotes).forEach((toolId) => { - const tool = toolsWithVotes[toolId]; - - if (isSingleLanguageTool(tool)) { - const language = tool.languages[0]; - - if (languageStats[language]) { - const toolObj: Tool = { - id: toolId, - ...tool, - votes: tool.votes || 0, - }; - - if (tool.categories.includes('formatter')) { - languageStats[language].formatters.push(toolObj); - } - if (tool.categories.includes('linter')) { - languageStats[language].linters.push(toolObj); - } - - // Sort by votes after pushing tools - languageStats[language].formatters.sort(sortByVote); - languageStats[language].linters.sort(sortByVote); - - // Keep top 3 - if (languageStats[language].formatters.length > 3) { - languageStats[language].formatters.pop(); - } - if (languageStats[language].linters.length > 3) { - languageStats[language].linters.pop(); - } - } - } - }); - - // Filter out languages with no tools - Object.keys(languageStats).forEach((language) => { - if ( - languageStats[language].formatters.length === 0 && - languageStats[language].linters.length === 0 - ) { - delete languageStats[language]; - } - }); - - return languageStats; -} - -/** - * Get most viewed tools - * - * @param votes - Optional votes data to merge with tools - * @returns Array of tools sorted by views - */ -export function getMostViewedTools(votes?: VotesApiData | null): Tool[] { - const tools = getStaticTools(); - const toolStats = getStaticToolStats(); - - // Merge votes if provided - const toolsWithVotes = votes ? mergeToolsWithVotes(tools, votes) : tools; - - const mostViewedToolIds = Object.keys(toolStats); - - const mostViewedTools = mostViewedToolIds - .map((id) => { - const tool = toolsWithVotes[id]; - if (!tool) { - return null; - } - - return { - id, - ...tool, - votes: tool.votes || 0, - views: toolStats[id], - } as Tool & { views: number }; - }) - .filter((tool): tool is Tool & { views: number } => tool !== null); - - return mostViewedTools; -} - -/** - * Get view count for a specific tool - * - * @param toolId - The tool ID - * @returns View count or 0 if not found - */ -export function getToolViewCount(toolId: string): number { - const stats = getStaticToolStats(); - return stats[toolId] || 0; -} - -/** - * Get view count for a specific tag - * - * @param tag - The tag name - * @returns View count or 0 if not found - */ -export function getTagViewCount(tag: string): number { - const stats = getStaticTagStats(); - return stats[tag] || 0; -} diff --git a/utils/tags.ts b/utils/tags.ts deleted file mode 100644 index b66a32e..0000000 --- a/utils/tags.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Tags Utility Module - * - * This module provides utilities for working with tags (languages and others). - * It uses the pre-built static data from data/tags.json (generated by scripts/build-data.ts) - * and local data files for descriptions. - * - * This replaces the old utils-api/tags.ts which fetched from GitHub at runtime. - */ - -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; -import { getStaticTags, getStaticTools } from './static-data'; -import { isLanguageData } from './type-guards'; -import type { TagsType, LanguageData, ApiTag } from './types'; - -/** - * Local file path for language/tag description data - */ -const DESCRIPTIONS_PATH = join(process.cwd(), 'data', 'descriptions.json'); -const RELATED_TAGS_PATH = join(process.cwd(), 'data', 'relatedTags.json'); - -/** - * Get tags from static data - * - * @param type - 'languages', 'other', or 'all' - * @returns Array of tag objects - */ -export function getTags(type: TagsType): ApiTag[] { - const staticTags = getStaticTags(); - - // Build tag objects from the static tags arrays - const languageTags: ApiTag[] = staticTags.languages.map((lang) => ({ - name: capitalizeFirstLetter(lang), - value: lang, - tag_type: 'languages', - })); - - const otherTags: ApiTag[] = staticTags.others.map((other) => ({ - name: capitalizeFirstLetter(other), - value: other, - tag_type: 'other', - })); - - switch (type) { - case 'languages': - return languageTags; - case 'other': - return otherTags; - case 'all': - return [...languageTags, ...otherTags]; - default: - console.error(`Unknown tag type: ${type}`); - return []; - } -} - -/** - * Get a single tag by type and ID - */ -export function getTag(type: TagsType, tagId: string): ApiTag | null { - const tags = getTags(type); - return ( - tags.find((t) => t.value.toLowerCase() === tagId.toLowerCase()) || null - ); -} - -/** - * Get all unique tags from tools data - * This is useful when you need fresh tag data directly from tools - */ -export function getTagsFromTools(): { languages: string[]; others: string[] } { - const tools = getStaticTools(); - const languages = new Set(); - const others = new Set(); - - for (const tool of Object.values(tools)) { - tool.languages?.forEach((lang) => languages.add(lang)); - tool.other?.forEach((other) => others.add(other)); - } - - return { - languages: Array.from(languages).sort(), - others: Array.from(others).sort(), - }; -} - -/** - * Get language/tag data (description, website, etc.) - * - * @param tagId - The tag identifier (e.g., 'javascript', 'python') - * @returns Language data object with name, website, and description - */ -export function getLanguageData(tagId: string): LanguageData { - const defaultTagData: LanguageData = { - name: capitalizeFirstLetter(tagId), - website: '', - description: '', - }; - - try { - if (!existsSync(DESCRIPTIONS_PATH)) { - return defaultTagData; - } - - const fileContents = readFileSync(DESCRIPTIONS_PATH, 'utf-8'); - const data = JSON.parse(fileContents); - - if (!data || !data[tagId] || !isLanguageData(data[tagId])) { - return defaultTagData; - } - - return data[tagId]; - } catch (error) { - console.error('Error loading language data:', error); - return defaultTagData; - } -} - -/** - * Get similar/related tags for a given tag - * - * @param tag - The tag to find related tags for - * @returns Array of related tag strings - */ -export function getSimilarTags(tag: string): string[] { - try { - if (!existsSync(RELATED_TAGS_PATH)) { - return []; - } - - const data = readFileSync(RELATED_TAGS_PATH, 'utf-8'); - const relatedTags: string[][] = JSON.parse(data) || []; - - // Find the array that contains the tag - const relatedTagsArray = relatedTags.find((tags) => - tags.includes(tag.toLowerCase()), - ); - - if (!relatedTagsArray) { - return []; - } - - // Remove the current tag from the array - return relatedTagsArray.filter((t) => t !== tag.toLowerCase()); - } catch (error) { - console.error('Error loading related tags:', error); - return []; - } -} - -/** - * Get count of tools for each tag - * - * @param type - 'languages' or 'other' - * @returns Map of tag to tool count - */ -export function getTagCounts(type: 'languages' | 'other'): Map { - const tools = getStaticTools(); - const counts = new Map(); - - for (const tool of Object.values(tools)) { - const tags = type === 'languages' ? tool.languages : tool.other; - tags?.forEach((tag) => { - counts.set(tag, (counts.get(tag) || 0) + 1); - }); - } - - return counts; -} - -/** - * Get tags sorted by tool count (most popular first) - */ -export function getPopularTags( - type: 'languages' | 'other', - limit?: number, -): Array<{ tag: string; count: number }> { - const counts = getTagCounts(type); - const sorted = Array.from(counts.entries()) - .map(([tag, count]) => ({ tag, count })) - .sort((a, b) => b.count - a.count); - - return limit ? sorted.slice(0, limit) : sorted; -} - -/** - * Helper to capitalize first letter of a string - */ -function capitalizeFirstLetter(str: string): string { - return str.charAt(0).toUpperCase() + str.slice(1); -} diff --git a/utils/tools-with-votes.ts b/utils/tools-with-votes.ts deleted file mode 100644 index 12ddde2..0000000 --- a/utils/tools-with-votes.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * Tools With Votes Utility - * - * This module combines static tools data with votes from Firebase. - * It provides a simplified interface for getting tools with their vote counts. - * - * This replaces the old utils-api/toolsWithVotes.ts - */ - -import { getStaticTools } from './static-data'; -import { calculateUpvotePercentage } from './votes'; -import { isToolsApiData, isVotesApiData } from './type-guards'; -import type { ToolsApiData, VotesApiData } from './types'; -import { Tool } from '@components/tools/types'; - -/** - * Merge tools data with votes data - * This is a pure function that takes tools and votes and returns enriched tools - */ -export function mergeToolsWithVotes( - tools: ToolsApiData, - votes: VotesApiData | null, -): ToolsApiData { - if (!votes) { - return tools; - } - - const result = { ...tools }; - - Object.keys(result).forEach((toolId) => { - const key = `toolsyaml${toolId}`; - const v = votes[key]; - - const sum = v?.sum || 0; - const upVotes = v?.upVotes || 0; - const downVotes = v?.downVotes || 0; - - result[toolId] = { - ...result[toolId], - votes: sum, - upVotes, - downVotes, - upvotePercentage: calculateUpvotePercentage(upVotes, downVotes), - }; - }); - - return result; -} - -/** - * Get tools with votes as an array - */ -export function toolsToArray(tools: ToolsApiData): Tool[] { - return Object.entries(tools).map(([id, tool]) => ({ - ...tool, - id, - votes: tool.votes || 0, - })) as Tool[]; -} - -/** - * Get all tools with votes from static data - * - * This is the main function to use. It: - * 1. Gets tools from static JSON (no network call) - * 2. Optionally merges with votes if provided - * - * For server-side use, pass votes fetched from Firebase. - * For client-side use without votes, just call with no arguments. - */ -export function getToolsWithVotes(votes?: VotesApiData | null): ToolsApiData { - const tools = getStaticTools(); - - if (!isToolsApiData(tools)) { - console.error('Error loading tools data'); - return {}; - } - - if (votes && isVotesApiData(votes)) { - return mergeToolsWithVotes(tools, votes); - } - - return tools; -} - -/** - * Get tools with votes as an array - */ -export function getToolsWithVotesArray(votes?: VotesApiData | null): Tool[] { - const tools = getToolsWithVotes(votes); - return toolsToArray(tools); -} - -/** - * Get a single tool with votes - */ -export function getToolWithVotes( - toolId: string, - votes?: VotesApiData | null, -): Tool | null { - const tools = getToolsWithVotes(votes); - const tool = tools[toolId]; - - if (!tool) { - return null; - } - - return { - ...tool, - id: toolId, - votes: tool.votes || 0, - } as Tool; -} diff --git a/utils/tools.ts b/utils/tools.ts deleted file mode 100644 index fadd664..0000000 --- a/utils/tools.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * Tools Utility Module - * - * This module provides utilities for working with tools data. - * It uses the pre-built static data from data/tools.json (generated by scripts/build-data.ts) - * and optionally enriches it with votes from Firebase. - * - * This replaces the old utils-api/tools.ts which fetched from GitHub at runtime. - */ - -import { getStaticTools, getStaticTool, getStaticToolIds } from './static-data'; -import type { ToolsApiData, ApiTool } from './types'; -import { Tool } from '@components/tools/types'; -import * as fs from 'fs'; - -/** - * Get all tools from static data - * This is the replacement for the old getAllTools() from utils-api/tools.ts - */ -export function getAllTools(): ToolsApiData { - return getStaticTools(); -} - -/** - * Get a single tool by ID - * This is the replacement for the old getTool() from utils-api/tools.ts - * - * Note: This returns the basic tool data without GitHub stats or star history. - * Those enrichments can be added separately if needed. - */ -export function getTool(toolId: string): Tool | null { - const tool = getStaticTool(toolId); - if (!tool) { - return null; - } - - // Return tool with id added (matching the expected Tool interface) - return { - ...tool, - id: toolId, - votes: tool.votes || 0, - } as Tool; -} - -/** - * Get all tool IDs - * Useful for generating static paths - */ -export function getAllToolIds(): string[] { - return getStaticToolIds(); -} - -/** - * Check if there is an icon for the tool - */ -export function getToolIcon(toolId: string): string | null { - // Get the absolute path to the icon from project root - const iconPath = `${process.cwd()}/public/assets/images/tools/${toolId}.png`; - if (fs.existsSync(iconPath)) { - // Return web-accessible path - return `/assets/images/tools/${toolId}.png`; - } - return null; -} - -/** - * Get tools as an array (with IDs included in each tool object) - */ -export function getToolsArray(): Tool[] { - const tools = getStaticTools(); - return Object.entries(tools).map(([id, tool]) => ({ - ...tool, - id, - votes: tool.votes || 0, - })) as Tool[]; -} - -/** - * Get tools filtered by a predicate function - */ -export function getToolsWhere( - predicate: (tool: ApiTool, id: string) => boolean, -): Tool[] { - const tools = getStaticTools(); - return Object.entries(tools) - .filter(([id, tool]) => predicate(tool, id)) - .map(([id, tool]) => ({ - ...tool, - id, - votes: tool.votes || 0, - })) as Tool[]; -} - -/** - * Get tools for a specific language - */ -export function getToolsByLanguage(language: string): Tool[] { - return getToolsWhere((tool) => - tool.languages.includes(language.toLowerCase()), - ); -} - -/** - * Get tools for a specific category - */ -export function getToolsByCategory(category: string): Tool[] { - return getToolsWhere((tool) => - tool.categories.includes(category.toLowerCase()), - ); -} - -/** - * Get tools for a specific type - */ -export function getToolsByType(type: string): Tool[] { - return getToolsWhere((tool) => tool.types.includes(type.toLowerCase())); -} - -/** - * Get total count of tools - */ -export function getToolsCount(): number { - return Object.keys(getStaticTools()).length; -}