diff --git a/.analysis-options b/.analysis-options new file mode 100644 index 00000000..518eb901 --- /dev/null +++ b/.analysis-options @@ -0,0 +1,2 @@ +analyzer: + strong-mode: true \ No newline at end of file diff --git a/README.md b/README.md index 8272c5af..0d3b019c 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ In `pubspec.yaml`: ```yaml dependencies: - angel_static: ^1.1.0 + angel_static: ^1.2.0 ``` # Usage @@ -58,3 +58,76 @@ The `VirtualDirectory` API accepts a few named parameters: - **callback**: Runs before sending a file to a client. Use this to set headers, etc. If it returns anything other than `null` or `true`, then the callback's result will be sent to the user, instead of the file contents. - **streamToIO**: If set to `true`, files will be streamed to `res.io`, instead of added to `res.buffer`.. Default is `false`. + +# Transformers +`angel_static` now supports *transformers*. Similarly to `pub serve`, or `package:build`, these +let you dynamically compile assets before sending them to users. For example, in development, you might +consider using transformers to compile CSS files, or to even replace `pub serve`. +Transformers are supported by `VirtualDirectory` and `CachingVirtualDirectory`. + +To create a transformer: +```dart +class MinifierTransformer { + /// Use this to declare outputs, and indicate if your transformer + /// will compile a file. + @override + FileInfo declareOutput(FileInfo file) { + // For example, we might only want to minify HTML files. + if (!file.extensions.endsWith('.min.html')) + return null; + else return file.changeExtension('.min.html'); + } + + /// Actually compile the asset here. + @override + FutureOr transform(FileInfo file) async { + return file + .changeExtension('.min.html') + .changeContent( + file.content + .transform(UTF8.decoder) + .transform(const LineSplitter() + .transform(UTF8.encoder)) + ); + } +} +``` + +To use it: +```dart +configureServer(Angel app) async { + var vDir = new CachingVirtualDirectory( + transformers: [new MinifierTransformer()] + ); + await app.configure(vDir); + + // It is suggested that you await `transformersLoaded`. + // Otherwise, you may receive 404's on paths that should send a compiled asset. + await vDir.transformersLoaded; +} +``` + +## Pre-building +You can pre-build all your assets with one command: + +```dart +configureServer(Angel app) async { + var vDir = new VirtualDirectory(transformers: [...]); + await app.configure(vDir); + + // Build if in production + if (app.isProduction) { + await vDir.buildToDisk(); + } +} +``` + +## In Production +By default, transformers are disabled in production mode. +To force-enable them: + +```dart +configureServer(Angel app) async { + var vDir = new VirtualDirectory(useTransformersInProduction: true, transformers: [...]); +} +``` \ No newline at end of file diff --git a/lib/angel_static.dart b/lib/angel_static.dart index 79485e3d..624906cd 100644 --- a/lib/angel_static.dart +++ b/lib/angel_static.dart @@ -1,5 +1,7 @@ library angel_static; export 'src/cache.dart'; +export 'src/file_info.dart'; +export 'src/file_transformer.dart'; export 'src/serve_static.dart'; export 'src/virtual_directory.dart'; diff --git a/lib/src/cache.dart b/lib/src/cache.dart index f49f0069..f75f2635 100644 --- a/lib/src/cache.dart +++ b/lib/src/cache.dart @@ -5,6 +5,8 @@ import 'package:angel_framework/angel_framework.dart'; import 'package:crypto/crypto.dart'; import 'package:intl/intl.dart'; import 'package:mime/mime.dart'; +import 'file_info.dart'; +import 'file_transformer.dart'; import 'virtual_directory.dart'; final DateFormat _fmt = new DateFormat('EEE, d MMM yyyy HH:mm:ss'); @@ -75,14 +77,16 @@ class CachingVirtualDirectory extends VirtualDirectory { this.useWeakEtags: true, String publicPath, StaticFileCallback callback, - bool streamToIO: false}) + bool streamToIO: false, + Iterable transformers: const []}) : super( source: source, debug: debug == true, indexFileNames: indexFileNames ?? ['index.html'], publicPath: publicPath ?? '/', callback: callback, - streamToIO: streamToIO == true); + streamToIO: streamToIO == true, + transformers: transformers ?? []); @override Future serveFile( @@ -112,7 +116,7 @@ class CachingVirtualDirectory extends VirtualDirectory { if (hasBeenModified) { res.statusCode = HttpStatus.NOT_MODIFIED; - setCachedHeaders(file, stat, req, res); + setCachedHeaders(stat.modified, req, res); return new Future.value(false); } } @@ -124,7 +128,7 @@ class CachingVirtualDirectory extends VirtualDirectory { if (ifModifiedSince.compareTo(stat.modified) >= 0) { res.statusCode = HttpStatus.NOT_MODIFIED; - setCachedHeaders(file, stat, req, res); + setCachedHeaders(stat.modified, req, res); if (_etags.containsKey(file.absolute.path)) res.headers[HttpHeaders.ETAG] = _etags[file.absolute.path]; @@ -143,7 +147,7 @@ class CachingVirtualDirectory extends VirtualDirectory { res.headers ..[HttpHeaders.ETAG] = etag ..[HttpHeaders.CONTENT_TYPE] = lookupMimeType(file.path); - setCachedHeaders(file, stat, req, res); + setCachedHeaders(stat.modified, req, res); if (useWeakEtags == false) { res @@ -160,14 +164,71 @@ class CachingVirtualDirectory extends VirtualDirectory { } void setCachedHeaders( - File file, FileStat stat, RequestContext req, ResponseContext res) { + DateTime modified, RequestContext req, ResponseContext res) { var privacy = accessLevelToString(accessLevel ?? CacheAccessLevel.PUBLIC); var expiry = new DateTime.now().add(new Duration(seconds: maxAge ?? 0)); res.headers ..[HttpHeaders.CACHE_CONTROL] = '$privacy, max-age=${maxAge ?? 0}' ..[HttpHeaders.EXPIRES] = formatDateForHttp(expiry) - ..[HttpHeaders.LAST_MODIFIED] = formatDateForHttp(stat.modified); + ..[HttpHeaders.LAST_MODIFIED] = formatDateForHttp(modified); + } + + @override + Future serveAsset( + FileInfo fileInfo, RequestContext req, ResponseContext res) { + if (onlyInProduction == true && req.app.isProduction == true) { + return super.serveAsset(fileInfo, req, res); + } + + if (noCache == true) { + res.headers[HttpHeaders.CACHE_CONTROL] = 'private, max-age=0, no-cache'; + return super.serveAsset(fileInfo, req, res); + } else { + if (useEtags == true) { + var etags = req.headers[HttpHeaders.IF_NONE_MATCH]; + + if (etags?.isNotEmpty == true) { + bool hasBeenModified = false; + + for (var etag in etags) { + if (etag == '*') + hasBeenModified = true; + else { + hasBeenModified = _etags.containsKey(fileInfo.filename) && + _etags[fileInfo.filename] == etag; + } + } + + if (hasBeenModified) { + res.statusCode = HttpStatus.NOT_MODIFIED; + setCachedHeaders(fileInfo.lastModified, req, res); + return new Future.value(false); + } + } + } + } + + if (req.headers[HttpHeaders.IF_MODIFIED_SINCE] != null) { + try { + var ifModifiedSince = req.headers.ifModifiedSince; + + if (ifModifiedSince.compareTo(fileInfo.lastModified) >= 0) { + res.statusCode = HttpStatus.NOT_MODIFIED; + setCachedHeaders(fileInfo.lastModified, req, res); + + if (_etags.containsKey(fileInfo.filename)) + res.headers[HttpHeaders.ETAG] = _etags[fileInfo.filename]; + + return new Future.value(false); + } + } catch (_) { + throw new AngelHttpException.badRequest( + message: 'Invalid date for If-Modified-Since header.'); + } + } + + return super.serveAsset(fileInfo, req, res); } } diff --git a/lib/src/file_info.dart b/lib/src/file_info.dart new file mode 100644 index 00000000..f0394b64 --- /dev/null +++ b/lib/src/file_info.dart @@ -0,0 +1,91 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:mime/mime.dart'; +import 'package:path/path.dart' as p; + +/// Represents information about a file, regardless of whether it exists in the filesystem +/// or in memory. +abstract class FileInfo { + /// Returns the content of the file. + Stream> get content; + + /// This file's extension. + String get extension; + + /// The name of the file. + String get filename; + + /// The time when this file was last modified. + DateTime get lastModified; + + /// The file's MIME type. + String get mimeType; + + /// Creates a [FileInfo] instance representing a physical file. + factory FileInfo.fromFile(File file) => new _FileInfoImpl( + () => file.openRead(), + file.absolute.path, + lookupMimeType(file.path), + file.statSync().modified); + + /// Creates a [FileInfo] describing a file that might not even exists to begin with. + factory FileInfo.hypothetical(String hypotheticalFileName) => + new _FileInfoImpl(null, hypotheticalFileName, + lookupMimeType(hypotheticalFileName), null); + + /// Returns an identical instance, but with a different filename. + FileInfo changeFilename(String newFilename); + + /// Returns an identical instance, but with a different extension. + FileInfo changeExtension(String newExtension); + + /// Returns an identical instance, but with a different content. + FileInfo changeContent(Stream> newContent); + + /// Returns an identical instance, but with differnet content, set to the given String. + FileInfo changeText(String newText, {Encoding encoding: UTF8}); + + /// Returns an identical instance, but with a different MIME type. + FileInfo changeMimeType(String newMimeType); +} + +class _FileInfoImpl implements FileInfo { + @override + Stream> get content => getContent(); + + @override + final String filename, mimeType; + + @override + final DateTime lastModified; + + final Function getContent; + + _FileInfoImpl(Stream> this.getContent(), this.filename, + this.mimeType, this.lastModified); + + @override + String get extension => p.extension(filename); + + @override + FileInfo changeFilename(String newFilename) => + new _FileInfoImpl(getContent, newFilename, mimeType, lastModified); + + @override + FileInfo changeExtension(String newExtension) => + changeFilename(p.withoutExtension(filename) + newExtension); + + @override + FileInfo changeContent(Stream> newContent) => + new _FileInfoImpl(() => newContent, filename, mimeType, lastModified); + + @override + FileInfo changeText(String newText, {Encoding encoding: UTF8}) => + changeContent(new Stream>.fromIterable( + [(encoding ?? UTF8).encode(newText)])); + + @override + FileInfo changeMimeType(String newMimeType) => + new _FileInfoImpl(getContent, filename, newMimeType, lastModified); +} diff --git a/lib/src/file_transformer.dart b/lib/src/file_transformer.dart new file mode 100644 index 00000000..21be2f85 --- /dev/null +++ b/lib/src/file_transformer.dart @@ -0,0 +1,15 @@ +import 'dart:async'; +import 'file_info.dart'; + +/// A class capable of transforming inputs into new outputs, on-the-fly. +/// +/// Ex. A transformer that compiles Stylus files. +abstract class FileTransformer { + /// Changes the name of a [file] into what it will be once it is transformed. + /// + /// If this transformer will not be consume the file, then return `null`. + FileInfo declareOutput(FileInfo file); + + /// Transforms an input [file] into a new representation. + FutureOr transform(FileInfo file); +} \ No newline at end of file diff --git a/lib/src/virtual_directory.dart b/lib/src/virtual_directory.dart index f1e717b4..9a7f1717 100644 --- a/lib/src/virtual_directory.dart +++ b/lib/src/virtual_directory.dart @@ -2,7 +2,12 @@ import 'dart:async'; import 'dart:io'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_route/angel_route.dart'; +import 'package:cli_util/cli_logging.dart' as cli; import 'package:mime/mime.dart'; +import 'package:pool/pool.dart'; +import 'package:watcher/watcher.dart'; +import 'file_info.dart'; +import 'file_transformer.dart'; typedef StaticFileCallback(File file, RequestContext req, ResponseContext res); @@ -28,8 +33,16 @@ String _pathify(String path) { /// A static server plug-in. class VirtualDirectory implements AngelPlugin { final bool debug; + Angel _app; String _prefix; Directory _source; + final Completer> _transformerLoad = + new Completer>(); + final Map _transformerMap = {}; + Pool _transformerMapMutex; + final List _transformers = []; + List _transformersCache; + StreamSubscription _watch; /// The directory to serve files from. Directory get source => _source; @@ -46,14 +59,35 @@ class VirtualDirectory implements AngelPlugin { /// If set to `true`, files will be streamed to `res.io`, instead of added to `res.buffer`. final bool streamToIO; + /// A collection of [FileTransformer] instances that will be used to dynamically compile assets, if any. **READ-ONLY**. + List get transformers => + _transformersCache ?? + (_transformersCache = + new List.unmodifiable(_transformers)); + + /// If `true` (default: `false`), then transformers will not be disabled in production. + final bool useTransformersInProduction; + + /// Completes when all [transformers] are loaded. + Future> get transformersLoaded { + if ((!_app.isProduction || useTransformersInProduction == true) && + !_transformerLoad.isCompleted) + return _transformerLoad.future; + else + return new Future.value(_transformerMap); + } + VirtualDirectory( {Directory source, this.debug: false, this.indexFileNames: const ['index.html'], this.publicPath: '/', this.callback, - this.streamToIO: false}) { + this.streamToIO: false, + this.useTransformersInProduction: false, + Iterable transformers: const []}) { _prefix = publicPath.replaceAll(_straySlashes, ''); + this._transformers.addAll(transformers ?? []); if (source != null) { _source = source; @@ -65,20 +99,79 @@ class VirtualDirectory implements AngelPlugin { } } - _printDebug(msg) { - if (debug) print(msg); + call(Angel app) async { + serve(_app = app); + app.justBeforeStop.add((_) => close()); } - call(Angel app) async => serve(app); - void serve(Router router) { - _printDebug('Source directory: ${source.absolute.path}'); - _printDebug('Public path prefix: "$_prefix"'); + // _printDebug('Source directory: ${source.absolute.path}'); + // _printDebug('Public path prefix: "$_prefix"'); router.get('$publicPath/*', (RequestContext req, ResponseContext res) async { var path = req.path.replaceAll(_straySlashes, ''); return servePath(path, req, res); }); + + if ((!_app.isProduction || useTransformersInProduction == true) && + _transformers.isNotEmpty) { + // Create mutex, and watch for file changes + _transformerMapMutex = new Pool(1); + _transformerMapMutex.request().then((resx) { + _buildTransformerMap().then((_) => resx.release()); + }); + } + } + + close() async { + if (!_transformerLoad.isCompleted) { + _transformerLoad.completeError(new StateError( + 'VirtualDirectory was closed before all transformers loaded.')); + } + + _transformerMapMutex?.close(); + _watch?.cancel(); + } + + Future _buildTransformerMap() async { + print('VirtualDirectory is loading transformers...'); + + await for (var entity in source.list(recursive: true)) { + if (entity is File) { + _applyTransformers(entity.absolute.uri.toFilePath()); + } + } + + print('VirtualDirectory finished loading transformers.'); + _transformerLoad.complete(_transformerMap); + + _watch = + new DirectoryWatcher(source.absolute.path).events.listen((e) async { + _transformerMapMutex.withResource(() { + _applyTransformers(e.path); + }); + }); + } + + void _applyTransformers(String originalAbsolutePath) { + FileInfo file = new FileInfo.fromFile(new File(originalAbsolutePath)); + FileInfo outFile = file; + var wasClaimed = false; + + do { + wasClaimed = false; + for (var transformer in _transformers) { + var claimed = transformer.declareOutput(outFile); + if (claimed != null) { + outFile = claimed; + wasClaimed = true; + } + } + } while (wasClaimed); + + var finalName = outFile.filename; + if (finalName?.isNotEmpty == true && outFile != file) + _transformerMap[finalName] = originalAbsolutePath; } servePath(String path, RequestContext req, ResponseContext res) async { @@ -95,23 +188,44 @@ class VirtualDirectory implements AngelPlugin { Future serveStat(String absolute, FileStat stat, RequestContext req, ResponseContext res) async { - if (stat.type == FileSystemEntityType.NOT_FOUND) - return true; - else if (stat.type == FileSystemEntityType.DIRECTORY) + if (stat.type == FileSystemEntityType.DIRECTORY) return await serveDirectory(new Directory(absolute), stat, req, res); else if (stat.type == FileSystemEntityType.FILE) return await serveFile(new File(absolute), stat, req, res); else if (stat.type == FileSystemEntityType.LINK) { var link = new Link(absolute); return await servePath(await link.resolveSymbolicLinks(), req, res); + } else if (_transformerMapMutex != null) { + var resx = await _transformerMapMutex.request(); + if (!_transformerMap.containsKey(absolute)) return true; + var sourceFile = new File(_transformerMap[absolute]); + resx.release(); + if (!await sourceFile.exists()) + return true; + else { + return await serveAsset(new FileInfo.fromFile(sourceFile), req, res); + } } else return true; } - Future serveFile( + Future serveDirectory(Directory directory, FileStat stat, + RequestContext req, ResponseContext res) async { + for (String indexFileName in indexFileNames) { + final index = + new File.fromUri(directory.absolute.uri.resolve(indexFileName)); + if (await index.exists()) { + return await serveFile(index, stat, req, res); + } + } + + return true; + } + + Future serveFileOld( File file, FileStat stat, RequestContext req, ResponseContext res) async { - _printDebug('Sending file ${file.absolute.path}...'); - _printDebug('MIME type for ${file.path}: ${lookupMimeType(file.path)}'); + // _printDebug('Sending file ${file.absolute.path}...'); + // _printDebug('MIME type for ${file.path}: ${lookupMimeType(file.path)}'); res.statusCode = 200; if (callback != null) { @@ -135,16 +249,131 @@ class VirtualDirectory implements AngelPlugin { return false; } - Future serveDirectory(Directory directory, FileStat stat, - RequestContext req, ResponseContext res) async { - for (String indexFileName in indexFileNames) { - final index = - new File.fromUri(directory.absolute.uri.resolve(indexFileName)); - if (await index.exists()) { - return await serveFile(index, stat, req, res); + void _ensureContentTypeAllowed(String mimeType, RequestContext req) { + var value = req.headers.value(HttpHeaders.ACCEPT); + bool acceptable = value == null || + value.isEmpty || + value.contains(mimeType) || + value.contains('*/*'); + if (!acceptable) + throw new AngelHttpException( + new UnsupportedError( + 'Client requested $value, but server wanted to send $mimeType.'), + statusCode: HttpStatus.NOT_ACCEPTABLE, + message: '406 Not Acceptable'); + } + + Future serveFile( + File file, FileStat stat, RequestContext req, ResponseContext res) async { + // _printDebug('Sending file ${file.absolute.path}...'); + // _printDebug('MIME type for ${file.path}: ${lookupMimeType(file.path)}'); + res.statusCode = 200; + + if (callback != null) { + var r = callback(file, req, res); + r = r is Future ? await r : r; + if (r != null && r != true) return r; + } + + var type = lookupMimeType(file.path); + _ensureContentTypeAllowed(type, req); + res.headers[HttpHeaders.CONTENT_TYPE] = type; + + if (streamToIO == true) { + res + ..io.headers.set(HttpHeaders.CONTENT_TYPE, lookupMimeType(file.path)) + ..io.headers.set(HttpHeaders.CONTENT_ENCODING, 'gzip') + ..end() + ..willCloseItself = true; + + await file.openRead().transform(GZIP.encoder).pipe(res.io); + } else + await res.sendFile(file); + return false; + } + + Future serveAsset( + FileInfo fileInfo, RequestContext req, ResponseContext res) async { + var file = await compileAsset(fileInfo); + if (file == null) return true; + _ensureContentTypeAllowed(file.mimeType, req); + res.headers[HttpHeaders.CONTENT_TYPE] = file.mimeType; + res.statusCode = 200; + + if (streamToIO == true) { + res + ..statusCode = 200 + ..io.headers.set(HttpHeaders.CONTENT_TYPE, file.mimeType) + ..io.headers.set(HttpHeaders.CONTENT_ENCODING, 'gzip') + ..end() + ..willCloseItself = true; + await file.content.transform(GZIP.encoder).pipe(res.io); + } else { + await file.content.forEach(res.buffer.add); + } + + return false; + } + + /// Applies all [_transformers] to an input [file], if any. + Future compileAsset(FileInfo file) async { + var iterations = 0; + FileInfo result = file; + bool wasTransformed = false; + + do { + wasTransformed = false; + String originalName = file.filename; + for (var transformer in _transformers) { + if (++iterations >= 100) { + print( + 'VirtualDirectory has tried 100 times to compile ${file.filename}. Perhaps one of your transformers is not changing the output file\'s extension.'); + throw new AngelHttpException(new StackOverflowError(), + statusCode: 500); + } else if (iterations < 100) iterations++; + var claimed = transformer.declareOutput(result); + if (claimed != null) { + result = await transformer.transform(result); + wasTransformed = true; + } + } + + // Don't re-compile infinitely... + if (result.filename == originalName) wasTransformed = false; + } while (wasTransformed); + + return result == file ? null : result; + } + + /// Builds assets to disk using [transformers]. + Future buildToDisk() async { + var l = new cli.Logger.standard(); + print('Building assets in "${source.absolute.path}"...'); + + await for (var entity in source.list(recursive: true)) { + if (entity is File) { + var p = l.progress('Building "${entity.absolute.path}"'); + + try { + var asset = new FileInfo.fromFile(entity); + var compiled = await compileAsset(asset); + if (compiled == null) + p.finish(message: '"${entity.absolute.path}" did not require compilation; skipping it.'); + else { + p.finish( + message: + 'Built "${entity.absolute.path}" to "${compiled.filename}".', + showTiming: true); + } + } on AngelHttpException { + // Ignore 500 + } catch (e, st) { + p.finish(message: 'Failed to build "${entity.absolute.path}".'); + stderr..writeln(e)..writeln(st); + } } } - return true; + print('Build of assets in "${source.absolute.path}" complete.'); } } diff --git a/pubspec.yaml b/pubspec.yaml index 8f93d485..03bdcd62 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,13 +2,20 @@ name: angel_static description: Static server middleware for Angel. environment: sdk: ">=1.19.0" -homepage: https://github.com/angel-dart/angel_static -author: thosakwe -version: 1.1.4+2 +homepage: https://github.com/angel-dart/static +author: Tobe O +version: 1.2.0 dependencies: angel_framework: ^1.0.0-dev + cli_util: ^0.1.1 + crypto: ^2.0.0 intl: ">=0.0.0 <1.0.0" mime: ^0.9.3 + path: ^1.4.2 + pool: ^1.0.0 + watcher: ^0.9.7 dev_dependencies: + angel_diagnostics: ^1.0.0 + angel_test: ^1.0.0 http: ^0.11.3 test: ^0.12.13 diff --git a/test/all_test.dart b/test/all_test.dart index 80be5b20..a5fa5516 100644 --- a/test/all_test.dart +++ b/test/all_test.dart @@ -1,4 +1,5 @@ import 'dart:io'; +import 'package:angel_diagnostics/angel_diagnostics.dart'; import 'package:angel_framework/angel_framework.dart'; import 'package:angel_static/angel_static.dart'; import 'package:http/http.dart' show Client; @@ -24,10 +25,11 @@ main() { source: testDir, indexFileNames: ['index.php', 'index.txt'])); - app.get('*', 'Fallback'); + app.after.add('Fallback'); app.dumpTree(showMatchers: true); + await app.configure(logRequests()); await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); url = "http://${app.httpServer.address.host}:${app.httpServer.port}"; }); diff --git a/test/transformer_test.dart b/test/transformer_test.dart new file mode 100644 index 00000000..69b43548 --- /dev/null +++ b/test/transformer_test.dart @@ -0,0 +1,41 @@ +import 'dart:async'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:test/test.dart'; + +main() { + TestClient client; + + setUp(() async { + var app = new Angel(); + var vDir = new CachingVirtualDirectory( + source: new Directory('test'), + transformers: [new ExtensionTransformer()]); + await app.configure(vDir); + await vDir.transformersLoaded.then((map) { + print('Loaded transformer map: $map'); + }); + client = await connectTo(app); + }); + + tearDown(() => client.close()); + + test('foo', () async { + var response = await client.get('/index.ext'); + print('Response: ${response.body}'); + expect(response, hasBody('.txt')); + }); +} + +class ExtensionTransformer implements FileTransformer { + @override + FileInfo declareOutput(FileInfo file) { + return file.extension == '.ext' ? null : file.changeExtension('.ext'); + } + + @override + FutureOr transform(FileInfo file) => + file.changeText(file.extension).changeExtension('.ext'); +}