Skip to content

Commit 1bb497e

Browse files
committed
feat: implement icons collection controller and service for dynamic SVG rendering and caching
1 parent 3a6303b commit 1bb497e

2 files changed

Lines changed: 84 additions & 14 deletions

File tree

src/modules/icons/icons-collection.controller.ts

Lines changed: 50 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -81,13 +81,44 @@ export class IconsCollectionController {
8181
.filter(Boolean);
8282
}
8383

84+
private static readonly ICON_ALIASES: Record<string, string> = {
85+
nodedotjs: 'nodejs',
86+
node: 'nodejs',
87+
vue: 'vuedotjs',
88+
ember: 'emberdotjs',
89+
emberjs: 'emberdotjs',
90+
three: 'threedotjs',
91+
threejs: 'threedotjs',
92+
chart: 'chartdotjs',
93+
chartjs: 'chartdotjs',
94+
fly: 'flydotio',
95+
flyio: 'flydotio',
96+
devto: 'devdotto',
97+
gitignoredotio: 'gitignoredotio',
98+
gitignore: 'gitignoredotio',
99+
js: 'javascript',
100+
ts: 'typescript',
101+
py: 'python',
102+
cpp: 'cplusplus',
103+
'c++': 'cplusplus',
104+
cs: 'csharp',
105+
postgres: 'postgresql',
106+
golang: 'go',
107+
};
108+
109+
public static resolveIconName(iconName: string): string {
110+
const normalized = iconName.toLowerCase();
111+
return IconsCollectionController.ICON_ALIASES[normalized] || normalized;
112+
}
113+
84114
private static resolveIconPath(iconName: string): string | null {
85-
if (!IconsCollectionController.ICON_NAME_REGEX.test(iconName)) {
115+
const targetName = IconsCollectionController.resolveIconName(iconName);
116+
if (!IconsCollectionController.ICON_NAME_REGEX.test(targetName)) {
86117
return null;
87118
}
88119

89120
const resolvedIconsDir = path.resolve(IconsCollectionController.iconsDir);
90-
const iconPath = path.resolve(IconsCollectionController.iconsDir, `${iconName}.svg`);
121+
const iconPath = path.resolve(IconsCollectionController.iconsDir, `${targetName}.svg`);
91122

92123
if (!iconPath.startsWith(resolvedIconsDir + path.sep) && iconPath !== resolvedIconsDir) {
93124
return null;
@@ -97,16 +128,20 @@ export class IconsCollectionController {
97128
}
98129

99130
private static async readBaseIconContent(iconName: string): Promise<string> {
100-
const iconPath = IconsCollectionController.resolveIconPath(iconName);
131+
const targetName = IconsCollectionController.resolveIconName(iconName);
132+
const iconPath = IconsCollectionController.resolveIconPath(targetName);
101133
if (!iconPath) {
102134
throw new Error('INVALID_ICON_NAME');
103135
}
104136

105-
let pending = IconsCollectionController.pendingLoads.get(iconName);
137+
let pending = IconsCollectionController.pendingLoads.get(targetName);
106138
if (!pending) {
107-
pending = fs.readFile(iconPath, 'utf-8');
108-
IconsCollectionController.pendingLoads.set(iconName, pending);
109-
pending.finally(() => IconsCollectionController.pendingLoads.delete(iconName));
139+
pending = fs.readFile(iconPath, 'utf-8').then((content) => {
140+
// Strip embedded individual icon popup styles so collection rendering is clean and instant
141+
return content.replace(/<style[\s\S]*?<\/style>/gi, '');
142+
});
143+
IconsCollectionController.pendingLoads.set(targetName, pending);
144+
pending.finally(() => IconsCollectionController.pendingLoads.delete(targetName));
110145
}
111146

112147
return pending;
@@ -264,12 +299,14 @@ export class IconsCollectionController {
264299
const preset = IconsCollectionController.ICON_SIZE_PRESETS[size];
265300
const totalColumns = Math.min(columns, iconNames.length);
266301
const totalRows = Math.ceil(iconNames.length / totalColumns);
302+
const waveYOffset = effect === 'wave' ? 10 : 0;
267303
const width =
268304
preset.padding * 2 +
269305
totalColumns * preset.cell +
270306
(totalColumns - 1) * preset.gap;
271307
const height =
272308
preset.padding * 2 +
309+
waveYOffset +
273310
totalRows * preset.cell +
274311
(totalRows - 1) * preset.gap;
275312

@@ -300,6 +337,7 @@ export class IconsCollectionController {
300337
(preset.cell - preset.icon) / 2;
301338
const y =
302339
preset.padding +
340+
waveYOffset +
303341
row * (preset.cell + preset.gap) +
304342
(preset.cell - preset.icon) / 2;
305343
const delay = column * 0.12 + row * 0.08;
@@ -330,7 +368,7 @@ export class IconsCollectionController {
330368
}),
331369
);
332370

333-
return `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="Icon collection">
371+
return `<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" overflow="visible" role="img" aria-label="Icon collection">
334372
<title>Icon collection</title>
335373
${defs.length > 0 ? `<defs>
336374
${defs.join('\n ')}
@@ -408,12 +446,13 @@ export class IconsCollectionController {
408446

409447
const resolvedIconNames = (
410448
await Promise.all(
411-
normalizedIconNames.map(async (iconName) => {
449+
normalizedIconNames.map(async (rawName) => {
450+
const targetName = IconsCollectionController.resolveIconName(rawName);
412451
try {
413452
await fs.access(
414-
path.resolve(IconsCollectionController.iconsDir, `${iconName}.svg`),
453+
path.resolve(IconsCollectionController.iconsDir, `${targetName}.svg`),
415454
);
416-
return iconName;
455+
return targetName;
417456
} catch {
418457
return null;
419458
}

src/modules/icons/icons.service.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,36 @@ export class IconsService {
3636
'#06B6D4',
3737
] as const;
3838

39+
private readonly ICON_ALIASES: Record<string, string> = {
40+
nodedotjs: 'nodejs',
41+
node: 'nodejs',
42+
vue: 'vuedotjs',
43+
ember: 'emberdotjs',
44+
emberjs: 'emberdotjs',
45+
three: 'threedotjs',
46+
threejs: 'threedotjs',
47+
chart: 'chartdotjs',
48+
chartjs: 'chartdotjs',
49+
fly: 'flydotio',
50+
flyio: 'flydotio',
51+
devto: 'devdotto',
52+
gitignoredotio: 'gitignoredotio',
53+
gitignore: 'gitignoredotio',
54+
js: 'javascript',
55+
ts: 'typescript',
56+
py: 'python',
57+
cpp: 'cplusplus',
58+
'c++': 'cplusplus',
59+
cs: 'csharp',
60+
postgres: 'postgresql',
61+
golang: 'go',
62+
};
63+
64+
private resolveIconName(name: string): string {
65+
const normalized = name.toLowerCase();
66+
return this.ICON_ALIASES[normalized] || normalized;
67+
}
68+
3969
constructor() {
4070
this.iconsDir = path.join(__dirname, '..', '..', '..', 'public', 'assets', 'icons');
4171
this.svgCache = new Map();
@@ -121,8 +151,8 @@ export class IconsService {
121151
throw new Error('Invalid effect. Supported: glow, wave');
122152
}
123153

124-
if (!Number.isInteger(options.columns) || options.columns < 1 || options.columns > 20) {
125-
throw new Error('Invalid columns. Supported range: 1-20');
154+
if (!Number.isInteger(options.columns) || options.columns < 1 || options.columns > 40) {
155+
throw new Error('Invalid columns. Supported range: 1-40');
126156
}
127157

128158
const cacheKey = `collection:${options.iconNames.join(',')}:${options.size}:${options.effect || 'none'}:${options.columns}:${(options.colors || []).join(',')}`;
@@ -305,7 +335,8 @@ export class IconsService {
305335
* Load icon from file system
306336
*/
307337
private async loadIcon(iconName: string, color?: string): Promise<string> {
308-
const iconPath = path.join(this.iconsDir, `${iconName}.svg`);
338+
const targetName = this.resolveIconName(iconName);
339+
const iconPath = path.join(this.iconsDir, `${targetName}.svg`);
309340

310341
// Verify path doesn't escape icons directory
311342
const resolvedPath = path.resolve(iconPath);

0 commit comments

Comments
 (0)