@truenas/ui-components 0.1.46 → 0.1.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -1
- package/assets/tn-icons/forwarding-mappings.json +33 -0
- package/fesm2022/truenas-ui-components.mjs +39 -3
- package/fesm2022/truenas-ui-components.mjs.map +1 -1
- package/package.json +1 -1
- package/scripts/icon-sprite/__fixtures__/consumer-templates/basic.component.html +30 -0
- package/scripts/icon-sprite/__fixtures__/custom-icons/brand.svg +1 -0
- package/scripts/icon-sprite/__fixtures__/custom-icons/logo.svg +1 -0
- package/scripts/icon-sprite/__fixtures__/marker-sources/icons.ts +24 -0
- package/scripts/icon-sprite/cli-main.ts +27 -8
- package/scripts/icon-sprite/generate-sprite.ts +15 -4
- package/scripts/icon-sprite/jest.config.ts +14 -0
- package/scripts/icon-sprite/lib/find-icons-in-forwarding-components.spec.ts +182 -0
- package/scripts/icon-sprite/lib/find-icons-in-forwarding-components.ts +68 -0
- package/scripts/icon-sprite/lib/find-icons-in-templates.spec.ts +119 -0
- package/scripts/icon-sprite/lib/find-icons-in-templates.ts +66 -17
- package/scripts/icon-sprite/lib/find-icons-with-marker.spec.ts +58 -0
- package/scripts/icon-sprite/lib/find-icons-with-marker.ts +37 -22
- package/scripts/icon-sprite/lib/validate-icons.spec.ts +170 -0
- package/scripts/icon-sprite/lib/validate-icons.ts +185 -0
- package/scripts/icon-sprite/tsconfig.json +14 -0
- package/types/truenas-ui-components.d.ts +7 -0
|
@@ -1,31 +1,69 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import fg from 'fast-glob';
|
|
3
3
|
import * as cheerio from 'cheerio';
|
|
4
|
+
import type { ForwardingComponentMapping } from './find-icons-in-forwarding-components';
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
+
export interface ScanResult {
|
|
7
|
+
icons: Set<string>;
|
|
8
|
+
sources: Map<string, string[]>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function findIconsInTemplates(
|
|
12
|
+
path: string,
|
|
13
|
+
skipIcons?: Set<string>,
|
|
14
|
+
forwardingMappings?: ForwardingComponentMapping[],
|
|
15
|
+
): ScanResult {
|
|
6
16
|
const iconNames = new Set<string>();
|
|
17
|
+
const sources = new Map<string, string[]>();
|
|
7
18
|
|
|
8
19
|
const templates = fg.sync(`${path}/**/*.html`);
|
|
9
20
|
|
|
21
|
+
const addIcon = (iconName: string, templateFile: string) => {
|
|
22
|
+
iconNames.add(iconName);
|
|
23
|
+
const existing = sources.get(iconName) || [];
|
|
24
|
+
existing.push(templateFile);
|
|
25
|
+
sources.set(iconName, existing);
|
|
26
|
+
};
|
|
27
|
+
|
|
10
28
|
templates.forEach((template) => {
|
|
11
29
|
const content = fs.readFileSync(template, 'utf-8');
|
|
12
30
|
const parsedTemplate = cheerio.load(content);
|
|
13
31
|
|
|
14
|
-
//
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
// Generic function to extract icon names from an element's attributes
|
|
33
|
+
const processElement = (
|
|
34
|
+
el: cheerio.Element,
|
|
35
|
+
iconAttr: string,
|
|
36
|
+
libraryAttr: string | undefined,
|
|
37
|
+
defaultLibrary: string | undefined,
|
|
38
|
+
) => {
|
|
39
|
+
// Check both static and [bound] attributes (Angular binding syntax)
|
|
40
|
+
// Cheerio lowercases HTML attribute names, so look up the lowercase form
|
|
41
|
+
const iconAttrLower = iconAttr.toLowerCase();
|
|
42
|
+
const staticName = parsedTemplate(el).attr(iconAttrLower);
|
|
43
|
+
const boundName = parsedTemplate(el).attr(`[${iconAttrLower}]`);
|
|
44
|
+
const libraryAttrLower = libraryAttr?.toLowerCase();
|
|
45
|
+
const library = libraryAttrLower
|
|
46
|
+
? parsedTemplate(el).attr(libraryAttrLower) || parsedTemplate(el).attr(`[${libraryAttrLower}]`)
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
// Resolve library: use explicit value, fall back to default
|
|
50
|
+
let resolvedLibrary = defaultLibrary;
|
|
51
|
+
if (library) {
|
|
52
|
+
// Handle both static library="mdi" and bound [library]="'mdi'"
|
|
53
|
+
const staticLib = library.replace(/^['"]|['"]$/g, '');
|
|
54
|
+
if (/^[a-z]+$/.test(staticLib)) {
|
|
55
|
+
resolvedLibrary = staticLib;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
20
58
|
|
|
21
59
|
const extractedNames: string[] = [];
|
|
22
60
|
|
|
23
|
-
// Handle static name attribute:
|
|
61
|
+
// Handle static name attribute: icon="folder"
|
|
24
62
|
if (staticName) {
|
|
25
63
|
extractedNames.push(staticName);
|
|
26
64
|
}
|
|
27
65
|
|
|
28
|
-
// Handle bound name attribute: [
|
|
66
|
+
// Handle bound name attribute: [icon]="expression"
|
|
29
67
|
// Extract string literals from the expression
|
|
30
68
|
if (boundName) {
|
|
31
69
|
// Match string literals that are values, not comparison operands
|
|
@@ -46,7 +84,7 @@ export function findIconsInTemplates(path: string, skipIcons?: Set<string>): Set
|
|
|
46
84
|
}
|
|
47
85
|
|
|
48
86
|
// Also handle simple string literals (no ternary, no comparison)
|
|
49
|
-
// e.g., [
|
|
87
|
+
// e.g., [icon]="'folder'" (though this is unusual)
|
|
50
88
|
if (!boundName.includes('?') && !boundName.includes('=')) {
|
|
51
89
|
const simpleStringRegex = /^['"]([^'"]+)['"]$/;
|
|
52
90
|
const simpleMatch = boundName.match(simpleStringRegex);
|
|
@@ -72,13 +110,13 @@ export function findIconsInTemplates(path: string, skipIcons?: Set<string>): Set
|
|
|
72
110
|
let finalIconName: string;
|
|
73
111
|
|
|
74
112
|
// Handle library attribute - prefix the icon name with library prefix
|
|
75
|
-
if (
|
|
113
|
+
if (resolvedLibrary === 'mdi' && !iconName.startsWith('mdi-')) {
|
|
76
114
|
finalIconName = `mdi-${iconName}`;
|
|
77
|
-
} else if (
|
|
115
|
+
} else if (resolvedLibrary === 'custom' && !iconName.startsWith('app-') && !iconName.startsWith('tn-')) {
|
|
78
116
|
// Consumer custom icons get app- prefix
|
|
79
117
|
// (Library templates should never use library="custom", they use libIconMarker() instead)
|
|
80
118
|
finalIconName = `app-${iconName}`;
|
|
81
|
-
} else if (
|
|
119
|
+
} else if (resolvedLibrary === 'material' && !iconName.startsWith('mat-')) {
|
|
82
120
|
finalIconName = `mat-${iconName}`; // Material icons get mat- prefix
|
|
83
121
|
} else {
|
|
84
122
|
finalIconName = iconName;
|
|
@@ -89,20 +127,31 @@ export function findIconsInTemplates(path: string, skipIcons?: Set<string>): Set
|
|
|
89
127
|
return;
|
|
90
128
|
}
|
|
91
129
|
|
|
92
|
-
|
|
130
|
+
addIcon(finalIconName, template);
|
|
93
131
|
});
|
|
94
132
|
};
|
|
95
133
|
|
|
96
134
|
// Scan tn-icon elements
|
|
97
135
|
parsedTemplate('tn-icon').each((_, iconTag) => {
|
|
98
|
-
|
|
136
|
+
processElement(iconTag, 'name', 'library', undefined);
|
|
99
137
|
});
|
|
100
138
|
|
|
101
139
|
// Scan tn-icon-button elements (they also have name and library attributes)
|
|
102
140
|
parsedTemplate('tn-icon-button').each((_, iconTag) => {
|
|
103
|
-
|
|
141
|
+
processElement(iconTag, 'name', 'library', undefined);
|
|
104
142
|
});
|
|
143
|
+
|
|
144
|
+
// Scan icon-forwarding components
|
|
145
|
+
if (forwardingMappings) {
|
|
146
|
+
for (const mapping of forwardingMappings) {
|
|
147
|
+
parsedTemplate(mapping.selector).each((_, el) => {
|
|
148
|
+
for (const slot of mapping.iconSlots) {
|
|
149
|
+
processElement(el, slot.iconAttribute, slot.libraryAttribute, slot.defaultLibrary);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
105
154
|
});
|
|
106
155
|
|
|
107
|
-
return iconNames;
|
|
156
|
+
return { icons: iconNames, sources };
|
|
108
157
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { findIconsWithMarker } from './find-icons-with-marker';
|
|
3
|
+
|
|
4
|
+
const MARKER_FIXTURES = path.resolve(__dirname, '../__fixtures__/marker-sources');
|
|
5
|
+
|
|
6
|
+
describe('findIconsWithMarker', () => {
|
|
7
|
+
it('should find tnIconMarker calls with mdi library', () => {
|
|
8
|
+
const { icons } = findIconsWithMarker(MARKER_FIXTURES);
|
|
9
|
+
expect(icons.has('mdi-content-save')).toBe(true);
|
|
10
|
+
expect(icons.has('mdi-delete')).toBe(true);
|
|
11
|
+
expect(icons.has('mdi-pencil')).toBe(true);
|
|
12
|
+
expect(icons.has('mdi-trash-can')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('should find tnIconMarker calls with material library', () => {
|
|
16
|
+
const { icons } = findIconsWithMarker(MARKER_FIXTURES);
|
|
17
|
+
expect(icons.has('mat-check_circle')).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should find tnIconMarker calls with custom library', () => {
|
|
21
|
+
const { icons } = findIconsWithMarker(MARKER_FIXTURES);
|
|
22
|
+
expect(icons.has('app-my-logo')).toBe(true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should find tnIconMarker calls without library', () => {
|
|
26
|
+
const { icons } = findIconsWithMarker(MARKER_FIXTURES);
|
|
27
|
+
expect(icons.has('some-icon')).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should find libIconMarker calls with tn- prefix', () => {
|
|
31
|
+
const { icons } = findIconsWithMarker(MARKER_FIXTURES);
|
|
32
|
+
expect(icons.has('tn-dataset')).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should skip icons in the skip set', () => {
|
|
36
|
+
const skipIcons = new Set(['mdi-content-save', 'tn-dataset']);
|
|
37
|
+
const { icons } = findIconsWithMarker(MARKER_FIXTURES, skipIcons);
|
|
38
|
+
expect(icons.has('mdi-content-save')).toBe(false);
|
|
39
|
+
expect(icons.has('tn-dataset')).toBe(false);
|
|
40
|
+
// Others should still be found
|
|
41
|
+
expect(icons.has('mdi-delete')).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('source tracking', () => {
|
|
45
|
+
it('should return source file paths for each icon', () => {
|
|
46
|
+
const { sources } = findIconsWithMarker(MARKER_FIXTURES);
|
|
47
|
+
expect(sources.has('mdi-content-save')).toBe(true);
|
|
48
|
+
const saveSources = sources.get('mdi-content-save')!;
|
|
49
|
+
expect(saveSources.length).toBeGreaterThan(0);
|
|
50
|
+
expect(saveSources[0]).toContain('icons.ts');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should return empty set for directory with no markers', () => {
|
|
55
|
+
const { icons } = findIconsWithMarker(path.resolve(__dirname, '../__fixtures__/custom-icons'));
|
|
56
|
+
expect(icons.size).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -1,22 +1,46 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import type { ScanResult } from './find-icons-in-templates';
|
|
2
3
|
|
|
3
|
-
export function findIconsWithMarker(path: string, skipIcons?: Set<string>):
|
|
4
|
+
export function findIconsWithMarker(path: string, skipIcons?: Set<string>): ScanResult {
|
|
4
5
|
// Updated regex to capture tnIconMarker() and libIconMarker() calls with optional second parameter
|
|
5
6
|
// Matches: tnIconMarker('name') or tnIconMarker('name', 'library') or libIconMarker('tn-name')
|
|
6
|
-
const
|
|
7
|
+
const pattern = "(tn|lib)IconMarker\\('[^']+',?\\s*'?[^'\\)]*'?\\)";
|
|
7
8
|
|
|
8
9
|
const icons = new Set<string>();
|
|
10
|
+
const sources = new Map<string, string[]>();
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
const addIcon = (iconName: string, sourceFile: string) => {
|
|
13
|
+
icons.add(iconName);
|
|
14
|
+
const existing = sources.get(iconName) || [];
|
|
15
|
+
existing.push(sourceFile);
|
|
16
|
+
sources.set(iconName, existing);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const result = spawnSync(
|
|
20
|
+
'grep',
|
|
21
|
+
['-rEo', pattern, '--include=*.ts', '--include=*.html', path],
|
|
22
|
+
{ encoding: 'utf-8' },
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
if (result.status === 1 && !result.stderr) {
|
|
26
|
+
// grep returns exit code 1 when no matches found
|
|
27
|
+
return { icons, sources };
|
|
28
|
+
}
|
|
29
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
30
|
+
throw new Error(`grep failed: ${result.stderr}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
result.stdout
|
|
34
|
+
.split('\n')
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.forEach((line) => {
|
|
37
|
+
// grep output format: "filepath:match"
|
|
38
|
+
const colonIdx = line.indexOf(':');
|
|
39
|
+
if (colonIdx === -1) {
|
|
18
40
|
return;
|
|
19
41
|
}
|
|
42
|
+
const sourceFile = line.substring(0, colonIdx);
|
|
43
|
+
const match = line.substring(colonIdx + 1);
|
|
20
44
|
|
|
21
45
|
// Extract icon name (first parameter)
|
|
22
46
|
const iconNameMatch = /'([^']+)'/.exec(match);
|
|
@@ -47,17 +71,8 @@ export function findIconsWithMarker(path: string, skipIcons?: Set<string>): Set<
|
|
|
47
71
|
return;
|
|
48
72
|
}
|
|
49
73
|
|
|
50
|
-
|
|
74
|
+
addIcon(iconName, sourceFile);
|
|
51
75
|
});
|
|
52
|
-
} catch (error: any) {
|
|
53
|
-
// grep returns exit code 1 when no matches are found, which is not an error
|
|
54
|
-
if (error.status === 1 && !error.stderr) {
|
|
55
|
-
// No matches found, return empty set
|
|
56
|
-
return icons;
|
|
57
|
-
}
|
|
58
|
-
// Re-throw actual errors
|
|
59
|
-
throw error;
|
|
60
|
-
}
|
|
61
76
|
|
|
62
|
-
return icons;
|
|
77
|
+
return { icons, sources };
|
|
63
78
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { validateIcons, printValidationReport } from './validate-icons';
|
|
5
|
+
import type { ResolvedSpriteConfig } from '../sprite-config-interface';
|
|
6
|
+
|
|
7
|
+
const FIXTURES_BASE = path.resolve(__dirname, '../__fixtures__');
|
|
8
|
+
|
|
9
|
+
function createTempConfig(overrides: Partial<ResolvedSpriteConfig> = {}): ResolvedSpriteConfig {
|
|
10
|
+
return {
|
|
11
|
+
projectRoot: FIXTURES_BASE,
|
|
12
|
+
srcDirs: ['./consumer-templates', './marker-sources'],
|
|
13
|
+
outputDir: './output',
|
|
14
|
+
spriteUrlPath: 'assets/tn-icons',
|
|
15
|
+
customIconsDir: overrides.customIconsDir ?? null,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function writeSpriteConfig(dir: string, icons: string[]): void {
|
|
21
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(
|
|
23
|
+
path.join(dir, 'sprite-config.json'),
|
|
24
|
+
JSON.stringify({ iconUrl: 'sprite.svg?v=abc', icons }),
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('validateIcons', () => {
|
|
29
|
+
let tempDir: string;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'icon-validate-'));
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should report missing sprite config', () => {
|
|
40
|
+
const config = createTempConfig({ outputDir: path.join(tempDir, 'nonexistent') });
|
|
41
|
+
const result = validateIcons(config);
|
|
42
|
+
|
|
43
|
+
expect(result.spriteConfigFound).toBe(false);
|
|
44
|
+
expect(result.spriteIcons.size).toBe(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should detect icons missing from sprite', () => {
|
|
48
|
+
const outputDir = path.join(tempDir, 'output');
|
|
49
|
+
// Write a sprite config with only some icons
|
|
50
|
+
writeSpriteConfig(outputDir, ['mdi-folder']);
|
|
51
|
+
|
|
52
|
+
const config = createTempConfig({ outputDir });
|
|
53
|
+
const result = validateIcons(config);
|
|
54
|
+
|
|
55
|
+
expect(result.spriteConfigFound).toBe(true);
|
|
56
|
+
expect(result.missingFromSprite.size).toBeGreaterThan(0);
|
|
57
|
+
// mdi-folder is in the sprite, so it should NOT be missing
|
|
58
|
+
expect(result.missingFromSprite.has('mdi-folder')).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should detect stale icons in sprite', () => {
|
|
62
|
+
const outputDir = path.join(tempDir, 'output');
|
|
63
|
+
writeSpriteConfig(outputDir, ['mdi-folder', 'mdi-nonexistent-icon']);
|
|
64
|
+
|
|
65
|
+
const config = createTempConfig({ outputDir });
|
|
66
|
+
const result = validateIcons(config);
|
|
67
|
+
|
|
68
|
+
expect(result.staleInSprite.has('mdi-nonexistent-icon')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should report clean when sprite matches scanned icons', () => {
|
|
72
|
+
const outputDir = path.join(tempDir, 'output');
|
|
73
|
+
// First, scan to get the full set
|
|
74
|
+
const config = createTempConfig({ outputDir });
|
|
75
|
+
const scan = validateIcons(config);
|
|
76
|
+
// Now write a sprite config with exactly those icons
|
|
77
|
+
writeSpriteConfig(outputDir, [...scan.scannedIcons]);
|
|
78
|
+
|
|
79
|
+
const result = validateIcons(config);
|
|
80
|
+
expect(result.missingFromSprite.size).toBe(0);
|
|
81
|
+
expect(result.staleInSprite.size).toBe(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should include custom icons in scanned set', () => {
|
|
85
|
+
const outputDir = path.join(tempDir, 'output');
|
|
86
|
+
writeSpriteConfig(outputDir, ['tn-logo', 'tn-brand']);
|
|
87
|
+
|
|
88
|
+
const config = createTempConfig({
|
|
89
|
+
outputDir,
|
|
90
|
+
customIconsDir: './custom-icons',
|
|
91
|
+
});
|
|
92
|
+
const result = validateIcons(config);
|
|
93
|
+
|
|
94
|
+
expect(result.scannedIcons.has('tn-logo')).toBe(true);
|
|
95
|
+
expect(result.scannedIcons.has('tn-brand')).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('printValidationReport', () => {
|
|
100
|
+
let consoleOutput: string[];
|
|
101
|
+
const originalLog = console.log;
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
consoleOutput = [];
|
|
105
|
+
console.log = (...args: unknown[]) => {
|
|
106
|
+
consoleOutput.push(args.map(String).join(' '));
|
|
107
|
+
};
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
console.log = originalLog;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should return 0 when sprite is clean', () => {
|
|
115
|
+
const exitCode = printValidationReport({
|
|
116
|
+
scannedIcons: new Set(['mdi-folder']),
|
|
117
|
+
spriteIcons: new Set(['mdi-folder']),
|
|
118
|
+
missingFromSprite: new Set(),
|
|
119
|
+
staleInSprite: new Set(),
|
|
120
|
+
sources: new Map(),
|
|
121
|
+
spriteConfigFound: true,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(exitCode).toBe(0);
|
|
125
|
+
expect(consoleOutput.some(line => line.includes('up to date'))).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should return 1 when icons are missing', () => {
|
|
129
|
+
const exitCode = printValidationReport({
|
|
130
|
+
scannedIcons: new Set(['mdi-folder', 'mdi-new']),
|
|
131
|
+
spriteIcons: new Set(['mdi-folder']),
|
|
132
|
+
missingFromSprite: new Set(['mdi-new']),
|
|
133
|
+
staleInSprite: new Set(),
|
|
134
|
+
sources: new Map([['mdi-new', ['src/app/test.html']]]),
|
|
135
|
+
spriteConfigFound: true,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(exitCode).toBe(1);
|
|
139
|
+
expect(consoleOutput.some(line => line.includes('mdi-new'))).toBe(true);
|
|
140
|
+
expect(consoleOutput.some(line => line.includes('test.html'))).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should return 1 when no sprite config found', () => {
|
|
144
|
+
const exitCode = printValidationReport({
|
|
145
|
+
scannedIcons: new Set(['mdi-folder']),
|
|
146
|
+
spriteIcons: new Set(),
|
|
147
|
+
missingFromSprite: new Set(),
|
|
148
|
+
staleInSprite: new Set(),
|
|
149
|
+
sources: new Map(),
|
|
150
|
+
spriteConfigFound: false,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(exitCode).toBe(1);
|
|
154
|
+
expect(consoleOutput.some(line => line.includes('No sprite-config.json'))).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('should return 0 when only stale icons exist', () => {
|
|
158
|
+
const exitCode = printValidationReport({
|
|
159
|
+
scannedIcons: new Set(['mdi-folder']),
|
|
160
|
+
spriteIcons: new Set(['mdi-folder', 'mdi-old']),
|
|
161
|
+
missingFromSprite: new Set(),
|
|
162
|
+
staleInSprite: new Set(['mdi-old']),
|
|
163
|
+
sources: new Map(),
|
|
164
|
+
spriteConfigFound: true,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(exitCode).toBe(0);
|
|
168
|
+
expect(consoleOutput.some(line => line.includes('mdi-old'))).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import { discoverForwardingMappings } from './find-icons-in-forwarding-components';
|
|
4
|
+
import { findIconsInTemplates } from './find-icons-in-templates';
|
|
5
|
+
import type { ScanResult } from './find-icons-in-templates';
|
|
6
|
+
import { findIconsWithMarker } from './find-icons-with-marker';
|
|
7
|
+
import type { ResolvedSpriteConfig } from '../sprite-config-interface';
|
|
8
|
+
|
|
9
|
+
export interface ValidationResult {
|
|
10
|
+
scannedIcons: Set<string>;
|
|
11
|
+
spriteIcons: Set<string>;
|
|
12
|
+
missingFromSprite: Set<string>;
|
|
13
|
+
staleInSprite: Set<string>;
|
|
14
|
+
sources: Map<string, string[]>;
|
|
15
|
+
spriteConfigFound: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validates icon usage against the current sprite manifest.
|
|
20
|
+
* Reports missing icons (in code but not in sprite) and stale icons (in sprite but not in code).
|
|
21
|
+
*/
|
|
22
|
+
export function validateIcons(resolved: ResolvedSpriteConfig): ValidationResult {
|
|
23
|
+
const srcDirs = resolved.srcDirs.map(dir => resolve(resolved.projectRoot, dir));
|
|
24
|
+
const configPath = resolve(resolved.projectRoot, resolved.outputDir, 'sprite-config.json');
|
|
25
|
+
|
|
26
|
+
// Load library icons
|
|
27
|
+
const libraryIcons = loadLibraryIconsForValidation(resolved.projectRoot);
|
|
28
|
+
|
|
29
|
+
// Discover forwarding component mappings
|
|
30
|
+
const forwardingMappings = discoverForwardingMappings(srcDirs, resolved.projectRoot);
|
|
31
|
+
|
|
32
|
+
// Scan all source directories
|
|
33
|
+
const allSources = new Map<string, string[]>();
|
|
34
|
+
const scannedIcons = new Set<string>();
|
|
35
|
+
|
|
36
|
+
for (const srcDir of srcDirs) {
|
|
37
|
+
if (!fs.existsSync(srcDir)) {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const templateResult = findIconsInTemplates(srcDir, libraryIcons, forwardingMappings);
|
|
42
|
+
const markerResult = findIconsWithMarker(srcDir, libraryIcons);
|
|
43
|
+
|
|
44
|
+
mergeScanResult(scannedIcons, allSources, templateResult);
|
|
45
|
+
mergeScanResult(scannedIcons, allSources, markerResult);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Include library icons in the scanned set (they're expected in the sprite)
|
|
49
|
+
for (const icon of libraryIcons) {
|
|
50
|
+
scannedIcons.add(icon);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Include custom icons (they're always added to the sprite by generate)
|
|
54
|
+
if (resolved.customIconsDir) {
|
|
55
|
+
const customDir = resolve(resolved.projectRoot, resolved.customIconsDir);
|
|
56
|
+
if (fs.existsSync(customDir)) {
|
|
57
|
+
for (const filename of fs.readdirSync(customDir)) {
|
|
58
|
+
if (filename.endsWith('.svg')) {
|
|
59
|
+
scannedIcons.add(`tn-${filename.replace('.svg', '')}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Load existing sprite config
|
|
66
|
+
let spriteIcons = new Set<string>();
|
|
67
|
+
let spriteConfigFound = false;
|
|
68
|
+
|
|
69
|
+
if (fs.existsSync(configPath)) {
|
|
70
|
+
spriteConfigFound = true;
|
|
71
|
+
try {
|
|
72
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
73
|
+
spriteIcons = new Set<string>(config.icons || []);
|
|
74
|
+
} catch {
|
|
75
|
+
// Corrupt config — treat as empty
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Compute differences
|
|
80
|
+
const missingFromSprite = new Set<string>();
|
|
81
|
+
for (const icon of scannedIcons) {
|
|
82
|
+
if (!spriteIcons.has(icon)) {
|
|
83
|
+
missingFromSprite.add(icon);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const staleInSprite = new Set<string>();
|
|
88
|
+
for (const icon of spriteIcons) {
|
|
89
|
+
if (!scannedIcons.has(icon)) {
|
|
90
|
+
staleInSprite.add(icon);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
scannedIcons,
|
|
96
|
+
spriteIcons,
|
|
97
|
+
missingFromSprite,
|
|
98
|
+
staleInSprite,
|
|
99
|
+
sources: allSources,
|
|
100
|
+
spriteConfigFound,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Prints the validation report to stdout.
|
|
106
|
+
* Returns the appropriate exit code (0 = clean, 1 = missing icons).
|
|
107
|
+
*/
|
|
108
|
+
export function printValidationReport(result: ValidationResult): number {
|
|
109
|
+
console.log('Icon Sprite Validation Report');
|
|
110
|
+
console.log('==============================\n');
|
|
111
|
+
|
|
112
|
+
if (!result.spriteConfigFound) {
|
|
113
|
+
console.log('⚠ No sprite-config.json found. Run "npx truenas-icons generate" first.\n');
|
|
114
|
+
console.log(`Scanned: ${result.scannedIcons.size} icon(s) across templates and markers`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log(`Scanned: ${result.scannedIcons.size} icon(s) across templates and markers`);
|
|
119
|
+
console.log(`Sprite: ${result.spriteIcons.size} icon(s) in current sprite\n`);
|
|
120
|
+
|
|
121
|
+
if (result.missingFromSprite.size > 0) {
|
|
122
|
+
console.log(`Missing from sprite (${result.missingFromSprite.size} new icon(s) found):`);
|
|
123
|
+
for (const icon of [...result.missingFromSprite].sort()) {
|
|
124
|
+
const sourceFiles = result.sources.get(icon);
|
|
125
|
+
const sourceHint = sourceFiles?.length
|
|
126
|
+
? ` (from ${sourceFiles[0]})`
|
|
127
|
+
: '';
|
|
128
|
+
console.log(` + ${icon}${sourceHint}`);
|
|
129
|
+
}
|
|
130
|
+
console.log('');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (result.staleInSprite.size > 0) {
|
|
134
|
+
console.log(`Stale icons (${result.staleInSprite.size} in sprite but not referenced):`);
|
|
135
|
+
for (const icon of [...result.staleInSprite].sort()) {
|
|
136
|
+
console.log(` - ${icon}`);
|
|
137
|
+
}
|
|
138
|
+
console.log('');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (result.missingFromSprite.size === 0 && result.staleInSprite.size === 0) {
|
|
142
|
+
console.log('✓ Sprite is up to date — no missing or stale icons.\n');
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (result.missingFromSprite.size > 0) {
|
|
147
|
+
console.log('Run "npx truenas-icons generate" to rebuild the sprite.\n');
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function mergeScanResult(
|
|
155
|
+
icons: Set<string>,
|
|
156
|
+
sources: Map<string, string[]>,
|
|
157
|
+
result: ScanResult,
|
|
158
|
+
): void {
|
|
159
|
+
for (const icon of result.icons) {
|
|
160
|
+
icons.add(icon);
|
|
161
|
+
}
|
|
162
|
+
for (const [icon, files] of result.sources) {
|
|
163
|
+
const existing = sources.get(icon) || [];
|
|
164
|
+
existing.push(...files);
|
|
165
|
+
sources.set(icon, existing);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function loadLibraryIconsForValidation(projectRoot: string): Set<string> {
|
|
170
|
+
const librarySpritePath = resolve(
|
|
171
|
+
projectRoot,
|
|
172
|
+
'node_modules/@truenas/ui-components/assets/tn-icons/sprite-config.json'
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
if (!fs.existsSync(librarySpritePath)) {
|
|
176
|
+
return new Set<string>();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const config = JSON.parse(fs.readFileSync(librarySpritePath, 'utf-8'));
|
|
181
|
+
return new Set<string>(config.icons || []);
|
|
182
|
+
} catch {
|
|
183
|
+
return new Set<string>();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "Node16",
|
|
5
|
+
"moduleResolution": "Node16",
|
|
6
|
+
"esModuleInterop": true,
|
|
7
|
+
"isolatedModules": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"types": ["jest", "node"],
|
|
10
|
+
"rootDir": ".",
|
|
11
|
+
"outDir": "../../dist/out-tsc/scripts"
|
|
12
|
+
},
|
|
13
|
+
"include": ["**/*.ts"]
|
|
14
|
+
}
|
|
@@ -628,6 +628,7 @@ interface IconResult {
|
|
|
628
628
|
spriteUrl?: string;
|
|
629
629
|
}
|
|
630
630
|
declare class TnIconComponent {
|
|
631
|
+
private static warnedIcons;
|
|
631
632
|
name: _angular_core.InputSignal<string>;
|
|
632
633
|
size: _angular_core.InputSignal<IconSize>;
|
|
633
634
|
color: _angular_core.InputSignal<string | undefined>;
|
|
@@ -658,6 +659,12 @@ declare class TnIconComponent {
|
|
|
658
659
|
private tryCssIcon;
|
|
659
660
|
private tryUnicodeIcon;
|
|
660
661
|
private generateTextAbbreviation;
|
|
662
|
+
/**
|
|
663
|
+
* Warns once per icon name in dev mode when a sprite-prefixed icon is not
|
|
664
|
+
* found in the sprite. Only fires when the sprite is loaded and the icon
|
|
665
|
+
* name has a prefix indicating it should be a sprite icon.
|
|
666
|
+
*/
|
|
667
|
+
private warnMissingSpriteIcon;
|
|
661
668
|
static ɵfac: _angular_core.ɵɵFactoryDeclaration<TnIconComponent, never>;
|
|
662
669
|
static ɵcmp: _angular_core.ɵɵComponentDeclaration<TnIconComponent, "tn-icon", never, { "name": { "alias": "name"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "color": { "alias": "color"; "required": false; "isSignal": true; }; "tooltip": { "alias": "tooltip"; "required": false; "isSignal": true; }; "ariaLabel": { "alias": "ariaLabel"; "required": false; "isSignal": true; }; "library": { "alias": "library"; "required": false; "isSignal": true; }; "fullSize": { "alias": "fullSize"; "required": false; "isSignal": true; }; "customSize": { "alias": "customSize"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
|
|
663
670
|
}
|