@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
package/package.json
CHANGED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!-- Static tn-icon -->
|
|
2
|
+
<tn-icon name="folder" library="mdi"></tn-icon>
|
|
3
|
+
|
|
4
|
+
<!-- Bound tn-icon with ternary -->
|
|
5
|
+
<tn-icon [name]="expanded ? 'chevron-down' : 'chevron-right'" library="mdi"></tn-icon>
|
|
6
|
+
|
|
7
|
+
<!-- Material icon -->
|
|
8
|
+
<tn-icon name="check_circle" library="material"></tn-icon>
|
|
9
|
+
|
|
10
|
+
<!-- Registry format (should be skipped) -->
|
|
11
|
+
<tn-icon name="lucide:home"></tn-icon>
|
|
12
|
+
|
|
13
|
+
<!-- tn-icon-button -->
|
|
14
|
+
<tn-icon-button name="close" library="mdi"></tn-icon-button>
|
|
15
|
+
|
|
16
|
+
<!-- Forwarding: tn-empty with static icon -->
|
|
17
|
+
<tn-empty icon="inbox" iconLibrary="mdi" title="No messages"></tn-empty>
|
|
18
|
+
|
|
19
|
+
<!-- Forwarding: tn-empty with bound icon ternary -->
|
|
20
|
+
<tn-empty [icon]="hasData ? 'check' : 'alert'" title="Status"></tn-empty>
|
|
21
|
+
|
|
22
|
+
<!-- Forwarding: tn-input with prefix/suffix icons -->
|
|
23
|
+
<tn-input prefixIcon="search" prefixIconLibrary="mdi"></tn-input>
|
|
24
|
+
<tn-input suffixIcon="eye" suffixIconLibrary="material"></tn-input>
|
|
25
|
+
|
|
26
|
+
<!-- Forwarding: tn-chip (no library, no default) -->
|
|
27
|
+
<tn-chip icon="star"></tn-chip>
|
|
28
|
+
|
|
29
|
+
<!-- Dynamic binding (not extractable) -->
|
|
30
|
+
<tn-empty [icon]="dynamicIcon()" title="Dynamic"></tn-empty>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg viewBox="0 0 24 24"><path d="M12 2L2 7v10l10 5 10-5V7z"/></svg>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { tnIconMarker } from '../lib/icon/icon-marker';
|
|
2
|
+
import { libIconMarker } from '../lib/icon/icon-marker';
|
|
3
|
+
|
|
4
|
+
// MDI icons
|
|
5
|
+
const saveIcon = tnIconMarker('content-save', 'mdi');
|
|
6
|
+
const deleteIcon = tnIconMarker('delete', 'mdi');
|
|
7
|
+
|
|
8
|
+
// Material icons
|
|
9
|
+
const checkIcon = tnIconMarker('check_circle', 'material');
|
|
10
|
+
|
|
11
|
+
// Custom icon
|
|
12
|
+
const logoIcon = tnIconMarker('my-logo', 'custom');
|
|
13
|
+
|
|
14
|
+
// No library
|
|
15
|
+
const rawIcon = tnIconMarker('some-icon');
|
|
16
|
+
|
|
17
|
+
// Library internal
|
|
18
|
+
const datasetIcon = libIconMarker('tn-dataset');
|
|
19
|
+
|
|
20
|
+
// Array of icons
|
|
21
|
+
const actions = [
|
|
22
|
+
{ name: 'Edit', icon: tnIconMarker('pencil', 'mdi') },
|
|
23
|
+
{ name: 'Remove', icon: tnIconMarker('trash-can', 'mdi') },
|
|
24
|
+
];
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Usage:
|
|
7
7
|
* npx truenas-icons generate [options]
|
|
8
|
+
* npx truenas-icons validate [options]
|
|
8
9
|
*
|
|
9
10
|
* Options:
|
|
10
11
|
* --src <dirs> Comma-separated source directories to scan (default: ./src/lib,./src/app)
|
|
@@ -25,6 +26,8 @@
|
|
|
25
26
|
*/
|
|
26
27
|
|
|
27
28
|
import { generateSprite } from './generate-sprite.js';
|
|
29
|
+
import { validateIcons, printValidationReport } from './lib/validate-icons.js';
|
|
30
|
+
import { resolveConfig } from './sprite-config-interface.js';
|
|
28
31
|
import fs from 'fs';
|
|
29
32
|
import path from 'path';
|
|
30
33
|
|
|
@@ -33,6 +36,11 @@ truenas-icons - Icon sprite generation for TrueNAS UI components
|
|
|
33
36
|
|
|
34
37
|
Usage:
|
|
35
38
|
npx truenas-icons generate [options]
|
|
39
|
+
npx truenas-icons validate [options]
|
|
40
|
+
|
|
41
|
+
Commands:
|
|
42
|
+
generate Scan source files and generate the icon sprite
|
|
43
|
+
validate Check for missing or stale icons without rebuilding
|
|
36
44
|
|
|
37
45
|
Options:
|
|
38
46
|
--src <dirs> Comma-separated source directories to scan
|
|
@@ -67,6 +75,9 @@ Examples:
|
|
|
67
75
|
# Generate with defaults
|
|
68
76
|
npx truenas-icons generate
|
|
69
77
|
|
|
78
|
+
# Validate current sprite against code
|
|
79
|
+
npx truenas-icons validate
|
|
80
|
+
|
|
70
81
|
# Specify custom source directories
|
|
71
82
|
npx truenas-icons generate --src ./src,./app
|
|
72
83
|
|
|
@@ -94,8 +105,8 @@ function parseArgs() {
|
|
|
94
105
|
|
|
95
106
|
if (arg === '--help' || arg === '-h') {
|
|
96
107
|
parsed.showHelp = true;
|
|
97
|
-
} else if (arg === 'generate') {
|
|
98
|
-
parsed.command =
|
|
108
|
+
} else if (arg === 'generate' || arg === 'validate') {
|
|
109
|
+
parsed.command = arg;
|
|
99
110
|
} else if (arg === '--src') {
|
|
100
111
|
parsed.srcDirs = args[++i]?.split(',') || null;
|
|
101
112
|
} else if (arg === '--output') {
|
|
@@ -143,8 +154,8 @@ async function main() {
|
|
|
143
154
|
process.exit(0);
|
|
144
155
|
}
|
|
145
156
|
|
|
146
|
-
if (!args.command || args.command
|
|
147
|
-
console.error('Error: No command specified. Use "generate"
|
|
157
|
+
if (!args.command || !['generate', 'validate'].includes(args.command)) {
|
|
158
|
+
console.error('Error: No command specified. Use "generate" or "validate".');
|
|
148
159
|
console.log('\nRun "truenas-icons --help" for usage information.');
|
|
149
160
|
process.exit(1);
|
|
150
161
|
}
|
|
@@ -162,11 +173,19 @@ async function main() {
|
|
|
162
173
|
projectRoot: process.cwd(),
|
|
163
174
|
};
|
|
164
175
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
176
|
+
if (args.command === 'generate') {
|
|
177
|
+
console.log('Generating icon sprite...\n');
|
|
178
|
+
await generateSprite(config);
|
|
179
|
+
console.log('\nIcon sprite generated successfully!');
|
|
180
|
+
} else if (args.command === 'validate') {
|
|
181
|
+
console.log('Validating icon sprite...\n');
|
|
182
|
+
const resolved = resolveConfig(config);
|
|
183
|
+
const result = validateIcons(resolved);
|
|
184
|
+
const exitCode = printValidationReport(result);
|
|
185
|
+
process.exit(exitCode);
|
|
186
|
+
}
|
|
168
187
|
} catch (error: any) {
|
|
169
|
-
console.error('\nError
|
|
188
|
+
console.error('\nError:', error.message);
|
|
170
189
|
process.exit(1);
|
|
171
190
|
}
|
|
172
191
|
}
|
|
@@ -2,6 +2,7 @@ import crypto from 'crypto';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import { resolve } from 'path';
|
|
4
4
|
import { buildSprite } from './lib/build-sprite';
|
|
5
|
+
import { discoverForwardingMappings } from './lib/find-icons-in-forwarding-components';
|
|
5
6
|
import { findIconsInTemplates } from './lib/find-icons-in-templates';
|
|
6
7
|
import { findIconsWithMarker } from './lib/find-icons-with-marker';
|
|
7
8
|
import { getIconPaths } from './lib/get-icon-paths';
|
|
@@ -35,17 +36,27 @@ export async function generateSprite(config: SpriteGeneratorConfig = {}): Promis
|
|
|
35
36
|
console.info(`Loaded ${libraryIcons.size} icon(s) from truenas-ui library`);
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// Discover icon-forwarding components from source dirs AND from the library
|
|
40
|
+
const forwardingMappings = discoverForwardingMappings(srcDirs, resolved.projectRoot);
|
|
41
|
+
if (forwardingMappings.length > 0) {
|
|
42
|
+
console.info(`Discovered ${forwardingMappings.length} icon-forwarding component(s):`);
|
|
43
|
+
for (const mapping of forwardingMappings) {
|
|
44
|
+
const slots = mapping.iconSlots.map(s => s.iconAttribute).join(', ');
|
|
45
|
+
console.info(` ${mapping.selector} → [${slots}]`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
38
49
|
// Scan all source directories for icon usage, skipping icons already in library
|
|
39
50
|
const templateIcons = new Set<string>();
|
|
40
51
|
const markerIcons = new Set<string>();
|
|
41
52
|
|
|
42
53
|
for (const srcDir of srcDirs) {
|
|
43
54
|
if (fs.existsSync(srcDir)) {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
55
|
+
const dirTemplateResult = findIconsInTemplates(srcDir, libraryIcons, forwardingMappings);
|
|
56
|
+
const dirMarkerResult = findIconsWithMarker(srcDir, libraryIcons);
|
|
46
57
|
|
|
47
|
-
|
|
48
|
-
|
|
58
|
+
dirTemplateResult.icons.forEach(icon => templateIcons.add(icon));
|
|
59
|
+
dirMarkerResult.icons.forEach(icon => markerIcons.add(icon));
|
|
49
60
|
} else {
|
|
50
61
|
console.warn(`Source directory not found, skipping: ${srcDir}`);
|
|
51
62
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Config } from 'jest';
|
|
2
|
+
|
|
3
|
+
const config: Config = {
|
|
4
|
+
testEnvironment: 'node',
|
|
5
|
+
transform: {
|
|
6
|
+
'^.+\\.tsx?$': ['ts-jest', {
|
|
7
|
+
tsconfig: '<rootDir>/tsconfig.json',
|
|
8
|
+
}],
|
|
9
|
+
},
|
|
10
|
+
testMatch: ['<rootDir>/**/*.spec.ts'],
|
|
11
|
+
moduleFileExtensions: ['ts', 'js', 'json'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export default config;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { discoverForwardingMappings } from './find-icons-in-forwarding-components';
|
|
5
|
+
import type { ForwardingComponentMapping } from './find-icons-in-forwarding-components';
|
|
6
|
+
|
|
7
|
+
function writeManifest(dir: string, mappings: ForwardingComponentMapping[]): void {
|
|
8
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
9
|
+
fs.writeFileSync(
|
|
10
|
+
path.join(dir, 'forwarding-mappings.json'),
|
|
11
|
+
JSON.stringify(mappings),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('discoverForwardingMappings', () => {
|
|
16
|
+
let tempDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'fwd-mappings-'));
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('library manifest loading', () => {
|
|
27
|
+
it('should load mappings from the installed library manifest', () => {
|
|
28
|
+
const manifestDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
|
|
29
|
+
writeManifest(manifestDir, [
|
|
30
|
+
{
|
|
31
|
+
selector: 'tn-empty',
|
|
32
|
+
iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
selector: 'tn-chip',
|
|
36
|
+
iconSlots: [{ iconAttribute: 'icon' }],
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
const mappings = discoverForwardingMappings([], tempDir);
|
|
41
|
+
expect(mappings).toHaveLength(2);
|
|
42
|
+
|
|
43
|
+
const selectors = mappings.map(m => m.selector).sort();
|
|
44
|
+
expect(selectors).toEqual(['tn-chip', 'tn-empty']);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should extract icon slot details from manifest', () => {
|
|
48
|
+
const manifestDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
|
|
49
|
+
writeManifest(manifestDir, [
|
|
50
|
+
{
|
|
51
|
+
selector: 'tn-empty',
|
|
52
|
+
iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
|
|
53
|
+
},
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
const mappings = discoverForwardingMappings([], tempDir);
|
|
57
|
+
expect(mappings[0].iconSlots[0]).toEqual({
|
|
58
|
+
iconAttribute: 'icon',
|
|
59
|
+
libraryAttribute: 'iconLibrary',
|
|
60
|
+
defaultLibrary: 'mdi',
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should return empty array when library is not installed', () => {
|
|
65
|
+
const mappings = discoverForwardingMappings([], tempDir);
|
|
66
|
+
expect(mappings).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should handle corrupt library manifest gracefully', () => {
|
|
70
|
+
const manifestDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
|
|
71
|
+
fs.mkdirSync(manifestDir, { recursive: true });
|
|
72
|
+
fs.writeFileSync(path.join(manifestDir, 'forwarding-mappings.json'), 'not valid json');
|
|
73
|
+
|
|
74
|
+
const mappings = discoverForwardingMappings([], tempDir);
|
|
75
|
+
expect(mappings).toEqual([]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should handle manifest with non-array content gracefully', () => {
|
|
79
|
+
const manifestDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
|
|
80
|
+
fs.mkdirSync(manifestDir, { recursive: true });
|
|
81
|
+
fs.writeFileSync(path.join(manifestDir, 'forwarding-mappings.json'), '{"not": "an array"}');
|
|
82
|
+
|
|
83
|
+
const mappings = discoverForwardingMappings([], tempDir);
|
|
84
|
+
expect(mappings).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('consumer manifest loading', () => {
|
|
89
|
+
it('should load mappings from a consumer source directory', () => {
|
|
90
|
+
const consumerSrc = path.join(tempDir, 'src/app');
|
|
91
|
+
writeManifest(consumerSrc, [
|
|
92
|
+
{
|
|
93
|
+
selector: 'app-status',
|
|
94
|
+
iconSlots: [{ iconAttribute: 'statusIcon', libraryAttribute: 'statusIconLibrary' }],
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
|
|
98
|
+
const mappings = discoverForwardingMappings([consumerSrc], tempDir);
|
|
99
|
+
expect(mappings).toHaveLength(1);
|
|
100
|
+
expect(mappings[0].selector).toBe('app-status');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('should ignore source dirs without a manifest', () => {
|
|
104
|
+
const emptySrc = path.join(tempDir, 'src/app');
|
|
105
|
+
fs.mkdirSync(emptySrc, { recursive: true });
|
|
106
|
+
|
|
107
|
+
const mappings = discoverForwardingMappings([emptySrc], tempDir);
|
|
108
|
+
expect(mappings).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should handle non-existent source dirs', () => {
|
|
112
|
+
const mappings = discoverForwardingMappings(['/non/existent/path'], tempDir);
|
|
113
|
+
expect(mappings).toEqual([]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('merging library and consumer manifests', () => {
|
|
118
|
+
it('should merge mappings from library and consumer', () => {
|
|
119
|
+
// Library provides tn-empty
|
|
120
|
+
const libraryDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
|
|
121
|
+
writeManifest(libraryDir, [
|
|
122
|
+
{
|
|
123
|
+
selector: 'tn-empty',
|
|
124
|
+
iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
|
|
125
|
+
},
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
// Consumer provides app-status
|
|
129
|
+
const consumerSrc = path.join(tempDir, 'src/app');
|
|
130
|
+
writeManifest(consumerSrc, [
|
|
131
|
+
{
|
|
132
|
+
selector: 'app-status',
|
|
133
|
+
iconSlots: [{ iconAttribute: 'statusIcon' }],
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
|
|
137
|
+
const mappings = discoverForwardingMappings([consumerSrc], tempDir);
|
|
138
|
+
const selectors = mappings.map(m => m.selector).sort();
|
|
139
|
+
expect(selectors).toEqual(['app-status', 'tn-empty']);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('should allow consumer to override a library mapping by selector', () => {
|
|
143
|
+
// Library defines tn-empty with defaultLibrary: 'mdi'
|
|
144
|
+
const libraryDir = path.join(tempDir, 'node_modules/@truenas/ui-components/assets/tn-icons');
|
|
145
|
+
writeManifest(libraryDir, [
|
|
146
|
+
{
|
|
147
|
+
selector: 'tn-empty',
|
|
148
|
+
iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
|
|
149
|
+
},
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
// Consumer overrides tn-empty with a different default
|
|
153
|
+
const consumerSrc = path.join(tempDir, 'src/app');
|
|
154
|
+
writeManifest(consumerSrc, [
|
|
155
|
+
{
|
|
156
|
+
selector: 'tn-empty',
|
|
157
|
+
iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'material' }],
|
|
158
|
+
},
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
const mappings = discoverForwardingMappings([consumerSrc], tempDir);
|
|
162
|
+
expect(mappings).toHaveLength(1);
|
|
163
|
+
expect(mappings[0].iconSlots[0].defaultLibrary).toBe('material');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should merge manifests from multiple consumer source dirs', () => {
|
|
167
|
+
const srcApp = path.join(tempDir, 'src/app');
|
|
168
|
+
writeManifest(srcApp, [
|
|
169
|
+
{ selector: 'app-foo', iconSlots: [{ iconAttribute: 'icon' }] },
|
|
170
|
+
]);
|
|
171
|
+
|
|
172
|
+
const srcLib = path.join(tempDir, 'src/lib');
|
|
173
|
+
writeManifest(srcLib, [
|
|
174
|
+
{ selector: 'app-bar', iconSlots: [{ iconAttribute: 'barIcon' }] },
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
const mappings = discoverForwardingMappings([srcApp, srcLib], tempDir);
|
|
178
|
+
const selectors = mappings.map(m => m.selector).sort();
|
|
179
|
+
expect(selectors).toEqual(['app-bar', 'app-foo']);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
|
|
4
|
+
export interface IconSlotMapping {
|
|
5
|
+
iconAttribute: string;
|
|
6
|
+
libraryAttribute?: string;
|
|
7
|
+
defaultLibrary?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ForwardingComponentMapping {
|
|
11
|
+
selector: string;
|
|
12
|
+
iconSlots: IconSlotMapping[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Load forwarding component mappings from JSON manifest files.
|
|
17
|
+
*
|
|
18
|
+
* Mappings tell the sprite scanner which component attributes forward icon
|
|
19
|
+
* values to `<tn-icon>`. For example, `<tn-empty icon="inbox">` forwards
|
|
20
|
+
* its `icon` attribute to an internal `<tn-icon [name]>`.
|
|
21
|
+
*
|
|
22
|
+
* Sources (merged in order, later entries override by selector):
|
|
23
|
+
* 1. The library's published manifest at
|
|
24
|
+
* `node_modules/@truenas/ui-components/assets/tn-icons/forwarding-mappings.json`
|
|
25
|
+
* 2. Any `forwarding-mappings.json` files found in the consumer's source dirs
|
|
26
|
+
*/
|
|
27
|
+
export function discoverForwardingMappings(srcDirs: string[], projectRoot: string): ForwardingComponentMapping[] {
|
|
28
|
+
const bySelector = new Map<string, ForwardingComponentMapping>();
|
|
29
|
+
|
|
30
|
+
// Load library manifest (published with the npm package)
|
|
31
|
+
const libraryMappings = loadManifest(resolve(
|
|
32
|
+
projectRoot,
|
|
33
|
+
'node_modules/@truenas/ui-components/assets/tn-icons/forwarding-mappings.json',
|
|
34
|
+
));
|
|
35
|
+
for (const mapping of libraryMappings) {
|
|
36
|
+
bySelector.set(mapping.selector, mapping);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Load consumer manifests from source dirs
|
|
40
|
+
for (const srcDir of srcDirs) {
|
|
41
|
+
const consumerManifest = resolve(srcDir, 'forwarding-mappings.json');
|
|
42
|
+
for (const mapping of loadManifest(consumerManifest)) {
|
|
43
|
+
bySelector.set(mapping.selector, mapping);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return [...bySelector.values()];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read and parse a forwarding-mappings.json file.
|
|
52
|
+
* Returns an empty array if the file is missing or malformed.
|
|
53
|
+
*/
|
|
54
|
+
function loadManifest(filePath: string): ForwardingComponentMapping[] {
|
|
55
|
+
if (!fs.existsSync(filePath)) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
61
|
+
if (!Array.isArray(data)) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return data;
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { findIconsInTemplates } from './find-icons-in-templates';
|
|
3
|
+
import type { ForwardingComponentMapping } from './find-icons-in-forwarding-components';
|
|
4
|
+
|
|
5
|
+
const CONSUMER_FIXTURES = path.resolve(__dirname, '../__fixtures__/consumer-templates');
|
|
6
|
+
|
|
7
|
+
const FORWARDING_MAPPINGS: ForwardingComponentMapping[] = [
|
|
8
|
+
{
|
|
9
|
+
selector: 'tn-empty',
|
|
10
|
+
iconSlots: [{ iconAttribute: 'icon', libraryAttribute: 'iconLibrary', defaultLibrary: 'mdi' }],
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
selector: 'tn-input',
|
|
14
|
+
iconSlots: [
|
|
15
|
+
{ iconAttribute: 'prefixIcon', libraryAttribute: 'prefixIconLibrary' },
|
|
16
|
+
{ iconAttribute: 'suffixIcon', libraryAttribute: 'suffixIconLibrary' },
|
|
17
|
+
],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
selector: 'tn-chip',
|
|
21
|
+
iconSlots: [{ iconAttribute: 'icon' }],
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe('findIconsInTemplates', () => {
|
|
26
|
+
describe('tn-icon scanning (existing behavior)', () => {
|
|
27
|
+
it('should find static tn-icon names with library prefix', () => {
|
|
28
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
|
|
29
|
+
expect(icons.has('mdi-folder')).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should find tn-icon ternary expressions', () => {
|
|
33
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
|
|
34
|
+
expect(icons.has('mdi-chevron-down')).toBe(true);
|
|
35
|
+
expect(icons.has('mdi-chevron-right')).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should find material library icons', () => {
|
|
39
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
|
|
40
|
+
expect(icons.has('mat-check_circle')).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should skip registry format icons (with colon)', () => {
|
|
44
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
|
|
45
|
+
const registryIcons = [...icons].filter(i => i.includes(':'));
|
|
46
|
+
expect(registryIcons).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should find tn-icon-button names', () => {
|
|
50
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES);
|
|
51
|
+
expect(icons.has('mdi-close')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('forwarding component scanning', () => {
|
|
56
|
+
it('should find icons from tn-empty static attributes', () => {
|
|
57
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
|
|
58
|
+
expect(icons.has('mdi-inbox')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should find icons from tn-empty ternary with default library', () => {
|
|
62
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
|
|
63
|
+
// tn-empty has defaultLibrary: 'mdi', ternary values get prefixed
|
|
64
|
+
expect(icons.has('mdi-check')).toBe(true);
|
|
65
|
+
expect(icons.has('mdi-alert')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should find icons from tn-input prefixIcon with explicit library', () => {
|
|
69
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
|
|
70
|
+
expect(icons.has('mdi-search')).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should find icons from tn-input suffixIcon with material library', () => {
|
|
74
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
|
|
75
|
+
expect(icons.has('mat-eye')).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should find icons from tn-chip (no library, no default)', () => {
|
|
79
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
|
|
80
|
+
expect(icons.has('star')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('should not extract dynamic signal bindings', () => {
|
|
84
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
|
|
85
|
+
// [icon]="dynamicIcon()" should NOT produce any icon
|
|
86
|
+
const dynamicArtifacts = [...icons].filter(i => i.includes('dynamic') || i.includes('Icon'));
|
|
87
|
+
expect(dynamicArtifacts).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should not find forwarding icons when mappings are not provided', () => {
|
|
91
|
+
const withMappings = findIconsInTemplates(CONSUMER_FIXTURES, undefined, FORWARDING_MAPPINGS);
|
|
92
|
+
const withoutMappings = findIconsInTemplates(CONSUMER_FIXTURES);
|
|
93
|
+
|
|
94
|
+
// Without mappings, tn-empty/tn-input/tn-chip icons should be missing
|
|
95
|
+
expect(withMappings.icons.size).toBeGreaterThan(withoutMappings.icons.size);
|
|
96
|
+
expect(withoutMappings.icons.has('mdi-inbox')).toBe(false);
|
|
97
|
+
expect(withoutMappings.icons.has('mdi-search')).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('skipIcons filtering', () => {
|
|
102
|
+
it('should exclude icons in the skip set', () => {
|
|
103
|
+
const skipIcons = new Set(['mdi-folder', 'mat-check_circle']);
|
|
104
|
+
const { icons } = findIconsInTemplates(CONSUMER_FIXTURES, skipIcons);
|
|
105
|
+
expect(icons.has('mdi-folder')).toBe(false);
|
|
106
|
+
expect(icons.has('mat-check_circle')).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('source tracking', () => {
|
|
111
|
+
it('should return source file paths for each icon', () => {
|
|
112
|
+
const { sources } = findIconsInTemplates(CONSUMER_FIXTURES);
|
|
113
|
+
expect(sources.has('mdi-folder')).toBe(true);
|
|
114
|
+
const folderSources = sources.get('mdi-folder')!;
|
|
115
|
+
expect(folderSources.length).toBeGreaterThan(0);
|
|
116
|
+
expect(folderSources[0]).toContain('basic.component.html');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|