[web] Put all index.html operations in one place (#118188)

* [web] Put all index.html operations in one place

* review comments

* fix build

* change quotes

* fix test
diff --git a/packages/flutter_tools/lib/src/build_system/targets/web.dart b/packages/flutter_tools/lib/src/build_system/targets/web.dart
index ceb05ac..3812729 100644
--- a/packages/flutter_tools/lib/src/build_system/targets/web.dart
+++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart
@@ -17,6 +17,7 @@
 import '../../dart/package_map.dart';
 import '../../flutter_plugins.dart';
 import '../../globals.dart' as globals;
+import '../../html_utils.dart';
 import '../../project.dart';
 import '../../web/compile.dart';
 import '../../web/file_generators/flutter_js.dart' as flutter_js;
@@ -50,9 +51,6 @@
 /// Base href to set in index.html in flutter build command
 const String kBaseHref = 'baseHref';
 
-/// Placeholder for base href
-const String kBaseHrefPlaceholder = r'$FLUTTER_BASE_HREF';
-
 /// The caching strategy to use for service worker generation.
 const String kServiceWorkerStrategy = 'ServiceWorkerStrategy';
 
@@ -442,25 +440,12 @@
       // because it would need to be the hash for the entire bundle and not just the resource
       // in question.
       if (environment.fileSystem.path.basename(inputFile.path) == 'index.html') {
-        final String randomHash = Random().nextInt(4294967296).toString();
-        String resultString = inputFile.readAsStringSync()
-          .replaceFirst(
-            'var serviceWorkerVersion = null',
-            "var serviceWorkerVersion = '$randomHash'",
-          )
-          // This is for legacy index.html that still use the old service
-          // worker loading mechanism.
-          .replaceFirst(
-            "navigator.serviceWorker.register('flutter_service_worker.js')",
-            "navigator.serviceWorker.register('flutter_service_worker.js?v=$randomHash')",
-          );
-        final String? baseHref = environment.defines[kBaseHref];
-        if (resultString.contains(kBaseHrefPlaceholder) && baseHref == null) {
-          resultString = resultString.replaceAll(kBaseHrefPlaceholder, '/');
-        } else if (resultString.contains(kBaseHrefPlaceholder) && baseHref != null) {
-          resultString = resultString.replaceAll(kBaseHrefPlaceholder, baseHref);
-        }
-        outputFile.writeAsStringSync(resultString);
+        final IndexHtml indexHtml = IndexHtml(inputFile.readAsStringSync());
+        indexHtml.applySubstitutions(
+          baseHref: environment.defines[kBaseHref] ?? '/',
+          serviceWorkerVersion: Random().nextInt(4294967296).toString(),
+        );
+        outputFile.writeAsStringSync(indexHtml.content);
         continue;
       }
       inputFile.copySync(outputFile.path);
diff --git a/packages/flutter_tools/lib/src/commands/build_web.dart b/packages/flutter_tools/lib/src/commands/build_web.dart
index 8c288b1..8515f17 100644
--- a/packages/flutter_tools/lib/src/commands/build_web.dart
+++ b/packages/flutter_tools/lib/src/commands/build_web.dart
@@ -7,6 +7,7 @@
 import '../build_info.dart';
 import '../build_system/targets/web.dart';
 import '../features.dart';
+import '../html_utils.dart';
 import '../project.dart';
 import '../runner/flutter_command.dart'
     show DevelopmentArtifact, FlutterCommandResult;
@@ -127,7 +128,7 @@
         baseHref != null) {
       throwToolExit(
         "Couldn't find the placeholder for base href. "
-        r'Please add `<base href="$FLUTTER_BASE_HREF">` to web/index.html'
+        'Please add `<base href="$kBaseHrefPlaceholder">` to web/index.html'
       );
     }
 
diff --git a/packages/flutter_tools/lib/src/html_utils.dart b/packages/flutter_tools/lib/src/html_utils.dart
new file mode 100644
index 0000000..259f038
--- /dev/null
+++ b/packages/flutter_tools/lib/src/html_utils.dart
@@ -0,0 +1,112 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:html/dom.dart';
+import 'package:html/parser.dart';
+
+import 'base/common.dart';
+
+/// Placeholder for base href
+const String kBaseHrefPlaceholder = r'$FLUTTER_BASE_HREF';
+
+/// Utility class for parsing and performing operations on the contents of the
+/// index.html file.
+///
+/// For example, to parse the base href from the index.html file:
+///
+/// ```dart
+/// String parseBaseHref(File indexHtmlFile) {
+///   final IndexHtml indexHtml = IndexHtml(indexHtmlFile.readAsStringSync());
+///   return indexHtml.getBaseHref();
+/// }
+/// ```
+class IndexHtml {
+  IndexHtml(this._content);
+
+  String get content => _content;
+  String _content;
+
+  Document _getDocument() => parse(_content);
+
+  /// Parses the base href from the index.html file.
+  String getBaseHref() {
+    final Element? baseElement = _getDocument().querySelector('base');
+    final String? baseHref = baseElement?.attributes == null
+        ? null
+        : baseElement!.attributes['href'];
+
+    if (baseHref == null || baseHref == kBaseHrefPlaceholder) {
+      return '';
+    }
+
+    if (!baseHref.startsWith('/')) {
+      throw ToolExit(
+        'Error: The base href in "web/index.html" must be absolute (i.e. start '
+        'with a "/"), but found: `${baseElement!.outerHtml}`.\n'
+        '$_kBasePathExample',
+      );
+    }
+
+    if (!baseHref.endsWith('/')) {
+      throw ToolExit(
+        'Error: The base href in "web/index.html" must end with a "/", but found: `${baseElement!.outerHtml}`.\n'
+        '$_kBasePathExample',
+      );
+    }
+
+    return stripLeadingSlash(stripTrailingSlash(baseHref));
+  }
+
+  /// Applies substitutions to the content of the index.html file.
+  void applySubstitutions({
+    required String baseHref,
+    required String? serviceWorkerVersion,
+  }) {
+    if (_content.contains(kBaseHrefPlaceholder)) {
+      _content = _content.replaceAll(kBaseHrefPlaceholder, baseHref);
+    }
+
+    if (serviceWorkerVersion != null) {
+      _content = _content
+          .replaceFirst(
+            'var serviceWorkerVersion = null',
+            'var serviceWorkerVersion = "$serviceWorkerVersion"',
+          )
+          // This is for legacy index.html that still uses the old service
+          // worker loading mechanism.
+          .replaceFirst(
+            "navigator.serviceWorker.register('flutter_service_worker.js')",
+            "navigator.serviceWorker.register('flutter_service_worker.js?v=$serviceWorkerVersion')",
+          );
+    }
+  }
+}
+
+/// Strips the leading slash from a path.
+String stripLeadingSlash(String path) {
+  while (path.startsWith('/')) {
+    path = path.substring(1);
+  }
+  return path;
+}
+
+/// Strips the trailing slash from a path.
+String stripTrailingSlash(String path) {
+  while (path.endsWith('/')) {
+    path = path.substring(0, path.length - 1);
+  }
+  return path;
+}
+
+const String _kBasePathExample = '''
+For example, to serve from the root use:
+
+    <base href="/">
+
+To serve from a subpath "foo" (i.e. http://localhost:8080/foo/ instead of http://localhost:8080/) use:
+
+    <base href="/foo/">
+
+For more information, see: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
+''';
diff --git a/packages/flutter_tools/lib/src/isolated/devfs_web.dart b/packages/flutter_tools/lib/src/isolated/devfs_web.dart
index 2bf0d38..2f188ea 100644
--- a/packages/flutter_tools/lib/src/isolated/devfs_web.dart
+++ b/packages/flutter_tools/lib/src/isolated/devfs_web.dart
@@ -8,8 +8,6 @@
 import 'package:dwds/data/build_result.dart';
 // ignore: import_of_legacy_library_into_null_safe
 import 'package:dwds/dwds.dart';
-import 'package:html/dom.dart';
-import 'package:html/parser.dart';
 import 'package:logging/logging.dart' as logging;
 import 'package:meta/meta.dart';
 import 'package:mime/mime.dart' as mime;
@@ -29,7 +27,6 @@
 import '../build_info.dart';
 import '../build_system/targets/scene_importer.dart';
 import '../build_system/targets/shader_compiler.dart';
-import '../build_system/targets/web.dart';
 import '../bundle_builder.dart';
 import '../cache.dart';
 import '../compile.dart';
@@ -37,6 +34,7 @@
 import '../dart/package_map.dart';
 import '../devfs.dart';
 import '../globals.dart' as globals;
+import '../html_utils.dart';
 import '../project.dart';
 import '../vmservice.dart';
 import '../web/bootstrap.dart';
@@ -136,9 +134,7 @@
     this._modules,
     this._digests,
     this._nullSafetyMode,
-  ) : basePath = _parseBasePathFromIndexHtml(globals.fs.currentDirectory
-            .childDirectory('web')
-            .childFile('index.html'));
+  ) : basePath = _getIndexHtml().getBaseHref();
 
   // Fallback to "application/octet-stream" on null which
   // makes no claims as to the structure of the data.
@@ -299,7 +295,7 @@
         server,
         PackageUriMapper(packageConfig),
         digestProvider,
-        server.basePath!,
+        server.basePath,
       ).strategy,
       expressionCompiler: expressionCompiler,
       spawnDds: enableDds,
@@ -345,12 +341,11 @@
   @visibleForTesting
   Uint8List? getMetadata(String path) => _webMemoryFS.metadataFiles[path];
 
-  @visibleForTesting
-
   /// The base path to serve from.
   ///
   /// It should have no leading or trailing slashes.
-  String? basePath = '';
+  @visibleForTesting
+  String basePath;
 
   // handle requests for JavaScript source, dart sources maps, or asset files.
   @visibleForTesting
@@ -496,27 +491,20 @@
   WebRendererMode webRenderer = WebRendererMode.html;
 
   shelf.Response _serveIndex() {
+
+    final IndexHtml indexHtml = _getIndexHtml();
+
+    indexHtml.applySubstitutions(
+      // Currently, we don't support --base-href for the "run" command.
+      baseHref: '/',
+      serviceWorkerVersion: null,
+    );
+
     final Map<String, String> headers = <String, String>{
       HttpHeaders.contentTypeHeader: 'text/html',
+      HttpHeaders.contentLengthHeader: indexHtml.content.length.toString(),
     };
-    final File indexFile = globals.fs.currentDirectory
-        .childDirectory('web')
-        .childFile('index.html');
-
-    if (indexFile.existsSync()) {
-      String indexFileContent =  indexFile.readAsStringSync();
-      if (indexFileContent.contains(kBaseHrefPlaceholder)) {
-          indexFileContent =  indexFileContent.replaceAll(kBaseHrefPlaceholder, '/');
-          headers[HttpHeaders.contentLengthHeader] = indexFileContent.length.toString();
-          return shelf.Response.ok(indexFileContent,headers: headers);
-        }
-      headers[HttpHeaders.contentLengthHeader] =
-          indexFile.lengthSync().toString();
-      return shelf.Response.ok(indexFile.openRead(), headers: headers);
-    }
-
-    headers[HttpHeaders.contentLengthHeader] = _kDefaultIndex.length.toString();
-    return shelf.Response.ok(_kDefaultIndex, headers: headers);
+    return shelf.Response.ok(indexHtml.content, headers: headers);
   }
 
   // Attempt to resolve `path` to a dart file.
@@ -783,9 +771,9 @@
       webAssetServer.webRenderer = WebRendererMode.canvaskit;
     }
     if (hostname == 'any') {
-      _baseUri = Uri.http('localhost:$selectedPort', webAssetServer.basePath!);
+      _baseUri = Uri.http('localhost:$selectedPort', webAssetServer.basePath);
     } else {
-      _baseUri = Uri.http('$hostname:$selectedPort', webAssetServer.basePath!);
+      _baseUri = Uri.http('$hostname:$selectedPort', webAssetServer.basePath);
     }
     return _baseUri!;
   }
@@ -977,12 +965,11 @@
   final FileSystemUtils _fileSystemUtils;
   final Platform _platform;
 
-  @visibleForTesting
-
   /// The base path to serve from.
   ///
   /// It should have no leading or trailing slashes.
-  final String? basePath;
+  @visibleForTesting
+  final String basePath;
 
   // Locations where source files, assets, or source maps may be located.
   List<Uri> _searchPaths() => <Uri>[
@@ -1070,67 +1057,21 @@
   return fileSystem.directory(packageConfig['dwds']!.packageUriRoot);
 }
 
-String? _stripBasePath(String path, String? basePath) {
-  path = _stripLeadingSlashes(path);
-  if (basePath != null && path.startsWith(basePath)) {
+String? _stripBasePath(String path, String basePath) {
+  path = stripLeadingSlash(path);
+  if (path.startsWith(basePath)) {
     path = path.substring(basePath.length);
   } else {
     // The given path isn't under base path, return null to indicate that.
     return null;
   }
-  return _stripLeadingSlashes(path);
+  return stripLeadingSlash(path);
 }
 
-String _stripLeadingSlashes(String path) {
-  while (path.startsWith('/')) {
-    path = path.substring(1);
-  }
-  return path;
-}
-
-String _stripTrailingSlashes(String path) {
-  while (path.endsWith('/')) {
-    path = path.substring(0, path.length - 1);
-  }
-  return path;
-}
-
-String? _parseBasePathFromIndexHtml(File indexHtml) {
+IndexHtml _getIndexHtml() {
+  final File indexHtml =
+      globals.fs.currentDirectory.childDirectory('web').childFile('index.html');
   final String htmlContent =
       indexHtml.existsSync() ? indexHtml.readAsStringSync() : _kDefaultIndex;
-  final Document document = parse(htmlContent);
-  final Element? baseElement = document.querySelector('base');
-  String? baseHref =
-      baseElement?.attributes == null ? null : baseElement!.attributes['href'];
-
-  if (baseHref == null || baseHref == kBaseHrefPlaceholder) {
-    baseHref = '';
-  } else if (!baseHref.startsWith('/')) {
-    throw ToolExit(
-      'Error: The base href in "web/index.html" must be absolute (i.e. start '
-      'with a "/"), but found: `${baseElement!.outerHtml}`.\n'
-      '$basePathExample',
-    );
-  } else if (!baseHref.endsWith('/')) {
-    throw ToolExit(
-      'Error: The base href in "web/index.html" must end with a "/", but found: `${baseElement!.outerHtml}`.\n'
-      '$basePathExample',
-    );
-  } else {
-    baseHref = _stripLeadingSlashes(_stripTrailingSlashes(baseHref));
-  }
-
-  return baseHref;
+  return IndexHtml(htmlContent);
 }
-
-const String basePathExample = '''
-For example, to serve from the root use:
-
-    <base href="/">
-
-To serve from a subpath "foo" (i.e. http://localhost:8080/foo/ instead of http://localhost:8080/) use:
-
-    <base href="/foo/">
-
-For more information, see: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
-''';
diff --git a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart
index e363165..03ec242 100644
--- a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart
+++ b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart
@@ -372,7 +372,6 @@
           true,
           debuggingOptions.nativeNullAssertions,
           false,
-          baseHref: kBaseHref,
         );
       } on ToolExit {
         return OperationResult(1, 'Failed to recompile application.');
diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart
index 5125100..05eda1d 100644
--- a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart
+++ b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart
@@ -12,6 +12,7 @@
 import 'package:flutter_tools/src/build_system/depfile.dart';
 import 'package:flutter_tools/src/build_system/targets/web.dart';
 import 'package:flutter_tools/src/globals.dart' as globals;
+import 'package:flutter_tools/src/html_utils.dart';
 import 'package:flutter_tools/src/isolated/mustache_template.dart';
 import 'package:flutter_tools/src/web/compile.dart';
 import 'package:flutter_tools/src/web/file_generators/flutter_js.dart' as flutter_js;
diff --git a/packages/flutter_tools/test/general.shard/html_utils_test.dart b/packages/flutter_tools/test/general.shard/html_utils_test.dart
new file mode 100644
index 0000000..b8c1052
--- /dev/null
+++ b/packages/flutter_tools/test/general.shard/html_utils_test.dart
@@ -0,0 +1,141 @@
+// Copyright 2014 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter_tools/src/html_utils.dart';
+
+import '../src/common.dart';
+
+const String htmlSample1 = '''
+<!DOCTYPE html>
+<html>
+<head>
+  <title></title>
+  <base href="/foo/222/">
+  <meta charset="utf-8">
+  <link rel="icon" type="image/png" href="favicon.png"/>
+</head>
+<body>
+  <div></div>
+  <script src="main.dart.js"></script>
+</body>
+</html>
+''';
+
+const String htmlSample2 = '''
+<!DOCTYPE html>
+<html>
+<head>
+  <title></title>
+  <base href="$kBaseHrefPlaceholder">
+  <meta charset="utf-8">
+  <link rel="icon" type="image/png" href="favicon.png"/>
+</head>
+<body>
+  <div></div>
+  <script src="main.dart.js"></script>
+  <script>
+    var serviceWorkerVersion = null;
+  </script>
+  <script>
+    navigator.serviceWorker.register('flutter_service_worker.js');
+  </script>
+</body>
+</html>
+''';
+
+String htmlSample2Replaced({
+  required String baseHref,
+  required String serviceWorkerVersion,
+}) =>
+    '''
+<!DOCTYPE html>
+<html>
+<head>
+  <title></title>
+  <base href="$baseHref">
+  <meta charset="utf-8">
+  <link rel="icon" type="image/png" href="favicon.png"/>
+</head>
+<body>
+  <div></div>
+  <script src="main.dart.js"></script>
+  <script>
+    var serviceWorkerVersion = "$serviceWorkerVersion";
+  </script>
+  <script>
+    navigator.serviceWorker.register('flutter_service_worker.js?v=$serviceWorkerVersion');
+  </script>
+</body>
+</html>
+''';
+
+const String htmlSample3 = '''
+<!DOCTYPE html>
+<html>
+<head>
+  <title></title>
+  <meta charset="utf-8">
+  <link rel="icon" type="image/png" href="favicon.png"/>
+</head>
+<body>
+  <div></div>
+  <script src="main.dart.js"></script>
+</body>
+</html>
+''';
+
+void main() {
+  test('can parse baseHref', () {
+    expect(IndexHtml('<base href="/foo/111/">').getBaseHref(), 'foo/111');
+    expect(IndexHtml(htmlSample1).getBaseHref(), 'foo/222');
+    expect(IndexHtml(htmlSample2).getBaseHref(), ''); // Placeholder base href.
+  });
+
+  test('handles missing baseHref', () {
+    expect(IndexHtml('').getBaseHref(), '');
+    expect(IndexHtml('<base>').getBaseHref(), '');
+    expect(IndexHtml(htmlSample3).getBaseHref(), '');
+  });
+
+  test('throws on invalid baseHref', () {
+    expect(() => IndexHtml('<base href>').getBaseHref(), throwsToolExit());
+    expect(() => IndexHtml('<base href="">').getBaseHref(), throwsToolExit());
+    expect(() => IndexHtml('<base href="foo/111">').getBaseHref(), throwsToolExit());
+    expect(
+      () => IndexHtml('<base href="foo/111/">').getBaseHref(),
+      throwsToolExit(),
+    );
+    expect(
+      () => IndexHtml('<base href="/foo/111">').getBaseHref(),
+      throwsToolExit(),
+    );
+  });
+
+  test('applies substitutions', () {
+    final IndexHtml indexHtml = IndexHtml(htmlSample2);
+    indexHtml.applySubstitutions(
+      baseHref: '/foo/333/',
+      serviceWorkerVersion: 'v123xyz',
+    );
+    expect(
+      indexHtml.content,
+      htmlSample2Replaced(
+        baseHref: '/foo/333/',
+        serviceWorkerVersion: 'v123xyz',
+      ),
+    );
+  });
+
+  test('re-parses after substitutions', () {
+    final IndexHtml indexHtml = IndexHtml(htmlSample2);
+    expect(indexHtml.getBaseHref(), ''); // Placeholder base href.
+
+    indexHtml.applySubstitutions(
+      baseHref: '/foo/333/',
+      serviceWorkerVersion: 'v123xyz',
+    );
+    // The parsed base href should be updated after substitutions.
+    expect(indexHtml.getBaseHref(), 'foo/333');
+  });
+}
diff --git a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart
index 163ac50..f4cbe89 100644
--- a/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart
+++ b/packages/flutter_tools/test/general.shard/web/devfs_web_test.dart
@@ -13,11 +13,11 @@
 import 'package:flutter_tools/src/base/platform.dart';
 import 'package:flutter_tools/src/build_info.dart';
 import 'package:flutter_tools/src/build_system/targets/shader_compiler.dart';
-import 'package:flutter_tools/src/build_system/targets/web.dart';
 import 'package:flutter_tools/src/compile.dart';
 import 'package:flutter_tools/src/convert.dart';
 import 'package:flutter_tools/src/devfs.dart';
 import 'package:flutter_tools/src/globals.dart' as globals;
+import 'package:flutter_tools/src/html_utils.dart';
 import 'package:flutter_tools/src/isolated/devfs_web.dart';
 import 'package:flutter_tools/src/web/compile.dart';
 import 'package:logging/logging.dart' as logging;
@@ -73,7 +73,6 @@
         flutterRoot: null, // ignore: avoid_redundant_argument_values
         platform: FakePlatform(),
         webBuildDirectory: null, // ignore: avoid_redundant_argument_values
-        basePath: null,
       );
     }, overrides: <Type, Generator>{
       Logger: () => logger,