@truenas/ui-components 0.1.45 → 0.1.47
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/fesm2022/truenas-ui-components.mjs +78 -4
- 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__/forwarding-components/multi-icon.component.html +7 -0
- package/scripts/icon-sprite/__fixtures__/forwarding-components/multi-icon.component.ts +15 -0
- package/scripts/icon-sprite/__fixtures__/forwarding-components/no-library.component.html +3 -0
- package/scripts/icon-sprite/__fixtures__/forwarding-components/no-library.component.ts +11 -0
- package/scripts/icon-sprite/__fixtures__/forwarding-components/not-forwarding.component.html +1 -0
- package/scripts/icon-sprite/__fixtures__/forwarding-components/not-forwarding.component.ts +10 -0
- package/scripts/icon-sprite/__fixtures__/forwarding-components/single-icon.component.html +3 -0
- package/scripts/icon-sprite/__fixtures__/forwarding-components/single-icon.component.ts +13 -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 +77 -0
- package/scripts/icon-sprite/lib/find-icons-in-forwarding-components.ts +209 -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 +70 -5
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,15 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
import type { TnIconForwardingComponent } from '../icon/icon-forwarding';
|
|
3
|
+
import type { IconLibraryType } from '../icon/icon.component';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'tn-input',
|
|
7
|
+
standalone: true,
|
|
8
|
+
templateUrl: './multi-icon.component.html',
|
|
9
|
+
})
|
|
10
|
+
export class TnInputComponent implements TnIconForwardingComponent, AfterViewInit {
|
|
11
|
+
prefixIcon = input<string | undefined>(undefined);
|
|
12
|
+
prefixIconLibrary = input<IconLibraryType | undefined>(undefined);
|
|
13
|
+
suffixIcon = input<string | undefined>(undefined);
|
|
14
|
+
suffixIconLibrary = input<IconLibraryType | undefined>(undefined);
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
import type { TnIconForwardingComponent } from '../icon/icon-forwarding';
|
|
3
|
+
|
|
4
|
+
@Component({
|
|
5
|
+
selector: 'tn-chip',
|
|
6
|
+
standalone: true,
|
|
7
|
+
templateUrl: './no-library.component.html',
|
|
8
|
+
})
|
|
9
|
+
export class TnChipComponent implements TnIconForwardingComponent {
|
|
10
|
+
icon = input<string | undefined>(undefined);
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<button class="tn-button">{{ label() }}</button>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Component, input } from '@angular/core';
|
|
2
|
+
import type { TnIconForwardingComponent } from '../icon/icon-forwarding';
|
|
3
|
+
import type { IconLibraryType } from '../icon/icon.component';
|
|
4
|
+
|
|
5
|
+
@Component({
|
|
6
|
+
selector: 'tn-empty',
|
|
7
|
+
standalone: true,
|
|
8
|
+
templateUrl: './single-icon.component.html',
|
|
9
|
+
})
|
|
10
|
+
export class TnEmptyComponent implements TnIconForwardingComponent {
|
|
11
|
+
icon = input<string>();
|
|
12
|
+
iconLibrary = input<IconLibraryType>('mdi');
|
|
13
|
+
}
|
|
@@ -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,77 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { findForwardingComponentMappings } from './find-icons-in-forwarding-components';
|
|
3
|
+
|
|
4
|
+
const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__/forwarding-components');
|
|
5
|
+
|
|
6
|
+
describe('findForwardingComponentMappings', () => {
|
|
7
|
+
it('should discover components implementing TnIconForwardingComponent', () => {
|
|
8
|
+
const mappings = findForwardingComponentMappings([FIXTURES_DIR]);
|
|
9
|
+
const selectors = mappings.map(m => m.selector).sort();
|
|
10
|
+
expect(selectors).toEqual(['tn-chip', 'tn-empty', 'tn-input']);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should not discover components that do not implement the interface', () => {
|
|
14
|
+
const mappings = findForwardingComponentMappings([FIXTURES_DIR]);
|
|
15
|
+
const selectors = mappings.map(m => m.selector);
|
|
16
|
+
expect(selectors).not.toContain('tn-button');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should extract single icon slot with library from tn-empty', () => {
|
|
20
|
+
const mappings = findForwardingComponentMappings([FIXTURES_DIR]);
|
|
21
|
+
const empty = mappings.find(m => m.selector === 'tn-empty');
|
|
22
|
+
|
|
23
|
+
expect(empty).toBeDefined();
|
|
24
|
+
expect(empty!.iconSlots).toHaveLength(1);
|
|
25
|
+
expect(empty!.iconSlots[0]).toEqual({
|
|
26
|
+
iconAttribute: 'icon',
|
|
27
|
+
libraryAttribute: 'iconLibrary',
|
|
28
|
+
defaultLibrary: 'mdi',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('should extract multiple icon slots from tn-input', () => {
|
|
33
|
+
const mappings = findForwardingComponentMappings([FIXTURES_DIR]);
|
|
34
|
+
const input = mappings.find(m => m.selector === 'tn-input');
|
|
35
|
+
|
|
36
|
+
expect(input).toBeDefined();
|
|
37
|
+
expect(input!.iconSlots).toHaveLength(2);
|
|
38
|
+
|
|
39
|
+
const attrNames = input!.iconSlots.map(s => s.iconAttribute).sort();
|
|
40
|
+
expect(attrNames).toEqual(['prefixIcon', 'suffixIcon']);
|
|
41
|
+
|
|
42
|
+
const prefix = input!.iconSlots.find(s => s.iconAttribute === 'prefixIcon');
|
|
43
|
+
expect(prefix!.libraryAttribute).toBe('prefixIconLibrary');
|
|
44
|
+
|
|
45
|
+
const suffix = input!.iconSlots.find(s => s.iconAttribute === 'suffixIcon');
|
|
46
|
+
expect(suffix!.libraryAttribute).toBe('suffixIconLibrary');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should handle component with no library attribute (tn-chip)', () => {
|
|
50
|
+
const mappings = findForwardingComponentMappings([FIXTURES_DIR]);
|
|
51
|
+
const chip = mappings.find(m => m.selector === 'tn-chip');
|
|
52
|
+
|
|
53
|
+
expect(chip).toBeDefined();
|
|
54
|
+
expect(chip!.iconSlots).toHaveLength(1);
|
|
55
|
+
expect(chip!.iconSlots[0].iconAttribute).toBe('icon');
|
|
56
|
+
expect(chip!.iconSlots[0].libraryAttribute).toBeUndefined();
|
|
57
|
+
expect(chip!.iconSlots[0].defaultLibrary).toBeUndefined();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should return empty array for directory with no forwarding components', () => {
|
|
61
|
+
const mappings = findForwardingComponentMappings([path.resolve(__dirname, '../__fixtures__/custom-icons')]);
|
|
62
|
+
expect(mappings).toEqual([]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should return empty array for non-existent directory', () => {
|
|
66
|
+
const mappings = findForwardingComponentMappings(['/non/existent/path']);
|
|
67
|
+
expect(mappings).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should deduplicate results from overlapping search paths', () => {
|
|
71
|
+
// Pass the same path twice — duplicates should be removed by selector
|
|
72
|
+
const mappings = findForwardingComponentMappings([FIXTURES_DIR, FIXTURES_DIR]);
|
|
73
|
+
expect(mappings).toHaveLength(3);
|
|
74
|
+
const selectors = mappings.map(m => m.selector).sort();
|
|
75
|
+
expect(selectors).toEqual(['tn-chip', 'tn-empty', 'tn-input']);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { resolve } from 'path';
|
|
5
|
+
import * as cheerio from 'cheerio';
|
|
6
|
+
|
|
7
|
+
export interface IconSlotMapping {
|
|
8
|
+
iconAttribute: string;
|
|
9
|
+
libraryAttribute?: string;
|
|
10
|
+
defaultLibrary?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ForwardingComponentMapping {
|
|
14
|
+
selector: string;
|
|
15
|
+
iconSlots: IconSlotMapping[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Discovers components implementing TnIconForwardingComponent and derives
|
|
20
|
+
* their icon attribute mappings by reading their templates.
|
|
21
|
+
*
|
|
22
|
+
* Steps:
|
|
23
|
+
* 1. Grep for `implements TnIconForwardingComponent` (or `implements.*TnIconForwardingComponent`)
|
|
24
|
+
* 2. Extract the @Component selector and templateUrl from the same file
|
|
25
|
+
* 3. Read the component template and find <tn-icon> [name] / [library] bindings
|
|
26
|
+
* 4. Map binding expressions back to input names (strip signal call syntax)
|
|
27
|
+
* 5. Extract default library values from input declarations in the TS file
|
|
28
|
+
*/
|
|
29
|
+
export function findForwardingComponentMappings(searchPaths: string[]): ForwardingComponentMapping[] {
|
|
30
|
+
const bySelector = new Map<string, ForwardingComponentMapping>();
|
|
31
|
+
const componentFiles = findForwardingComponentFiles(searchPaths);
|
|
32
|
+
|
|
33
|
+
for (const filePath of componentFiles) {
|
|
34
|
+
const mapping = extractMappingFromComponent(filePath);
|
|
35
|
+
if (mapping && !bySelector.has(mapping.selector)) {
|
|
36
|
+
bySelector.set(mapping.selector, mapping);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return [...bySelector.values()];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Discover icon-forwarding component mappings from consumer source dirs
|
|
45
|
+
* and the installed library (if present in node_modules).
|
|
46
|
+
*/
|
|
47
|
+
export function discoverForwardingMappings(srcDirs: string[], projectRoot: string): ForwardingComponentMapping[] {
|
|
48
|
+
const searchPaths = [...srcDirs];
|
|
49
|
+
|
|
50
|
+
const libSrcPath = resolve(projectRoot, 'node_modules/@truenas/ui-components/src/lib');
|
|
51
|
+
if (fs.existsSync(libSrcPath)) {
|
|
52
|
+
searchPaths.push(libSrcPath);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return findForwardingComponentMappings(searchPaths);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Grep for .ts files containing `implements TnIconForwardingComponent`
|
|
60
|
+
* (handles multi-interface: `implements TnIconForwardingComponent, AfterViewInit`)
|
|
61
|
+
*/
|
|
62
|
+
function findForwardingComponentFiles(searchPaths: string[]): string[] {
|
|
63
|
+
const files: string[] = [];
|
|
64
|
+
|
|
65
|
+
for (const searchPath of searchPaths) {
|
|
66
|
+
if (!fs.existsSync(searchPath)) {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const result = spawnSync(
|
|
71
|
+
'grep',
|
|
72
|
+
['-rl', 'implements.*TnIconForwardingComponent', '--include=*.ts', searchPath],
|
|
73
|
+
{ encoding: 'utf-8' },
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
if (result.status === 1 && !result.stderr) {
|
|
77
|
+
// grep returns exit code 1 when no matches found
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (result.status !== 0 && result.status !== 1) {
|
|
81
|
+
throw new Error(`grep failed: ${result.stderr}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
result.stdout
|
|
85
|
+
.split('\n')
|
|
86
|
+
.filter(Boolean)
|
|
87
|
+
.forEach((file) => files.push(file));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return files;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Extract the forwarding component mapping from a single component .ts file.
|
|
95
|
+
*
|
|
96
|
+
* Reads the TS file to get the selector and templateUrl, then reads the
|
|
97
|
+
* template to find <tn-icon> bindings and maps them back to input names.
|
|
98
|
+
*/
|
|
99
|
+
function extractMappingFromComponent(tsFilePath: string): ForwardingComponentMapping | null {
|
|
100
|
+
const tsContent = fs.readFileSync(tsFilePath, 'utf-8');
|
|
101
|
+
|
|
102
|
+
// Extract selector from @Component({ selector: '...' })
|
|
103
|
+
const selectorMatch = /selector:\s*['"]([^'"]+)['"]/.exec(tsContent);
|
|
104
|
+
if (!selectorMatch) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const selector = selectorMatch[1];
|
|
108
|
+
|
|
109
|
+
// Extract templateUrl from @Component({ templateUrl: '...' })
|
|
110
|
+
const templateUrlMatch = /templateUrl:\s*['"]([^'"]+)['"]/.exec(tsContent);
|
|
111
|
+
if (!templateUrlMatch) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Resolve template path relative to the TS file
|
|
116
|
+
const templatePath = path.resolve(path.dirname(tsFilePath), templateUrlMatch[1]);
|
|
117
|
+
if (!fs.existsSync(templatePath)) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const templateContent = fs.readFileSync(templatePath, 'utf-8');
|
|
122
|
+
const iconSlots = extractIconSlotsFromTemplate(templateContent, tsContent);
|
|
123
|
+
|
|
124
|
+
if (iconSlots.length === 0) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { selector, iconSlots };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Find <tn-icon> elements in the template and extract the input names
|
|
133
|
+
* from their [name] and [library] bindings.
|
|
134
|
+
*
|
|
135
|
+
* For example:
|
|
136
|
+
* <tn-icon [name]="icon()!" [library]="iconLibrary()">
|
|
137
|
+
* yields: { iconAttribute: 'icon', libraryAttribute: 'iconLibrary' }
|
|
138
|
+
*
|
|
139
|
+
* <tn-icon [name]="prefixIcon()!">
|
|
140
|
+
* yields: { iconAttribute: 'prefixIcon' }
|
|
141
|
+
*/
|
|
142
|
+
function extractIconSlotsFromTemplate(templateContent: string, tsContent: string): IconSlotMapping[] {
|
|
143
|
+
const $ = cheerio.load(templateContent);
|
|
144
|
+
const slots: IconSlotMapping[] = [];
|
|
145
|
+
|
|
146
|
+
$('tn-icon').each((_, el) => {
|
|
147
|
+
const boundName = $(el).attr('[name]');
|
|
148
|
+
if (!boundName) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Extract the signal/input name from the binding expression
|
|
153
|
+
// Handles: "icon()", "icon()!", "prefixIcon()!", etc.
|
|
154
|
+
const inputName = extractInputName(boundName);
|
|
155
|
+
if (!inputName) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const slot: IconSlotMapping = { iconAttribute: inputName };
|
|
160
|
+
|
|
161
|
+
// Check for [library] binding on the same <tn-icon>
|
|
162
|
+
const boundLibrary = $(el).attr('[library]');
|
|
163
|
+
if (boundLibrary) {
|
|
164
|
+
const libraryInputName = extractInputName(boundLibrary);
|
|
165
|
+
if (libraryInputName) {
|
|
166
|
+
slot.libraryAttribute = libraryInputName;
|
|
167
|
+
|
|
168
|
+
// Try to extract the default value from the input declaration in the TS
|
|
169
|
+
const defaultLibrary = extractInputDefault(tsContent, libraryInputName);
|
|
170
|
+
if (defaultLibrary) {
|
|
171
|
+
slot.defaultLibrary = defaultLibrary;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
slots.push(slot);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return slots;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Extract a simple input/signal name from a template binding expression.
|
|
184
|
+
*
|
|
185
|
+
* Matches patterns like:
|
|
186
|
+
* "icon()" -> "icon"
|
|
187
|
+
* "icon()!" -> "icon"
|
|
188
|
+
* "prefixIcon()!" -> "prefixIcon"
|
|
189
|
+
*
|
|
190
|
+
* Returns null for complex expressions (ternaries, method calls with args, etc.)
|
|
191
|
+
* since those represent computed values, not direct input forwarding.
|
|
192
|
+
*/
|
|
193
|
+
function extractInputName(expression: string): string | null {
|
|
194
|
+
const match = /^\s*(\w+)\(\)!?\s*$/.exec(expression);
|
|
195
|
+
return match ? match[1] : null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Extract the default value from an Angular input declaration.
|
|
200
|
+
*
|
|
201
|
+
* Matches patterns like:
|
|
202
|
+
* iconLibrary = input<IconLibraryType>('mdi') -> "mdi"
|
|
203
|
+
* iconLibrary = input('mdi') -> "mdi"
|
|
204
|
+
*/
|
|
205
|
+
function extractInputDefault(tsContent: string, inputName: string): string | null {
|
|
206
|
+
const regex = new RegExp(`${inputName}\\s*=\\s*input[^(]*\\(\\s*['"]([^'"]+)['"]`);
|
|
207
|
+
const match = regex.exec(tsContent);
|
|
208
|
+
return match ? match[1] : null;
|
|
209
|
+
}
|