Skip to content

Commit 145991d

Browse files
committed
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.
1 parent 90d4335 commit 145991d

17 files changed

Lines changed: 974 additions & 1300 deletions
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
/**
2+
* StatsRepository
3+
*
4+
* Repository class for accessing tool and tag statistics from static JSON files.
5+
* Data is pre-built at build time by scripts/build-data.ts.
6+
*/
7+
8+
import * as fs from 'fs';
9+
import * as path from 'path';
10+
import type { StatsApiData, VotesApiData } from 'utils/types';
11+
import type { Tool, ToolsByLanguage } from '@components/tools/types';
12+
import { ToolsRepository } from './ToolsRepository';
13+
import { sortByVote } from 'utils/votes';
14+
15+
export class StatsRepository {
16+
private static instance: StatsRepository | null = null;
17+
private toolStatsData: StatsApiData | null = null;
18+
private tagStatsData: StatsApiData | null = null;
19+
20+
private readonly toolStatsPath: string;
21+
private readonly tagStatsPath: string;
22+
23+
private constructor() {
24+
const dataDir = path.join(process.cwd(), 'data');
25+
this.toolStatsPath = path.join(dataDir, 'tool-stats.json');
26+
this.tagStatsPath = path.join(dataDir, 'tag-stats.json');
27+
}
28+
29+
static getInstance(): StatsRepository {
30+
if (!StatsRepository.instance) {
31+
StatsRepository.instance = new StatsRepository();
32+
}
33+
return StatsRepository.instance;
34+
}
35+
36+
private loadToolStats(): StatsApiData {
37+
if (this.toolStatsData) {
38+
return this.toolStatsData;
39+
}
40+
41+
if (!fs.existsSync(this.toolStatsPath)) {
42+
console.warn(
43+
'Static tool stats not found. Run `npm run build-data` first.',
44+
);
45+
return {};
46+
}
47+
48+
const content = fs.readFileSync(this.toolStatsPath, 'utf-8');
49+
this.toolStatsData = JSON.parse(content) as StatsApiData;
50+
return this.toolStatsData;
51+
}
52+
53+
private loadTagStats(): StatsApiData {
54+
if (this.tagStatsData) {
55+
return this.tagStatsData;
56+
}
57+
58+
if (!fs.existsSync(this.tagStatsPath)) {
59+
console.warn(
60+
'Static tag stats not found. Run `npm run build-data` first.',
61+
);
62+
return {};
63+
}
64+
65+
const content = fs.readFileSync(this.tagStatsPath, 'utf-8');
66+
this.tagStatsData = JSON.parse(content) as StatsApiData;
67+
return this.tagStatsData;
68+
}
69+
70+
getToolStats(): StatsApiData {
71+
return this.loadToolStats();
72+
}
73+
74+
getTagStats(): StatsApiData {
75+
return this.loadTagStats();
76+
}
77+
78+
getToolViewCount(toolId: string): number {
79+
const stats = this.loadToolStats();
80+
return stats[toolId] || 0;
81+
}
82+
83+
getTagViewCount(tag: string): number {
84+
const stats = this.loadTagStats();
85+
return stats[tag] || 0;
86+
}
87+
88+
getLanguageStats(): ToolsByLanguage {
89+
const tagStats = this.loadTagStats();
90+
91+
return Object.entries(tagStats)
92+
.sort(([, a], [, b]) => b - a)
93+
.reduce(
94+
(result, [key, value]) => ({
95+
...result,
96+
[key]: {
97+
views: value,
98+
formatters: [],
99+
linters: [],
100+
},
101+
}),
102+
{} as ToolsByLanguage,
103+
);
104+
}
105+
106+
getPopularLanguageStats(votes?: VotesApiData | null): ToolsByLanguage {
107+
const toolsRepo = ToolsRepository.getInstance();
108+
const tools = toolsRepo.withVotes(votes || null);
109+
const languageStats = this.getLanguageStats();
110+
111+
for (const [toolId, tool] of Object.entries(tools)) {
112+
const isSingleLanguage = tool.languages.length <= 2;
113+
114+
if (isSingleLanguage && tool.languages.length > 0) {
115+
const language = tool.languages[0];
116+
117+
if (languageStats[language]) {
118+
const toolObj: Tool = {
119+
id: toolId,
120+
...tool,
121+
votes: tool.votes || 0,
122+
} as Tool;
123+
124+
if (tool.categories.includes('formatter')) {
125+
languageStats[language].formatters.push(toolObj);
126+
}
127+
if (tool.categories.includes('linter')) {
128+
languageStats[language].linters.push(toolObj);
129+
}
130+
131+
languageStats[language].formatters.sort(sortByVote);
132+
languageStats[language].linters.sort(sortByVote);
133+
134+
if (languageStats[language].formatters.length > 3) {
135+
languageStats[language].formatters.pop();
136+
}
137+
if (languageStats[language].linters.length > 3) {
138+
languageStats[language].linters.pop();
139+
}
140+
}
141+
}
142+
}
143+
144+
// Filter out languages with no tools
145+
for (const language of Object.keys(languageStats)) {
146+
if (
147+
languageStats[language].formatters.length === 0 &&
148+
languageStats[language].linters.length === 0
149+
) {
150+
delete languageStats[language];
151+
}
152+
}
153+
154+
return languageStats;
155+
}
156+
157+
getMostViewedTools(votes?: VotesApiData | null): Tool[] {
158+
const toolsRepo = ToolsRepository.getInstance();
159+
const tools = toolsRepo.withVotes(votes || null);
160+
const toolStats = this.loadToolStats();
161+
162+
const mostViewedToolIds = Object.keys(toolStats);
163+
164+
return mostViewedToolIds
165+
.map((id) => {
166+
const tool = tools[id];
167+
if (!tool) {
168+
return null;
169+
}
170+
171+
return {
172+
id,
173+
...tool,
174+
votes: tool.votes || 0,
175+
views: toolStats[id],
176+
} as Tool & { views: number };
177+
})
178+
.filter((tool): tool is Tool & { views: number } => tool !== null);
179+
}
180+
181+
clearCache(): void {
182+
this.toolStatsData = null;
183+
this.tagStatsData = null;
184+
}
185+
}

lib/repositories/TagsRepository.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* TagsRepository
3+
*
4+
* Repository class for accessing tags (languages and others) from static JSON files.
5+
* Data is pre-built at build time by scripts/build-data.ts.
6+
*/
7+
8+
import * as fs from 'fs';
9+
import * as path from 'path';
10+
import type { ApiTag, LanguageData, TagsType } from 'utils/types';
11+
import { isLanguageData } from 'utils/type-guards';
12+
13+
interface StaticTagsData {
14+
languages: string[];
15+
others: string[];
16+
}
17+
18+
export class TagsRepository {
19+
private static instance: TagsRepository | null = null;
20+
private tagsData: StaticTagsData | null = null;
21+
private descriptionsData: Record<string, LanguageData> | null = null;
22+
private relatedTagsData: string[][] | null = null;
23+
24+
private readonly tagsPath: string;
25+
private readonly descriptionsPath: string;
26+
private readonly relatedTagsPath: string;
27+
28+
private constructor() {
29+
const dataDir = path.join(process.cwd(), 'data');
30+
this.tagsPath = path.join(dataDir, 'tags.json');
31+
this.descriptionsPath = path.join(dataDir, 'descriptions.json');
32+
this.relatedTagsPath = path.join(dataDir, 'relatedTags.json');
33+
}
34+
35+
static getInstance(): TagsRepository {
36+
if (!TagsRepository.instance) {
37+
TagsRepository.instance = new TagsRepository();
38+
}
39+
return TagsRepository.instance;
40+
}
41+
42+
private loadTags(): StaticTagsData {
43+
if (this.tagsData) {
44+
return this.tagsData;
45+
}
46+
47+
if (!fs.existsSync(this.tagsPath)) {
48+
console.warn(
49+
'Static tags data not found. Run `npm run build-data` first.',
50+
);
51+
return { languages: [], others: [] };
52+
}
53+
54+
const content = fs.readFileSync(this.tagsPath, 'utf-8');
55+
this.tagsData = JSON.parse(content) as StaticTagsData;
56+
return this.tagsData;
57+
}
58+
59+
private loadDescriptions(): Record<string, LanguageData> {
60+
if (this.descriptionsData) {
61+
return this.descriptionsData;
62+
}
63+
64+
if (!fs.existsSync(this.descriptionsPath)) {
65+
return {};
66+
}
67+
68+
const content = fs.readFileSync(this.descriptionsPath, 'utf-8');
69+
this.descriptionsData = JSON.parse(content);
70+
return this.descriptionsData || {};
71+
}
72+
73+
private loadRelatedTags(): string[][] {
74+
if (this.relatedTagsData) {
75+
return this.relatedTagsData;
76+
}
77+
78+
if (!fs.existsSync(this.relatedTagsPath)) {
79+
return [];
80+
}
81+
82+
const content = fs.readFileSync(this.relatedTagsPath, 'utf-8');
83+
this.relatedTagsData = JSON.parse(content) || [];
84+
return this.relatedTagsData || [];
85+
}
86+
87+
private capitalizeFirstLetter(str: string): string {
88+
return str.charAt(0).toUpperCase() + str.slice(1);
89+
}
90+
91+
getAll(type: TagsType): ApiTag[] {
92+
const tags = this.loadTags();
93+
94+
const languageTags: ApiTag[] = tags.languages.map((lang) => ({
95+
name: this.capitalizeFirstLetter(lang),
96+
value: lang,
97+
tag_type: 'languages',
98+
}));
99+
100+
const otherTags: ApiTag[] = tags.others.map((other) => ({
101+
name: this.capitalizeFirstLetter(other),
102+
value: other,
103+
tag_type: 'other',
104+
}));
105+
106+
switch (type) {
107+
case 'languages':
108+
return languageTags;
109+
case 'other':
110+
return otherTags;
111+
case 'all':
112+
return [...languageTags, ...otherTags];
113+
default:
114+
console.error(`Unknown tag type: ${type}`);
115+
return [];
116+
}
117+
}
118+
119+
getLanguages(): string[] {
120+
return this.loadTags().languages;
121+
}
122+
123+
getOthers(): string[] {
124+
return this.loadTags().others;
125+
}
126+
127+
getById(type: TagsType, tagId: string): ApiTag | null {
128+
const tags = this.getAll(type);
129+
return (
130+
tags.find((t) => t.value.toLowerCase() === tagId.toLowerCase()) ||
131+
null
132+
);
133+
}
134+
135+
getDescription(tagId: string): LanguageData {
136+
const defaultData: LanguageData = {
137+
name: this.capitalizeFirstLetter(tagId),
138+
website: '',
139+
description: '',
140+
};
141+
142+
const descriptions = this.loadDescriptions();
143+
const data = descriptions[tagId];
144+
145+
if (!data || !isLanguageData(data)) {
146+
return defaultData;
147+
}
148+
149+
return data;
150+
}
151+
152+
getRelated(tag: string): string[] {
153+
const relatedTags = this.loadRelatedTags();
154+
155+
const relatedGroup = relatedTags.find((tags) =>
156+
tags.includes(tag.toLowerCase()),
157+
);
158+
159+
if (!relatedGroup) {
160+
return [];
161+
}
162+
163+
return relatedGroup.filter((t) => t !== tag.toLowerCase());
164+
}
165+
166+
clearCache(): void {
167+
this.tagsData = null;
168+
this.descriptionsData = null;
169+
this.relatedTagsData = null;
170+
}
171+
}

0 commit comments

Comments
 (0)