@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.
@@ -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 function findIconsInTemplates(path: string, skipIcons?: Set<string>): Set<string> {
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
- // Helper function to extract icon names from elements (used for both tn-icon and tn-icon-button)
15
- const processIconElement = (iconTag: cheerio.Element) => {
16
- // Check both 'name' and '[name]' attributes (Angular binding syntax)
17
- const staticName = parsedTemplate(iconTag).attr('name');
18
- const boundName = parsedTemplate(iconTag).attr('[name]');
19
- const library = parsedTemplate(iconTag).attr('library');
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: name="folder"
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: [name]="expression"
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., [name]="'folder'" (though this is unusual)
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 (library === 'mdi' && !iconName.startsWith('mdi-')) {
113
+ if (resolvedLibrary === 'mdi' && !iconName.startsWith('mdi-')) {
76
114
  finalIconName = `mdi-${iconName}`;
77
- } else if (library === 'custom' && !iconName.startsWith('app-') && !iconName.startsWith('tn-')) {
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 (library === 'material' && !iconName.startsWith('mat-')) {
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
- iconNames.add(finalIconName);
130
+ addIcon(finalIconName, template);
93
131
  });
94
132
  };
95
133
 
96
134
  // Scan tn-icon elements
97
135
  parsedTemplate('tn-icon').each((_, iconTag) => {
98
- processIconElement(iconTag);
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
- processIconElement(iconTag);
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 { execSync } from 'node:child_process';
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>): 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 command = `grep -rEo "(tn|lib)IconMarker\\\\('[^']+',?\\s*'?[^'\\)]*'?\\)" --include="*.ts" --include="*.html" ${path}`;
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
- try {
11
- const output = execSync(command, { encoding: 'utf-8' });
12
- output
13
- .split('\n')
14
- .filter(Boolean)
15
- .forEach((line) => {
16
- const [, match] = line.split(':');
17
- if (!match) {
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
- icons.add(iconName);
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
  }