blob: 6c73b2b6f2b0c4696c981d1ff816aee56132d7cc [file] [edit]
/// 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)');
}
}
}
}