@truenas/ui-components 0.1.46 → 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.
Files changed (28) hide show
  1. package/fesm2022/truenas-ui-components.mjs +39 -3
  2. package/fesm2022/truenas-ui-components.mjs.map +1 -1
  3. package/package.json +1 -1
  4. package/scripts/icon-sprite/__fixtures__/consumer-templates/basic.component.html +30 -0
  5. package/scripts/icon-sprite/__fixtures__/custom-icons/brand.svg +1 -0
  6. package/scripts/icon-sprite/__fixtures__/custom-icons/logo.svg +1 -0
  7. package/scripts/icon-sprite/__fixtures__/forwarding-components/multi-icon.component.html +7 -0
  8. package/scripts/icon-sprite/__fixtures__/forwarding-components/multi-icon.component.ts +15 -0
  9. package/scripts/icon-sprite/__fixtures__/forwarding-components/no-library.component.html +3 -0
  10. package/scripts/icon-sprite/__fixtures__/forwarding-components/no-library.component.ts +11 -0
  11. package/scripts/icon-sprite/__fixtures__/forwarding-components/not-forwarding.component.html +1 -0
  12. package/scripts/icon-sprite/__fixtures__/forwarding-components/not-forwarding.component.ts +10 -0
  13. package/scripts/icon-sprite/__fixtures__/forwarding-components/single-icon.component.html +3 -0
  14. package/scripts/icon-sprite/__fixtures__/forwarding-components/single-icon.component.ts +13 -0
  15. package/scripts/icon-sprite/__fixtures__/marker-sources/icons.ts +24 -0
  16. package/scripts/icon-sprite/cli-main.ts +27 -8
  17. package/scripts/icon-sprite/generate-sprite.ts +15 -4
  18. package/scripts/icon-sprite/jest.config.ts +14 -0
  19. package/scripts/icon-sprite/lib/find-icons-in-forwarding-components.spec.ts +77 -0
  20. package/scripts/icon-sprite/lib/find-icons-in-forwarding-components.ts +209 -0
  21. package/scripts/icon-sprite/lib/find-icons-in-templates.spec.ts +119 -0
  22. package/scripts/icon-sprite/lib/find-icons-in-templates.ts +66 -17
  23. package/scripts/icon-sprite/lib/find-icons-with-marker.spec.ts +58 -0
  24. package/scripts/icon-sprite/lib/find-icons-with-marker.ts +37 -22
  25. package/scripts/icon-sprite/lib/validate-icons.spec.ts +170 -0
  26. package/scripts/icon-sprite/lib/validate-icons.ts +185 -0
  27. package/scripts/icon-sprite/tsconfig.json +14 -0
  28. package/types/truenas-ui-components.d.ts +42 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truenas/ui-components",
3
- "version": "0.1.46",
3
+ "version": "0.1.47",
4
4
  "publishConfig": {
5
5
  "registry": "https://registry.npmjs.org",
6
6
  "access": "public"
@@ -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,7 @@
1
+ @if (prefixIcon()) {
2
+ <tn-icon [name]="prefixIcon()!" [library]="prefixIconLibrary()" />
3
+ }
4
+ <input class="tn-input" />
5
+ @if (suffixIcon()) {
6
+ <tn-icon [name]="suffixIcon()!" [library]="suffixIconLibrary()" />
7
+ }
@@ -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,3 @@
1
+ @if (icon()) {
2
+ <tn-icon [name]="icon()!" />
3
+ }
@@ -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,10 @@
1
+ import { Component, input } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'tn-button',
5
+ standalone: true,
6
+ templateUrl: './not-forwarding.component.html',
7
+ })
8
+ export class TnButtonComponent {
9
+ label = input<string>('');
10
+ }
@@ -0,0 +1,3 @@
1
+ @if (icon()) {
2
+ <tn-icon [name]="icon()!" [library]="iconLibrary()" />
3
+ }
@@ -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 = 'generate';
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 !== 'generate') {
147
- console.error('Error: No command specified. Use "generate" to create icon sprite.');
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
- console.log('Generating icon sprite...\n');
166
- await generateSprite(config);
167
- console.log('\nIcon sprite generated successfully!');
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 generating sprite:', error.message);
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 dirTemplateIcons = findIconsInTemplates(srcDir, libraryIcons);
45
- const dirMarkerIcons = findIconsWithMarker(srcDir, libraryIcons);
55
+ const dirTemplateResult = findIconsInTemplates(srcDir, libraryIcons, forwardingMappings);
56
+ const dirMarkerResult = findIconsWithMarker(srcDir, libraryIcons);
46
57
 
47
- dirTemplateIcons.forEach(icon => templateIcons.add(icon));
48
- dirMarkerIcons.forEach(icon => markerIcons.add(icon));
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
+ }