Migrate from rules_nodejs to rules_js

This patch is huge because I can't easily break it into smaller
pieces. This is largely because a few things are changing with the
migration.

Firstly, we're upgrading the angular libraries and our version of
typescript. This is actually not too disruptive. It required some
changes in the top-level `package.json` file.

Secondly, the new rules have this concept of copying everything into
the `bazel-bin` directory and executing out of there. This makes the
various tools like node and angular happy, but means that a few file
paths are changing. For example, the `common.css` file can't have the
same path in the source tree as it does in the `bazel-bin` tree, so I
moved it to a `common` directory inside the `bazel-bin` tree. You can
read more about this here:
https://docs.aspect.build/rules/aspect_rules_js#running-nodejs-programs

Thirdly, I couldn't find a simple way to support Protractor in
rules_js. Protractor is the end-to-end testing framework we use for
the scouting application. Since protractor is getting deprecated and
won't receive any more updates, it's time to move to something else.
We settled on Cypress because it appears to be popular and should make
debugging easier for the students. For example, it takes screenshots
of the browser when an assertion fails. It would have been ideal to
switch to Cypress before this migration, but I couldn't find a simple
way to make that work with rules_nodejs. In other words, this
migration patch is huge in part because we are also switching testing
frameworks at the same time. I wanted to split it out, but it was more
difficult than I would have liked.

Fourthly, I also needed to migrate the flatbuffer rules. This I think
is relatively low impact, but it again means that this patch is bigger
than I wanted it to be.

Signed-off-by: Philipp Schrader <philipp.schrader@gmail.com>
Change-Id: I6674874f985952f2e3ef40274da0a2fb9e5631a7
diff --git a/scouting/www/BUILD b/scouting/www/BUILD
index ee0659b..cbcecc3 100644
--- a/scouting/www/BUILD
+++ b/scouting/www/BUILD
@@ -1,134 +1,47 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
-load("//tools/build_rules:js.bzl", "rollup_bundle")
-load("@npm//@babel/cli:index.bzl", "babel")
+load("@aspect_bazel_lib//lib:copy_file.bzl", "copy_file")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_application")
+load(":defs.bzl", "assemble_static_files")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_application(
     name = "app",
-    srcs = glob([
-        "*.ts",
-    ]),
-    angular_assets = glob([
-        "*.ng.html",
-        "*.css",
-    ]),
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
+    extra_srcs = [
+        "app/common.css",
+    ],
+    html_assets = [
+        "favicon.ico",
+    ],
     deps = [
-        "//scouting/www/driver_ranking",
-        "//scouting/www/entry",
-        "//scouting/www/import_match_list",
-        "//scouting/www/match_list",
-        "//scouting/www/notes",
-        "//scouting/www/shift_schedule",
-        "//scouting/www/view",
-        "@npm//@angular/animations",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/platform-browser",
+        "//:node_modules/@angular/animations",
+        "//scouting/www/driver_ranking:_lib",
+        "//scouting/www/entry:_lib",
+        "//scouting/www/import_match_list:_lib",
+        "//scouting/www/match_list:_lib",
+        "//scouting/www/notes:_lib",
+        "//scouting/www/shift_schedule:_lib",
+        "//scouting/www/view:_lib",
     ],
 )
 
-rollup_bundle(
-    name = "main_bundle",
-    entry_point = "main.ts",
-    deps = [
-        "app",
-    ],
-)
-
-babel(
-    name = "main_bundle_compiled",
-    args = [
-        "$(execpath :main_bundle.min.js)",
-        "--no-babelrc",
-        "--source-maps",
-        "--minified",
-        "--no-comments",
-        "--plugins=@angular/compiler-cli/linker/babel",
-        "--out-dir",
-        "$(@D)",
-    ],
-    data = [
-        ":main_bundle.min.js",
-        "@npm//@angular/compiler-cli",
-    ],
-    output_dir = True,
-)
-
-# The babel() rule above puts everything into a directory without telling bazel
-# what's in the directory. That makes it annoying to work with from other
-# rules. This genrule() here copies the one file in the directory we care about
-# so that other rules have an easier time using the file.
-genrule(
-    name = "main_bundle_file",
-    srcs = [":main_bundle_compiled"],
-    outs = ["main_bundle_file.js"],
-    cmd = "cp $(location :main_bundle_compiled)/main_bundle.min.js $(OUTS)",
-)
-
-py_binary(
-    name = "index_html_generator",
-    srcs = ["index_html_generator.py"],
-)
-
-genrule(
-    name = "generate_index_html",
-    srcs = [
-        "index.template.html",
-        "main_bundle_file.js",
-    ],
-    outs = ["index.html"],
-    cmd = " ".join([
-        "$(location :index_html_generator)",
-        "--template $(location index.template.html)",
-        "--bundle $(location main_bundle_file.js)",
-        "--output $(location index.html)",
-    ]),
-    tools = [
-        ":index_html_generator",
-    ],
-)
-
-# Create a copy of zone.js here so that we can have a predictable path to
-# source it from on the webserver.
-genrule(
-    name = "zonejs_copy",
-    srcs = [
-        "@npm//:node_modules/zone.js/dist/zone.min.js",
-    ],
-    outs = [
-        "npm/node_modules/zone.js/dist/zone.min.js",
-    ],
-    cmd = "cp $(SRCS) $(OUTS)",
-)
-
-genrule(
-    name = "field_pictures_copy",
-    srcs = ["//third_party/y2022/field:pictures"],
-    outs = [
-        "pictures/field/balls.jpeg",
-        "pictures/field/quadrants.jpeg",
-        "pictures/field/reversed_quadrants.jpeg",
-        "pictures/field/reversed_balls.jpeg",
-    ],
-    cmd = "cp $(SRCS) $(@D)/pictures/field/",
-)
-
-filegroup(
+assemble_static_files(
     name = "static_files",
-    srcs = [
-        "index.html",
-        ":field_pictures_copy",
-        ":main_bundle_file.js",
-        ":zonejs_copy",
+    app_files = ":app",
+    pictures = [
+        "//third_party/y2022/field:pictures",
     ],
+    replace_prefixes = {
+        "prod": "",
+        "dev": "",
+        "third_party/y2022": "pictures",
+    },
     visibility = ["//visibility:public"],
 )
 
-filegroup(
-    name = "common_css",
-    srcs = ["common.css"],
+copy_file(
+    name = "app_common_css",
+    src = "common.css",
+    out = "app/common.css",
     visibility = ["//scouting/www:__subpackages__"],
 )
diff --git a/scouting/www/app/app.module.ts b/scouting/www/app/app.module.ts
new file mode 100644
index 0000000..ead8b37
--- /dev/null
+++ b/scouting/www/app/app.module.ts
@@ -0,0 +1,30 @@
+import {NgModule} from '@angular/core';
+import {BrowserModule} from '@angular/platform-browser';
+import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
+
+import {App} from './app';
+import {EntryModule} from '../entry';
+import {ImportMatchListModule} from '../import_match_list';
+import {MatchListModule} from '../match_list';
+import {NotesModule} from '../notes';
+import {ShiftScheduleModule} from '../shift_schedule';
+import {ViewModule} from '../view';
+import {DriverRankingModule} from '../driver_ranking';
+
+@NgModule({
+  declarations: [App],
+  imports: [
+    BrowserModule,
+    BrowserAnimationsModule,
+    EntryModule,
+    NotesModule,
+    ImportMatchListModule,
+    MatchListModule,
+    ShiftScheduleModule,
+    DriverRankingModule,
+    ViewModule,
+  ],
+  exports: [App],
+  bootstrap: [App],
+})
+export class AppModule {}
diff --git a/scouting/www/app.ng.html b/scouting/www/app/app.ng.html
similarity index 100%
rename from scouting/www/app.ng.html
rename to scouting/www/app/app.ng.html
diff --git a/scouting/www/app.ts b/scouting/www/app/app.ts
similarity index 97%
rename from scouting/www/app.ts
rename to scouting/www/app/app.ts
index b26f815..7e81d84 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app/app.ts
@@ -22,7 +22,7 @@
 @Component({
   selector: 'my-app',
   templateUrl: './app.ng.html',
-  styleUrls: ['./common.css'],
+  styleUrls: ['../app/common.css'],
 })
 export class App {
   selectedTeamInMatch: TeamInMatch = {
diff --git a/scouting/www/app_module.ts b/scouting/www/app_module.ts
deleted file mode 100644
index 04d72b3..0000000
--- a/scouting/www/app_module.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import {NgModule} from '@angular/core';
-import {BrowserModule} from '@angular/platform-browser';
-import {BrowserAnimationsModule} from '@angular/platform-browser/animations';
-
-import {App} from './app';
-import {EntryModule} from './entry/entry.module';
-import {ImportMatchListModule} from './import_match_list/import_match_list.module';
-import {MatchListModule} from './match_list/match_list.module';
-import {NotesModule} from './notes/notes.module';
-import {ShiftScheduleModule} from './shift_schedule/shift_schedule.module';
-import {ViewModule} from './view/view.module';
-import {DriverRankingModule} from './driver_ranking/driver_ranking.module';
-
-@NgModule({
-  declarations: [App],
-  imports: [
-    BrowserModule,
-    BrowserAnimationsModule,
-    EntryModule,
-    NotesModule,
-    ImportMatchListModule,
-    MatchListModule,
-    ShiftScheduleModule,
-    DriverRankingModule,
-    ViewModule,
-  ],
-  exports: [App],
-  bootstrap: [App],
-})
-export class AppModule {}
diff --git a/scouting/www/counter_button/BUILD b/scouting/www/counter_button/BUILD
index 1dbcdfc..d081f9d 100644
--- a/scouting/www/counter_button/BUILD
+++ b/scouting/www/counter_button/BUILD
@@ -1,21 +1,8 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "counter_button",
-    srcs = [
-        "counter_button.component.ts",
-        "counter_button.module.ts",
-    ],
-    angular_assets = [
-        "counter_button.component.css",
-        "counter_button.ng.html",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
-    deps = [
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-    ],
 )
diff --git a/scouting/www/defs.bzl b/scouting/www/defs.bzl
new file mode 100644
index 0000000..828f30a
--- /dev/null
+++ b/scouting/www/defs.bzl
@@ -0,0 +1,36 @@
+load("@aspect_bazel_lib//lib:copy_to_directory.bzl", "copy_to_directory_bin_action")
+
+def _assemble_static_files_impl(ctx):
+    out_dir = ctx.actions.declare_directory(ctx.label.name)
+
+    copy_to_directory_bin = ctx.toolchains["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"].copy_to_directory_info.bin
+
+    copy_to_directory_bin_action(
+        ctx,
+        dst = out_dir,
+        name = ctx.label.name,
+        copy_to_directory_bin = copy_to_directory_bin,
+        files = ctx.files.pictures + ctx.attr.app_files.files.to_list(),
+        replace_prefixes = ctx.attr.replace_prefixes,
+    )
+
+    return [DefaultInfo(
+        files = depset([out_dir]),
+        runfiles = ctx.runfiles([out_dir]),
+    )]
+
+assemble_static_files = rule(
+    implementation = _assemble_static_files_impl,
+    attrs = {
+        "app_files": attr.label(
+            mandatory = True,
+        ),
+        "pictures": attr.label_list(
+            mandatory = True,
+        ),
+        "replace_prefixes": attr.string_dict(
+            mandatory = True,
+        ),
+    },
+    toolchains = ["@aspect_bazel_lib//lib:copy_to_directory_toolchain_type"],
+)
diff --git a/scouting/www/driver_ranking/BUILD b/scouting/www/driver_ranking/BUILD
index 10b6f99..a934ffe 100644
--- a/scouting/www/driver_ranking/BUILD
+++ b/scouting/www/driver_ranking/BUILD
@@ -1,26 +1,17 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "driver_ranking",
-    srcs = [
-        "driver_ranking.component.ts",
-        "driver_ranking.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "driver_ranking.component.css",
-        "driver_ranking.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_driver_ranking_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/driver_ranking/driver_ranking.component.ts b/scouting/www/driver_ranking/driver_ranking.component.ts
index b251938..f61d901 100644
--- a/scouting/www/driver_ranking/driver_ranking.component.ts
+++ b/scouting/www/driver_ranking/driver_ranking.component.ts
@@ -12,7 +12,7 @@
 @Component({
   selector: 'app-driver-ranking',
   templateUrl: './driver_ranking.ng.html',
-  styleUrls: ['../common.css', './driver_ranking.component.css'],
+  styleUrls: ['../app/common.css', './driver_ranking.component.css'],
 })
 export class DriverRankingComponent {
   section: Section = 'TeamSelection';
diff --git a/scouting/www/driver_ranking/package.json b/scouting/www/driver_ranking/package.json
index 06472cf..83dacf4 100644
--- a/scouting/www/driver_ranking/package.json
+++ b/scouting/www/driver_ranking/package.json
@@ -2,6 +2,6 @@
     "name": "@org_frc971/scouting/www/driver_ranking",
     "private": true,
     "dependencies": {
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/entry/BUILD b/scouting/www/entry/BUILD
index 17eed23..37b7fe6 100644
--- a/scouting/www/entry/BUILD
+++ b/scouting/www/entry/BUILD
@@ -1,28 +1,19 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "entry",
-    srcs = [
-        "entry.component.ts",
-        "entry.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "entry.component.css",
-        "entry.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_data_scouting_ts_fbs",
-        "//scouting/www/counter_button",
+        "//scouting/www/counter_button:_lib",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index 01c9fff..6209669 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -58,7 +58,7 @@
 @Component({
   selector: 'app-entry',
   templateUrl: './entry.ng.html',
-  styleUrls: ['../common.css', './entry.component.css'],
+  styleUrls: ['../app/common.css', './entry.component.css'],
 })
 export class EntryComponent {
   // Re-export the type here so that we can use it in the `[value]` attribute
diff --git a/scouting/www/entry/entry.module.ts b/scouting/www/entry/entry.module.ts
index 3322c01..a2aa7bb 100644
--- a/scouting/www/entry/entry.module.ts
+++ b/scouting/www/entry/entry.module.ts
@@ -2,7 +2,7 @@
 import {CommonModule} from '@angular/common';
 import {FormsModule} from '@angular/forms';
 
-import {CounterButtonModule} from '../counter_button/counter_button.module';
+import {CounterButtonModule} from '@org_frc971/scouting/www/counter_button';
 import {EntryComponent} from './entry.component';
 
 import {ClimbLevel} from '../../webserver/requests/messages/submit_data_scouting_generated';
diff --git a/scouting/www/entry/package.json b/scouting/www/entry/package.json
index 5ecf57a..4c05778 100644
--- a/scouting/www/entry/package.json
+++ b/scouting/www/entry/package.json
@@ -3,6 +3,6 @@
     "private": true,
     "dependencies": {
         "@org_frc971/scouting/www/counter_button": "workspace:*",
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/favicon.ico b/scouting/www/favicon.ico
new file mode 100644
index 0000000..2bf69ea
--- /dev/null
+++ b/scouting/www/favicon.ico
Binary files differ
diff --git a/scouting/www/import_match_list/BUILD b/scouting/www/import_match_list/BUILD
index 9e40794..bc1d5d5 100644
--- a/scouting/www/import_match_list/BUILD
+++ b/scouting/www/import_match_list/BUILD
@@ -1,27 +1,18 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "import_match_list",
-    srcs = [
-        "import_match_list.component.ts",
-        "import_match_list.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "import_match_list.component.css",
-        "import_match_list.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:refresh_match_list_response_ts_fbs",
         "//scouting/webserver/requests/messages:refresh_match_list_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/import_match_list/import_match_list.component.ts b/scouting/www/import_match_list/import_match_list.component.ts
index 0e292ae..526a636 100644
--- a/scouting/www/import_match_list/import_match_list.component.ts
+++ b/scouting/www/import_match_list/import_match_list.component.ts
@@ -8,7 +8,7 @@
 @Component({
   selector: 'app-import-match-list',
   templateUrl: './import_match_list.ng.html',
-  styleUrls: ['../common.css', './import_match_list.component.css'],
+  styleUrls: ['../app/common.css', './import_match_list.component.css'],
 })
 export class ImportMatchListComponent {
   year: number = new Date().getFullYear();
diff --git a/scouting/www/import_match_list/package.json b/scouting/www/import_match_list/package.json
index d80b0dc..05aa790 100644
--- a/scouting/www/import_match_list/package.json
+++ b/scouting/www/import_match_list/package.json
@@ -2,6 +2,6 @@
     "name": "@org_frc971/scouting/www/import_match_list",
     "private": true,
     "dependencies": {
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/index.template.html b/scouting/www/index.html
similarity index 74%
rename from scouting/www/index.template.html
rename to scouting/www/index.html
index 8fd0d7c..208141a 100644
--- a/scouting/www/index.template.html
+++ b/scouting/www/index.html
@@ -1,10 +1,9 @@
 <!DOCTYPE html>
-<html>
+<html lang="en">
   <head>
+    <meta charset="utf-8" />
     <title>FRC971 Scouting Application</title>
     <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <base href="/" />
-    <script src="./npm/node_modules/zone.js/dist/zone.min.js"></script>
     <link
       href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
       rel="stylesheet"
@@ -19,7 +18,5 @@
   </head>
   <body>
     <my-app></my-app>
-    <!-- The path here is auto-generated to be /sha256/<checksum>/main_bundle_file.js. -->
-    <script src="{MAIN_BUNDLE_FILE}"></script>
   </body>
 </html>
diff --git a/scouting/www/index_html_generator.py b/scouting/www/index_html_generator.py
deleted file mode 100644
index bc0e63d..0000000
--- a/scouting/www/index_html_generator.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""Generates index.html with the right checksum for main_bundle_file.js filled in."""
-
-import argparse
-import hashlib
-import sys
-from pathlib import Path
-
-
-def compute_sha256(filepath):
-    return hashlib.sha256(filepath.read_bytes()).hexdigest()
-
-
-def main(argv):
-    parser = argparse.ArgumentParser()
-    parser.add_argument("--template", type=str)
-    parser.add_argument("--bundle", type=str)
-    parser.add_argument("--output", type=str)
-    args = parser.parse_args(argv[1:])
-
-    template = Path(args.template).read_text()
-    bundle_path = Path(args.bundle)
-    bundle_sha256 = compute_sha256(bundle_path)
-
-    output = template.format(
-        MAIN_BUNDLE_FILE=f"/sha256/{bundle_sha256}/{bundle_path.name}", )
-    Path(args.output).write_text(output)
-
-
-if __name__ == "__main__":
-    sys.exit(main(sys.argv))
diff --git a/scouting/www/main.ts b/scouting/www/main.ts
index e1a4ab5..1f8eb00 100644
--- a/scouting/www/main.ts
+++ b/scouting/www/main.ts
@@ -1,4 +1,4 @@
 import {platformBrowser} from '@angular/platform-browser';
-import {AppModule} from './app_module';
+import {AppModule} from './app/app.module';
 
 platformBrowser().bootstrapModule(AppModule);
diff --git a/scouting/www/match_list/BUILD b/scouting/www/match_list/BUILD
index 10c0a22..c713dda 100644
--- a/scouting/www/match_list/BUILD
+++ b/scouting/www/match_list/BUILD
@@ -1,28 +1,19 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "match_list",
-    srcs = [
-        "match_list.component.ts",
-        "match_list.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "match_list.component.css",
-        "match_list.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
-        "//scouting/www/rpc",
+        "//scouting/www/rpc:_lib",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/match_list/match_list.component.ts b/scouting/www/match_list/match_list.component.ts
index c50cffd..eb5284f 100644
--- a/scouting/www/match_list/match_list.component.ts
+++ b/scouting/www/match_list/match_list.component.ts
@@ -7,7 +7,7 @@
   RequestAllMatchesResponse,
 } from '../../webserver/requests/messages/request_all_matches_response_generated';
 
-import {MatchListRequestor} from '../rpc/match_list_requestor';
+import {MatchListRequestor} from '@org_frc971/scouting/www/rpc';
 
 type TeamInMatch = {
   teamNumber: number;
@@ -19,7 +19,7 @@
 @Component({
   selector: 'app-match-list',
   templateUrl: './match_list.ng.html',
-  styleUrls: ['../common.css', './match_list.component.css'],
+  styleUrls: ['../app/common.css', './match_list.component.css'],
 })
 export class MatchListComponent implements OnInit {
   @Output() selectedTeamEvent = new EventEmitter<TeamInMatch>();
diff --git a/scouting/www/match_list/package.json b/scouting/www/match_list/package.json
index 3a02094..284e77b 100644
--- a/scouting/www/match_list/package.json
+++ b/scouting/www/match_list/package.json
@@ -3,6 +3,6 @@
     "private": true,
     "dependencies": {
         "@org_frc971/scouting/www/rpc": "workspace:*",
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/notes/BUILD b/scouting/www/notes/BUILD
index 39a97ef..64e4ae7 100644
--- a/scouting/www/notes/BUILD
+++ b/scouting/www/notes/BUILD
@@ -1,29 +1,20 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "notes",
-    srcs = [
-        "notes.component.ts",
-        "notes.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "notes.component.css",
-        "notes.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_notes_for_team_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_notes_for_team_ts_fbs",
         "//scouting/webserver/requests/messages:submit_notes_response_ts_fbs",
         "//scouting/webserver/requests/messages:submit_notes_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/notes/notes.component.ts b/scouting/www/notes/notes.component.ts
index 177ad44..62b4990 100644
--- a/scouting/www/notes/notes.component.ts
+++ b/scouting/www/notes/notes.component.ts
@@ -64,7 +64,7 @@
 @Component({
   selector: 'frc971-notes',
   templateUrl: './notes.ng.html',
-  styleUrls: ['../common.css', './notes.component.css'],
+  styleUrls: ['../app/common.css', './notes.component.css'],
 })
 export class Notes {
   // Re-export KEYWORD_CHECKBOX_LABELS so that we can
diff --git a/scouting/www/notes/package.json b/scouting/www/notes/package.json
index 8cbeb94..c5c6afe 100644
--- a/scouting/www/notes/package.json
+++ b/scouting/www/notes/package.json
@@ -2,6 +2,6 @@
     "name": "@org_frc971/scouting/www/notes",
     "private": true,
     "dependencies": {
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/polyfills.ts b/scouting/www/polyfills.ts
new file mode 100644
index 0000000..e4555ed
--- /dev/null
+++ b/scouting/www/polyfills.ts
@@ -0,0 +1,52 @@
+/**
+ * This file includes polyfills needed by Angular and is loaded before the app.
+ * You can add your own extra polyfills to this file.
+ *
+ * This file is divided into 2 sections:
+ *   1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
+ *   2. Application imports. Files imported after ZoneJS that should be loaded before your main
+ *      file.
+ *
+ * The current setup is for so-called "evergreen" browsers; the last versions of browsers that
+ * automatically update themselves. This includes recent versions of Safari, Chrome (including
+ * Opera), Edge on the desktop, and iOS and Chrome on mobile.
+ *
+ * Learn more in https://angular.io/guide/browser-support
+ */
+
+/***************************************************************************************************
+ * BROWSER POLYFILLS
+ */
+
+/**
+ * By default, zone.js will patch all possible macroTask and DomEvents
+ * user can disable parts of macroTask/DomEvents patch by setting following flags
+ * because those flags need to be set before `zone.js` being loaded, and webpack
+ * will put import in the top of bundle, so user need to create a separate file
+ * in this directory (for example: zone-flags.ts), and put the following flags
+ * into that file, and then add the following code before importing zone.js.
+ * import './zone-flags';
+ *
+ * The flags allowed in zone-flags.ts are listed here.
+ *
+ * The following flags will work for all browsers.
+ *
+ * (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
+ * (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
+ * (window as any).__zone_symbol__UNPATCHED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
+ *
+ *  in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
+ *  with the following flag, it will bypass `zone.js` patch for IE/Edge
+ *
+ *  (window as any).__Zone_enable_cross_context_check = true;
+ *
+ */
+
+/***************************************************************************************************
+ * Zone JS is required by default for Angular itself.
+ */
+import 'zone.js'; // Included with Angular CLI.
+
+/***************************************************************************************************
+ * APPLICATION IMPORTS
+ */
diff --git a/scouting/www/rpc/BUILD b/scouting/www/rpc/BUILD
index 954b3fb..427b493 100644
--- a/scouting/www/rpc/BUILD
+++ b/scouting/www/rpc/BUILD
@@ -1,15 +1,14 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "rpc",
-    srcs = [
-        "match_list_requestor.ts",
-        "view_data_requestor.ts",
+    extra_srcs = [
+        "public-api.ts",
     ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
+    generate_public_api = False,
     deps = [
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_response_ts_fbs",
@@ -21,6 +20,5 @@
         "//scouting/webserver/requests/messages:request_data_scouting_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_data_scouting_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/core",
     ],
 )
diff --git a/scouting/www/rpc/public-api.ts b/scouting/www/rpc/public-api.ts
new file mode 100644
index 0000000..fe61453
--- /dev/null
+++ b/scouting/www/rpc/public-api.ts
@@ -0,0 +1,2 @@
+export * from './match_list_requestor';
+export * from './view_data_requestor';
diff --git a/scouting/www/shift_schedule/BUILD b/scouting/www/shift_schedule/BUILD
index 8fe99e4..3afb557 100644
--- a/scouting/www/shift_schedule/BUILD
+++ b/scouting/www/shift_schedule/BUILD
@@ -1,27 +1,18 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "shift_schedule",
-    srcs = [
-        "shift_schedule.component.ts",
-        "shift_schedule.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "shift_schedule.component.css",
-        "shift_schedule.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/shift_schedule/package.json b/scouting/www/shift_schedule/package.json
index 1d71623..1270235 100644
--- a/scouting/www/shift_schedule/package.json
+++ b/scouting/www/shift_schedule/package.json
@@ -2,6 +2,6 @@
     "name": "@org_frc971/scouting/www/shift_schedule",
     "private": true,
     "dependencies": {
-        "@angular/forms": "15.0.1"
+        "@angular/forms": "15.1.5"
     }
 }
diff --git a/scouting/www/shift_schedule/shift_schedule.component.ts b/scouting/www/shift_schedule/shift_schedule.component.ts
index 074eb97..de0b2e1 100644
--- a/scouting/www/shift_schedule/shift_schedule.component.ts
+++ b/scouting/www/shift_schedule/shift_schedule.component.ts
@@ -5,7 +5,7 @@
 @Component({
   selector: 'shift-schedule',
   templateUrl: './shift_schedule.ng.html',
-  styleUrls: ['../common.css', './shift_schedule.component.css'],
+  styleUrls: ['../app/common.css', './shift_schedule.component.css'],
 })
 export class ShiftsComponent {
   progressMessage: string = '';
diff --git a/scouting/www/view/BUILD b/scouting/www/view/BUILD
index b9b6030..168feb6 100644
--- a/scouting/www/view/BUILD
+++ b/scouting/www/view/BUILD
@@ -1,21 +1,15 @@
-load("@npm//@bazel/typescript:index.bzl", "ts_library")
+load("@npm//:defs.bzl", "npm_link_all_packages")
+load("//tools/build_rules:js.bzl", "ng_pkg")
 
-ts_library(
+npm_link_all_packages(name = "node_modules")
+
+ng_pkg(
     name = "view",
-    srcs = [
-        "view.component.ts",
-        "view.module.ts",
+    extra_srcs = [
+        "//scouting/www:app_common_css",
     ],
-    angular_assets = [
-        "view.component.css",
-        "view.ng.html",
-        "//scouting/www:common_css",
-    ],
-    compiler = "//tools:tsc_wrapped_with_angular",
-    target_compatible_with = ["@platforms//cpu:x86_64"],
-    use_angular_plugin = True,
-    visibility = ["//visibility:public"],
     deps = [
+        ":node_modules/@angular/forms",
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_driver_rankings_ts_fbs",
@@ -23,10 +17,7 @@
         "//scouting/webserver/requests/messages:request_all_notes_ts_fbs",
         "//scouting/webserver/requests/messages:request_data_scouting_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_data_scouting_ts_fbs",
-        "//scouting/www/rpc",
+        "//scouting/www/rpc:_lib",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
-        "@npm//@angular/common",
-        "@npm//@angular/core",
-        "@npm//@angular/forms",
     ],
 )
diff --git a/scouting/www/view/package.json b/scouting/www/view/package.json
new file mode 100644
index 0000000..ef94a11
--- /dev/null
+++ b/scouting/www/view/package.json
@@ -0,0 +1,7 @@
+{
+    "name": "@org_frc971/scouting/www/view",
+    "private": true,
+    "dependencies": {
+      "@angular/forms": "15.1.5"
+    }
+}
diff --git a/scouting/www/view/view.component.ts b/scouting/www/view/view.component.ts
index 593b0f4..c877faf 100644
--- a/scouting/www/view/view.component.ts
+++ b/scouting/www/view/view.component.ts
@@ -12,7 +12,7 @@
   RequestAllNotesResponse,
 } from '../../webserver/requests/messages/request_all_notes_response_generated';
 
-import {ViewDataRequestor} from '../rpc/view_data_requestor';
+import {ViewDataRequestor} from '../rpc';
 
 type Source = 'Notes' | 'Stats' | 'DriverRanking';
 
@@ -28,7 +28,7 @@
 @Component({
   selector: 'app-view',
   templateUrl: './view.ng.html',
-  styleUrls: ['../common.css', './view.component.css'],
+  styleUrls: ['../app/common.css', './view.component.css'],
 })
 export class ViewComponent {
   constructor(private readonly viewDataRequestor: ViewDataRequestor) {}