| /// Bundles example projects from `examples/` into TAR files and writes the |
| /// manifest (`examples.json`) into the frontend's `web/examples/` directory so |
| /// they are served as static assets. |
| /// |
| /// The source of truth is `examples/examples.yaml` which defines each |
| /// example's `id`, `title`, and an optional `prompt`. This script generates |
| /// `examples.json` from it (so the frontend only needs to parse JSON). |
| /// |
| /// Large examples may be checked in as a prebuilt TAR only. If an example source |
| /// directory is missing but `packages/frontend/web/examples/<id>.tar` already |
| /// exists, the script keeps the checked-in TAR and does not fail. |
| /// |
| /// Run from the project root: |
| /// dart run tool/bundle_examples.dart |
| /// |
| /// Only examples whose contents have changed since the last run are re-bundled. |
| /// A `.checksums.json` file in the output directory tracks content hashes. |
| library; |
| |
| import 'dart:convert'; |
| import 'dart:io'; |
| import 'dart:typed_data'; |
| |
| import 'package:archive/archive.dart'; |
| import 'package:crypto/crypto.dart'; |
| import 'package:yaml/yaml.dart'; |
| |
| const _examplesDir = 'examples'; |
| const _outputDir = 'packages/frontend/web/examples'; |
| const _yamlManifestName = 'examples.yaml'; |
| const _jsonManifestName = 'examples.json'; |
| const _checksumsName = '.checksums.json'; |
| |
| void main() { |
| final manifest = _readYamlManifest(); |
| final outputDir = _ensureOutputDir(); |
| final oldChecksums = _loadChecksums(outputDir); |
| final newChecksums = <String, String>{}; |
| |
| // Generate examples.json from the YAML source of truth. |
| _generateJsonManifest(manifest, outputDir); |
| |
| var bundled = 0; |
| var skipped = 0; |
| var prebundled = 0; |
| |
| for (final entry in manifest) { |
| final id = entry['id'] as String; |
| final sourceDir = Directory('$_examplesDir/$id'); |
| final outputTar = File('${outputDir.path}/$id.tar'); |
| |
| if (!sourceDir.existsSync()) { |
| _validatePrebundledExample(id, outputTar); |
| newChecksums[id] = oldChecksums[id] ?? _computeFileChecksum(outputTar); |
| stdout.writeln('↷ Using checked-in TAR for $id (source directory missing)'); |
| prebundled++; |
| continue; |
| } |
| |
| _validateExampleSource(id, sourceDir); |
| |
| final checksum = _computeChecksum(sourceDir); |
| newChecksums[id] = checksum; |
| |
| if (oldChecksums[id] == checksum && outputTar.existsSync()) { |
| skipped++; |
| } else { |
| _createTar(sourceDir, outputTar.path); |
| stdout.writeln('✓ Bundled $id'); |
| bundled++; |
| } |
| } |
| |
| _removeStaleTars(oldChecksums, newChecksums, outputDir); |
| _saveChecksums(outputDir, newChecksums); |
| |
| stdout.writeln('\nDone. $bundled bundled, $skipped unchanged, $prebundled prebundled.'); |
| } |
| |
| // --------------------------------------------------------------------------- |
| // YAML manifest reading & JSON generation |
| // --------------------------------------------------------------------------- |
| |
| /// Reads `examples/examples.yaml` and returns a list of example entries. |
| List<Map<String, dynamic>> _readYamlManifest() { |
| final file = File('$_examplesDir/$_yamlManifestName'); |
| if (!file.existsSync()) { |
| stderr.writeln('ERROR: $_examplesDir/$_yamlManifestName not found.'); |
| exit(1); |
| } |
| |
| final yamlContent = loadYaml(file.readAsStringSync()) as YamlMap; |
| final examples = yamlContent['examples'] as YamlList; |
| |
| if (examples.isEmpty) { |
| stderr.writeln('WARNING: $_yamlManifestName has no examples — nothing to bundle.'); |
| exit(0); |
| } |
| |
| return [ |
| for (final entry in examples.cast<YamlMap>()) |
| { |
| 'id': entry['id'] as String, |
| 'title': entry['title'] as String, |
| if (entry['prompt'] != null && (entry['prompt'] as String).isNotEmpty) 'prompt': entry['prompt'] as String, |
| if (entry['image'] != null) ..._readImage(entry['id'] as String, entry['image'] as String), |
| }, |
| ]; |
| } |
| |
| /// Reads an image file reference from an example directory and returns JSON |
| /// fields with the MIME type and file name. The actual image data is read |
| /// from the TAR at runtime by the frontend. |
| Map<String, String> _readImage(String exampleId, String imageName) { |
| final imageFile = File('$_examplesDir/$exampleId/$imageName'); |
| final sourceDirExists = Directory('$_examplesDir/$exampleId').existsSync(); |
| if (sourceDirExists && !imageFile.existsSync()) { |
| stderr.writeln('ERROR: Image file $_examplesDir/$exampleId/$imageName not found.'); |
| exit(1); |
| } |
| |
| final mimeType = _mimeTypeForExtension(imageName); |
| |
| stdout.writeln(' ↳ Image $imageName will be loaded from TAR at runtime'); |
| |
| return { |
| 'imageMimeType': mimeType, |
| 'imageFileName': imageName, |
| }; |
| } |
| |
| /// Returns a MIME type for common image file extensions. |
| String _mimeTypeForExtension(String fileName) { |
| final ext = fileName.split('.').last.toLowerCase(); |
| return switch (ext) { |
| 'png' => 'image/png', |
| 'jpg' || 'jpeg' => 'image/jpeg', |
| 'webp' => 'image/webp', |
| 'heic' => 'image/heic', |
| 'heif' => 'image/heif', |
| _ => 'image/png', |
| }; |
| } |
| |
| /// Writes `examples.json` into the output directory from the parsed YAML data. |
| void _generateJsonManifest(List<Map<String, dynamic>> manifest, Directory outputDir) { |
| final jsonContent = const JsonEncoder.withIndent(' ').convert(manifest); |
| File('${outputDir.path}/$_jsonManifestName').writeAsStringSync(jsonContent); |
| stdout.writeln('✓ Generated $_jsonManifestName'); |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Validation |
| // --------------------------------------------------------------------------- |
| |
| void _validateExampleSource(String id, Directory dir) { |
| if (!File('${dir.path}/pubspec.yaml').existsSync()) { |
| stderr.writeln( |
| 'ERROR: $_examplesDir/$id/pubspec.yaml not found. ' |
| 'Every example must have a pubspec.yaml in its root.', |
| ); |
| exit(1); |
| } |
| } |
| |
| void _validatePrebundledExample(String id, File outputTar) { |
| if (!outputTar.existsSync()) { |
| stderr.writeln( |
| 'ERROR: Directory $_examplesDir/$id/ does not exist and ${outputTar.path} was not found. ' |
| 'Add the example source directory locally and run this script, or check in a prebuilt TAR.', |
| ); |
| exit(1); |
| } |
| } |
| |
| // --------------------------------------------------------------------------- |
| // Checksums |
| // --------------------------------------------------------------------------- |
| |
| Map<String, String> _loadChecksums(Directory outputDir) { |
| final file = File('${outputDir.path}/$_checksumsName'); |
| if (!file.existsSync()) { |
| return {}; |
| } |
| return (jsonDecode(file.readAsStringSync()) as Map<String, dynamic>).cast<String, String>(); |
| } |
| |
| void _saveChecksums(Directory outputDir, Map<String, String> checksums) { |
| File('${outputDir.path}/$_checksumsName').writeAsStringSync( |
| const JsonEncoder.withIndent(' ').convert(checksums), |
| ); |
| } |
| |
| /// Computes a single SHA-256 hash over all file contents in [dir], |
| /// sorted by relative path for determinism. |
| String _computeChecksum(Directory dir) { |
| final files = |
| dir |
| .listSync(recursive: true) |
| .whereType<File>() |
| .map( |
| (f) => ( |
| path: f.path.substring(dir.path.length + 1).replaceAll('\\', '/'), |
| file: f, |
| ), |
| ) |
| .toList() |
| ..sort((a, b) => a.path.compareTo(b.path)); |
| |
| final allBytes = BytesBuilder(); |
| for (final entry in files) { |
| allBytes.add(utf8.encode(entry.path)); |
| allBytes.add(entry.file.readAsBytesSync()); |
| } |
| |
| return sha256.convert(allBytes.toBytes()).toString(); |
| } |
| |
| String _computeFileChecksum(File file) { |
| return sha256.convert(file.readAsBytesSync()).toString(); |
| } |
| |
| // --------------------------------------------------------------------------- |
| // TAR creation & cleanup |
| // --------------------------------------------------------------------------- |
| |
| Directory _ensureOutputDir() { |
| final dir = Directory(_outputDir); |
| if (!dir.existsSync()) { |
| dir.createSync(recursive: true); |
| } |
| return dir; |
| } |
| |
| void _createTar(Directory sourceDir, String outputPath) { |
| final archive = Archive(); |
| |
| for (final file in sourceDir.listSync(recursive: true).whereType<File>()) { |
| final relativePath = file.path.substring(sourceDir.path.length + 1).replaceAll('\\', '/'); |
| if (_isGeneratedExampleFile(relativePath)) { |
| continue; |
| } |
| archive.addFile(ArchiveFile.bytes(relativePath, file.readAsBytesSync())); |
| } |
| |
| File(outputPath).writeAsBytesSync(TarEncoder().encode(archive)); |
| } |
| |
| bool _isGeneratedExampleFile(String relativePath) { |
| return relativePath == 'pubspec.lock' || |
| relativePath == '.flutter-plugins' || |
| relativePath == '.flutter-plugins-dependencies' || |
| relativePath.startsWith('.dart_tool/') || |
| relativePath.startsWith('build/'); |
| } |
| |
| void _removeStaleTars( |
| Map<String, String> oldChecksums, |
| Map<String, String> newChecksums, |
| Directory outputDir, |
| ) { |
| for (final oldId in oldChecksums.keys) { |
| if (!newChecksums.containsKey(oldId)) { |
| final staleTar = File('${outputDir.path}/$oldId.tar'); |
| if (staleTar.existsSync()) { |
| staleTar.deleteSync(); |
| stdout.writeln('✗ Removed $oldId (no longer in manifest)'); |
| } |
| } |
| } |
| } |