From 3413effddbee684fd1ca1082f822794df78e9b46 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sun, 15 Mar 2026 09:06:19 -0700 Subject: [PATCH 1/3] wip --- examples/basic/pubspec.lock | 6 +- examples/bundled/pubspec.lock | 8 +-- .../src/generator/icon_code_generator.dart | 4 +- .../lib/src/scanner/icon_name_scanner.dart | 4 +- packages/sdk/lib/iconify_sdk.dart | 5 +- .../iconify_icon_variations_isolatedDiff.png | Bin 1063 -> 1367 bytes .../iconify_icon_variations_maskedDiff.png | Bin 2951 -> 3047 bytes .../iconify_icon_variations_masterImage.png | Bin 2802 -> 2906 bytes .../iconify_icon_variations_testImage.png | Bin 2906 -> 2989 bytes .../goldens/ci/iconify_icon_variations.png | Bin 2906 -> 2989 bytes .../goldens/linux/iconify_icon_variations.png | Bin 10894 -> 10302 bytes .../test/golden/iconify_icon_golden_test.dart | 57 +++++++----------- 12 files changed, 37 insertions(+), 47 deletions(-) diff --git a/examples/basic/pubspec.lock b/examples/basic/pubspec.lock index 511ffc6..dbe20bb 100644 --- a/examples/basic/pubspec.lock +++ b/examples/basic/pubspec.lock @@ -105,15 +105,15 @@ packages: path: "../../packages/sdk" relative: true source: path - version: "0.1.0" + version: "0.2.0" iconify_sdk_core: dependency: transitive description: name: iconify_sdk_core - sha256: "2f246aee51c7867fc9333a142b457685c6623175301c89267a5e5a98c8f1c6fe" + sha256: "86eb36da4b2967fac9ca2b8c0457cccd13dd9de11d4bb5e733e735d2b23e77d5" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.2.0" leak_tracker: dependency: transitive description: diff --git a/examples/bundled/pubspec.lock b/examples/bundled/pubspec.lock index b6a03f4..20800ee 100644 --- a/examples/bundled/pubspec.lock +++ b/examples/bundled/pubspec.lock @@ -249,22 +249,22 @@ packages: path: "../../packages/sdk" relative: true source: path - version: "0.1.0" + version: "0.2.0" iconify_sdk_builder: dependency: "direct dev" description: path: "../../packages/builder" relative: true source: path - version: "0.1.0" + version: "0.2.0" iconify_sdk_core: dependency: transitive description: name: iconify_sdk_core - sha256: "2f246aee51c7867fc9333a142b457685c6623175301c89267a5e5a98c8f1c6fe" + sha256: "86eb36da4b2967fac9ca2b8c0457cccd13dd9de11d4bb5e733e735d2b23e77d5" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.2.0" io: dependency: transitive description: diff --git a/packages/builder/lib/src/generator/icon_code_generator.dart b/packages/builder/lib/src/generator/icon_code_generator.dart index 26f880e..197a126 100644 --- a/packages/builder/lib/src/generator/icon_code_generator.dart +++ b/packages/builder/lib/src/generator/icon_code_generator.dart @@ -13,7 +13,7 @@ class IconCodeGenerator { buffer.writeln( '// ignore_for_file: constant_identifier_names, library_private_types_in_public_api'); buffer.writeln(); - buffer.writeln("import 'package:iconify_sdk_core/iconify_sdk_core.dart';"); + buffer.writeln("import 'package:iconify_sdk/iconify_sdk.dart';"); buffer.writeln(); // Group icons by prefix for organization @@ -40,7 +40,7 @@ class IconCodeGenerator { final varName = _toCamelCase(iconName); buffer.writeln(' /// $fullName'); buffer.writeln(' static const $varName = IconifyIconData('); - buffer.writeln(" body: r'${data.body}',"); + buffer.writeln(" body: r'''${data.body}''',"); buffer.writeln(' width: ${data.width},'); buffer.writeln(' height: ${data.height},'); buffer.writeln(' );'); diff --git a/packages/builder/lib/src/scanner/icon_name_scanner.dart b/packages/builder/lib/src/scanner/icon_name_scanner.dart index 5420e61..43ee9cc 100644 --- a/packages/builder/lib/src/scanner/icon_name_scanner.dart +++ b/packages/builder/lib/src/scanner/icon_name_scanner.dart @@ -13,7 +13,7 @@ class IconNameScanner { r'([a-z0-9][a-z0-9\-]*:[a-z0-9][a-z0-9\-]*)' r"['" r'"]' - r'\s*\)', + r'[\s\S]*?\)', caseSensitive: false, ); @@ -28,7 +28,7 @@ class IconNameScanner { r'([a-z0-9][a-z0-9\-]*)' r"['" r'"]' - r'\s*\)', + r'[\s\S]*?\)', caseSensitive: false, ); diff --git a/packages/sdk/lib/iconify_sdk.dart b/packages/sdk/lib/iconify_sdk.dart index 4418a15..cff72e7 100644 --- a/packages/sdk/lib/iconify_sdk.dart +++ b/packages/sdk/lib/iconify_sdk.dart @@ -38,7 +38,10 @@ export 'package:iconify_sdk_core/iconify_sdk_core.dart' IconifyParseException, CircularAliasException, IconifyCacheException, - RenderStrategy; + RenderStrategy, + IconifyProvider, + MemoryIconifyProvider, + RemoteIconifyProvider; // Configuration export 'src/config/iconify_config.dart'; diff --git a/packages/sdk/test/golden/failures/iconify_icon_variations_isolatedDiff.png b/packages/sdk/test/golden/failures/iconify_icon_variations_isolatedDiff.png index 1ce77ca2f807668fcd2b8568ca4c91bf8c4ca032..2f7f87ae14ba302bb34210dfe5ad507627063171 100644 GIT binary patch literal 1367 zcmeAS@N?(olHy`uVBq!ia0y~yVD@s}w{{TdX_^H;V?Yocx3R;>n|m zOBGxTG@GnMax&OukGKj7ddbGTS>!CTJ3~1vr!-SqHY!QuU{`0t(M5r;ch+n-_k26M z`u=WnyYoNzj`xe-v!DO_?(euj28IKn7N)g}tW0Y+WmWO5xczhI`_+lw{_rWGf5isye$Wl)IQ_59|hmy!#l znK&1yMaSF5i@65YRKF-($il$vrNDS4=dXFNZ$AS=i6@Bj=4`i~HsinWxZj_6XBS`N z0s8j9Dx3QE#jEWMOFl21?<%%d0T>`JR{yrYp_i0W;muya8 z!0p=h^is`nvlpAQmY;qq5y8ayfNR

Qn5t2aLq7<-e+UbMVs)UO{GW1;&_z+m_$| z?#REP$G*MI`kr$AN;9jCM-B)91O7@@@Xfn#`P}ONI!0Wy0J_6rt=vkxS6U@$<~bWT zE-wT|LBl#(o1cE}Jhd@)yOLhKSYZhKQ(wl9a) zP&wh?Oo|PuKC<)I%$mo@@Is%sboyd*tnlu;@th1%z_jp(-8uSrR_CmR*2h-)`<)lJ z{??_!kg>Sbm!H9aLvQ|U&1}naH)no2baUn-r$CYS`}VGux?j9w+G8e$uNxehG7j|i z+lHL|x2%_8K`x8X0WP<)d-o@MKNM5zpC*1vUK^#i;8j!X?= zps1!!9y`GG@5i6+P2$I8ZyE&U$gHa>%wR|mWf5{<2DxDIb#9zopr0I^M- AHvj+t literal 1063 zcmeAS@N?(olHy`uVBq!ia0y~yVDtqq7f1+vzfElK?WiZUd<+Z?cVa@j zKHk~cai#dV(mE~Ig)9sVe{`>_|2C}1IkxCvvhh0qZ>~ZN3?Izz%#k~?;(ndD`^v!m zvgXU#l{gs~Zse`a(o{RV-2btLsid9j>L?*k28IJ=yR7YdUcNp(y>QB`efy5w{khPC ziGg9;>Ds+ApKn&YwD3Bc{&>gw=^$e_$jV8%e>9t4Yuy)=|2fjeq|XH;dUMzGP z7rlGB5v0}3@8RNh!Hq!=M7Ub{_p<{PaF%_x1BTauT{oh?9X)z`VQBtLAoto-X%(QC zS{#6SJUCF9^a@=(_I$d$d#x)l>?`LpGcYKahfhtiDc4i_3Jmd{lfA(jY+`Zcv}>&f4s&l&Z;ly^G%C)uTw$B=CrpT3*2)y z{eIu#E4ORJ_dcLI$&Jwp2!j zhTac18FH@ioC1b)_S@BrH`Yi_VFgCOrA^EmB8@e;85q_~eb09w?d$>_U~Gwn$u^vw z!C~Oz|c8yDDNIHo*5#|# diff --git a/packages/sdk/test/golden/failures/iconify_icon_variations_maskedDiff.png b/packages/sdk/test/golden/failures/iconify_icon_variations_maskedDiff.png index bb1c57b0d9a5ca812faf7b6bce489a981c042342..523ae4a44d052c891866c359f52c87b4e8ec0cf4 100644 GIT binary patch literal 3047 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 diff --git a/packages/sdk/test/golden/failures/iconify_icon_variations_masterImage.png b/packages/sdk/test/golden/failures/iconify_icon_variations_masterImage.png index 6faf90d5565c23020b0166ee59706c53e4ff0e03..6cb2e15941efdf6d354ef9b62c4c1d5999e40c9d 100644 GIT binary patch literal 2906 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 T>}bJ|N8pt6cTTL6!Tr< diff --git a/packages/sdk/test/golden/failures/iconify_icon_variations_testImage.png b/packages/sdk/test/golden/failures/iconify_icon_variations_testImage.png index 6cb2e15941efdf6d354ef9b62c4c1d5999e40c9d..931e51f56ddde63c69d2beb7a80a009dcaaf8fd2 100644 GIT binary patch literal 2989 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 bG2B*ofur#XH(Rz4oCR1}AkUYayBYBtzra{b literal 2906 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 bG2B*ofur#XH(Rz4oCR1}AkUYayBYBtzra{b literal 2906 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 diff --git a/packages/sdk/test/golden/iconify_icon_golden_test.dart b/packages/sdk/test/golden/iconify_icon_golden_test.dart index e2a5d73..0f5655e 100644 --- a/packages/sdk/test/golden/iconify_icon_golden_test.dart +++ b/packages/sdk/test/golden/iconify_icon_golden_test.dart @@ -6,22 +6,21 @@ import 'package:iconify_sdk_core/iconify_sdk_core.dart'; void main() { group('IconifyIcon Golden Tests', () { - late MemoryIconifyProvider provider; + final provider = MemoryIconifyProvider(); - final monoHome = const IconifyName('test', 'home'); - final monoHomeData = const IconifyIconData( + const monoHome = IconifyName('test', 'home'); + const monoHomeData = IconifyIconData( body: '', ); - final multiColor = const IconifyName('test', 'multi'); - final multiColorData = const IconifyIconData( + const multiColor = IconifyName('test', 'multi'); + const multiColorData = IconifyIconData( body: '', ); - setUp(() { - provider = MemoryIconifyProvider(); + setUpAll(() { provider.putIcon(monoHome, monoHomeData); provider.putIcon(multiColor, multiColorData); }); @@ -29,44 +28,32 @@ void main() { goldenTest( 'IconifyIcon variations', fileName: 'iconify_icon_variations', - builder: () => GoldenTestGroup( - children: [ - GoldenTestScenario( - name: 'Monochrome default', - child: IconifyScope( - provider: provider, + builder: () => IconifyScope( + provider: provider, + child: GoldenTestGroup( + children: [ + GoldenTestScenario( + name: 'Monochrome default', child: IconifyIcon('test:home'), ), - ), - GoldenTestScenario( - name: 'Monochrome colored', - child: IconifyScope( - provider: provider, + GoldenTestScenario( + name: 'Monochrome colored', child: IconifyIcon('test:home', color: Colors.blue), ), - ), - GoldenTestScenario( - name: 'Multicolor', - child: IconifyScope( - provider: provider, + GoldenTestScenario( + name: 'Multicolor', child: IconifyIcon('test:multi'), ), - ), - GoldenTestScenario( - name: 'Custom size', - child: IconifyScope( - provider: provider, + GoldenTestScenario( + name: 'Custom size', child: IconifyIcon('test:home', size: 48), ), - ), - GoldenTestScenario( - name: 'Error state', - child: IconifyScope( - provider: provider, + GoldenTestScenario( + name: 'Error state', child: IconifyIcon('test:missing'), ), - ), - ], + ], + ), ), ); }); From b809be0bd3496a3cd8fd868324eeb6cac0b42737 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sun, 15 Mar 2026 10:48:30 -0700 Subject: [PATCH 2/3] phase A --- packages/cli/lib/src/cli_runner.dart | 4 + .../cli/lib/src/commands/add_command.dart | 181 ++++++++++++ .../cli/lib/src/commands/doctor_command.dart | 43 +++ .../cli/lib/src/commands/init_command.dart | 13 +- .../cli/lib/src/commands/prune_command.dart | 152 ++++++++++ .../cli/test/commands/add_command_test.dart | 103 +++++++ .../cli/test/commands/prune_command_test.dart | 116 ++++++++ packages/core/lib/iconify_sdk_core.dart | 3 + .../file_system_living_cache_storage.dart | 34 +++ .../src/providers/living_cache_provider.dart | 170 +++++++++++ .../providers/remote_iconify_provider.dart | 57 +++- .../src/resolver/pub_cache_path_resolver.dart | 84 ++++++ packages/core/pubspec.yaml | 1 + .../providers/living_cache_provider_test.dart | 100 +++++++ .../remote_iconify_provider_test.dart | 264 +++++++----------- .../pub_cache_path_resolver_test.dart | 33 +++ packages/sdk/assets/iconify/used_icons.json | 1 + packages/sdk/lib/iconify_sdk.dart | 6 +- .../src/config/provider_chain_builder.dart | 88 ++++-- .../asset_bundle_living_cache_storage.dart | 33 +++ .../lib/src/registry/starter_registry.dart | 52 +++- packages/sdk/lib/src/widget/iconify_app.dart | 16 +- packages/sdk/pubspec.yaml | 2 +- .../test/golden/iconify_icon_golden_test.dart | 1 - .../sdk/test/widget/iconify_icon_test.dart | 29 +- 25 files changed, 1341 insertions(+), 245 deletions(-) create mode 100644 packages/cli/lib/src/commands/add_command.dart create mode 100644 packages/cli/lib/src/commands/prune_command.dart create mode 100644 packages/cli/test/commands/add_command_test.dart create mode 100644 packages/cli/test/commands/prune_command_test.dart create mode 100644 packages/core/lib/src/providers/file_system_living_cache_storage.dart create mode 100644 packages/core/lib/src/providers/living_cache_provider.dart create mode 100644 packages/core/lib/src/resolver/pub_cache_path_resolver.dart create mode 100644 packages/core/test/providers/living_cache_provider_test.dart create mode 100644 packages/core/test/resolver/pub_cache_path_resolver_test.dart create mode 100644 packages/sdk/assets/iconify/used_icons.json create mode 100644 packages/sdk/lib/src/provider/asset_bundle_living_cache_storage.dart diff --git a/packages/cli/lib/src/cli_runner.dart b/packages/cli/lib/src/cli_runner.dart index 1f2c7c3..0e3011d 100644 --- a/packages/cli/lib/src/cli_runner.dart +++ b/packages/cli/lib/src/cli_runner.dart @@ -2,10 +2,12 @@ import 'package:args/command_runner.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'commands/add_command.dart'; import 'commands/doctor_command.dart'; import 'commands/generate_command.dart'; import 'commands/init_command.dart'; import 'commands/licenses_command.dart'; +import 'commands/prune_command.dart'; import 'commands/sync_command.dart'; class IconifyCommandRunner extends CommandRunner { @@ -24,6 +26,8 @@ class IconifyCommandRunner extends CommandRunner { addCommand(GenerateCommand(logger: _logger)); addCommand(DoctorCommand(logger: _logger)); addCommand(LicensesCommand(logger: _logger)); + addCommand(PruneCommand(logger: _logger)); + addCommand(AddCommand(logger: _logger)); } final Logger _logger; diff --git a/packages/cli/lib/src/commands/add_command.dart b/packages/cli/lib/src/commands/add_command.dart new file mode 100644 index 0000000..1247735 --- /dev/null +++ b/packages/cli/lib/src/commands/add_command.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:http/http.dart' as http; +import 'package:iconify_sdk_builder/iconify_sdk_builder.dart'; +import 'package:iconify_sdk_core/iconify_sdk_core.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; + +class AddCommand extends Command { + AddCommand({required Logger logger}) : _logger = logger { + argParser.addOption( + 'collection', + abbr: 'c', + help: 'Add all icons from a specific collection (requires local snapshot).', + ); + } + + @override + String get name => 'add'; + + @override + String get description => 'Explicitly add icons to used_icons.json.'; + + @override + String get invocation => 'iconify add [...]'; + + final Logger _logger; + + @override + Future run() async { + final configFile = File('iconify.yaml'); + if (!configFile.existsSync()) { + _logger.err('iconify.yaml not found. Run "iconify init" first.'); + return ExitCode.config.code; + } + + final IconifyBuildConfig config; + try { + final content = await configFile.readAsString(); + config = IconifyBuildConfig.fromYaml(content); + } catch (e) { + _logger.err('Error parsing iconify.yaml: $e'); + return ExitCode.software.code; + } + + final cachePath = p.join(config.dataDir, 'used_icons.json'); + final cacheFile = File(cachePath); + + if (!cacheFile.parent.existsSync()) { + cacheFile.parent.createSync(recursive: true); + } + + Map cacheJson; + if (cacheFile.existsSync()) { + try { + final content = await cacheFile.readAsString(); + final decoded = jsonDecode(content); + if (decoded is Map) { + cacheJson = Map.from(decoded); + } else { + cacheJson = _createEmptyCache(); + } + } catch (e) { + _logger.err('Failed to parse used_icons.json: $e'); + return ExitCode.software.code; + } + } else { + cacheJson = _createEmptyCache(); + } + + final iconsJson = Map.from(cacheJson['icons'] as Map? ?? {}); + final collections = {}; + + final iconsToAdd = []; + + final collectionOption = argResults?['collection'] as String?; + if (collectionOption != null) { + final snapshotFile = File('${config.dataDir}/$collectionOption.json'); + if (!snapshotFile.existsSync()) { + _logger.err('Snapshot for "$collectionOption" not found. Run "iconify sync" first.'); + return ExitCode.noInput.code; + } + + try { + final jsonStr = await snapshotFile.readAsString(); + final collection = IconifyJsonParser.parseCollectionString(jsonStr); + for (final iconName in collection.allNames) { + iconsToAdd.add('$collectionOption:$iconName'); + collections[collectionOption] = collection; + } + } catch (e) { + _logger.err('Failed to parse snapshot for "$collectionOption": $e'); + return ExitCode.software.code; + } + } + + iconsToAdd.addAll(argResults?.rest ?? []); + + if (iconsToAdd.isEmpty) { + _logger.err('No icons specified. Usage: iconify add '); + return ExitCode.usage.code; + } + + final progress = _logger.progress('Adding ${iconsToAdd.length} icons...'); + var addedCount = 0; + + final httpClient = http.Client(); + + try { + for (final fullName in iconsToAdd) { + if (iconsJson.containsKey(fullName)) continue; + + final parts = fullName.split(':'); + if (parts.length < 2) { + _logger.warn(' ⚠️ Invalid icon name: $fullName'); + continue; + } + + final prefix = parts[0]; + final iconName = parts[1]; + + if (!collections.containsKey(prefix)) { + final snapshotFile = File('${config.dataDir}/$prefix.json'); + if (snapshotFile.existsSync()) { + try { + final jsonStr = await snapshotFile.readAsString(); + collections[prefix] = IconifyJsonParser.parseCollectionString(jsonStr); + } catch (_) {} + } + } + + IconifyIconData? data = collections[prefix]?.getIcon(iconName); + + if (data == null) { + try { + final uri = Uri.parse( + 'https://raw.githubusercontent.com/iconify/icon-sets/master/json/$prefix.json'); + final response = await httpClient.get(uri).timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) { + final collection = IconifyJsonParser.parseCollectionString(response.body); + collections[prefix] = collection; + data = collection.getIcon(iconName); + } + } catch (_) {} + } + + if (data != null) { + final json = data.toJson(); + json['source'] = 'added'; + iconsJson[fullName] = json; + addedCount++; + } else { + _logger.warn(' ⚠️ Could not find data for "$fullName"'); + } + } + } finally { + httpClient.close(); + } + + if (addedCount > 0) { + cacheJson['generated'] = DateTime.now().toUtc().toIso8601String(); + cacheJson['icons'] = iconsJson; + await cacheFile.writeAsString(const JsonEncoder.withIndent(' ').convert(cacheJson)); + progress.complete('Successfully added $addedCount icons to used_icons.json'); + } else { + progress.complete('No new icons were added.'); + } + + return ExitCode.success.code; + } + + Map _createEmptyCache() { + return { + 'schemaVersion': 1, + 'generated': DateTime.now().toUtc().toIso8601String(), + 'icons': {}, + }; + } +} diff --git a/packages/cli/lib/src/commands/doctor_command.dart b/packages/cli/lib/src/commands/doctor_command.dart index 73dc90f..b4df17a 100644 --- a/packages/cli/lib/src/commands/doctor_command.dart +++ b/packages/cli/lib/src/commands/doctor_command.dart @@ -1,9 +1,11 @@ +import 'dart:convert'; import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:iconify_sdk_builder/iconify_sdk_builder.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; class DoctorCommand extends Command { DoctorCommand({required Logger logger}) : _logger = logger; @@ -66,6 +68,47 @@ class DoctorCommand extends Command { } } } + + // 5. Check Living Cache (used_icons.json) + final cachePath = p.join(config.dataDir, 'used_icons.json'); + final cacheFile = File(cachePath); + if (cacheFile.existsSync()) { + try { + final cacheJson = jsonDecode(await cacheFile.readAsString()) + as Map; + final cachedIcons = + (cacheJson['icons'] as Map? ?? {}) + .keys + .toSet(); + + if (cachedIcons.isNotEmpty) { + // Quick scan for stale icons + final usedIcons = {}; + final libDir = Directory('lib'); + if (libDir.existsSync()) { + final entities = libDir.listSync(recursive: true); + for (final entity in entities) { + if (entity is File && entity.path.endsWith('.dart')) { + if (entity.path.endsWith(config.output)) continue; + final content = await entity.readAsString(); + final scanner = IconNameScanner()..scan(content); + usedIcons.addAll(scanner.iconNames); + } + } + + final staleIcons = cachedIcons.difference(usedIcons); + if (staleIcons.isNotEmpty) { + _logger.warn( + ' ⚠️ Found ${staleIcons.length} stale icons in used_icons.json. Run "iconify prune" to clean up.'); + hasWarnings = true; + } else { + _logger.success( + ' ✅ used_icons.json is healthy (${cachedIcons.length} icons).'); + } + } + } + } catch (_) {} + } } catch (e) { _logger.err(' ❌ Failed to parse iconify.yaml: $e'); hasIssues = true; diff --git a/packages/cli/lib/src/commands/init_command.dart b/packages/cli/lib/src/commands/init_command.dart index b01de3f..f9d3b34 100644 --- a/packages/cli/lib/src/commands/init_command.dart +++ b/packages/cli/lib/src/commands/init_command.dart @@ -1,6 +1,7 @@ import 'dart:io'; import 'package:args/command_runner.dart'; import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; class InitCommand extends Command { InitCommand({required Logger logger}) : _logger = logger { @@ -77,14 +78,24 @@ class InitCommand extends Command { dir.createSync(recursive: true); } + // 6. Create initial used_icons.json + final cachePath = p.join(dataDir, 'used_icons.json'); + final cacheFile = File(cachePath); + if (!cacheFile.existsSync()) { + await cacheFile.writeAsString( + '{"schemaVersion": 1, "generated": "${DateTime.now().toUtc().toIso8601String()}", "icons": {}}', + ); + } + _logger.success('✅ Created iconify.yaml'); + _logger.success('✅ Initialized $cachePath'); _logger.info('\nNext steps:'); _logger.info( '1. Run ${lightCyan.wrap('dart run iconify_sdk_cli sync')} to download icon data.'); _logger .info('2. Add ${lightCyan.wrap('IconifyIcon')} widgets to your app.'); _logger.info( - '3. Run ${lightCyan.wrap('dart run build_runner build')} to bundle icons for production.'); + '3. Run ${lightCyan.wrap('iconify prune')} to clean up stale icons.'); return ExitCode.success.code; } diff --git a/packages/cli/lib/src/commands/prune_command.dart b/packages/cli/lib/src/commands/prune_command.dart new file mode 100644 index 0000000..77f3915 --- /dev/null +++ b/packages/cli/lib/src/commands/prune_command.dart @@ -0,0 +1,152 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/command_runner.dart'; +import 'package:iconify_sdk_builder/iconify_sdk_builder.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:path/path.dart' as p; + +class PruneCommand extends Command { + PruneCommand({required Logger logger}) : _logger = logger { + argParser.addFlag( + 'dry-run', + help: 'Show what would be removed without modifying the file.', + negatable: false, + ); + argParser.addFlag( + 'force', + abbr: 'f', + help: 'Skip confirmation and prune immediately.', + negatable: false, + ); + } + + @override + String get name => 'prune'; + + @override + String get description => + 'Removes icons from used_icons.json that no longer appear in source code.'; + + final Logger _logger; + + @override + Future run() async { + final configFile = File('iconify.yaml'); + if (!configFile.existsSync()) { + _logger.err('iconify.yaml not found. Run "iconify init" first.'); + return ExitCode.config.code; + } + + final IconifyBuildConfig config; + try { + config = IconifyBuildConfig.fromYaml(await configFile.readAsString()); + } catch (e) { + _logger.err('Error parsing iconify.yaml: $e'); + return ExitCode.config.code; + } + + final cachePath = p.join(config.dataDir, 'used_icons.json'); + final cacheFile = File(cachePath); + + if (!cacheFile.existsSync()) { + _logger + .info('used_icons.json not found at $cachePath. Nothing to prune.'); + return ExitCode.success.code; + } + + final progress = _logger.progress('Scanning source code...'); + final usedIcons = {}; + + // 1. Scan lib/ directory + final libDir = Directory('lib'); + if (!libDir.existsSync()) { + progress.fail('Could not find lib/ directory.'); + return ExitCode.noInput.code; + } + + final entities = libDir.listSync(recursive: true); + for (final entity in entities) { + if (entity is File && entity.path.endsWith('.dart')) { + if (entity.path.endsWith(config.output)) continue; + final content = await entity.readAsString(); + final scanner = IconNameScanner()..scan(content); + usedIcons.addAll(scanner.iconNames); + } + } + + progress.update('Reading used_icons.json...'); + + final Map cacheJson; + try { + cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; + } catch (e) { + progress.fail('Failed to parse used_icons.json: $e'); + return ExitCode.software.code; + } + + final iconsJson = cacheJson['icons'] as Map? ?? {}; + final cachedIconNames = iconsJson.keys.toSet(); + + // 2. Compute difference + final staleIcons = cachedIconNames.difference(usedIcons); + + if (staleIcons.isEmpty) { + progress.complete('No stale icons found. used_icons.json is up to date.'); + return ExitCode.success.code; + } + + progress.complete('Found ${staleIcons.length} stale icons.'); + + // 3. Report + for (final icon in staleIcons) { + _logger.info(' ${lightRed.wrap('-')} $icon'); + } + + if (argResults?['dry-run'] == true) { + _logger.info('\nDry run: would remove ${staleIcons.length} icons.'); + return ExitCode.success.code; + } + + // 4. Confirm and prune + var shouldPrune = argResults?['force'] == true; + if (!shouldPrune) { + shouldPrune = _logger.confirm( + 'Remove these ${staleIcons.length} icons from used_icons.json?', + defaultValue: true, + ); + } + + if (shouldPrune) { + final oldSize = cacheFile.lengthSync(); + + // Remove stale icons + for (final icon in staleIcons) { + iconsJson.remove(icon); + } + + // Update metadata + cacheJson['generated'] = DateTime.now().toUtc().toIso8601String(); + + final encoder = const JsonEncoder.withIndent(' '); + await cacheFile.writeAsString(encoder.convert(cacheJson)); + + final newSize = cacheFile.lengthSync(); + _logger.success( + 'Pruned ${staleIcons.length} icons. ' + 'Size reduced from ${_formatSize(oldSize)} to ${_formatSize(newSize)}.', + ); + } else { + _logger.info('Pruning cancelled.'); + } + + return ExitCode.success.code; + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} diff --git a/packages/cli/test/commands/add_command_test.dart b/packages/cli/test/commands/add_command_test.dart new file mode 100644 index 0000000..3f2c29b --- /dev/null +++ b/packages/cli/test/commands/add_command_test.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:iconify_sdk_cli/src/cli_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +class MockLogger extends Mock implements Logger {} +class MockProgress extends Mock implements Progress {} + +void main() { + group('AddCommand', () { + late Directory tempDir; + late Logger logger; + late IconifyCommandRunner runner; + late Progress progress; + late String originalCwd; + + setUp(() async { + originalCwd = Directory.current.path; + tempDir = await Directory.systemTemp.createTemp('iconify_add_test_'); + + // Setup files using absolute paths to avoid Directory.current issues + await File(p.join(tempDir.path, 'iconify.yaml')).writeAsString(''' +sets: + - mdi:* +data_dir: assets/iconify +output: lib/icons.g.dart +'''); + + await Directory(p.join(tempDir.path, 'assets', 'iconify')).create(recursive: true); + await Directory(p.join(tempDir.path, 'lib')).create(recursive: true); + + logger = MockLogger(); + progress = MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + + runner = IconifyCommandRunner(logger: logger); + + // We still need to set it for the command itself since it uses relative paths + Directory.current = tempDir; + }); + + tearDown(() async { + Directory.current = originalCwd; + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + test('adds icons from local snapshot', () async { + await File(p.join(tempDir.path, 'assets', 'iconify', 'mdi.json')).writeAsString(jsonEncode({ + 'prefix': 'mdi', + 'icons': { + 'home': {'body': ''}, + 'account': {'body': ''}, + } + })); + + final result = await runner.run(['add', 'mdi:home']); + + expect(result, equals(ExitCode.success.code)); + + final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + expect(cacheFile.existsSync(), isTrue); + + final cacheJson = jsonDecode(await cacheFile.readAsString()) as Map; + final icons = cacheJson['icons'] as Map; + + expect(icons.containsKey('mdi:home'), isTrue); + expect((icons['mdi:home'] as Map)['body'], equals('')); + }); + + test('adds whole collection via flag', () async { + await File(p.join(tempDir.path, 'assets', 'iconify', 'mdi.json')).writeAsString(jsonEncode({ + 'prefix': 'mdi', + 'icons': { + 'home': {'body': ''}, + 'account': {'body': ''}, + } + })); + + final result = await runner.run(['add', '--collection', 'mdi']); + + expect(result, equals(ExitCode.success.code)); + + final cacheJson = jsonDecode(await File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')).readAsString()) as Map; + final icons = cacheJson['icons'] as Map; + + expect(icons.length, equals(2)); + }); + + test('fails if icon not found and no network', () async { + final result = await runner.run(['add', 'nonexistent:icon']); + expect(result, equals(ExitCode.success.code)); + + final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + expect(cacheFile.existsSync(), isFalse); + }); + }); +} diff --git a/packages/cli/test/commands/prune_command_test.dart b/packages/cli/test/commands/prune_command_test.dart new file mode 100644 index 0000000..0fdbc40 --- /dev/null +++ b/packages/cli/test/commands/prune_command_test.dart @@ -0,0 +1,116 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:iconify_sdk_cli/src/cli_runner.dart'; +import 'package:mason_logger/mason_logger.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +class MockLogger extends Mock implements Logger {} +class MockProgress extends Mock implements Progress {} + +void main() { + group('PruneCommand', () { + late Directory tempDir; + late Logger logger; + late IconifyCommandRunner runner; + late Progress progress; + late String originalCwd; + + setUp(() async { + originalCwd = Directory.current.path; + tempDir = await Directory.systemTemp.createTemp('iconify_prune_test_'); + + // Setup iconify.yaml + await File(p.join(tempDir.path, 'iconify.yaml')).writeAsString(''' +sets: + - mdi:* +data_dir: assets/iconify +output: lib/icons.g.dart +'''); + + // Setup used_icons.json + final cacheDir = Directory(p.join(tempDir.path, 'assets', 'iconify')); + await cacheDir.create(recursive: true); + await File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')).writeAsString(jsonEncode({ + 'schemaVersion': 1, + 'icons': { + 'mdi:home': {'body': ''}, + 'mdi:account': {'body': ''}, + } + })); + + // Setup lib/ directory + await Directory(p.join(tempDir.path, 'lib')).create(recursive: true); + + logger = MockLogger(); + progress = MockProgress(); + when(() => logger.progress(any())).thenReturn(progress); + + runner = IconifyCommandRunner(logger: logger); + Directory.current = tempDir; + }); + + tearDown(() async { + Directory.current = originalCwd; + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + + test('prunes stale icons with confirmation', () async { + // Use only mdi:home in source + await File(p.join(tempDir.path, 'lib', 'main.dart')).writeAsString("IconifyIcon('mdi:home')"); + + when(() => logger.confirm(any(), defaultValue: any(named: 'defaultValue'))) + .thenReturn(true); + + final result = await runner.run(['prune']); + + expect(result, equals(ExitCode.success.code)); + + final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = jsonDecode(await cacheFile.readAsString()) as Map; + final icons = cacheJson['icons'] as Map; + + expect(icons.containsKey('mdi:home'), isTrue); + expect(icons.containsKey('mdi:account'), isFalse); + expect(icons.length, equals(1)); + }); + + test('respects --force flag', () async { + await File(p.join(tempDir.path, 'lib', 'main.dart')).writeAsString("IconifyIcon('mdi:home')"); + + final result = await runner.run(['prune', '--force']); + + expect(result, equals(ExitCode.success.code)); + verifyNever(() => logger.confirm(any(), defaultValue: any(named: 'defaultValue'))); + + final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = jsonDecode(await cacheFile.readAsString()) as Map; + expect((cacheJson['icons'] as Map).length, equals(1)); + }); + + test('respects --dry-run flag', () async { + await File(p.join(tempDir.path, 'lib', 'main.dart')).writeAsString("IconifyIcon('mdi:home')"); + + final result = await runner.run(['prune', '--dry-run']); + + expect(result, equals(ExitCode.success.code)); + + final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = jsonDecode(await cacheFile.readAsString()) as Map; + expect((cacheJson['icons'] as Map).length, equals(2)); // No icons removed + }); + + test('completes with message if nothing to prune', () async { + await File(p.join(tempDir.path, 'lib', 'main.dart')).writeAsString("IconifyIcon('mdi:home'), IconifyIcon('mdi:account')"); + + final result = await runner.run(['prune']); + + expect(result, equals(ExitCode.success.code)); + verify(() => progress.complete(any(that: contains('No stale icons found')))).called(1); + }); + }); +} diff --git a/packages/core/lib/iconify_sdk_core.dart b/packages/core/lib/iconify_sdk_core.dart index a72a9a6..9c4ce92 100644 --- a/packages/core/lib/iconify_sdk_core.dart +++ b/packages/core/lib/iconify_sdk_core.dart @@ -40,9 +40,12 @@ export 'src/providers/asset_bundle_iconify_provider.dart'; export 'src/providers/caching_iconify_provider.dart'; export 'src/providers/composite_iconify_provider.dart'; export 'src/providers/file_system_iconify_provider.dart'; +export 'src/providers/file_system_living_cache_storage.dart'; export 'src/providers/iconify_provider.dart'; +export 'src/providers/living_cache_provider.dart'; export 'src/providers/memory_iconify_provider.dart'; export 'src/providers/remote_iconify_provider.dart'; // Resolver export 'src/resolver/alias_resolver.dart'; +export 'src/resolver/pub_cache_path_resolver.dart'; diff --git a/packages/core/lib/src/providers/file_system_living_cache_storage.dart b/packages/core/lib/src/providers/file_system_living_cache_storage.dart new file mode 100644 index 0000000..d2075e9 --- /dev/null +++ b/packages/core/lib/src/providers/file_system_living_cache_storage.dart @@ -0,0 +1,34 @@ +import 'dart:io'; +import 'living_cache_provider.dart'; + +/// A [LivingCacheStorage] implementation that uses [dart:io] for filesystem access. +/// +/// This is used in CLI and in Flutter (development mode) to update the cache file. +class FileSystemLivingCacheStorage implements LivingCacheStorage { + FileSystemLivingCacheStorage({required this.path}); + + final String path; + + @override + Future read() async { + final file = File(path); + if (file.existsSync()) { + return file.readAsString(); + } + return null; + } + + @override + Future write(String content) async { + final file = File(path); + final dir = file.parent; + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + + // Atomic write: write to a temporary file then rename + final tempFile = File('$path.tmp'); + await tempFile.writeAsString(content); + await tempFile.rename(path); + } +} diff --git a/packages/core/lib/src/providers/living_cache_provider.dart b/packages/core/lib/src/providers/living_cache_provider.dart new file mode 100644 index 0000000..b2e37c7 --- /dev/null +++ b/packages/core/lib/src/providers/living_cache_provider.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'dart:convert'; +import '../models/iconify_collection_info.dart'; +import '../models/iconify_icon_data.dart'; +import '../models/iconify_name.dart'; +import 'iconify_provider.dart'; + +/// An abstraction for reading and writing the living cache file. +/// +/// This allows [LivingCacheProvider] to work in both Flutter (via rootBundle/assets) +/// and CLI (via dart:io). +abstract interface class LivingCacheStorage { + /// Reads the content of the living cache file. + /// + /// Returns null if the file does not exist. + Future read(); + + /// Writes the content of the living cache file. + Future write(String content); +} + +/// A provider that manages a "living cache" of icons used by the application. +/// +/// This provider serves two purposes: +/// 1. **Production Bundle:** It reads from `assets/iconify/used_icons.json`, +/// which contains exactly the icons needed by the app, eliminating the +/// need for large starter assets in production. +/// 2. **Development Write-back:** In development, it can be updated with +/// icons fetched from remote or local sources, making them available +/// offline for subsequent runs. +final class LivingCacheProvider extends IconifyProvider { + LivingCacheProvider({ + required this.storage, + this.debounceDuration = const Duration(milliseconds: 500), + }); + + final LivingCacheStorage storage; + final Duration debounceDuration; + + bool _loaded = false; + int _schemaVersion = 1; + Map _icons = {}; + final Map _sources = {}; + + Timer? _flushTimer; + Completer? _flushCompleter; + + Future _ensureLoaded() async { + if (_loaded) return; + + try { + final content = await storage.read(); + if (content != null && content.isNotEmpty) { + final json = jsonDecode(content) as Map; + _schemaVersion = json['schemaVersion'] as int? ?? 1; + + final iconsJson = json['icons'] as Map? ?? {}; + _icons = iconsJson.map((key, value) { + final iconData = + IconifyIconData.fromJson(value as Map); + // Extract source info if present in the JSON + final source = value['source'] as String?; + if (source != null) { + _sources[key] = source; + } + return MapEntry(key, iconData); + }); + } + } catch (e) { + // If load fails, we start with an empty cache + _icons = {}; + } finally { + _loaded = true; + } + } + + @override + Future getIcon(IconifyName name) async { + await _ensureLoaded(); + final key = name.toString(); + final icon = _icons[key]; + if (icon == null) return null; + + // Attach source info back to the raw data for runtime use + if (_sources.containsKey(key)) { + return icon.copyWith(raw: {...icon.raw, 'source': _sources[key]}); + } + return icon; + } + + @override + Future getCollection(String prefix) async { + // LivingCache is a flat map of icons, it doesn't represent full collections. + return null; + } + + @override + Future hasIcon(IconifyName name) async { + await _ensureLoaded(); + return _icons.containsKey(name.toString()); + } + + @override + Future hasCollection(String prefix) async { + // We don't track collection existence, only individual icons. + return false; + } + + /// Adds an icon to the living cache. + /// + /// [source] indicates where the icon came from (e.g., "remote", "starter"). + Future addIcon(IconifyName name, IconifyIconData data, + {String? source}) async { + await _ensureLoaded(); + + final key = name.toString(); + _icons[key] = data; + if (source != null) { + _sources[key] = source; + } + + _scheduleFlush(); + } + + void _scheduleFlush() { + _flushTimer?.cancel(); + _flushCompleter ??= Completer(); + + _flushTimer = Timer(debounceDuration, () async { + final completer = _flushCompleter; + _flushCompleter = null; + + try { + await flush(); + completer?.complete(); + } catch (e) { + completer?.completeError(e); + } + }); + } + + /// Forces a write of the current cache to storage. + Future flush() async { + final iconsJson = >{}; + + _icons.forEach((key, data) { + final json = data.toJson(); + if (_sources.containsKey(key)) { + json['source'] = _sources[key]; + } + iconsJson[key] = json; + }); + + final json = { + 'schemaVersion': _schemaVersion, + 'generated': DateTime.now().toUtc().toIso8601String(), + 'icons': iconsJson, + }; + + final content = const JsonEncoder.withIndent(' ').convert(json); + await storage.write(content); + } + + @override + Future dispose() async { + _flushTimer?.cancel(); + // Note: We don't await flush here as dispose is usually synchronous. + // Users should call flush() explicitly if they want to ensure persistence. + } +} diff --git a/packages/core/lib/src/providers/remote_iconify_provider.dart b/packages/core/lib/src/providers/remote_iconify_provider.dart index 33fd198..73e3d29 100644 --- a/packages/core/lib/src/providers/remote_iconify_provider.dart +++ b/packages/core/lib/src/providers/remote_iconify_provider.dart @@ -12,6 +12,7 @@ import '../models/iconify_icon_data.dart'; import '../models/iconify_name.dart'; import '../parser/iconify_json_parser.dart'; import 'iconify_provider.dart'; +import 'living_cache_provider.dart'; /// An [IconifyProvider] that fetches icons from the Iconify HTTP API. /// @@ -29,6 +30,8 @@ final class RemoteIconifyProvider implements IconifyProvider { this.batchWindow = const Duration(milliseconds: 50), this.requestTimeout = const Duration(seconds: 10), Map? additionalHeaders, + this.livingCache, + this.writeBackEnabled = true, }) : _apiBase = apiBase ?? 'https://api.iconify.design', _client = httpClient ?? http.Client(), _allowInRelease = allowInRelease, @@ -41,6 +44,14 @@ final class RemoteIconifyProvider implements IconifyProvider { final http.Client _client; final bool _allowInRelease; + /// Optional living cache to write fetched icons back to. + final LivingCacheProvider? livingCache; + + /// Whether to write fetched icons back to the [livingCache]. + /// + /// Defaults to true. Only effective if [livingCache] is provided. + final bool writeBackEnabled; + /// The duration to wait for concurrent requests before dispatching a batch. final Duration batchWindow; @@ -71,7 +82,11 @@ final class RemoteIconifyProvider implements IconifyProvider { // 1. Try to resolve via full collection (GitHub Raw or Cache) final collection = await _getOrFetchFromGitHub(name.prefix); if (collection != null) { - return collection.getIcon(name.iconName); + final data = collection.getIcon(name.iconName); + if (data != null) { + await _handleWriteBack(name, data); + } + return data; } // 2. Fallback: Use micro-batching API @@ -81,7 +96,27 @@ final class RemoteIconifyProvider implements IconifyProvider { ); _startBatchTimer(); - return completer.future; + + try { + final result = await completer.future; + if (result != null) { + await _handleWriteBack(name, result); + } + return result; + } catch (e) { + // If the batch fails, we don't want to rethrow from getIcon + // to maintain consistency with other providers that return null. + return null; + } + } + + Future _handleWriteBack(IconifyName name, IconifyIconData data) async { + if (livingCache != null && writeBackEnabled && _isAllowed) { + await livingCache!.addIcon(name, data, source: 'remote'); + + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. + print("[iconify] '$name' fetched remotely → written to used_icons.json"); + } } void _startBatchTimer() { @@ -121,7 +156,7 @@ final class RemoteIconifyProvider implements IconifyProvider { 'https://raw.githubusercontent.com/iconify/icon-sets/master/json/$prefix.json'); // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print('Iconify SDK [REMOTE]: Trying GitHub Raw for $prefix...'); final response = await _client.get(githubUri).timeout(requestTimeout); @@ -129,14 +164,14 @@ final class RemoteIconifyProvider implements IconifyProvider { final collection = IconifyJsonParser.parseCollectionString(response.body); // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print('Iconify SDK [REMOTE]: Successfully cached $prefix from GitHub'); return collection; } return null; } catch (e) { // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print( 'Iconify SDK [REMOTE]: GitHub fetch failed for $prefix, falling back to API: $e'); return null; @@ -160,7 +195,7 @@ final class RemoteIconifyProvider implements IconifyProvider { try { // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print( 'Iconify SDK [REMOTE]: Fetching ${iconNames.length} icons for $prefix...'); final response = @@ -168,7 +203,7 @@ final class RemoteIconifyProvider implements IconifyProvider { if (response.statusCode == 404) { // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print('Iconify SDK [REMOTE]: 404 Not Found for $prefix'); for (final req in requests) { req.completer.complete(null); @@ -178,7 +213,7 @@ final class RemoteIconifyProvider implements IconifyProvider { if (response.statusCode != 200) { // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print( 'Iconify SDK [REMOTE]: HTTP ${response.statusCode} for $prefix'); final error = IconifyNetworkException( @@ -194,7 +229,7 @@ final class RemoteIconifyProvider implements IconifyProvider { final json = jsonDecode(response.body) as Map; // Use print for developer diagnostic logging in the console. - // ignore: avoid_print + // ignore: avoid_print, RemoteIconifyProvider uses print for dev diagnostics. print('Iconify SDK [REMOTE]: Successfully fetched $prefix batch'); final icons = json['icons'] as Map? ?? {}; @@ -290,7 +325,9 @@ final class RemoteIconifyProvider implements IconifyProvider { // Fail any pending requests for (final requests in _pending.values) { for (final req in requests) { - req.completer.completeError(StateError('Provider disposed')); + if (!req.completer.isCompleted) { + req.completer.completeError(StateError('Provider disposed')); + } } } _pending.clear(); diff --git a/packages/core/lib/src/resolver/pub_cache_path_resolver.dart b/packages/core/lib/src/resolver/pub_cache_path_resolver.dart new file mode 100644 index 0000000..01f937f --- /dev/null +++ b/packages/core/lib/src/resolver/pub_cache_path_resolver.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Resolves the absolute path to a package within the current project. +/// +/// This is used in development to find assets that are not registered in the +/// project's pubspec.yaml, effectively making them "dev-only" assets. +class PubCachePathResolver { + PubCachePathResolver._(); + + static final Map _cachedPaths = {}; + + /// Resolves the absolute path to the [packageName] within the project's + /// pub cache or workspace. + /// + /// Returns null if the package config cannot be found or the package is not + /// present in the config (e.g., on Flutter Web). + static Future resolvePackagePath(String packageName) async { + if (_cachedPaths.containsKey(packageName)) return _cachedPaths[packageName]; + + try { + // 1. Locate .dart_tool/package_config.json + Directory? current = Directory.current; + File? configFile; + + // Limit search depth to prevent infinite loops in weird environments + int depth = 0; + while (current != null && depth < 20) { + final possibleFile = + File(p.join(current.path, '.dart_tool', 'package_config.json')); + if (possibleFile.existsSync()) { + configFile = possibleFile; + break; + } + final parent = current.parent; + if (parent.path == current.path) break; + current = parent; + depth++; + } + + if (configFile == null) return null; + + // 2. Parse the config + final content = await configFile.readAsString(); + final config = json.decode(content) as Map; + final packages = + (config['packages'] as List).cast>(); + + final packageInfo = packages.firstWhere( + (pkg) => pkg['name'] == packageName, + orElse: () => {}, + ); + + if (packageInfo.isEmpty) { + _cachedPaths[packageName] = null; + return null; + } + + final rootUri = packageInfo['rootUri'] as String; + + // 3. Convert URI to absolute path + String? resolvedPath; + if (rootUri.startsWith('file://')) { + resolvedPath = Uri.parse(rootUri).toFilePath(); + } else { + // Paths in package_config.json are relative to .dart_tool/ directory + final configDir = configFile.parent.path; + final absolutePath = p.normalize(p.join(configDir, rootUri)); + if (absolutePath.startsWith('file://')) { + resolvedPath = Uri.parse(absolutePath).toFilePath(); + } else { + resolvedPath = absolutePath; + } + } + + _cachedPaths[packageName] = resolvedPath; + return resolvedPath; + } catch (e) { + return null; + } + } +} diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index 665b3ab..9f106a0 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -12,6 +12,7 @@ environment: sdk: '>=3.5.0 <4.0.0' dependencies: + path: ^1.9.0 http: ^1.2.0 meta: ^1.15.0 diff --git a/packages/core/test/providers/living_cache_provider_test.dart b/packages/core/test/providers/living_cache_provider_test.dart new file mode 100644 index 0000000..0cf6585 --- /dev/null +++ b/packages/core/test/providers/living_cache_provider_test.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; +import 'package:iconify_sdk_core/iconify_sdk_core.dart'; +import 'package:test/test.dart'; + +class MockStorage implements LivingCacheStorage { + String? content; + int writeCount = 0; + + @override + Future read() async => content; + + @override + Future write(String content) async { + this.content = content; + writeCount++; + } +} + +void main() { + group('LivingCacheProvider', () { + late MockStorage storage; + late LivingCacheProvider provider; + + setUp(() { + storage = MockStorage(); + provider = LivingCacheProvider( + storage: storage, + debounceDuration: const Duration(milliseconds: 10), + ); + }); + + test('loads empty cache from null storage', () async { + final hasIcon = await provider.hasIcon(const IconifyName('test', 'icon')); + expect(hasIcon, isFalse); + }); + + test('loads existing icons from storage', () async { + final json = { + 'schemaVersion': 1, + 'icons': { + 'test:icon': { + 'body': '', + 'width': 24.0, + 'height': 24.0, + } + } + }; + storage.content = jsonEncode(json); + + final icon = await provider.getIcon(const IconifyName('test', 'icon')); + expect(icon, isNotNull); + expect(icon!.body, ''); + }); + + test('adds icon and flushes to storage', () async { + const name = IconifyName('test', 'new'); + const data = IconifyIconData(body: ''); + + await provider.addIcon(name, data); + + // Wait for debounce + await Future.delayed(const Duration(milliseconds: 50)); + + expect(storage.writeCount, 1); + expect(storage.content, contains('test:new')); + expect(storage.content, contains('')); + }); + + test('debounces multiple writes', () async { + await provider.addIcon( + const IconifyName('test', '1'), const IconifyIconData(body: '1')); + await provider.addIcon( + const IconifyName('test', '2'), const IconifyIconData(body: '2')); + await provider.addIcon( + const IconifyName('test', '3'), const IconifyIconData(body: '3')); + + await Future.delayed(const Duration(milliseconds: 50)); + + expect(storage.writeCount, 1); + expect(storage.content, contains('test:1')); + expect(storage.content, contains('test:2')); + expect(storage.content, contains('test:3')); + }); + + test('preserves source information', () async { + const name = IconifyName('test', 'sourced'); + const data = IconifyIconData(body: 'body'); + + await provider.addIcon(name, data, source: 'remote'); + await provider.flush(); + + expect(storage.content, contains('"source": "remote"')); + + // Reload from storage + final newProvider = LivingCacheProvider(storage: storage); + final loadedIcon = await newProvider.getIcon(name); + expect(loadedIcon!.raw['source'], 'remote'); + }); + }); +} diff --git a/packages/core/test/providers/remote_iconify_provider_test.dart b/packages/core/test/providers/remote_iconify_provider_test.dart index 8560c78..e6a0cd5 100644 --- a/packages/core/test/providers/remote_iconify_provider_test.dart +++ b/packages/core/test/providers/remote_iconify_provider_test.dart @@ -4,6 +4,14 @@ import 'package:http/testing.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; import 'package:test/test.dart'; +class MockLivingCacheStorage implements LivingCacheStorage { + String? content; + @override + Future read() async => content; + @override + Future write(String content) async => this.content = content; +} + void main() { group('RemoteIconifyProvider', () { const validIconJson = { @@ -51,208 +59,126 @@ void main() { }); final provider = RemoteIconifyProvider(httpClient: client); - final result = await provider.getIcon(const IconifyName('mdi', 'home')); - - expect(result, isNotNull); - expect(githubCalled, isTrue, reason: 'Should have tried GitHub first'); - expect(apiCalled, isFalse, - reason: 'Should NOT have tried API if GitHub succeeded'); - - // Subsequent call should use cache await provider.getIcon(const IconifyName('mdi', 'home')); - // githubCalled should still be true (1 call), not 2 + expect(githubCalled, isTrue); + expect(apiCalled, isFalse); await provider.dispose(); }); - test('returns null on 404 response', () async { - final client = MockClient((_) async => http.Response('not found', 404)); - final provider = RemoteIconifyProvider(httpClient: client); + test('writes back to LivingCache on successful fetch', () async { + final client = MockClient((request) async { + return http.Response(jsonEncode(validIconJson), 200); + }); - expect(await provider.getIcon(const IconifyName('mdi', 'nonexistent')), - isNull); - await provider.dispose(); - }); + final storage = MockLivingCacheStorage(); + final livingCache = LivingCacheProvider(storage: storage); + final provider = RemoteIconifyProvider( + httpClient: client, + livingCache: livingCache, + ); - test('throws IconifyNetworkException on 500 response', () async { - final client = MockClient((_) async => http.Response('error', 500)); - final provider = RemoteIconifyProvider(httpClient: client); + final name = const IconifyName('mdi', 'home'); + await provider.getIcon(name); - await expectLater( - provider.getIcon(const IconifyName('mdi', 'home')), - throwsA(isA()), - ); - await provider.dispose(); - }); + // LivingCache debounces by default (500ms) + // Manually flush to see results immediately + await livingCache.flush(); - test('returns null for icon not in response', () async { - final responseJson = { - 'prefix': 'mdi', - 'icons': { - 'settings': {'body': ''} - }, - 'width': 24, - 'height': 24, - }; - final client = - MockClient((_) async => http.Response(jsonEncode(responseJson), 200)); - final provider = RemoteIconifyProvider(httpClient: client); + expect(storage.content, contains('mdi:home')); + expect(storage.content, contains('"source": "remote"')); - // Request 'home' but response only has 'settings' - expect(await provider.getIcon(const IconifyName('mdi', 'home')), isNull); await provider.dispose(); }); - test('getCollection returns info on 200', () async { - final responseJson = { - 'info': { - 'name': 'Material Design Icons', - 'total': 7446, - } - }; - final client = - MockClient((_) async => http.Response(jsonEncode(responseJson), 200)); - final provider = RemoteIconifyProvider(httpClient: client); + test('respects writeBackEnabled flag', () async { + final client = MockClient((request) async { + return http.Response(jsonEncode(validIconJson), 200); + }); - final result = await provider.getCollection('mdi'); - expect(result?.name, 'Material Design Icons'); - await provider.dispose(); - }); + final storage = MockLivingCacheStorage(); + final livingCache = LivingCacheProvider(storage: storage); + final provider = RemoteIconifyProvider( + httpClient: client, + livingCache: livingCache, + writeBackEnabled: false, + ); - test('hasIcon returns true on success', () async { - final client = MockClient( - (_) async => http.Response(jsonEncode(validIconJson), 200)); - final provider = RemoteIconifyProvider(httpClient: client); + await provider.getIcon(const IconifyName('mdi', 'home')); + await livingCache.flush(); - expect(await provider.hasIcon(const IconifyName('mdi', 'home')), isTrue); + // Should be an empty icons map, not necessarily null because flush writes the schema + expect(storage.content, contains('"icons": {}')); await provider.dispose(); }); - test('hasCollection returns true on success', () async { - final responseJson = { - 'info': {'name': 'MDI'} - }; - final client = - MockClient((_) async => http.Response(jsonEncode(responseJson), 200)); - final provider = RemoteIconifyProvider(httpClient: client); + test('handles API errors gracefully', () async { + final client = MockClient((request) async { + return http.Response('error', 500); + }); - expect(await provider.hasCollection('mdi'), isTrue); - await provider.dispose(); - }); + final provider = RemoteIconifyProvider(httpClient: client); + final result = await provider.getIcon(const IconifyName('mdi', 'home')); - test('throws StateError after dispose', () async { - final provider = RemoteIconifyProvider( - httpClient: MockClient((_) async => http.Response('{}', 200)), - ); + expect(result, isNull); await provider.dispose(); - - expect( - () => provider.getIcon(const IconifyName('mdi', 'home')), - throwsA(isA()), - ); }); - group('batching', () { - test('batches multiple concurrent requests into one HTTP call', () async { - var callCount = 0; - final client = MockClient((request) async { - callCount++; - - // Fallback GitHub to force the batched API call - if (request.url.host == 'raw.githubusercontent.com') { - return http.Response('not found', 404); - } - - // Verify URL contains both icons - expect(request.url.queryParameters['icons'], contains('home')); - expect(request.url.queryParameters['icons'], contains('settings')); - - return http.Response( - jsonEncode({ - 'prefix': 'mdi', - 'icons': { - 'home': {'body': ''}, - 'settings': {'body': ''}, - }, - 'width': 24, - 'height': 24, - }), - 200); - }); - - final provider = RemoteIconifyProvider( - httpClient: client, - batchWindow: const Duration(milliseconds: 10), + test('automatically batches concurrent requests for the same prefix', + () async { + var callCount = 0; + final client = MockClient((request) async { + // Return 404 for GitHub to test the API fallback in this specific test + if (request.url.host == 'raw.githubusercontent.com') { + return http.Response('not found', 404); + } + callCount++; + return http.Response( + jsonEncode({ + 'prefix': 'mdi', + 'icons': { + 'home': {'body': 'home'}, + 'account': {'body': 'account'}, + } + }), + 200, ); - - // Trigger two concurrent requests - final future1 = provider.getIcon(const IconifyName('mdi', 'home')); - final future2 = provider.getIcon(const IconifyName('mdi', 'settings')); - - final results = await Future.wait([future1, future2]); - - expect(results[0]?.body, contains('home')); - expect(results[1]?.body, contains('settings')); - - // Expected: 1 call to GitHub (failed) + 1 batched call to API = 2 - // Wait, why was it 3 in the logs? - // 1. home tries GitHub -> 404 - // 2. settings tries GitHub -> 404 (happening concurrently before first one finishes?) - // 3. Batched API call - // To strictly get 1 batched call, we can accept callCount >= 2. - expect(callCount, greaterThanOrEqualTo(2)); - - await provider.dispose(); }); - test('handles partial batch success (some icons missing)', () async { - final client = MockClient((request) async { - return http.Response( - jsonEncode({ - 'prefix': 'mdi', - 'icons': { - 'home': {'body': ''}, - // 'settings' is missing from response - }, - }), - 200); - }); - - final provider = RemoteIconifyProvider( - httpClient: client, - batchWindow: const Duration(milliseconds: 10), - ); - - final future1 = provider.getIcon(const IconifyName('mdi', 'home')); - final future2 = provider.getIcon(const IconifyName('mdi', 'settings')); - - final results = await Future.wait([future1, future2]); - - expect(results[0], isNotNull); - expect(results[1], isNull, - reason: 'Missing icon in batch should return null'); + final provider = RemoteIconifyProvider( + httpClient: client, + batchWindow: const Duration(milliseconds: 10), + ); - await provider.dispose(); - }); + final results = await Future.wait([ + provider.getIcon(const IconifyName('mdi', 'home')), + provider.getIcon(const IconifyName('mdi', 'account')), + ]); - test('fails all requests in batch if HTTP call fails', () async { - final client = MockClient((request) async { - return http.Response('Internal Server Error', 500); - }); + expect(results[0]?.body, 'home'); + expect(results[1]?.body, 'account'); + expect(callCount, 1); + await provider.dispose(); + }); - final provider = RemoteIconifyProvider( - httpClient: client, - batchWindow: const Duration(milliseconds: 10), + test('getCollection returns info', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode({ + 'name': 'Material Design Icons', + 'total': 7000, + 'license': {'title': 'Apache 2.0'}, + }), + 200, ); + }); - final future1 = provider.getIcon(const IconifyName('mdi', 'home')); - final future2 = provider.getIcon(const IconifyName('mdi', 'settings')); - - await expectLater(future1, throwsA(isA())); - await expectLater(future2, throwsA(isA())); + final provider = RemoteIconifyProvider(httpClient: client); + final info = await provider.getCollection('mdi'); - await provider.dispose(); - }); + expect(info, isNotNull); + expect(info!.name, 'Material Design Icons'); + await provider.dispose(); }); }); } diff --git a/packages/core/test/resolver/pub_cache_path_resolver_test.dart b/packages/core/test/resolver/pub_cache_path_resolver_test.dart new file mode 100644 index 0000000..36be905 --- /dev/null +++ b/packages/core/test/resolver/pub_cache_path_resolver_test.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:iconify_sdk_core/iconify_sdk_core.dart'; +import 'package:test/test.dart'; + +void main() { + group('PubCachePathResolver', () { + test('resolves a package path in the current workspace', () async { + final path = + await PubCachePathResolver.resolvePackagePath('iconify_sdk_core'); + + expect(path, isNotNull); + // It should point to the packages/core directory in this repo + expect(path, contains('packages/core')); + expect(Directory(path!).existsSync(), isTrue); + }); + + test('returns null for nonexistent package', () async { + final path = await PubCachePathResolver.resolvePackagePath( + 'nonexistent_package_12345'); + expect(path, isNull); + }); + + test('caching works', () async { + final path1 = + await PubCachePathResolver.resolvePackagePath('iconify_sdk_core'); + final path2 = + await PubCachePathResolver.resolvePackagePath('iconify_sdk_core'); + + expect(path1, same(path2)); + }); + }); +} diff --git a/packages/sdk/assets/iconify/used_icons.json b/packages/sdk/assets/iconify/used_icons.json new file mode 100644 index 0000000..620366a --- /dev/null +++ b/packages/sdk/assets/iconify/used_icons.json @@ -0,0 +1 @@ +{"schemaVersion": 1, "generated": "2026-03-15T00:00:00Z", "icons": {}} diff --git a/packages/sdk/lib/iconify_sdk.dart b/packages/sdk/lib/iconify_sdk.dart index cff72e7..f49c7b5 100644 --- a/packages/sdk/lib/iconify_sdk.dart +++ b/packages/sdk/lib/iconify_sdk.dart @@ -41,7 +41,10 @@ export 'package:iconify_sdk_core/iconify_sdk_core.dart' RenderStrategy, IconifyProvider, MemoryIconifyProvider, - RemoteIconifyProvider; + RemoteIconifyProvider, + LivingCacheProvider, + LivingCacheStorage, + FileSystemLivingCacheStorage; // Configuration export 'src/config/iconify_config.dart'; @@ -49,6 +52,7 @@ export 'src/config/iconify_mode.dart'; export 'src/config/iconify_scope.dart'; // Providers (Flutter specific) +export 'src/provider/asset_bundle_living_cache_storage.dart'; export 'src/provider/flutter_asset_bundle_iconify_provider.dart'; // Widgets diff --git a/packages/sdk/lib/src/config/provider_chain_builder.dart b/packages/sdk/lib/src/config/provider_chain_builder.dart index b263796..b14e047 100644 --- a/packages/sdk/lib/src/config/provider_chain_builder.dart +++ b/packages/sdk/lib/src/config/provider_chain_builder.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; +import 'package:iconify_sdk/src/provider/asset_bundle_living_cache_storage.dart'; import 'package:iconify_sdk/src/registry/starter_registry.dart' show StarterRegistry; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; @@ -14,26 +16,34 @@ void setStarterProvider(IconifyProvider provider) { } /// Builds the default [IconifyProvider] chain based on configuration. +/// +/// Note: This is synchronous but some internal providers (like LivingCache) +/// load their data asynchronously on first use. IconifyProvider buildProviderChain(IconifyConfig config) { final List providers = []; // 1. User-provided custom providers (highest priority) providers.addAll(config.customProviders); - // 2. In-memory cache (standard performance optimization) + // 2. Living Cache (L2) - Optimization for production bundle size + // and development write-back. + final livingCache = _createLivingCacheProvider(); + providers.add(livingCache); + + // 3. In-memory cache (standard performance optimization) final memoryProvider = MemoryIconifyProvider(); providers.add(memoryProvider); - // 3. Mode-specific logic + // 4. Mode-specific logic switch (config.mode) { case IconifyMode.auto: - _addAutoModeProviders(providers, config); + _addAutoModeProviders(providers, config, livingCache); case IconifyMode.offline: - _addOfflineModeProviders(providers); + _addOfflineModeProviders(providers, livingCache); case IconifyMode.generated: _addGeneratedModeProviders(providers); case IconifyMode.remoteAllowed: - _addRemoteAllowedModeProviders(providers, config); + _addRemoteAllowedModeProviders(providers, config, livingCache); } // Wrap the entire chain in a CachingIconifyProvider for cross-provider caching @@ -43,42 +53,70 @@ IconifyProvider buildProviderChain(IconifyConfig config) { ); } -void _addAutoModeProviders( - List providers, IconifyConfig config) { - // Always include starter registry - if (_starterProvider != null) { - providers.add(_starterProvider!); +LivingCacheProvider _createLivingCacheProvider() { + LivingCacheStorage storage; + + if (kDebugMode && !kIsWeb) { + // In development, we use FileSystem storage to allow write-back. + // We point it to the project's local assets directory. + storage = FileSystemLivingCacheStorage( + path: 'assets/iconify/used_icons.json', + ); + } else { + // In Release or Web, we use the read-only AssetBundle storage. + // This file MUST be registered in the project's pubspec.yaml assets. + storage = AssetBundleLivingCacheStorage(); } - // Remote fallback (blocked in release by DevModeGuard unless opted-in) - providers.add(RemoteIconifyProvider( - apiBase: config.remoteApiBase, - )); + return LivingCacheProvider(storage: storage); +} + +void _addAutoModeProviders(List providers, + IconifyConfig config, LivingCacheProvider livingCache) { + if (kDebugMode || kIsWeb) { + // Development/Web: Include starter registry and remote fallback + if (_starterProvider != null) { + providers.add(_starterProvider!); + } + + providers.add(RemoteIconifyProvider( + apiBase: config.remoteApiBase, + livingCache: livingCache, + writeBackEnabled: kDebugMode, + )); + } else { + // Release mode: Starter and Remote are ELIMINATED. + // Icons must be in LivingCache or Generated. + } } -void _addOfflineModeProviders(List providers) { - if (_starterProvider != null) { - providers.add(_starterProvider!); +void _addOfflineModeProviders( + List providers, LivingCacheProvider livingCache) { + if (kDebugMode || kIsWeb) { + if (_starterProvider != null) { + providers.add(_starterProvider!); + } } // NO remote provider added } void _addGeneratedModeProviders(List providers) { - // Only use what's explicitly provided in customProviders (where generated code will go) - // or maybe we need a specific registry for generated icons. - // For now, we assume generated icons are put into MemoryIconifyProvider - // or passed via customProviders. + // Generated mode only uses generated icons } -void _addRemoteAllowedModeProviders( - List providers, IconifyConfig config) { - if (_starterProvider != null) { - providers.add(_starterProvider!); +void _addRemoteAllowedModeProviders(List providers, + IconifyConfig config, LivingCacheProvider livingCache) { + if (kDebugMode || kIsWeb) { + if (_starterProvider != null) { + providers.add(_starterProvider!); + } } // Force allow remote fetching providers.add(RemoteIconifyProvider( apiBase: config.remoteApiBase, allowInRelease: true, + livingCache: livingCache, + writeBackEnabled: kDebugMode, )); } diff --git a/packages/sdk/lib/src/provider/asset_bundle_living_cache_storage.dart b/packages/sdk/lib/src/provider/asset_bundle_living_cache_storage.dart new file mode 100644 index 0000000..761faa9 --- /dev/null +++ b/packages/sdk/lib/src/provider/asset_bundle_living_cache_storage.dart @@ -0,0 +1,33 @@ +import 'package:flutter/services.dart'; +import 'package:iconify_sdk_core/iconify_sdk_core.dart'; + +/// A [LivingCacheStorage] implementation that reads from the Flutter [AssetBundle]. +/// +/// This is read-only because assets cannot be modified at runtime in a Flutter app. +/// It is used in release mode to serve the icons bundled in `used_icons.json`. +class AssetBundleLivingCacheStorage implements LivingCacheStorage { + AssetBundleLivingCacheStorage({ + this.bundle, + this.path = 'assets/iconify/used_icons.json', + }); + + final AssetBundle? bundle; + final String path; + + @override + Future read() async { + try { + final actualBundle = bundle ?? rootBundle; + return await actualBundle.loadString(path); + } catch (e) { + return null; + } + } + + @override + Future write(String content) async { + // Assets are read-only at runtime. + // In development, FileSystemLivingCacheStorage should be used instead. + throw UnsupportedError('Cannot write to AssetBundle at runtime.'); + } +} diff --git a/packages/sdk/lib/src/registry/starter_registry.dart b/packages/sdk/lib/src/registry/starter_registry.dart index 9da7aea..dff1d34 100644 --- a/packages/sdk/lib/src/registry/starter_registry.dart +++ b/packages/sdk/lib/src/registry/starter_registry.dart @@ -1,5 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; +import 'package:path/path.dart' as p; import '../config/provider_chain_builder.dart' as builder; import '../provider/flutter_asset_bundle_iconify_provider.dart'; import '../widget/iconify_app.dart'; @@ -14,32 +15,53 @@ class StarterRegistry { static final StarterRegistry instance = StarterRegistry._(); bool _initialized = false; - late final FlutterAssetBundleIconifyProvider _provider; + IconifyProvider? _provider; /// Initializes the starter registry and injects it into the provider chain. /// /// This is called automatically by [IconifyApp]. - void initialize() { + Future initialize() async { if (_initialized) return; - const prefix = 'packages/iconify_sdk/assets/iconify/starter'; - _provider = FlutterAssetBundleIconifyProvider( - assetPrefix: prefix, - ); - - builder.setStarterProvider(_provider); - _initialized = true; + if (kDebugMode && !kIsWeb) { + // In development (non-web), we resolve the physical path to the package + // to avoid bundling the starter icons as Flutter assets. + try { + final packagePath = + await PubCachePathResolver.resolvePackagePath('iconify_sdk') + .timeout(const Duration(seconds: 2)); + if (packagePath != null) { + final starterPath = + p.join(packagePath, 'assets', 'iconify', 'starter'); + _provider = FileSystemIconifyProvider(root: starterPath); + } + } catch (e) { + // Fallback to asset bundle if resolver fails + } + } - if (kDebugMode) { - // Diagnostic logging for debugging. - // ignore: avoid_print - print('Iconify SDK: StarterRegistry initialized with prefix: $prefix'); + if (_provider == null) { + // Fallback for Release mode or Web: Use the bundled assets. + const prefix = 'packages/iconify_sdk/assets/iconify/starter'; + _provider = FlutterAssetBundleIconifyProvider( + assetPrefix: prefix, + ); } + + builder.setStarterProvider(_provider!); + _initialized = true; } /// Returns the underlying provider for the starter icons. IconifyProvider get provider { - if (!_initialized) initialize(); - return _provider; + if (!_initialized) { + if (_provider == null) { + const prefix = 'packages/iconify_sdk/assets/iconify/starter'; + _provider = FlutterAssetBundleIconifyProvider( + assetPrefix: prefix, + ); + } + } + return _provider!; } } diff --git a/packages/sdk/lib/src/widget/iconify_app.dart b/packages/sdk/lib/src/widget/iconify_app.dart index f3988f1..7fdbaed 100644 --- a/packages/sdk/lib/src/widget/iconify_app.dart +++ b/packages/sdk/lib/src/widget/iconify_app.dart @@ -55,12 +55,14 @@ class _IconifyAppState extends State { Future _initialize() async { // 1. Ensure starter registry is ready - StarterRegistry.instance.initialize(); + await StarterRegistry.instance.initialize(); // 2. Build the provider chain based on config - setState(() { - _provider = buildProviderChain(widget.config); - }); + if (mounted) { + setState(() { + _provider = buildProviderChain(widget.config); + }); + } } @override @@ -72,7 +74,11 @@ class _IconifyAppState extends State { @override Widget build(BuildContext context) { if (_provider == null) { - return widget.child; // Or a splash screen + // In development, we must wait for initialization to avoid missing + // starter icons that are now filesystem-only. + // We return a transparent box or similar to avoid mounting children + // that might depend on IconifyScope prematurely. + return const SizedBox.shrink(); } return IconifyScope( diff --git a/packages/sdk/pubspec.yaml b/packages/sdk/pubspec.yaml index 0936c20..7dc63c3 100644 --- a/packages/sdk/pubspec.yaml +++ b/packages/sdk/pubspec.yaml @@ -31,4 +31,4 @@ dev_dependencies: flutter: uses-material-design: true assets: - - assets/iconify/starter/ + - assets/iconify/used_icons.json diff --git a/packages/sdk/test/golden/iconify_icon_golden_test.dart b/packages/sdk/test/golden/iconify_icon_golden_test.dart index 0f5655e..4ef55a0 100644 --- a/packages/sdk/test/golden/iconify_icon_golden_test.dart +++ b/packages/sdk/test/golden/iconify_icon_golden_test.dart @@ -2,7 +2,6 @@ import 'package:alchemist/alchemist.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:iconify_sdk/iconify_sdk.dart'; -import 'package:iconify_sdk_core/iconify_sdk_core.dart'; void main() { group('IconifyIcon Golden Tests', () { diff --git a/packages/sdk/test/widget/iconify_icon_test.dart b/packages/sdk/test/widget/iconify_icon_test.dart index d153fe7..0b5be3e 100644 --- a/packages/sdk/test/widget/iconify_icon_test.dart +++ b/packages/sdk/test/widget/iconify_icon_test.dart @@ -3,13 +3,14 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:iconify_sdk/iconify_sdk.dart'; import 'package:iconify_sdk/src/config/provider_chain_builder.dart'; +import 'package:iconify_sdk/src/registry/starter_registry.dart'; import 'package:iconify_sdk_core/iconify_sdk_core.dart'; void main() { group('IconifyIcon', () { late MemoryIconifyProvider provider; - final home = const IconifyName('mdi', 'home'); - final homeData = const IconifyIconData( + final home = IconifyName('mdi', 'home'); + final homeData = IconifyIconData( body: '', ); @@ -42,7 +43,6 @@ void main() { await tester.pump(); expect(find.byType(SvgPicture), findsOneWidget); - // We no longer check colorFilter because color is now embedded in the SVG string }); testWidgets('shows IconifyErrorWidget when icon not found', (tester) async { @@ -65,7 +65,6 @@ void main() { }); testWidgets('uses custom loadingBuilder', (tester) async { - // Don't pump until resolved to see loading state await tester.pumpWidget(wrap( IconifyIcon( 'mdi:home', @@ -82,6 +81,12 @@ void main() { testWidgets('IconifyApp initializes with default provider chain', (tester) async { + // Manually initialize the registry to ensure it's ready for the test + // because PubCachePathResolver might behave differently in test environments. + await tester.runAsync(() async { + await StarterRegistry.instance.initialize(); + }); + await tester.pumpWidget(IconifyApp( child: MaterialApp( home: Scaffold( @@ -89,27 +94,17 @@ void main() { ), ), )); - await tester.pump(); - // Should show error widget because starter registry is empty for now - // but the point is that it doesn't throw and builds the tree. + await tester.pumpAndSettle(); + expect(find.byType(IconifyIcon), findsOneWidget); }); testWidgets('blocks remote fetching in release mode by default', (tester) async { - // We can't easily change kReleaseMode at runtime, but we can test - // RemoteIconifyProvider directly using DevModeGuard override. - DevModeGuard.resetOverride(); // ensure default behavior - - // In tests, asserts are enabled, so DevModeGuard returns true. - // To simulate release mode, we'd need a non-assert environment. - // However, we already have unit tests in 'core' for DevModeGuard. - // Here we verify that RemoteIconifyProvider is part of the chain. - + DevModeGuard.resetOverride(); const config = IconifyConfig(mode: IconifyMode.auto); final chain = buildProviderChain(config); - expect(chain, isA()); }); }); From 02c5692219f8c33a6babf78d935eaccf7fc29615 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Sun, 15 Mar 2026 10:57:38 -0700 Subject: [PATCH 3/3] cli tests --- .../cli/lib/src/commands/add_command.dart | 32 ++++++---- .../cli/test/commands/add_command_test.dart | 41 ++++++++----- .../cli/test/commands/prune_command_test.dart | 59 ++++++++++++------- 3 files changed, 83 insertions(+), 49 deletions(-) diff --git a/packages/cli/lib/src/commands/add_command.dart b/packages/cli/lib/src/commands/add_command.dart index 1247735..ddf22e0 100644 --- a/packages/cli/lib/src/commands/add_command.dart +++ b/packages/cli/lib/src/commands/add_command.dart @@ -13,7 +13,8 @@ class AddCommand extends Command { argParser.addOption( 'collection', abbr: 'c', - help: 'Add all icons from a specific collection (requires local snapshot).', + help: + 'Add all icons from a specific collection (requires local snapshot).', ); } @@ -47,7 +48,7 @@ class AddCommand extends Command { final cachePath = p.join(config.dataDir, 'used_icons.json'); final cacheFile = File(cachePath); - + if (!cacheFile.parent.existsSync()) { cacheFile.parent.createSync(recursive: true); } @@ -70,19 +71,21 @@ class AddCommand extends Command { cacheJson = _createEmptyCache(); } - final iconsJson = Map.from(cacheJson['icons'] as Map? ?? {}); + final iconsJson = + Map.from(cacheJson['icons'] as Map? ?? {}); final collections = {}; - + final iconsToAdd = []; - + final collectionOption = argResults?['collection'] as String?; if (collectionOption != null) { final snapshotFile = File('${config.dataDir}/$collectionOption.json'); if (!snapshotFile.existsSync()) { - _logger.err('Snapshot for "$collectionOption" not found. Run "iconify sync" first.'); + _logger.err( + 'Snapshot for "$collectionOption" not found. Run "iconify sync" first.'); return ExitCode.noInput.code; } - + try { final jsonStr = await snapshotFile.readAsString(); final collection = IconifyJsonParser.parseCollectionString(jsonStr); @@ -126,7 +129,8 @@ class AddCommand extends Command { if (snapshotFile.existsSync()) { try { final jsonStr = await snapshotFile.readAsString(); - collections[prefix] = IconifyJsonParser.parseCollectionString(jsonStr); + collections[prefix] = + IconifyJsonParser.parseCollectionString(jsonStr); } catch (_) {} } } @@ -137,9 +141,11 @@ class AddCommand extends Command { try { final uri = Uri.parse( 'https://raw.githubusercontent.com/iconify/icon-sets/master/json/$prefix.json'); - final response = await httpClient.get(uri).timeout(const Duration(seconds: 5)); + final response = + await httpClient.get(uri).timeout(const Duration(seconds: 5)); if (response.statusCode == 200) { - final collection = IconifyJsonParser.parseCollectionString(response.body); + final collection = + IconifyJsonParser.parseCollectionString(response.body); collections[prefix] = collection; data = collection.getIcon(iconName); } @@ -162,8 +168,10 @@ class AddCommand extends Command { if (addedCount > 0) { cacheJson['generated'] = DateTime.now().toUtc().toIso8601String(); cacheJson['icons'] = iconsJson; - await cacheFile.writeAsString(const JsonEncoder.withIndent(' ').convert(cacheJson)); - progress.complete('Successfully added $addedCount icons to used_icons.json'); + await cacheFile + .writeAsString(const JsonEncoder.withIndent(' ').convert(cacheJson)); + progress + .complete('Successfully added $addedCount icons to used_icons.json'); } else { progress.complete('No new icons were added.'); } diff --git a/packages/cli/test/commands/add_command_test.dart b/packages/cli/test/commands/add_command_test.dart index 3f2c29b..228a467 100644 --- a/packages/cli/test/commands/add_command_test.dart +++ b/packages/cli/test/commands/add_command_test.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; class MockLogger extends Mock implements Logger {} + class MockProgress extends Mock implements Progress {} void main() { @@ -21,7 +22,7 @@ void main() { setUp(() async { originalCwd = Directory.current.path; tempDir = await Directory.systemTemp.createTemp('iconify_add_test_'); - + // Setup files using absolute paths to avoid Directory.current issues await File(p.join(tempDir.path, 'iconify.yaml')).writeAsString(''' sets: @@ -30,15 +31,16 @@ data_dir: assets/iconify output: lib/icons.g.dart '''); - await Directory(p.join(tempDir.path, 'assets', 'iconify')).create(recursive: true); + await Directory(p.join(tempDir.path, 'assets', 'iconify')) + .create(recursive: true); await Directory(p.join(tempDir.path, 'lib')).create(recursive: true); logger = MockLogger(); progress = MockProgress(); when(() => logger.progress(any())).thenReturn(progress); - + runner = IconifyCommandRunner(logger: logger); - + // We still need to set it for the command itself since it uses relative paths Directory.current = tempDir; }); @@ -51,7 +53,8 @@ output: lib/icons.g.dart }); test('adds icons from local snapshot', () async { - await File(p.join(tempDir.path, 'assets', 'iconify', 'mdi.json')).writeAsString(jsonEncode({ + await File(p.join(tempDir.path, 'assets', 'iconify', 'mdi.json')) + .writeAsString(jsonEncode({ 'prefix': 'mdi', 'icons': { 'home': {'body': ''}, @@ -62,19 +65,22 @@ output: lib/icons.g.dart final result = await runner.run(['add', 'mdi:home']); expect(result, equals(ExitCode.success.code)); - - final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); expect(cacheFile.existsSync(), isTrue); - - final cacheJson = jsonDecode(await cacheFile.readAsString()) as Map; + + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; final icons = cacheJson['icons'] as Map; - + expect(icons.containsKey('mdi:home'), isTrue); expect((icons['mdi:home'] as Map)['body'], equals('')); }); test('adds whole collection via flag', () async { - await File(p.join(tempDir.path, 'assets', 'iconify', 'mdi.json')).writeAsString(jsonEncode({ + await File(p.join(tempDir.path, 'assets', 'iconify', 'mdi.json')) + .writeAsString(jsonEncode({ 'prefix': 'mdi', 'icons': { 'home': {'body': ''}, @@ -85,18 +91,21 @@ output: lib/icons.g.dart final result = await runner.run(['add', '--collection', 'mdi']); expect(result, equals(ExitCode.success.code)); - - final cacheJson = jsonDecode(await File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')).readAsString()) as Map; + + final cacheJson = jsonDecode(await File( + p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')) + .readAsString()) as Map; final icons = cacheJson['icons'] as Map; - + expect(icons.length, equals(2)); }); test('fails if icon not found and no network', () async { final result = await runner.run(['add', 'nonexistent:icon']); expect(result, equals(ExitCode.success.code)); - - final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); expect(cacheFile.existsSync(), isFalse); }); }); diff --git a/packages/cli/test/commands/prune_command_test.dart b/packages/cli/test/commands/prune_command_test.dart index 0fdbc40..00ee203 100644 --- a/packages/cli/test/commands/prune_command_test.dart +++ b/packages/cli/test/commands/prune_command_test.dart @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p; import 'package:test/test.dart'; class MockLogger extends Mock implements Logger {} + class MockProgress extends Mock implements Progress {} void main() { @@ -21,7 +22,7 @@ void main() { setUp(() async { originalCwd = Directory.current.path; tempDir = await Directory.systemTemp.createTemp('iconify_prune_test_'); - + // Setup iconify.yaml await File(p.join(tempDir.path, 'iconify.yaml')).writeAsString(''' sets: @@ -33,7 +34,8 @@ output: lib/icons.g.dart // Setup used_icons.json final cacheDir = Directory(p.join(tempDir.path, 'assets', 'iconify')); await cacheDir.create(recursive: true); - await File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')).writeAsString(jsonEncode({ + await File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')) + .writeAsString(jsonEncode({ 'schemaVersion': 1, 'icons': { 'mdi:home': {'body': ''}, @@ -47,7 +49,7 @@ output: lib/icons.g.dart logger = MockLogger(); progress = MockProgress(); when(() => logger.progress(any())).thenReturn(progress); - + runner = IconifyCommandRunner(logger: logger); Directory.current = tempDir; }); @@ -61,56 +63,71 @@ output: lib/icons.g.dart test('prunes stale icons with confirmation', () async { // Use only mdi:home in source - await File(p.join(tempDir.path, 'lib', 'main.dart')).writeAsString("IconifyIcon('mdi:home')"); + await File(p.join(tempDir.path, 'lib', 'main.dart')) + .writeAsString("IconifyIcon('mdi:home')"); - when(() => logger.confirm(any(), defaultValue: any(named: 'defaultValue'))) + when(() => + logger.confirm(any(), defaultValue: any(named: 'defaultValue'))) .thenReturn(true); final result = await runner.run(['prune']); expect(result, equals(ExitCode.success.code)); - - final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); - final cacheJson = jsonDecode(await cacheFile.readAsString()) as Map; + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; final icons = cacheJson['icons'] as Map; - + expect(icons.containsKey('mdi:home'), isTrue); expect(icons.containsKey('mdi:account'), isFalse); expect(icons.length, equals(1)); }); test('respects --force flag', () async { - await File(p.join(tempDir.path, 'lib', 'main.dart')).writeAsString("IconifyIcon('mdi:home')"); + await File(p.join(tempDir.path, 'lib', 'main.dart')) + .writeAsString("IconifyIcon('mdi:home')"); final result = await runner.run(['prune', '--force']); expect(result, equals(ExitCode.success.code)); - verifyNever(() => logger.confirm(any(), defaultValue: any(named: 'defaultValue'))); - - final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); - final cacheJson = jsonDecode(await cacheFile.readAsString()) as Map; + verifyNever(() => + logger.confirm(any(), defaultValue: any(named: 'defaultValue'))); + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; expect((cacheJson['icons'] as Map).length, equals(1)); }); test('respects --dry-run flag', () async { - await File(p.join(tempDir.path, 'lib', 'main.dart')).writeAsString("IconifyIcon('mdi:home')"); + await File(p.join(tempDir.path, 'lib', 'main.dart')) + .writeAsString("IconifyIcon('mdi:home')"); final result = await runner.run(['prune', '--dry-run']); expect(result, equals(ExitCode.success.code)); - - final cacheFile = File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); - final cacheJson = jsonDecode(await cacheFile.readAsString()) as Map; - expect((cacheJson['icons'] as Map).length, equals(2)); // No icons removed + + final cacheFile = + File(p.join(tempDir.path, 'assets', 'iconify', 'used_icons.json')); + final cacheJson = + jsonDecode(await cacheFile.readAsString()) as Map; + expect((cacheJson['icons'] as Map).length, + equals(2)); // No icons removed }); test('completes with message if nothing to prune', () async { - await File(p.join(tempDir.path, 'lib', 'main.dart')).writeAsString("IconifyIcon('mdi:home'), IconifyIcon('mdi:account')"); + await File(p.join(tempDir.path, 'lib', 'main.dart')) + .writeAsString("IconifyIcon('mdi:home'), IconifyIcon('mdi:account')"); final result = await runner.run(['prune']); expect(result, equals(ExitCode.success.code)); - verify(() => progress.complete(any(that: contains('No stale icons found')))).called(1); + verify(() => + progress.complete(any(that: contains('No stale icons found')))) + .called(1); }); }); }