[tool] Refactor WebTemplate to be immutable (#168201)

This flows better. No uncertainty about calling the substitutions
function more than once, etc.
This commit is contained in:
Kevin Moore 2025-05-05 22:28:25 -05:00 committed by GitHub
parent 429d7e886a
commit d80c390dc3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 89 additions and 83 deletions

View File

@ -571,7 +571,7 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)};
// in question. // in question.
final String? serviceWorkerVersion = final String? serviceWorkerVersion =
includeServiceWorkerSettings ? Random().nextInt(1 << 32).toString() : null; includeServiceWorkerSettings ? Random().nextInt(1 << 32).toString() : null;
bootstrapTemplate.applySubstitutions( final String bootstrapContent = bootstrapTemplate.withSubstitutions(
baseHref: '', baseHref: '',
serviceWorkerVersion: serviceWorkerVersion, serviceWorkerVersion: serviceWorkerVersion,
flutterJsFile: flutterJsFile, flutterJsFile: flutterJsFile,
@ -581,7 +581,7 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)};
final File outputFlutterBootstrapJs = fileSystem.file( final File outputFlutterBootstrapJs = fileSystem.file(
fileSystem.path.join(environment.outputDir.path, 'flutter_bootstrap.js'), fileSystem.path.join(environment.outputDir.path, 'flutter_bootstrap.js'),
); );
await outputFlutterBootstrapJs.writeAsString(bootstrapTemplate.content); await outputFlutterBootstrapJs.writeAsString(bootstrapContent);
await for (final FileSystemEntity file in webResources.list(recursive: true)) { await for (final FileSystemEntity file in webResources.list(recursive: true)) {
if (file is File && file.basename == 'index.html') { if (file is File && file.basename == 'index.html') {
@ -592,18 +592,18 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)};
_emitWebTemplateWarning(environment, relativePath, warning); _emitWebTemplateWarning(environment, relativePath, warning);
} }
indexHtmlTemplate.applySubstitutions( final String indexHtmlContent = indexHtmlTemplate.withSubstitutions(
baseHref: environment.defines[kBaseHref] ?? '/', baseHref: environment.defines[kBaseHref] ?? '/',
serviceWorkerVersion: serviceWorkerVersion, serviceWorkerVersion: serviceWorkerVersion,
flutterJsFile: flutterJsFile, flutterJsFile: flutterJsFile,
buildConfig: buildConfig, buildConfig: buildConfig,
flutterBootstrapJs: bootstrapTemplate.content, flutterBootstrapJs: bootstrapContent,
); );
final File outputIndexHtml = fileSystem.file( final File outputIndexHtml = fileSystem.file(
fileSystem.path.join(environment.outputDir.path, relativePath), fileSystem.path.join(environment.outputDir.path, relativePath),
); );
await outputIndexHtml.create(recursive: true); await outputIndexHtml.create(recursive: true);
await outputIndexHtml.writeAsString(indexHtmlTemplate.content); await outputIndexHtml.writeAsString(indexHtmlContent);
} }
} }
} }

View File

@ -132,7 +132,7 @@ class WebAssetServer implements AssetReader {
this._canaryFeatures, { this._canaryFeatures, {
required this.webRenderer, required this.webRenderer,
required this.useLocalCanvasKit, required this.useLocalCanvasKit,
}) : basePath = _getWebTemplate('index.html', _kDefaultIndex).getBaseHref() { }) : basePath = WebTemplate.baseHref(_htmlTemplate('index.html', _kDefaultIndex)) {
// TODO(srujzs): Remove this assertion when the library bundle format is // TODO(srujzs): Remove this assertion when the library bundle format is
// supported without canary mode. // supported without canary mode.
if (_ddcModuleSystem) { if (_ddcModuleSystem) {
@ -671,13 +671,12 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)};
'flutter_bootstrap.js', 'flutter_bootstrap.js',
generateDefaultFlutterBootstrapScript(includeServiceWorkerSettings: false), generateDefaultFlutterBootstrapScript(includeServiceWorkerSettings: false),
); );
bootstrapTemplate.applySubstitutions( return bootstrapTemplate.withSubstitutions(
baseHref: '/', baseHref: '/',
serviceWorkerVersion: null, serviceWorkerVersion: null,
buildConfig: _buildConfigString, buildConfig: _buildConfigString,
flutterJsFile: _flutterJsFile, flutterJsFile: _flutterJsFile,
); );
return bootstrapTemplate.content;
} }
shelf.Response _serveFlutterBootstrapJs() { shelf.Response _serveFlutterBootstrapJs() {
@ -689,16 +688,15 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)};
shelf.Response _serveIndexHtml() { shelf.Response _serveIndexHtml() {
final WebTemplate indexHtml = _getWebTemplate('index.html', _kDefaultIndex); final WebTemplate indexHtml = _getWebTemplate('index.html', _kDefaultIndex);
indexHtml.applySubstitutions(
// Currently, we don't support --base-href for the "run" command.
baseHref: '/',
serviceWorkerVersion: null,
buildConfig: _buildConfigString,
flutterJsFile: _flutterJsFile,
flutterBootstrapJs: _flutterBootstrapJsContent,
);
return shelf.Response.ok( return shelf.Response.ok(
indexHtml.content, indexHtml.withSubstitutions(
// Currently, we don't support --base-href for the "run" command.
baseHref: '/',
serviceWorkerVersion: null,
buildConfig: _buildConfigString,
flutterJsFile: _flutterJsFile,
flutterBootstrapJs: _flutterBootstrapJsContent,
),
headers: <String, String>{HttpHeaders.contentTypeHeader: 'text/html'}, headers: <String, String>{HttpHeaders.contentTypeHeader: 'text/html'},
); );
} }
@ -1375,7 +1373,11 @@ String? _stripBasePath(String path, String basePath) {
} }
WebTemplate _getWebTemplate(String filename, String fallbackContent) { WebTemplate _getWebTemplate(String filename, String fallbackContent) {
final File template = globals.fs.currentDirectory.childDirectory('web').childFile(filename); final String htmlContent = _htmlTemplate(filename, fallbackContent);
final String htmlContent = template.existsSync() ? template.readAsStringSync() : fallbackContent;
return WebTemplate(htmlContent); return WebTemplate(htmlContent);
} }
String _htmlTemplate(String filename, String fallbackContent) {
final File template = globals.fs.currentDirectory.childDirectory('web').childFile(filename);
return template.existsSync() ? template.readAsStringSync() : fallbackContent;
}

View File

@ -4,6 +4,7 @@
import 'package:html/dom.dart'; import 'package:html/dom.dart';
import 'package:html/parser.dart'; import 'package:html/parser.dart';
import 'package:meta/meta.dart';
import 'base/common.dart'; import 'base/common.dart';
import 'base/file_system.dart'; import 'base/file_system.dart';
@ -29,16 +30,12 @@ class WebTemplateWarning {
/// } /// }
/// ``` /// ```
class WebTemplate { class WebTemplate {
WebTemplate(this._content); const WebTemplate(this._content);
String get content => _content; final String _content;
String _content;
Document _getDocument() => parse(_content); static String baseHref(String html) {
final Element? baseElement = parse(html).querySelector('base');
/// Parses the base href from the index.html file.
String getBaseHref() {
final Element? baseElement = _getDocument().querySelector('base');
final String? baseHref = final String? baseHref =
baseElement?.attributes == null ? null : baseElement!.attributes['href']; baseElement?.attributes == null ? null : baseElement!.attributes['href'];
@ -94,20 +91,23 @@ class WebTemplate {
return WebTemplateWarning(warningText, lineCount + 1); return WebTemplateWarning(warningText, lineCount + 1);
} }
/// Applies substitutions to the content of the index.html file. /// Applies substitutions to the content of the index.html file and returns the result.
void applySubstitutions({ @useResult
String withSubstitutions({
required String baseHref, required String baseHref,
required String? serviceWorkerVersion, required String? serviceWorkerVersion,
required File flutterJsFile, required File flutterJsFile,
String? buildConfig, String? buildConfig,
String? flutterBootstrapJs, String? flutterBootstrapJs,
}) { }) {
if (_content.contains(kBaseHrefPlaceholder)) { String newContent = _content;
_content = _content.replaceAll(kBaseHrefPlaceholder, baseHref);
if (newContent.contains(kBaseHrefPlaceholder)) {
newContent = newContent.replaceAll(kBaseHrefPlaceholder, baseHref);
} }
if (serviceWorkerVersion != null) { if (serviceWorkerVersion != null) {
_content = _content newContent = newContent
.replaceFirst( .replaceFirst(
// Support older `var` syntax as well as new `const` syntax // Support older `var` syntax as well as new `const` syntax
RegExp('(const|var) serviceWorkerVersion = null'), RegExp('(const|var) serviceWorkerVersion = null'),
@ -120,21 +120,22 @@ class WebTemplate {
"navigator.serviceWorker.register('flutter_service_worker.js?v=$serviceWorkerVersion')", "navigator.serviceWorker.register('flutter_service_worker.js?v=$serviceWorkerVersion')",
); );
} }
_content = _content.replaceAll( newContent = newContent.replaceAll(
'{{flutter_service_worker_version}}', '{{flutter_service_worker_version}}',
serviceWorkerVersion != null ? '"$serviceWorkerVersion"' : 'null', serviceWorkerVersion != null ? '"$serviceWorkerVersion"' : 'null',
); );
if (buildConfig != null) { if (buildConfig != null) {
_content = _content.replaceAll('{{flutter_build_config}}', buildConfig); newContent = newContent.replaceAll('{{flutter_build_config}}', buildConfig);
} }
if (_content.contains('{{flutter_js}}')) { if (newContent.contains('{{flutter_js}}')) {
_content = _content.replaceAll('{{flutter_js}}', flutterJsFile.readAsStringSync()); newContent = newContent.replaceAll('{{flutter_js}}', flutterJsFile.readAsStringSync());
} }
if (flutterBootstrapJs != null) { if (flutterBootstrapJs != null) {
_content = _content.replaceAll('{{flutter_bootstrap_js}}', flutterBootstrapJs); newContent = newContent.replaceAll('{{flutter_bootstrap_js}}', flutterBootstrapJs);
} }
return newContent;
} }
} }

View File

@ -221,93 +221,96 @@ void main() {
flutterJs.writeAsStringSync('(flutter.js content)'); flutterJs.writeAsStringSync('(flutter.js content)');
test('can parse baseHref', () { test('can parse baseHref', () {
expect(WebTemplate('<base href="/foo/111/">').getBaseHref(), 'foo/111'); expect(WebTemplate.baseHref('<base href="/foo/111/">'), 'foo/111');
expect(WebTemplate(htmlSample1).getBaseHref(), 'foo/222'); expect(WebTemplate.baseHref(htmlSample1), 'foo/222');
expect(WebTemplate(htmlSample2).getBaseHref(), ''); // Placeholder base href. expect(WebTemplate.baseHref(htmlSample2), ''); // Placeholder base href.
}); });
test('handles missing baseHref', () { test('handles missing baseHref', () {
expect(WebTemplate('').getBaseHref(), ''); expect(WebTemplate.baseHref(''), '');
expect(WebTemplate('<base>').getBaseHref(), ''); expect(WebTemplate.baseHref('<base>'), '');
expect(WebTemplate(htmlSample3).getBaseHref(), ''); expect(WebTemplate.baseHref(htmlSample3), '');
}); });
test('throws on invalid baseHref', () { test('throws on invalid baseHref', () {
expect(() => WebTemplate('<base href>').getBaseHref(), throwsToolExit()); expect(() => WebTemplate.baseHref('<base href>'), throwsToolExit());
expect(() => WebTemplate('<base href="">').getBaseHref(), throwsToolExit()); expect(() => WebTemplate.baseHref('<base href="">'), throwsToolExit());
expect(() => WebTemplate('<base href="foo/111">').getBaseHref(), throwsToolExit()); expect(() => WebTemplate.baseHref('<base href="foo/111">'), throwsToolExit());
expect(() => WebTemplate('<base href="foo/111/">').getBaseHref(), throwsToolExit()); expect(() => WebTemplate.baseHref('<base href="foo/111/">'), throwsToolExit());
expect(() => WebTemplate('<base href="/foo/111">').getBaseHref(), throwsToolExit()); expect(() => WebTemplate.baseHref('<base href="/foo/111">'), throwsToolExit());
}); });
test('applies substitutions', () { test('applies substitutions', () {
final WebTemplate indexHtml = WebTemplate(htmlSample2); const WebTemplate indexHtml = WebTemplate(htmlSample2);
indexHtml.applySubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
flutterJsFile: flutterJs,
);
expect( expect(
indexHtml.content, indexHtml.withSubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
flutterJsFile: flutterJs,
),
htmlSample2Replaced(baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz'), htmlSample2Replaced(baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz'),
); );
}); });
test('applies substitutions with legacy var version syntax', () { test('applies substitutions with legacy var version syntax', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyVar); const WebTemplate indexHtml = WebTemplate(htmlSampleLegacyVar);
indexHtml.applySubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
flutterJsFile: flutterJs,
);
expect( expect(
indexHtml.content, indexHtml.withSubstitutions(
baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz',
flutterJsFile: flutterJs,
),
htmlSample2Replaced(baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz'), htmlSample2Replaced(baseHref: '/foo/333/', serviceWorkerVersion: 'v123xyz'),
); );
}); });
test('applies substitutions to inline flutter.js bootstrap script', () { test('applies substitutions to inline flutter.js bootstrap script', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleInlineFlutterJsBootstrap); const WebTemplate indexHtml = WebTemplate(htmlSampleInlineFlutterJsBootstrap);
expect(indexHtml.getWarnings(), isEmpty); expect(indexHtml.getWarnings(), isEmpty);
indexHtml.applySubstitutions( expect(
baseHref: '/', indexHtml.withSubstitutions(
serviceWorkerVersion: '(service worker version)', baseHref: '/',
flutterJsFile: flutterJs, serviceWorkerVersion: '(service worker version)',
buildConfig: '(build config)', flutterJsFile: flutterJs,
buildConfig: '(build config)',
),
htmlSampleInlineFlutterJsBootstrapOutput,
); );
expect(indexHtml.content, htmlSampleInlineFlutterJsBootstrapOutput);
}); });
test('applies substitutions to full flutter_bootstrap.js replacement', () { test('applies substitutions to full flutter_bootstrap.js replacement', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleFullFlutterBootstrapReplacement); const WebTemplate indexHtml = WebTemplate(htmlSampleFullFlutterBootstrapReplacement);
expect(indexHtml.getWarnings(), isEmpty); expect(indexHtml.getWarnings(), isEmpty);
indexHtml.applySubstitutions( expect(
baseHref: '/', indexHtml.withSubstitutions(
serviceWorkerVersion: '(service worker version)', baseHref: '/',
flutterJsFile: flutterJs, serviceWorkerVersion: '(service worker version)',
buildConfig: '(build config)', flutterJsFile: flutterJs,
flutterBootstrapJs: '(flutter bootstrap script)', buildConfig: '(build config)',
flutterBootstrapJs: '(flutter bootstrap script)',
),
htmlSampleFullFlutterBootstrapReplacementOutput,
); );
expect(indexHtml.content, htmlSampleFullFlutterBootstrapReplacementOutput);
}); });
test('re-parses after substitutions', () { test('re-parses after substitutions', () {
final WebTemplate indexHtml = WebTemplate(htmlSample2); const WebTemplate indexHtml = WebTemplate(htmlSample2);
expect(indexHtml.getBaseHref(), ''); // Placeholder base href. expect(WebTemplate.baseHref(htmlSample2), ''); // Placeholder base href.
indexHtml.applySubstitutions( final String substituted = indexHtml.withSubstitutions(
baseHref: '/foo/333/', baseHref: '/foo/333/',
serviceWorkerVersion: 'v123xyz', serviceWorkerVersion: 'v123xyz',
flutterJsFile: flutterJs, flutterJsFile: flutterJs,
); );
// The parsed base href should be updated after substitutions. // The parsed base href should be updated after substitutions.
expect(indexHtml.getBaseHref(), 'foo/333'); expect(WebTemplate.baseHref(substituted), 'foo/333');
}); });
test('warns on legacy service worker patterns', () { test('warns on legacy service worker patterns', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyVar); const WebTemplate indexHtml = WebTemplate(htmlSampleLegacyVar);
final List<WebTemplateWarning> warnings = indexHtml.getWarnings(); final List<WebTemplateWarning> warnings = indexHtml.getWarnings();
expect(warnings.length, 2); expect(warnings.length, 2);
@ -316,7 +319,7 @@ void main() {
}); });
test('warns on legacy FlutterLoader.loadEntrypoint', () { test('warns on legacy FlutterLoader.loadEntrypoint', () {
final WebTemplate indexHtml = WebTemplate(htmlSampleLegacyLoadEntrypoint); const WebTemplate indexHtml = WebTemplate(htmlSampleLegacyLoadEntrypoint);
final List<WebTemplateWarning> warnings = indexHtml.getWarnings(); final List<WebTemplateWarning> warnings = indexHtml.getWarnings();
expect(warnings.length, 1); expect(warnings.length, 1);