diff --git a/.bazelignore b/.bazelignore deleted file mode 100644 index 284b0692ec13..000000000000 --- a/.bazelignore +++ /dev/null @@ -1,3 +0,0 @@ -.git -dist -node_modules diff --git a/.bazelrc b/.bazelrc index c8af3b4f534b..a02316a9aa50 100644 --- a/.bazelrc +++ b/.bazelrc @@ -1,3 +1,10 @@ +# Disable NG CLI TTY mode +build --action_env=NG_FORCE_TTY=false + +# Required by `rules_ts`. +common --@aspect_rules_ts//ts:skipLibCheck=always +common --@aspect_rules_ts//ts:default_to_tsc_transpiler + # Make TypeScript compilation fast, by keeping a few copies of the compiler # running as daemons, and cache SourceFile AST's to reduce parse time. build --strategy=TypeScriptCompile=worker @@ -5,6 +12,13 @@ build --strategy=TypeScriptCompile=worker # Enable debugging tests with --config=debug test:debug --test_arg=--node_options=--inspect-brk --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results +# Enable debugging tests with --config=no-sharding +# The below is useful to while using `fit` and `fdescribe` to avoid sharing and re-runs of failed flaky tests. +test:no-sharding --flaky_test_attempts=1 --test_sharding_strategy=disabled + +# Frozen lockfile +common --lockfile_mode=error + ############################### # Filesystem interactions # ############################### @@ -22,24 +36,28 @@ test:debug --test_arg=--node_options=--inspect-brk --test_output=streamed --test # See https://github.com/bazelbuild/bazel/issues/4603 build --symlink_prefix=dist/ -# Disable watchfs as it causes tests to be flaky on Windows -# https://github.com/angular/angular/issues/29541 -build --nowatchfs - # Turn off legacy external runfiles -run --nolegacy_external_runfiles -test --nolegacy_external_runfiles +build --nolegacy_external_runfiles # Turn on --incompatible_strict_action_env which was on by default # in Bazel 0.21.0 but turned off again in 0.22.0. Follow # https://github.com/bazelbuild/bazel/issues/7026 for more details. # This flag is needed to so that the bazel cache is not invalidated -# when running bazel via `yarn bazel`. +# when running bazel via `pnpm bazel`. # See https://github.com/angular/angular/issues/27514. build --incompatible_strict_action_env run --incompatible_strict_action_env test --incompatible_strict_action_env +# Enable remote caching of build/action tree +build --experimental_remote_merkle_tree_cache + +# Ensure that tags applied in BUILDs propagate to actions +common --incompatible_allow_tags_propagation + +# Ensure sandboxing is enabled even for exclusive tests +test --incompatible_exclusive_test_sandboxed + ############################### # Saucelabs support # # Turn on these settings with # @@ -65,7 +83,21 @@ test:saucelabs --define=KARMA_WEB_TEST_MODE=SL_REQUIRED # Releases should always be stamped with version control info # This command assumes node on the path and is a workaround for # https://github.com/bazelbuild/bazel/issues/4802 -build:release --workspace_status_command="node ./tools/bazel_stamp_vars.js" +build:release --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=release" +build:release --stamp + +build:snapshot --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=snapshot" +build:snapshot --stamp +build:snapshot --//:enable_snapshot_repo_deps + +build:e2e --workspace_status_command="pnpm --silent ng-dev release build-env-stamp --mode=release" +build:e2e --stamp +test:e2e --test_timeout=3600 --experimental_ui_max_stdouterr_bytes=2097152 + +# Retry in the event of flakes +test:e2e --flaky_test_attempts=2 + +build:local --//:enable_package_json_tar_deps ############################### # Output # @@ -77,20 +109,13 @@ query --output=label_kind # By default, failing tests don't print any output, it goes to the log file test --test_output=errors - -################################ -# Settings for CircleCI # -################################ - -# Bazel flags for CircleCI are in /.circleci/bazel.rc - ################################ # Remote Execution Setup # ################################ # Use the Angular team internal GCP instance for remote execution. -build:remote --remote_instance_name=projects/internal-200822/instances/default_instance -build:remote --project_id=internal-200822 +build:remote --remote_instance_name=projects/internal-200822/instances/primary_instance +build:remote --bes_instance_name=internal-200822 # Starting with Bazel 0.27.0 strategies do not need to be explicitly # defined. See https://github.com/bazelbuild/bazel/issues/7480 @@ -99,40 +124,52 @@ build:remote --define=EXECUTOR=remote # Setup the remote build execution servers. build:remote --remote_cache=remotebuildexecution.googleapis.com build:remote --remote_executor=remotebuildexecution.googleapis.com -build:remote --tls_enabled=true -build:remote --auth_enabled=true -build:remote --remote_timeout=3600 -build:remote --jobs=50 - - # Setup the toolchain and platform for the remote build execution. The platform -# is automatically configured by the "rbe_autoconfig" rule in the project workpsace. -build:remote --host_javabase=@rbe_ubuntu1604_angular//java:jdk -build:remote --javabase=@rbe_ubuntu1604_angular//java:jdk -build:remote --host_java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8 -build:remote --java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8 -build:remote --crosstool_top=@rbe_ubuntu1604_angular//cc:toolchain -build:remote --action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1 -build:remote --extra_toolchains=@rbe_ubuntu1604_angular//config:cc-toolchain -build:remote --extra_execution_platforms=//tools:rbe_ubuntu1604-angular -build:remote --host_platform=//tools:rbe_ubuntu1604-angular -build:remote --platforms=//tools:rbe_ubuntu1604-angular - - # Setup Build Event Service -build:remote --bes_backend=buildeventservice.googleapis.com -build:remote --bes_timeout=30s -build:remote --bes_results_url="https://source.cloud.google.com/results/invocations/" - - # Set remote caching settings +build:remote --remote_timeout=600 +build:remote --jobs=150 + +# Setup the toolchain and platform for the remote build execution. The platform +# is provided by the shared dev-infra package and targets k8 remote containers. +build:remote --extra_execution_platforms=@devinfra//bazel/remote-execution:platform_with_network +build:remote --host_platform=@devinfra//bazel/remote-execution:platform_with_network +build:remote --platforms=@devinfra//bazel/remote-execution:platform_with_network + +# Set remote caching settings build:remote --remote_accept_cached=true +build:remote --remote_upload_local_results=false -############################### -# NodeJS rules settings -# These settings are required for rules_nodejs -############################### +# Force remote executions to consider the entire run as linux. +# This is required for OSX cross-platform RBE. +build:remote --cpu=k8 +build:remote --host_cpu=k8 + +# Set up authentication mechanism for RBE +build:remote --google_default_credentials + +# Use HTTP remote cache +build:remote-cache --remote_cache=https://storage.googleapis.com/angular-team-cache +build:remote-cache --remote_accept_cached=true +build:remote-cache --remote_upload_local_results=false +build:remote-cache --google_default_credentials -# Turn on managed directories feature in Bazel -# This allows us to avoid installing a second copy of node_modules -common --experimental_allow_incremental_repository_updates +# Additional flags added when running a "trusted build" with additional access +build:trusted-build --remote_upload_local_results=true + +# Fixes issues with browser archives and files with spaces. Could be +# removed in Bazel 8 when Bazel runfiles supports spaces. +build --experimental_inprocess_symlink_creation + +#################################################### +# rules_js specific flags +#################################################### + +# TODO(josephperrott): investigate if this can be removed eventually. +# Prevents the npm package extract from occuring or caching on RBE which overwhelms our quota +build --modify_execution_info=NpmPackageExtract=+no-remote + +# Allow the Bazel server to check directory sources for changes. `rules_js` previously +# heavily relied on this, but still uses directory "inputs" in some cases. +# See: https://github.com/aspect-build/rules_js/issues/1408. +startup --host_jvm_args=-DBAZEL_TRACK_SOURCE_DIRECTORIES=1 #################################################### # User bazel configuration @@ -142,7 +179,3 @@ common --experimental_allow_incremental_repository_updates # Load any settings which are specific to the current user. Needs to be *last* statement # in this config, as the user configuration should be able to overwrite flags from this file. try-import .bazelrc.user - -# Enable runfiles even on Windows. -# Architect resolves output files from data files, and this isn't possible without runfile support. -test --enable_runfiles \ No newline at end of file diff --git a/.bazelversion b/.bazelversion new file mode 100644 index 000000000000..eec6dacbd482 --- /dev/null +++ b/.bazelversion @@ -0,0 +1 @@ +8.8.1 diff --git a/.circleci/bazel.rc b/.circleci/bazel.rc deleted file mode 100644 index 18fd21f023b3..000000000000 --- a/.circleci/bazel.rc +++ /dev/null @@ -1,23 +0,0 @@ -# These options are enabled when running on CI -# We do this by copying this file to /etc/bazel.bazelrc at the start of the build. - -# Echo all the configuration settings and their source -build --announce_rc - -# Don't be spammy in the logs -build --noshow_progress - -# Don't run manual tests -test --test_tag_filters=-manual - -# Workaround https://github.com/bazelbuild/bazel/issues/3645 -# Bazel doesn't calculate the memory ceiling correctly when running under Docker. -# Limit Bazel to consuming resources that fit in CircleCI "medium" class which is the default: -# https://circleci.com/docs/2.0/configuration-reference/#resource_class -build --local_resources=3072,2.0,1.0 - -# More details on failures -build --verbose_failures=true - -# Retry in the event of flakes -test --flaky_test_attempts=2 diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 6ea8320a7e38..000000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,457 +0,0 @@ -# Configuration file for https://circleci.com/gh/angular/angular-cli - -# Note: YAML anchors allow an object to be re-used, reducing duplication. -# The ampersand declares an alias for an object, then later the `<<: *name` -# syntax dereferences it. -# See http://blog.daemonl.com/2016/02/yaml.html -# To validate changes, use an online parser, eg. -# http://yaml-online-parser.appspot.com/ - -version: 2.1 - -# Variables - -## IMPORTANT -# If you change the cache key prefix, also sync the fallback_cache_key fallback to match. -# Keep the static part of the cache key as prefix to enable correct fallbacks. -# Windows needs its own cache key because binaries in node_modules are different. -# See https://circleci.com/docs/2.0/caching/#restoring-cache for how prefixes work in CircleCI. -var_1: &cache_key angular_devkit-0.12.0-{{ checksum "yarn.lock" }} -var_2: &cache_key_fallback angular_devkit-0.12.0 -var_1_win: &cache_key_win angular_devkit-win-0.12.0-{{ checksum "yarn.lock" }} -var_2_win: &cache_key_fallback_win angular_devkit-win-0.12.0 -var_3: &default_nodeversion "12.9" -# Workspace initially persisted by the `setup` job, and then enhanced by `setup-and-build-win`. -# https://circleci.com/docs/2.0/workflows/#using-workspaces-to-share-data-among-jobs -# https://circleci.com/blog/deep-diving-into-circleci-workspaces/ -var_4: &workspace_location . -# Filter to only release branches on a given job. -var_5: &only_release_branches - filters: - branches: - only: - - master - - /\d+\.\d+\.x/ - -# Executor Definitions -# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-executors -executors: - action-executor: - parameters: - nodeversion: - type: string - default: *default_nodeversion - docker: - - image: circleci/node:<< parameters.nodeversion >> - working_directory: ~/ng - resource_class: small - - test-executor: - parameters: - nodeversion: - type: string - default: *default_nodeversion - docker: - - image: circleci/node:<< parameters.nodeversion >>-browsers - working_directory: ~/ng - environment: - NPM_CONFIG_PREFIX: ~/.npm-global - resource_class: large - - windows-executor: - # Same as https://circleci.com/orbs/registry/orb/circleci/windows, but named. - working_directory: ~/ng - resource_class: windows.medium - shell: powershell.exe -ExecutionPolicy Bypass - machine: - # Contents of this image: - # https://circleci.com/docs/2.0/hello-world-windows/#software-pre-installed-in-the-windows-image - image: windows-server-2019-vs2019:stable - -# Command Definitions -# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-commands -commands: - custom_attach_workspace: - description: Attach workspace at a predefined location - steps: - - attach_workspace: - at: *workspace_location - setup_windows: - steps: - - run: nvm install 12.1.0 - - run: nvm use 12.1.0 - - run: npm install -g yarn@1.17.3 - - run: node --version - - run: yarn --version - - setup_bazel_rbe: - parameters: - key: - type: env_var_name - default: CIRCLE_PROJECT_REPONAME - steps: - - run: - name: "Setup bazel RBE remote execution" - command: | - touch .bazelrc.user; - # We need ensure that the same default digest is used for encoding and decoding - # with openssl. Openssl versions might have different default digests which can - # cause decryption failures based on the openssl version. https://stackoverflow.com/a/39641378/4317734 - openssl aes-256-cbc -d -in .circleci/gcp_token -md md5 -k "${<< parameters.key >>}" -out /home/circleci/.gcp_credentials; - sudo bash -c "echo -e 'build --google_credentials=/home/circleci/.gcp_credentials' >> .bazelrc.user"; - # Upload/don't upload local results to cache based on environment - if [[ -n "{$CIRCLE_PR_NUMBER}" ]]; then - sudo bash -c "echo -e 'build:remote --remote_upload_local_results=false\n' >> .bazelrc.user"; - echo "Not uploading local build results to remote cache."; - else - sudo bash -c "echo -e 'build:remote --remote_upload_local_results=true\n' >> .bazelrc.user"; - echo "Uploading local build results to remote cache."; - fi - # Enable remote builds - sudo bash -c "echo -e 'build --config=remote' >> .bazelrc.user"; - echo "Reading from remote cache for bazel remote jobs."; - -# Job definitions -jobs: - setup: - executor: action-executor - resource_class: medium - steps: - - checkout - - run: - name: Rebase PR on target branch - command: > - if [[ -n "${CIRCLE_PR_NUMBER}" ]]; then - # User is required for rebase. - git config user.name "angular-ci" - git config user.email "angular-ci" - # Rebase PR on top of target branch. - node tools/rebase-pr.js angular/angular-cli ${CIRCLE_PR_NUMBER} - else - echo "This build is not over a PR, nothing to do." - fi - - restore_cache: - keys: - - *cache_key - - *cache_key_fallback - - run: yarn install --frozen-lockfile - - persist_to_workspace: - root: *workspace_location - paths: - - ./* - - save_cache: - key: *cache_key - paths: - - ~/.cache/yarn - - lint: - executor: action-executor - steps: - - custom_attach_workspace - - run: yarn lint - - run: 'yarn bazel:format -mode=check || - (echo "BUILD files not formatted. Please run ''yarn bazel:format''" ; exit 1)' - # Run the skylark linter to check our Bazel rules - - run: 'yarn bazel:lint || - (echo -e "\n.bzl files have lint errors. Please run ''yarn bazel:lint-fix''"; exit 1)' - - validate: - executor: action-executor - steps: - - custom_attach_workspace - - run: yarn validate --ci - - test: - executor: action-executor - steps: - - custom_attach_workspace - - run: yarn test --full - - test-large: - parameters: - ve: - type: boolean - default: false - glob: - type: string - default: "" - executor: test-executor - parallelism: 4 - steps: - - custom_attach_workspace - - run: yarn webdriver-update - - run: yarn test-large --full <<# parameters.ve >>--ve<> <<# parameters.glob >>--glob="<< parameters.glob >>"<> --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} - - e2e-cli: - parameters: - ve: - type: boolean - default: false - snapshots: - type: boolean - default: false - executor: test-executor - parallelism: 6 - steps: - - custom_attach_workspace - - run: - name: Initialize Environment - command: ./.circleci/env.sh - - run: - name: Execute CLI E2E Tests - command: PATH=~/.npm-global/bin:$PATH node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} <<# parameters.ve >>--ve<> <<# parameters.snapshots >>--ng-snapshots<> - - e2e-cli-node-10: - executor: - name: test-executor - nodeversion: "10.16" - parallelism: 4 - steps: - - custom_attach_workspace - - run: - name: Initialize Environment - command: | - ./.circleci/env.sh - # Ensure latest npm version to support local package repository - PATH=~/.npm-global/bin:$PATH npm install --global npm - - run: PATH=~/.npm-global/bin:$PATH node ./tests/legacy-cli/run_e2e --nb-shards=${CIRCLE_NODE_TOTAL} --shard=${CIRCLE_NODE_INDEX} - - test-browsers: - executor: - name: test-executor - environment: - E2E_BROWSERS: true - resource_class: medium - steps: - - custom_attach_workspace - - run: - name: Initialize Environment - command: ./.circleci/env.sh - - run: - name: Initialize Saucelabs - command: setSecretVar SAUCE_ACCESS_KEY $(echo $SAUCE_ACCESS_KEY | rev) - - run: - name: Start Saucelabs Tunnel - command: ./scripts/saucelabs/start-tunnel.sh - background: true - # Waits for the Saucelabs tunnel to be ready. This ensures that we don't run tests - # too early without Saucelabs not being ready. - - run: ./scripts/saucelabs/wait-for-tunnel.sh - - run: PATH=~/.npm-global/bin:$PATH node ./tests/legacy-cli/run_e2e ./tests/legacy-cli/e2e/tests/misc/browsers.ts --ve - - run: PATH=~/.npm-global/bin:$PATH node ./tests/legacy-cli/run_e2e ./tests/legacy-cli/e2e/tests/misc/browsers.ts - - run: ./scripts/saucelabs/stop-tunnel.sh - - build: - executor: action-executor - steps: - - custom_attach_workspace - - run: yarn build - - # This is where we put all the misbehaving and flaky tests so we can fine-tune their conditions - # and rerun them faster. - flake-jail: - executor: test-executor - resource_class: medium - steps: - - custom_attach_workspace - - run: yarn webdriver-update - - run: yarn test-large --full --flakey --ve - - run: yarn test-large --full --flakey - - build-bazel: - executor: action-executor - resource_class: large - steps: - - custom_attach_workspace - - setup_bazel_rbe - - run: sudo cp .circleci/bazel.rc /etc/bazel.bazelrc - - run: yarn bazel:test - - snapshot_publish: - executor: action-executor - resource_class: medium - steps: - - custom_attach_workspace - - run: - name: Decrypt Credentials - # Note: when changing the image, you might have to re-encrypt the credentials with a - # matching version of openssl. - # See https://stackoverflow.com/a/43847627/2116927 for more info. - command: | - openssl aes-256-cbc -d -in .circleci/github_token -k "${KEY}" -out ~/github_token -md md5 - - run: - name: Deployment to Snapshot - command: | - yarn admin snapshots --verbose --githubTokenFile=${HOME}/github_token - - publish: - executor: action-executor - steps: - - custom_attach_workspace - - run: - name: Decrypt Credentials - command: | - openssl aes-256-cbc -d -in .circleci/npm_token -k "${KEY}" -out ~/.npmrc - - run: - name: Deployment to NPM - command: | - yarn admin publish --verbose - - # Windows jobs - # CircleCI support for Windows jobs is still in preview. - # Docs: https://github.com/CircleCI-Public/windows-preview-docs - setup-and-build-win: - executor: windows-executor - steps: - - custom_attach_workspace - - setup_windows - - restore_cache: - keys: - - *cache_key_win - - *cache_key_fallback_win - - run: yarn install --frozen-lockfile - - run: yarn build - - save_cache: - key: *cache_key_win - paths: - # Get cache dir on windows via `yarn cache dir` - - C:\Users\circleci\AppData\Local\Yarn\Cache\v4 - # Only jobs downstream from this one will see the updated workspace - # https://circleci.com/blog/deep-diving-into-circleci-workspaces/ - - persist_to_workspace: - root: *workspace_location - paths: - - ./* - - test-win: - executor: windows-executor - steps: - - custom_attach_workspace - - setup_windows - - run: yarn test --full - # Run partial e2e suite on PRs only. Master will run the full e2e suite with sharding. - - run: if (Test-Path env:CIRCLE_PR_NUMBER) { node tests\legacy-cli\run_e2e.js "--glob=tests/{basic,ivy}/**" } - - e2e-cli-win: - executor: windows-executor - parallelism: 4 - steps: - - custom_attach_workspace - - setup_windows - - run: node tests\legacy-cli\run_e2e.js --nb-shards=$env:CIRCLE_NODE_TOTAL --shard=$env:CIRCLE_NODE_INDEX - -workflows: - version: 2 - default_workflow: - jobs: - # Linux jobs - - setup - - lint: - requires: - - setup - - validate: - requires: - - setup - - build: - requires: - - setup - filters: - branches: - ignore: - - /docs-preview/ - - build-bazel: - requires: - - build - - test: - requires: - - build - - test-large: - requires: - - build - - test-large: - name: test-large-ve - ve: true - glob: "packages/angular_devkit/@(build_angular|build_ng_packagr)/@(src|test)/@(build|browser)/*_spec_large.ts" - requires: - - build - - e2e-cli: - post-steps: - - store_artifacts: - path: /tmp/dist - destination: cli/new-production - requires: - - build - - e2e-cli: - name: e2e-cli-ve - ve: true - requires: - - build - - e2e-cli: - name: e2e-cli-ng-snapshots - snapshots: true - requires: - - e2e-cli - filters: - branches: - only: - - renovate/angular - - master - - e2e-cli: - name: e2e-cli-ng-ve-snapshots - snapshots: true - ve: true - requires: - - e2e-cli - filters: - branches: - only: - - renovate/angular - - master - - e2e-cli-node-10: - <<: *only_release_branches - requires: - - e2e-cli - - test-browsers: - requires: - - build - - flake-jail: - requires: - - build - - # Windows jobs - # These jobs only run after their non-windows counterparts finish successfully. - # This isn't strictly necessary as there is no artifact dependency, but helps economize - # CI resources by not attempting to build when we know it should fail. - - setup-and-build-win: - requires: - # The Linux setup job also does checkout and rebase, which we get via the workspace. - - setup - - build - - test-win: - requires: - - setup-and-build-win - - test - - e2e-cli-win: - <<: *only_release_branches - requires: - - setup-and-build-win - - e2e-cli - - # Publish jobs - - snapshot_publish: - <<: *only_release_branches - requires: - - test - - build - - e2e-cli - - publish: - requires: - - test - - build - - e2e-cli - - snapshot_publish - filters: - tags: - only: /^v\d+/ - branches: - ignore: /.*/ diff --git a/.circleci/env-helpers.inc.sh b/.circleci/env-helpers.inc.sh deleted file mode 100644 index 5fa1263e112f..000000000000 --- a/.circleci/env-helpers.inc.sh +++ /dev/null @@ -1,73 +0,0 @@ -#################################################################################################### -# Helpers for defining environment variables for CircleCI. -# -# In CircleCI, each step runs in a new shell. The way to share ENV variables across steps is to -# export them from `$BASH_ENV`, which is automatically sourced at the beginning of every step (for -# the default `bash` shell). -# -# See also https://circleci.com/docs/2.0/env-vars/#using-bash_env-to-set-environment-variables. -#################################################################################################### - -# Set and print an environment variable. -# -# Use this function for setting environment variables that are public, i.e. it is OK for them to be -# visible to anyone through the CI logs. -# -# Usage: `setPublicVar ` -function setPublicVar() { - setSecretVar $1 "$2"; - echo "$1=$2"; -} - -# Set (without printing) an environment variable. -# -# Use this function for setting environment variables that are secret, i.e. should not be visible to -# everyone through the CI logs. -# -# Usage: `setSecretVar ` -function setSecretVar() { - # WARNING: Secrets (e.g. passwords, access tokens) should NOT be printed. - # (Keep original shell options to restore at the end.) - local -r originalShellOptions=$(set +o); - set +x -eu -o pipefail; - - echo "export $1=\"${2:-}\";" >> $BASH_ENV; - - # Restore original shell options. - eval "$originalShellOptions"; -} - - -# Create a function to set an environment variable, when called. -# -# Use this function for creating setter for public environment variables that require expensive or -# time-consuming computaions and may not be needed. When needed, you can call this function to set -# the environment variable (which will be available through `$BASH_ENV` from that point onwards). -# -# Arguments: -# - ``: The name of the environment variable. The generated setter function will be -# `setPublicVar_`. -# - ``: The code to run to compute the value for the variable. Since this code should be -# executed lazily, it must be properly escaped. For example: -# ```sh -# # DO NOT do this: -# createPublicVarSetter MY_VAR "$(whoami)"; # `whoami` will be evaluated eagerly -# -# # DO this isntead: -# createPublicVarSetter MY_VAR "\$(whoami)"; # `whoami` will NOT be evaluated eagerly -# ``` -# -# Usage: `createPublicVarSetter ` -# -# Example: -# ```sh -# createPublicVarSetter MY_VAR 'echo "FOO"'; -# echo $MY_VAR; # Not defined -# -# setPublicVar_MY_VAR; -# source $BASH_ENV; -# echo $MY_VAR; # FOO -# ``` -function createPublicVarSetter() { - echo "setPublicVar_$1() { setPublicVar $1 \"$2\"; }" >> $BASH_ENV; -} diff --git a/.circleci/env.sh b/.circleci/env.sh deleted file mode 100755 index b7b6e94a5732..000000000000 --- a/.circleci/env.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash - -# Variables -readonly projectDir=$(realpath "$(dirname ${BASH_SOURCE[0]})/..") -readonly envHelpersPath="$projectDir/.circleci/env-helpers.inc.sh"; - -# Load helpers and make them available everywhere (through `$BASH_ENV`). -source $envHelpersPath; -echo "source $envHelpersPath;" >> $BASH_ENV; - - -#################################################################################################### -# Define PUBLIC environment variables for CircleCI. -#################################################################################################### -# See https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables for more info. -#################################################################################################### -setPublicVar PROJECT_ROOT "$projectDir"; - -#################################################################################################### -# Define SauceLabs environment variables for CircleCI. -#################################################################################################### -setPublicVar SAUCE_USERNAME "angular-tooling"; -setSecretVar SAUCE_ACCESS_KEY "8c4ffce86ae6-c419-8ef4-0513-54267305"; -setPublicVar SAUCE_LOG_FILE /tmp/angular/sauce-connect.log -setPublicVar SAUCE_READY_FILE /tmp/angular/sauce-connect-ready-file.lock -setPublicVar SAUCE_PID_FILE /tmp/angular/sauce-connect-pid-file.lock -setPublicVar SAUCE_TUNNEL_IDENTIFIER "angular-${CIRCLE_BUILD_NUM}-${CIRCLE_NODE_INDEX}" -# Amount of seconds we wait for sauceconnect to establish a tunnel instance. In order to not -# acquire CircleCI instances for too long if sauceconnect failed, we need a connect timeout. -setPublicVar SAUCE_READY_FILE_TIMEOUT 120 - -# Source `$BASH_ENV` to make the variables available immediately. -source $BASH_ENV; diff --git a/.circleci/gcp_token b/.circleci/gcp_token deleted file mode 100644 index 06773903e8d8..000000000000 Binary files a/.circleci/gcp_token and /dev/null differ diff --git a/.circleci/github_token b/.circleci/github_token deleted file mode 100644 index 450cb2c93f8c..000000000000 --- a/.circleci/github_token +++ /dev/null @@ -1 +0,0 @@ -Salted__zÈùº¬ö"Bõ¾Y¾’|‚ۍ¢V”QֳUzWò±/G…îR ¡e}j‘% þÿ¦<%öáÉÿ–¼ \ No newline at end of file diff --git a/.circleci/npm_token b/.circleci/npm_token deleted file mode 100644 index 5a1bb6303052..000000000000 --- a/.circleci/npm_token +++ /dev/null @@ -1 +0,0 @@ -Salted__°/¥ÙL Ž¡”ö¦°Â;ª…(.|©í¡ –ÚCŒàԒ�“þÖ5`h¢8Ðài8JÁo*´?}çû£3§0f´!Ð'ÛB¸̠œ"UƊ&K!¨%Áɵڤ \ No newline at end of file diff --git a/.editorconfig b/.editorconfig index 1c658dfbc777..c0a70d2acb14 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,6 +9,7 @@ indent_size = 2 insert_final_newline = true spaces_around_brackets = inside trim_trailing_whitespace = true +quote_type = single [*.md] insert_final_newline = false diff --git a/.gemini/config.yaml b/.gemini/config.yaml new file mode 100644 index 000000000000..795367b13a53 --- /dev/null +++ b/.gemini/config.yaml @@ -0,0 +1,13 @@ +have_fun: false +code_review: + disable: false + comment_severity_threshold: MEDIUM + max_review_comments: -1 + pull_request_opened: + help: false + summary: false + code_review: true + include_drafts: false +ignore_patterns: + - pnpm-lock.yaml + - CHANGELOG.md diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index b5135def5125..000000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,10 +0,0 @@ -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please help us process issues more efficiently by filing an -issue using one of the following templates: - -https://github.com/angular/angular-cli/issues/new/choose - -Thank you! - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.md b/.github/ISSUE_TEMPLATE/1-bug-report.md deleted file mode 100644 index 3e69b63b677c..000000000000 --- a/.github/ISSUE_TEMPLATE/1-bug-report.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -name: "\U0001F41EBug report" -about: Report a bug in Angular CLI ---- - - - -# 🐞 Bug report - -### Command (mark with an `x`) - - - -- [ ] new -- [ ] build -- [ ] serve -- [ ] test -- [ ] e2e -- [ ] generate -- [ ] add -- [ ] update -- [ ] lint -- [ ] xi18n -- [ ] run -- [ ] config -- [ ] help -- [ ] version -- [ ] doc - - -### Is this a regression? - - - Yes, the previous version in which this bug was not present was: .... - - -### Description - - A clear and concise description of the problem... - - -## 🔬 Minimal Reproduction - - -## 🔥 Exception or Error -

-
-
-
-
- - -## 🌍 Your Environment -

-
-
-
-
- -**Anything else relevant?** - - - diff --git a/.github/ISSUE_TEMPLATE/1-bug-report.yml b/.github/ISSUE_TEMPLATE/1-bug-report.yml new file mode 100644 index 000000000000..5c4ea7d2cbdb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1-bug-report.yml @@ -0,0 +1,102 @@ +name: Bug report +description: Report a bug in Angular CLI +body: + - type: markdown + attributes: + value: | + Oh hi there! + + To expedite issue processing please search open and closed issues before submitting a new one. + Existing issues often contain information about workarounds, resolution, or progress updates. + - type: dropdown + id: command + attributes: + label: Command + description: Can you pin-point the command or commands that are effected by this bug? + options: + - add + - build + - config + - doc + - e2e + - extract-i18n + - generate + - help + - lint + - new + - other + - run + - serve + - test + - update + - version + multiple: true + validations: + required: true + - type: checkboxes + id: is-regression + attributes: + label: Is this a regression? + description: Did this behavior use to work in the previous version? + options: + - label: Yes, this behavior used to work in the previous version + - type: input + id: version-bug-was-not-present + attributes: + label: The previous version in which this bug was not present was + validations: + required: false + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the problem. + validations: + required: true + - type: textarea + id: minimal-reproduction + attributes: + label: Minimal Reproduction + description: | + Simple steps to reproduce this bug. + + **Please include:** + * commands run (including args) + * packages added + * related code changes + + + If reproduction steps are not enough for reproduction of your issue, please create a minimal GitHub repository with the reproduction of the issue. + A good way to make a minimal reproduction is to create a new app via `ng new repro-app` and add the minimum possible code to show the problem. + Share the link to the repo below along with step-by-step instructions to reproduce the problem, as well as expected and actual behavior. + + Issues that don't have enough info and can't be reproduced will be closed. + + You can read more about issue submission guidelines [here](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue). + validations: + required: true + - type: textarea + id: exception-or-error + attributes: + label: Exception or Error + description: If the issue is accompanied by an exception or an error, please share it below. + render: text + validations: + required: false + - type: textarea + id: environment + attributes: + label: Your Environment + description: Run `ng version` and paste output below. + render: text + validations: + required: true + - type: textarea + id: other + attributes: + label: Anything else relevant? + description: | + Is this a browser specific issue? If so, please specify the browser and version. + Do any of these matter: operating system, IDE, package manager, HTTP server, ...? If so, please mention it below. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.md b/.github/ISSUE_TEMPLATE/2-feature-request.md deleted file mode 100644 index ca92a7100ced..000000000000 --- a/.github/ISSUE_TEMPLATE/2-feature-request.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: "\U0001F680Feature request" -about: Suggest a feature for Angular CLI - ---- - - - -# 🚀 Feature request - - -### Command (mark with an `x`) - - -- [ ] new -- [ ] build -- [ ] serve -- [ ] test -- [ ] e2e -- [ ] generate -- [ ] add -- [ ] update -- [ ] lint -- [ ] xi18n -- [ ] run -- [ ] config -- [ ] help -- [ ] version -- [ ] doc - -### Description - A clear and concise description of the problem or missing capability... - - -### Describe the solution you'd like - If you have a solution in mind, please describe it. - - -### Describe alternatives you've considered - Have you considered any alternative solutions or workarounds? diff --git a/.github/ISSUE_TEMPLATE/2-feature-request.yml b/.github/ISSUE_TEMPLATE/2-feature-request.yml new file mode 100644 index 000000000000..4a01698e0f37 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2-feature-request.yml @@ -0,0 +1,55 @@ +name: Feature request +description: Suggest a feature for Angular CLI +body: + - type: markdown + attributes: + value: | + Oh hi there! + + To expedite issue processing please search open and closed issues before submitting a new one. + Existing issues often contain information about workarounds, resolution, or progress updates. + - type: dropdown + id: command + attributes: + label: Command + description: Can you pin-point the command or commands that are relevant for this feature request? + options: + - add + - build + - config + - doc + - e2e + - extract-i18n + - generate + - help + - lint + - new + - run + - serve + - test + - update + - version + multiple: true + validations: + required: true + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of the problem or missing capability. + validations: + required: true + - type: textarea + id: desired-solution + attributes: + label: Describe the solution you'd like + description: If you have a solution in mind, please describe it. + validations: + required: false + - type: textarea + id: alternatives + attributes: + label: Describe alternatives you've considered + description: Have you considered any alternative solutions or workarounds? + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/3-docs-bug.md b/.github/ISSUE_TEMPLATE/3-docs-bug.md deleted file mode 100644 index 7cd9ec28753a..000000000000 --- a/.github/ISSUE_TEMPLATE/3-docs-bug.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: "📚 Docs or angular.io issue report" -about: Report an issue in Angular's documentation or angular.io application - ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please file any Docs or angular.io issues at: https://github.com/angular/angular/issues/new/choose - -For the time being, we keep Angular AIO issues in a separate repository. - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/4-security-issue-disclosure.md b/.github/ISSUE_TEMPLATE/4-security-issue-disclosure.md deleted file mode 100644 index b789da9f6da1..000000000000 --- a/.github/ISSUE_TEMPLATE/4-security-issue-disclosure.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: ⚠️Security issue disclosure -about: Report a security issue in Angular Framework, Material, or CLI - ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please read https://angular.io/guide/security#report-issues on how to disclose security related issues. - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/5-support-request.md b/.github/ISSUE_TEMPLATE/5-support-request.md deleted file mode 100644 index f6e6e66ff893..000000000000 --- a/.github/ISSUE_TEMPLATE/5-support-request.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: "❓Support request" -about: Questions and requests for support - ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please do not file questions or support requests on the GitHub issues tracker. - -You can get your questions answered using other communication channels. Please see: -https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#question - -Thank you! - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/6-angular-framework.md b/.github/ISSUE_TEMPLATE/6-angular-framework.md deleted file mode 100644 index 8a689c55de35..000000000000 --- a/.github/ISSUE_TEMPLATE/6-angular-framework.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: "⚡Angular Framework" -about: Issues and feature requests for Angular Framework - ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please file any Angular Framework issues at: https://github.com/angular/angular/issues/new/choose - -For the time being, we keep Angular issues in a separate repository. - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/7-angular-material.md b/.github/ISSUE_TEMPLATE/7-angular-material.md deleted file mode 100644 index b023135b0cc5..000000000000 --- a/.github/ISSUE_TEMPLATE/7-angular-material.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: "\U0001F48EAngular Material" -about: Issues and feature requests for Angular Material - ---- - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 - -Please file any Angular Material issues at: https://github.com/angular/material2/issues/new - -For the time being, we keep Angular Material issues in a separate repository. - -🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑🛑 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000000..898698af3906 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,17 @@ +blank_issues_enabled: false +contact_links: + - name: Docs or angular.dev issue report + url: https://github.com/angular/angular/issues/new + about: Report an issue in Angular's documentation or angular.dev application + - name: Security issue disclosure + url: https://angular.dev/best-practices/security + about: Report a security issue in Angular Framework, Material, or CLI + - name: Support request + url: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#question + about: Questions and requests for support. + - name: Angular Framework + url: https://github.com/angular/angular/issues/new/choose + about: Issues and feature requests for Angular Framework + - name: Angular Material + url: https://github.com/angular/components/issues/new/choose + about: Issues and feature requests for Angular Material diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000000..3214dde0a4f4 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,43 @@ +## PR Checklist + +Please check to confirm your PR fulfills the following requirements: + + + +- [ ] The commit message follows our guidelines: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-commit-message-guidelines +- [ ] Tests for the changes have been added (for bug fixes / features) +- [ ] Docs have been added / updated (for bug fixes / features) + +## PR Type + +What kind of change does this PR introduce? + + + +- [ ] Bugfix +- [ ] Feature +- [ ] Code style update (formatting, local variables) +- [ ] Refactoring (no functional changes, no api changes) +- [ ] Build related changes +- [ ] CI related changes +- [ ] Documentation content changes +- [ ] Other... Please describe: + +## What is the current behavior? + + + +Issue Number: N/A + +## What is the new behavior? + + + +## Does this PR introduce a breaking change? + +- [ ] Yes +- [ ] No + + + +## Other information diff --git a/.github/SAVED_REPLIES.md b/.github/SAVED_REPLIES.md index 29e19832903c..dfce61802c0e 100644 --- a/.github/SAVED_REPLIES.md +++ b/.github/SAVED_REPLIES.md @@ -4,87 +4,87 @@ The following are canned responses that the Angular CLI team should use to close Since GitHub currently doesn't allow us to have a repository-wide or organization-wide list of [saved replies](https://help.github.com/articles/working-with-saved-replies/), these replies need to be maintained by individual team members. Since the responses can be modified in the future, all responses are versioned to simplify the process of keeping the responses up to date. - ## Angular CLI: Already Fixed (v1) + ``` Thanks for reporting this issue. Luckily, it has already been fixed in one of the recent releases. Please update to the most recent version to resolve the problem. If the problem persists in your application after upgrading, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. You can use `ng new repro-app` to create a new project where you reproduce the problem. ``` - ## Angular CLI: Don't Understand (v1) + ``` I'm sorry, but we don't understand the problem you are reporting. If the problem persists, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. You can use `ng new repro-app` to create a new project where you reproduce the problem. ``` - ## Angular CLI: Duplicate (v1.1) + ``` Thanks for reporting this issue. However, this issue is a duplicate of #. Please subscribe to that issue for future updates. ``` - ## Angular CLI: Insufficient Information Provided (v1) + ``` -Thanks for reporting this issue. However, you didn't provide sufficient information for us to understand and reproduce the problem. Please check out [our submission guidelines](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-submitting-an-issue) to understand why we can't act on issues that are lacking important information. +Thanks for reporting this issue. However, you didn't provide sufficient information for us to understand and reproduce the problem. Please check out [our submission guidelines](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue) to understand why we can't act on issues that are lacking important information. If the problem persists, please file a new issue and ensure you provide all of the required information when filling out the issue template. ``` - ## Angular CLI: NPM install issue (v1) + ``` This seems like a problem with your node/npm and not with Angular CLI. Please have a look at the [fixing npm permissions page](https://docs.npmjs.com/getting-started/fixing-npm-permissions), [common errors page](https://docs.npmjs.com/troubleshooting/common-errors), [npm issue tracker](https://github.com/npm/npm/issues), or open a new issue if the problem you are experiencing isn't known. ``` - ## Angular CLI: Issue Outside of Angular CLI (v1.1) + ``` I'm sorry, but this issue is not caused by Angular CLI. Please contact the author(s) of the project or file an issue on their issue tracker. ``` - ## Angular CLI: Non-reproducible (v1) + ``` I'm sorry, but we can't reproduce the problem following the instructions you provided. Remember that we have a large number of issues to resolve, and have only a limited amount of time to reproduce your issue. Short, explicit instructions make it much more likely we'll be able to reproduce the problem so we can fix it. -If the problem persists, please open a new issue following [our submission guidelines](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-submitting-an-issue). +If the problem persists, please open a new issue following [our submission guidelines](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-submitting-an-issue). A good way to make a minimal repro is to create a new app via `ng new repro-app` and adding the minimum possible code to show the problem. Then you can push this repository to github and link it here. ``` - ## Angular CLI: Obsolete (v1) + ``` Thanks for reporting this issue. This issue is now obsolete due to changes in the recent releases. Please update to the most recent Angular CLI version. If the problem persists after upgrading, please open a new issue, provide a simple repository reproducing the problem, and describe the difference between the expected and current behavior. ``` - ## Angular CLI: Support Request (v1) -``` -Hello, we reviewed this issue and determined that it doesn't fall into the bug report or feature request category. This issue tracker is not suitable for support requests, please repost your issue on [StackOverflow](http://stackoverflow.com/) using tag `angular-cli`. -If you are wondering why we don't resolve support issues via the issue tracker, please [check out this explanation](https://github.com/angular/angular-cli/blob/master/CONTRIBUTING.md#-got-a-question-or-problem). ``` +Hello, we reviewed this issue and determined that it doesn't fall into the bug report or feature request category. This issue tracker is not suitable for support requests, please repost your issue on [StackOverflow](https://stackoverflow.com/) using tag `angular-cli`. +If you are wondering why we don't resolve support issues via the issue tracker, please [check out this explanation](https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#-got-a-question-or-problem). +``` ## Angular CLI: Static Analysis errors (v1) + ``` Hello, errors like `Error encountered resolving symbol values statically` mean that there has been some problem in statically analyzing your app. Angular CLI always runs *some* static analysis, even in JIT mode, in order to discover lazy-loaded routes. This may cause a lot of static analysis errors to surface when importing your project into the CLI, or upgrading for older versions where we didn't run this kind of analysis. -Below are good resources on how to to debug these errors: +Below are good resources on how to debug these errors: - https://gist.github.com/chuckjaz/65dcc2fd5f4f5463e492ed0cb93bca60 - https://github.com/rangle/angular-2-aot-sandbox#aot-dos-and-donts @@ -93,8 +93,19 @@ In that case, please open an issue in https://github.com/angular/angular. ``` ## Angular CLI: Lockfiles (v1) + ``` I'd like to remind everyone that **you only have reproducible installs if you use a lockfile**. Both [NPM v5+](https://docs.npmjs.com/files/package-locks) and [Yarn](https://yarnpkg.com/lang/en/docs/yarn-lock/) support lockfiles. If your CI works one day but not the next and you did not change your code or `package.json`, it is likely because one of your dependencies had a bad release and you did not have a lockfile. **It is your responsibility as a library consumer to use lockfiles**. No one wants to do a release with bugs but it sometimes happens, and the best we can do is to fix it as fast as possible with a new release. When you have a couple of thousand total dependencies it is only a matter of time until one of them has a bad release. ``` + +## Angular CLI: Spam (v1) + +``` +Woah, looks like you've opened a lot of issues/PRs recently. While we appreciate contributions from the community, triaging and reviewing a large influx of content in a short time period takes time away from other ongoing projects. As a result, we're closing these issues/PRs to maintain the team's focus. + +Note that this is not necessarily a rejection of the goals or direction of any of these contributions in particular, so much as a reflection of the team's current capacity and priorities. + +You are welcome to open a smaller subset of issues/PRs in accordance with [our policy](https://github.com/angular/angular/blob/main/contributing-docs/spam.md) focused on the most important and impactful contributions and we will do our best to prioritize a response as soon as possible. +``` diff --git a/.github/_CODEOWNERS.tmp b/.github/_CODEOWNERS.tmp deleted file mode 100644 index 3e79d395914f..000000000000 --- a/.github/_CODEOWNERS.tmp +++ /dev/null @@ -1,17 +0,0 @@ -/packages/_/ @hansl @clydin -/packages/angular/pwa @hansl @Brocco -/packages/angular_devkit/architect/ @filipesilva @hansl -/packages/angular_devkit/architect_cli/ @filipesilva @hansl -/packages/angular_devkit/build_angular/ @filipesilva @clydin -/packages/angular_devkit/build_ng_packagr/ @filipesilva @clydin -/packages/angular_devkit/build_optimizer/ @filipesilva @clydin -/packages/angular_devkit/core/ @hansl @clydin -/packages/angular_devkit/schematics/ @hansl @Brocco -/packages/angular_devkit/schematics_cli/ @hansl @Brocco -/packages/ngtools/webpack/ @hansl @filipesilva @clydin -/packages/schematics/angular/ @hansl @Brocco -/packages/schematics/package_update/ @hansl @Brocco -/packages/schematics/schematics/ @hansl @Brocco -/packages/schematics/update/ @hansl @Brocco -/rules/ @hansl @clydin -/scripts/ @hansl @clydin diff --git a/.github/angular-robot.yml b/.github/angular-robot.yml index 8d06fa54ce8a..3e3a56a25603 100644 --- a/.github/angular-robot.yml +++ b/.github/angular-robot.yml @@ -5,20 +5,22 @@ merge: # the status will be added to your pull requests status: # set to true to disable - disabled: false + disabled: true # the name of the status - context: "ci/angular: merge status" + context: 'ci/angular: merge status' # text to show when all checks pass - successText: "All checks passed!" + successText: 'All checks passed!' # text to show when some checks are failing - failureText: "The following checks are failing:" + failureText: 'The following checks are failing:' # comment that will be added to a PR when there is a conflict, leave empty or set to false to disable - mergeConflictComment: "Hi @{{PRAuthor}}! This PR has merge conflicts due to recent upstream merges. -\nPlease help to unblock it by resolving these conflicts. Thanks!" + mergeConflictComment: >- + Hi @{{PRAuthor}}! This PR has merge conflicts due to recent upstream merges. + + Please help to unblock it by resolving these conflicts. Thanks! # label to monitor - mergeLabel: "PR action: merge" + mergeLabel: 'action: merge' # list of checks that will determine if the merge label can be added checks: @@ -26,81 +28,65 @@ merge: noConflict: true # whether the PR should have all reviews completed. requireReviews: true - # list of labels that a PR needs to have, checked with a regexp (e.g. "PR target:" will work for the label "PR target: master") + # list of labels that a PR needs to have, checked with a regexp (e.g. "target:" will work for the label "target: major") requiredLabels: - - "PR target: *" - - "cla: yes" + - 'target: *' # list of labels that a PR shouldn't have, checked after the required labels with a regexp forbiddenLabels: - - "PR target: TBD" - - "PR action: cleanup" - - "PR action: review" - - "PR state: blocked" - - "cla: no" + - 'action: cleanup' + - 'action: review' + - 'PR state: blocked' # list of PR statuses that need to be successful - requiredStatuses: - - "ci/circleci: build" - - "ci/circleci: build-bazel" - - "ci/circleci: setup" - - "ci/circleci: setup-and-build-win" - - "ci/circleci: lint" - - "ci/circleci: validate" - - "ci/circleci: test" - - "ci/circleci: test-win" - - "ci/circleci: test-large" - - "ci/circleci: test-large-ve" - - "ci/circleci: e2e-cli" - - "ci/circleci: e2e-cli-ve" - - "ci/circleci: test-browsers" - - "ci/angular: size" - - "cla/google" + # NOTE: Required PR statuses are now exclusively handled via Github configuration + requiredStatuses: [] # the comment that will be added when the merge label is added despite failing checks, leave empty or set to false to disable # {{MERGE_LABEL}} will be replaced by the value of the mergeLabel option # {{PLACEHOLDER}} will be replaced by the list of failing checks - mergeRemovedComment: "I see that you just added the `{{MERGE_LABEL}}` label, but the following checks are still failing: -\n{{PLACEHOLDER}} -\n -\n**If you want your PR to be merged, it has to pass all the CI checks.** -\n -\nIf you can't get the PR to a green state due to flakes or broken master, please try rebasing to master and/or restarting the CI job. If that fails and you believe that the issue is not due to your change, please contact the caretaker and ask for help." + mergeRemovedComment: >- + I see that you just added the `{{MERGE_LABEL}}` label, but the following + checks are still failing: + + {{PLACEHOLDER}} + + **If you want your PR to be merged, it has to pass all the CI checks.** + If you can't get the PR to a green state due to flakes or broken `main`, + please try rebasing to `main` and/or restarting the CI job. If that fails + and you believe that the issue is not due to your change, please contact the + caretaker and ask for help. # options for the triage plugin triage: # set to true to disable - disabled: false + disabled: true # number of the milestone to apply when the issue has not been triaged yet needsTriageMilestone: 11, # number of the milestone to apply when the issue is triaged defaultMilestone: 12, # arrays of labels that determine if an issue has been triaged by the caretaker l1TriageLabels: - - - - "comp: *" + - - 'area: *' # arrays of labels that determine if an issue has been fully triaged l2TriageLabels: - - - - "type: bug/fix" - - "severity*" - - "freq*" - - "comp: *" - - - - "type: feature" - - "comp: *" - - - - "type: refactor" - - "comp: *" - - - - "type: RFC / Discussion / question" - - "comp: *" - - - - "type: docs" - - "comp: *" + - - 'type: bug/fix' + - 'severity*' + - 'freq*' + - 'area: *' + - - 'type: feature' + - 'area: *' + - - 'type: refactor' + - 'area: *' + - - 'type: RFC / Discussion / question' + - 'area: *' + - - 'type: docs' + - 'area: *' # Size checking size: - circleCiStatusName: "ci/circleci: e2e-cli" + # Size checking for production build is performed via the E2E test `build/prod-build` + disabled: true + circleCiStatusName: 'ci/circleci: e2e-cli' maxSizeIncrease: 10000 comment: false diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000000..a01353b6bcf0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,16 @@ +version: 2 + +updates: + - package-ecosystem: 'npm' + directory: '/' + schedule: + interval: 'daily' + commit-message: + prefix: 'build' + labels: + - 'area: build & ci' + - 'target: patch' + - 'action: merge' + # Disable version updates + # This does not affect security updates + open-pull-requests-limit: 0 diff --git a/.github/shared-actions/windows-bazel-test/action.yml b/.github/shared-actions/windows-bazel-test/action.yml new file mode 100644 index 000000000000..7a5cf7c0297b --- /dev/null +++ b/.github/shared-actions/windows-bazel-test/action.yml @@ -0,0 +1,42 @@ +name: Native Windows Bazel E2E test +description: Runs an Angular CLI e2e Bazel test on native Windows +author: Angular + +inputs: + test_target_name: + description: E2E test target name. + required: true + e2e_temp_dir: + description: 'The temporary directory path for E2E tests.' + required: false + # Use D:\\ by default as it's much faster + # See: https://devblogs.microsoft.com/commandline/share-environment-vars-between-wsl-and-windows + default: 'D:\\tmp_dir' + +runs: + using: 'composite' + steps: + - name: Set up temp directory + shell: bash + run: | + mkdir ${{ inputs.e2e_temp_dir }} + + - name: Convert symlinks for Windows host + shell: pwsh + run: | + $runfiles_dir = "./dist/bin/tests/${{inputs.test_target_name}}_/${{inputs.test_target_name}}.bat.runfiles" + + # Needed for resolution because Aspect/Bazel looks for repositories at `/external`. + # TODO(devversion): consult with Aspect on why this is needed. + Set-Location -Path "${runfiles_dir}\_main" + New-Item -ItemType SymbolicLink -Path "external" -Target ".." + + - name: Run CLI E2E tests + shell: bash + env: + BAZEL_BINDIR: '.' + E2E_TEMP: ${{ inputs.e2e_temp_dir }} + run: | + node ./scripts/windows-testing/parallel-executor.mjs \ + "./dist/bin/tests/${{ inputs.test_target_name }}_/${{ inputs.test_target_name }}.bat.runfiles" \ + ${{ inputs.test_target_name }} diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml new file mode 100644 index 000000000000..f50263aad6ab --- /dev/null +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -0,0 +1,23 @@ +name: DevInfra + +on: + push: + # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code} + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, labeled] + +# Declare default permissions as read only. +permissions: + contents: read + +jobs: + assistant_to_the_branch_manager: + runs-on: ubuntu-latest + if: github.event.repository.fork == false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: angular/dev-infra/github-actions/branch-manager@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000000..6397ee443590 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,255 @@ +name: CI + +on: + push: + branches: + - main + - '[0-9]+.[0-9]+.x' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: pnpm run build-schema + - name: Run ESLint + run: pnpm lint --cache-strategy content + - name: Validate NgBot Configuration + run: pnpm ng-dev ngbot verify + - name: Validate Circular Dependencies + run: pnpm ts-circular-deps check + - name: Run Validation + run: pnpm admin validate + - name: Check tooling setup + run: pnpm check-tooling-setup + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build release targets + run: pnpm ng-dev release build + + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Run module and package tests + run: pnpm bazel test -- //... -//tests/... + + e2e: + needs: test + strategy: + fail-fast: false + matrix: + node: [22, 24, 26] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + build-e2e-windows: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build E2E tests for Windows on Linux + run: | + pnpm bazel build \ + --config=e2e \ + //tests:e2e.webpack_node24 \ + //tests:e2e.esbuild_node24 \ + --platforms=tools:windows_x64 + - name: Store built Windows E2E tests + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: win-e2e-build-artifacts + path: | + dist/bin/tests/** + !**/node_modules/** + retention-days: 1 + if-no-files-found: 'error' + + e2e-windows: + needs: build-e2e-windows + strategy: + fail-fast: false + matrix: + node: [24] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: windows-2025 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Download built Windows E2E tests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: win-e2e-build-artifacts + path: dist/bin/tests/ + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test + with: + test_target_name: e2e.${{ matrix.subset }}_node${{ matrix.node }} + env: + E2E_SHARD_TOTAL: 6 + E2E_SHARD_INDEX: ${{ matrix.shard }} + + e2e-package-managers: + needs: test + strategy: + fail-fast: false + matrix: + node: [22] + subset: [yarn, pnpm, bun] + shard: [0, 1, 2] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=3 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: test + if: github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} + + browsers: + needs: build + runs-on: ubuntu-latest + name: Browser Compatibility Tests + env: + SAUCE_TUNNEL_IDENTIFIER: angular-cli-${{ github.workflow }}-${{ github.run_number }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + google_credential: ${{ secrets.RBE_TRUSTED_BUILDS_USER }} + - name: Start Sauce Connect + uses: saucelabs/sauce-connect-action@cb88b508c6f9ff4d84490093733315dbd55de022 # v3 + timeout-minutes: 10 + with: + username: ${{ vars.SAUCE_USERNAME }} + accessKey: ${{ secrets.SAUCE_ACCESS_KEY }} + region: us-west-1 + tunnelName: ${{ env.SAUCE_TUNNEL_IDENTIFIER }} + proxyLocalhost: allow + # By default we disable SSL bumping for all requests. This is because SSL bumping is not needed + # for our test setup and in order to perform the SSL bumping, Saucelabs intercepts all HTTP + # requests in the tunnel VM and modifies them. This can cause flakiness as it makes all requests + # dependent on the SSL bumping middleware. + # See: https://wiki.saucelabs.com/display/DOCS/Troubleshooting+Sauce+Connect#TroubleshootingSauceConnect-DisablingSSLBumping + tlsPassthroughDomains: all + - name: Run E2E Browser tests + env: + SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }} + SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} + run: pnpm bazel test --config=saucelabs //tests:e2e.saucelabs + + publish-snapshots: + needs: build + runs-on: ubuntu-latest + env: + CIRCLE_BRANCH: ${{ github.ref_name }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - run: pnpm admin snapshots --verbose + env: + SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }} diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml new file mode 100644 index 000000000000..b85b240f82dc --- /dev/null +++ b/.github/workflows/dev-infra.yml @@ -0,0 +1,36 @@ +name: DevInfra + +on: + # zizmor: ignore[dangerous-triggers] - {Trigger is safe as workflow does not checkout untrusted code} + pull_request_target: + types: [opened, synchronize, reopened] + issues: + types: [opened, reopened] + +# Declare default permissions as read only. +permissions: + contents: read + +jobs: + labels: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/labeling/pull-request@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + post_approval_changes: + if: github.event_name == 'pull_request_target' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/post-approval-changes@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + issue_labels: + if: github.event_name == 'issues' + runs-on: ubuntu-latest + steps: + - uses: angular/dev-infra/github-actions/labeling/issue@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + with: + angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} + google-generative-ai-key: ${{ secrets.GOOGLE_GENERATIVE_AI_KEY }} diff --git a/.github/workflows/lock-closed.yml b/.github/workflows/lock-closed.yml deleted file mode 100644 index 2bc90deaeef2..000000000000 --- a/.github/workflows/lock-closed.yml +++ /dev/null @@ -1,14 +0,0 @@ -name: Lock Inactive Issues - -on: - schedule: - # Run at 08:00 every day - - cron: '0 8 * * *' - -jobs: - lock_closed: - runs-on: ubuntu-latest - steps: - - uses: angular/dev-infra/github-actions/lock-closed@a4fd924 - with: - lock-bot-key: ${{ secrets.LOCK_BOT_PRIVATE_KEY }} diff --git a/.github/workflows/perf.yml b/.github/workflows/perf.yml new file mode 100644 index 000000000000..21a061c26de5 --- /dev/null +++ b/.github/workflows/perf.yml @@ -0,0 +1,60 @@ +name: Performance Tracking + +on: + push: + branches: + - main + # Run workflows for all releasable branches + - '[0-9]+.[0-9]+.x' + +permissions: + contents: 'read' + +defaults: + run: + shell: bash + +jobs: + list: + timeout-minutes: 3 + runs-on: ubuntu-latest + outputs: + workflows: ${{ steps.workflows.outputs.workflows }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - id: workflows + run: echo "workflows=$(pnpm --silent ng-dev perf workflows --list)" >> "$GITHUB_OUTPUT" + + workflow: + timeout-minutes: 30 + runs-on: ubuntu-latest + needs: list + permissions: + contents: 'read' + id-token: 'write' + strategy: + matrix: + workflow: ${{ fromJSON(needs.list.outputs.workflows) }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + # We utilize the google-github-actions/auth action to allow us to get an active credential using workflow + # identity federation. This allows us to request short lived credentials on demand, rather than storing + # credentials in secrets long term. More information can be found at: + # https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-google-cloud-platform + - uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0 + with: + project_id: 'internal-200822' + workload_identity_provider: 'projects/823469418460/locations/global/workloadIdentityPools/measurables-tracking/providers/angular' + service_account: 'measures-uploader@internal-200822.iam.gserviceaccount.com' + - run: pnpm ng-dev perf workflows --name "$WORKFLOW" --commit-sha "$COMMIT_SHA" + env: + WORKFLOW: ${{ matrix.workflow }} + COMMIT_SHA: ${{ github.sha }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000000..f0eb6e091f47 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,216 @@ +name: Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + analyze: + runs-on: ubuntu-latest + outputs: + snapshots: ${{ steps.filter.outputs.snapshots }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: dorny/paths-filter@ceb8a2b8f2d89434be7ff52d3de7ec3738c5cc9d # v4.0.3 + id: filter + with: + filters: | + snapshots: + - 'tests/e2e/ng-snapshot/package.json' + + lint: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup ESLint Caching + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: .eslintcache + key: ${{ runner.os }}-${{ hashFiles('.eslintrc.json') }} + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: pnpm run build-schema + - name: Run ESLint + run: pnpm lint --cache-strategy content + - name: Validate NgBot Configuration + run: pnpm ng-dev ngbot verify + - name: Validate Circular Dependencies + run: pnpm ts-circular-deps check + - name: Run Validation + run: pnpm admin validate + - name: Check tooling setup + run: pnpm check-tooling-setup + - name: Check commit message + # Commit message validation is only done on pull requests as its too late to validate once + # it has been merged. + run: pnpm ng-dev commit-message validate-range ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} + - name: Check code format + # Code formatting checks are only done on pull requests as its too late to validate once + # it has been merged. + run: pnpm ng-dev format changed --check ${{ github.event.pull_request.base.sha }} + - name: Check Package Licenses + uses: angular/dev-infra/github-actions/linting/licenses@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build release targets + run: pnpm ng-dev release build + - name: Store PR release packages + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: packages + path: dist/releases/*.tgz + retention-days: 14 + + test: + needs: build + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Run module and package tests + run: pnpm bazel test -- //... -//tests/... + + e2e: + needs: build + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + build-e2e-windows-subset: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Build E2E tests for Windows on Linux + run: | + pnpm bazel build \ + --config=e2e \ + //tests:e2e.esbuild_node24 \ + --platforms=tools:windows_x64 + - name: Store built Windows E2E tests + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: win-e2e-build-artifacts + path: | + dist/bin/tests/** + !**/node_modules/** + retention-days: 1 + if-no-files-found: 'error' + + e2e-windows-subset: + needs: build-e2e-windows-subset + runs-on: windows-2025 + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Download built Windows E2E tests + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + name: win-e2e-build-artifacts + path: dist/bin/tests/ + - name: Run CLI E2E tests + uses: ./.github/shared-actions/windows-bazel-test + with: + test_target_name: e2e.esbuild_node24 + env: + E2E_SHARD_TOTAL: 1 + TESTBRIDGE_TEST_ONLY: tests/basic/{build,rebuild,serve}.ts + + e2e-package-managers: + needs: build + strategy: + fail-fast: false + matrix: + node: [22] + subset: [yarn, pnpm, bun] + shard: [0, 1, 2] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=3 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: [analyze, build] + if: needs.analyze.outputs.snapshots == 'true' + strategy: + fail-fast: false + matrix: + node: [22] + subset: [esbuild, webpack] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Install node modules + run: pnpm install --frozen-lockfile + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@c09a8058c7eee99ed9cec6213ff967bad6d6b73c # main + - name: Run CLI E2E tests + run: pnpm bazel test --test_env=E2E_SHARD_TOTAL=6 --test_env=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} diff --git a/.gitignore b/.gitignore index 140bf29b0e66..83582f8ece43 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,14 @@ test-project-host-* dist/ dist-schema/ +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + # IDEs jsconfig.json @@ -23,7 +31,7 @@ jsconfig.json # VSCode # https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore -.vscode/ +.vscode/* !.vscode/settings.json !.vscode/tasks.json !.vscode/launch.json @@ -40,6 +48,12 @@ tmp/ npm-debug.log* yarn-error.log* .ng_pkg_build/ +.ng-dev.log +.ng-dev.err*.log +.ng-dev.user* +.husky/_ +.bazelrc.user +.eslintcache # Mac OSX Finder files. **/.DS_Store diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000000..de361903a541 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1 @@ +pnpm --silent ng-dev commit-message pre-commit-validate --file $1; diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000000..7f9d46b47835 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm --silent ng-dev format staged; diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg new file mode 100755 index 000000000000..f4598b6d1b6e --- /dev/null +++ b/.husky/prepare-commit-msg @@ -0,0 +1 @@ +pnpm --silent ng-dev commit-message restore-commit-message-draft $1 $2; diff --git a/.idea/angular-cli.iml b/.idea/angular-cli.iml deleted file mode 100644 index 8080e800d2bb..000000000000 --- a/.idea/angular-cli.iml +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 28a804d8932a..000000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 6d8c965387b0..000000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/.idea/runConfigurations/Large_Tests.xml b/.idea/runConfigurations/Large_Tests.xml deleted file mode 100644 index 3d4f25fb3a76..000000000000 --- a/.idea/runConfigurations/Large_Tests.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - diff --git a/etc/cli.angular.io/license.html b/etc/cli.angular.io/license.html deleted file mode 100644 index 0131cc303c40..000000000000 --- a/etc/cli.angular.io/license.html +++ /dev/null @@ -1,23 +0,0 @@ -
The MIT License
-
-Copyright (c) Google, Inc. All Rights Reserved.
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in
-all copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
-THE SOFTWARE.
-
- diff --git a/etc/cli.angular.io/main.css b/etc/cli.angular.io/main.css deleted file mode 100644 index 61b0cb066543..000000000000 --- a/etc/cli.angular.io/main.css +++ /dev/null @@ -1 +0,0 @@ -body{font-family:"Roboto",Helvetica,sans-serif}h4,h5{font-size:30px;font-weight:400;line-height:40px;margin-bottom:15px;margin-top:15px}h5{font-size:16px;font-weight:300;line-height:28px;margin-bottom:25px;max-width:300px}.mdl-demo section.section--center{max-width:920px}.mdl-grid--no-spacing>.mdl-cell{width:100%}.mdl-layout--fixed-drawer>.mdl-layout__content{margin-left:0}.mdl-layout__header{background-color:#f44336;box-shadow:0 2px 5px 0 rgba(0,0,0,.26)}.mdl-layout__header a{color:#fff;text-decoration:none}.mdl-layout--fixed-drawer.is-upgraded:not(.is-small-screen)>.mdl-layout__header{margin-left:0;width:100%}.mdl-layout--fixed-drawer>.mdl-layout__header .mdl-layout__header-row{padding-left:25px;padding-right:0}.mdl-layout__drawer-button,.top-nav-wrapper label{display:none}@media (max-width:1024px){.mdl-layout__drawer-button{display:inline-block}}.mdl-layout__drawer{margin-top:65px;height:calc(100% - 65px)}@media (max-width:1024px){.mdl-layout__drawer{margin-top:0;height:100%}}.mdl-layout-title,.mdl-layout__title{font-size:16px;line-height:28px;letter-spacing:.02em}.microsite-name{display:inline-block;font-size:20px;margin-left:8px;margin-right:30px;text-transform:uppercase;-webkit-transform:translateY(3px);transform:translateY(3px)}.mdl-navigation__link{font-size:16px;text-transform:uppercase;text-decoration:none}.mdl-navigation__link:hover,.top-nav-wrapper label:hover{background-color:#d32f2f}.top-nav-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1}@media (max-width:800px){.top-nav-wrapper{display:block;position:absolute;right:0;top:0;width:100%}.top-nav-wrapper label{cursor:pointer;display:block;float:right;line-height:56px;padding:0 16px}.top-nav-wrapper nav{background:#d32f2f;clear:both;display:none;height:auto!important}.top-nav-wrapper nav a{display:block}.top-nav-wrapper .mdl-layout-spacer{display:none}input:checked+.top-nav-wrapper label{background:#d32f2f}input:checked+.top-nav-wrapper nav{display:block}}.hero-background{background:-webkit-linear-gradient(#d32f2f ,#f44336);background:linear-gradient(#d32f2f ,#f44336);color:#fff;margin-bottom:60px}.mdl-grid,.mdl-mega-footer--bottom-section .mdl-cell--9-col{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.hero-container{padding:56px 0!important}@media (max-width:830px){.hero-container{text-align:center}}.logo-container{overflow:hidden;text-align:center}@media (max-width:840px){.tagline{max-width:100%}}.mdl-button{height:45px;line-height:45px;min-width:140px;padding:0 30px}.mdl-button--primary.mdl-button--primary.mdl-button--fab,.mdl-button--primary.mdl-button--primary.mdl-button--raised{background-color:#fff;color:#b71c1c}.features-list{width:920px;margin:0 0 23px;padding:15px 200px 15px 15px}@media (max-width:840px){.features-list{padding-right:15px}}.features-list h4{color:#37474f;font-size:28px;font-weight:500;line-height:32px;margin:0 0 16px;opacity:.87}.features-list p,footer ul a{font-size:16px;line-height:30px;opacity:.87}.button-container{margin-bottom:24px!important;text-align:center}.mdl-button--accent.mdl-button--accent.mdl-button--fab,.mdl-button--accent.mdl-button--accent.mdl-button--raised{background-color:#f44336;color:#fff}.mdl-mega-footer--bottom-section .mdl-cell--9-col{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;display:-webkit-box;display:-ms-flexbox;display:flex}.mdl-mega-footer--bottom-section,.mdl-mega-footer__bottom-section{background-color:#263238;bottom:0;color:#fff;padding-top:0;right:0}footer ul{font-size:14px;font-weight:400;letter-spacing:0;line-height:24px;list-style:none;padding:0}footer ul a{color:#fff;line-height:28px;padding:0;text-decoration:none}footer ul a:hover{text-decoration:underline}@media (max-width:830px){footer ul{background-color:rgba(0,0,0,.12);padding:8px;text-align:center}}.mdl-mega-footer--bottom-section{margin-bottom:0}.mdl-mega-footer--bottom-section p{font-size:12px;margin:0;opacity:.54}.mdl-mega-footer--bottom-section a{color:#fff;font-weight:400;padding:0;text-decoration:none}.power-text{text-align:right}@media (max-width:830px){.power-text{text-align:center;width:calc(100% - 16px)}}.mdl-base{height:100vh} diff --git a/etc/cli.angular.io/material.min.css b/etc/cli.angular.io/material.min.css deleted file mode 100644 index e750f8137205..000000000000 --- a/etc/cli.angular.io/material.min.css +++ /dev/null @@ -1,9 +0,0 @@ -/** - * material-design-lite - Material Design Components in CSS, JS and HTML - * @version v1.1.3 - * @license Apache-2.0 - * @copyright 2015 Google, Inc. - * @link https://github.com/google/material-design-lite - */ -@charset "UTF-8";html{color:rgba(0,0,0,.87)}::-moz-selection{background:#b3d4fc;text-shadow:none}::selection{background:#b3d4fc;text-shadow:none}hr{display:block;height:1px;border:0;border-top:1px solid #ccc;margin:1em 0;padding:0}audio,canvas,iframe,img,svg,video{vertical-align:middle}fieldset{border:0;margin:0;padding:0}textarea{resize:vertical}.browserupgrade{margin:.2em 0;background:#ccc;color:#000;padding:.2em 0}.hidden{display:none!important}.visuallyhidden{border:0;clip:rect(0 0 0 0);height:1px;margin:-1px;overflow:hidden;padding:0;position:absolute;width:1px}.visuallyhidden.focusable:active,.visuallyhidden.focusable:focus{clip:auto;height:auto;margin:0;overflow:visible;position:static;width:auto}.invisible{visibility:hidden}.clearfix:before,.clearfix:after{content:" ";display:table}.clearfix:after{clear:both}@media print{*,*:before,*:after,*:first-letter{background:transparent!important;color:#000!important;box-shadow:none!important}a,a:visited{text-decoration:underline}a[href]:after{content:" (" attr(href)")"}abbr[title]:after{content:" (" attr(title)")"}a[href^="#"]:after,a[href^="javascript:"]:after{content:""}pre,blockquote{border:1px solid #999;page-break-inside:avoid}thead{display:table-header-group}tr,img{page-break-inside:avoid}img{max-width:100%!important}p,h2,h3{orphans:3;widows:3}h2,h3{page-break-after:avoid}}a,.mdl-accordion,.mdl-button,.mdl-card,.mdl-checkbox,.mdl-dropdown-menu,.mdl-icon-toggle,.mdl-item,.mdl-radio,.mdl-slider,.mdl-switch,.mdl-tabs__tab{-webkit-tap-highlight-color:transparent;-webkit-tap-highlight-color:rgba(255,255,255,0)}html{width:100%;height:100%;-ms-touch-action:manipulation;touch-action:manipulation}body{width:100%;min-height:100%;margin:0}main{display:block}*[hidden]{display:none!important}html,body{font-family:"Helvetica","Arial",sans-serif;font-size:14px;font-weight:400;line-height:20px}h1,h2,h3,h4,h5,h6,p{padding:0}h1 small,h2 small,h3 small,h4 small,h5 small,h6 small{font-family:"Roboto","Helvetica","Arial",sans-serif;font-weight:400;line-height:1.35;letter-spacing:-.02em;opacity:.54;font-size:.6em}h1{font-size:56px;line-height:1.35;letter-spacing:-.02em;margin:24px 0}h1,h2{font-family:"Roboto","Helvetica","Arial",sans-serif;font-weight:400}h2{font-size:45px;line-height:48px}h2,h3{margin:24px 0}h3{font-size:34px;line-height:40px}h3,h4{font-family:"Roboto","Helvetica","Arial",sans-serif;font-weight:400}h4{font-size:24px;line-height:32px;-moz-osx-font-smoothing:grayscale;margin:24px 0 16px}h5{font-size:20px;font-weight:500;line-height:1;letter-spacing:.02em}h5,h6{font-family:"Roboto","Helvetica","Arial",sans-serif;margin:24px 0 16px}h6{font-size:16px;letter-spacing:.04em}h6,p{font-weight:400;line-height:24px}p{font-size:14px;letter-spacing:0;margin:0 0 16px}a{color:#ff4081;font-weight:500}blockquote{font-family:"Roboto","Helvetica","Arial",sans-serif;position:relative;font-size:24px;font-weight:300;font-style:italic;line-height:1.35;letter-spacing:.08em}blockquote:before{position:absolute;left:-.5em;content:'“'}blockquote:after{content:'”';margin-left:-.05em}mark{background-color:#f4ff81}dt{font-weight:700}address{font-size:12px;line-height:1;font-style:normal}address,ul,ol{font-weight:400;letter-spacing:0}ul,ol{font-size:14px;line-height:24px}.mdl-typography--display-4,.mdl-typography--display-4-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:112px;font-weight:300;line-height:1;letter-spacing:-.04em}.mdl-typography--display-4-color-contrast{opacity:.54}.mdl-typography--display-3,.mdl-typography--display-3-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:56px;font-weight:400;line-height:1.35;letter-spacing:-.02em}.mdl-typography--display-3-color-contrast{opacity:.54}.mdl-typography--display-2,.mdl-typography--display-2-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:45px;font-weight:400;line-height:48px}.mdl-typography--display-2-color-contrast{opacity:.54}.mdl-typography--display-1,.mdl-typography--display-1-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:34px;font-weight:400;line-height:40px}.mdl-typography--display-1-color-contrast{opacity:.54}.mdl-typography--headline,.mdl-typography--headline-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:24px;font-weight:400;line-height:32px;-moz-osx-font-smoothing:grayscale}.mdl-typography--headline-color-contrast{opacity:.87}.mdl-typography--title,.mdl-typography--title-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:20px;font-weight:500;line-height:1;letter-spacing:.02em}.mdl-typography--title-color-contrast{opacity:.87}.mdl-typography--subhead,.mdl-typography--subhead-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:16px;font-weight:400;line-height:24px;letter-spacing:.04em}.mdl-typography--subhead-color-contrast{opacity:.87}.mdl-typography--body-2,.mdl-typography--body-2-color-contrast{font-size:14px;font-weight:700;line-height:24px;letter-spacing:0}.mdl-typography--body-2-color-contrast{opacity:.87}.mdl-typography--body-1,.mdl-typography--body-1-color-contrast{font-size:14px;font-weight:400;line-height:24px;letter-spacing:0}.mdl-typography--body-1-color-contrast{opacity:.87}.mdl-typography--body-2-force-preferred-font,.mdl-typography--body-2-force-preferred-font-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:500;line-height:24px;letter-spacing:0}.mdl-typography--body-2-force-preferred-font-color-contrast{opacity:.87}.mdl-typography--body-1-force-preferred-font,.mdl-typography--body-1-force-preferred-font-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:400;line-height:24px;letter-spacing:0}.mdl-typography--body-1-force-preferred-font-color-contrast{opacity:.87}.mdl-typography--caption,.mdl-typography--caption-force-preferred-font{font-size:12px;font-weight:400;line-height:1;letter-spacing:0}.mdl-typography--caption-force-preferred-font{font-family:"Roboto","Helvetica","Arial",sans-serif}.mdl-typography--caption-color-contrast,.mdl-typography--caption-force-preferred-font-color-contrast{font-size:12px;font-weight:400;line-height:1;letter-spacing:0;opacity:.54}.mdl-typography--caption-force-preferred-font-color-contrast,.mdl-typography--menu{font-family:"Roboto","Helvetica","Arial",sans-serif}.mdl-typography--menu{font-size:14px;font-weight:500;line-height:1;letter-spacing:0}.mdl-typography--menu-color-contrast{opacity:.87}.mdl-typography--menu-color-contrast,.mdl-typography--button,.mdl-typography--button-color-contrast{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:500;line-height:1;letter-spacing:0}.mdl-typography--button,.mdl-typography--button-color-contrast{text-transform:uppercase}.mdl-typography--button-color-contrast{opacity:.87}.mdl-typography--text-left{text-align:left}.mdl-typography--text-right{text-align:right}.mdl-typography--text-center{text-align:center}.mdl-typography--text-justify{text-align:justify}.mdl-typography--text-nowrap{white-space:nowrap}.mdl-typography--text-lowercase{text-transform:lowercase}.mdl-typography--text-uppercase{text-transform:uppercase}.mdl-typography--text-capitalize{text-transform:capitalize}.mdl-typography--font-thin{font-weight:200!important}.mdl-typography--font-light{font-weight:300!important}.mdl-typography--font-regular{font-weight:400!important}.mdl-typography--font-medium{font-weight:500!important}.mdl-typography--font-bold{font-weight:700!important}.mdl-typography--font-black{font-weight:900!important}.material-icons{font-family:'Material Icons';font-weight:400;font-style:normal;font-size:24px;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;word-wrap:normal;-moz-font-feature-settings:'liga';font-feature-settings:'liga';-webkit-font-feature-settings:'liga';-webkit-font-smoothing:antialiased}.mdl-color-text--red{color:#f44336 !important}.mdl-color--red{background-color:#f44336 !important}.mdl-color-text--red-50{color:#ffebee !important}.mdl-color--red-50{background-color:#ffebee !important}.mdl-color-text--red-100{color:#ffcdd2 !important}.mdl-color--red-100{background-color:#ffcdd2 !important}.mdl-color-text--red-200{color:#ef9a9a !important}.mdl-color--red-200{background-color:#ef9a9a !important}.mdl-color-text--red-300{color:#e57373 !important}.mdl-color--red-300{background-color:#e57373 !important}.mdl-color-text--red-400{color:#ef5350 !important}.mdl-color--red-400{background-color:#ef5350 !important}.mdl-color-text--red-500{color:#f44336 !important}.mdl-color--red-500{background-color:#f44336 !important}.mdl-color-text--red-600{color:#e53935 !important}.mdl-color--red-600{background-color:#e53935 !important}.mdl-color-text--red-700{color:#d32f2f !important}.mdl-color--red-700{background-color:#d32f2f !important}.mdl-color-text--red-800{color:#c62828 !important}.mdl-color--red-800{background-color:#c62828 !important}.mdl-color-text--red-900{color:#b71c1c !important}.mdl-color--red-900{background-color:#b71c1c !important}.mdl-color-text--red-A100{color:#ff8a80 !important}.mdl-color--red-A100{background-color:#ff8a80 !important}.mdl-color-text--red-A200{color:#ff5252 !important}.mdl-color--red-A200{background-color:#ff5252 !important}.mdl-color-text--red-A400{color:#ff1744 !important}.mdl-color--red-A400{background-color:#ff1744 !important}.mdl-color-text--red-A700{color:#d50000 !important}.mdl-color--red-A700{background-color:#d50000 !important}.mdl-color-text--pink{color:#e91e63 !important}.mdl-color--pink{background-color:#e91e63 !important}.mdl-color-text--pink-50{color:#fce4ec !important}.mdl-color--pink-50{background-color:#fce4ec !important}.mdl-color-text--pink-100{color:#f8bbd0 !important}.mdl-color--pink-100{background-color:#f8bbd0 !important}.mdl-color-text--pink-200{color:#f48fb1 !important}.mdl-color--pink-200{background-color:#f48fb1 !important}.mdl-color-text--pink-300{color:#f06292 !important}.mdl-color--pink-300{background-color:#f06292 !important}.mdl-color-text--pink-400{color:#ec407a !important}.mdl-color--pink-400{background-color:#ec407a !important}.mdl-color-text--pink-500{color:#e91e63 !important}.mdl-color--pink-500{background-color:#e91e63 !important}.mdl-color-text--pink-600{color:#d81b60 !important}.mdl-color--pink-600{background-color:#d81b60 !important}.mdl-color-text--pink-700{color:#c2185b !important}.mdl-color--pink-700{background-color:#c2185b !important}.mdl-color-text--pink-800{color:#ad1457 !important}.mdl-color--pink-800{background-color:#ad1457 !important}.mdl-color-text--pink-900{color:#880e4f !important}.mdl-color--pink-900{background-color:#880e4f !important}.mdl-color-text--pink-A100{color:#ff80ab !important}.mdl-color--pink-A100{background-color:#ff80ab !important}.mdl-color-text--pink-A200{color:#ff4081 !important}.mdl-color--pink-A200{background-color:#ff4081 !important}.mdl-color-text--pink-A400{color:#f50057 !important}.mdl-color--pink-A400{background-color:#f50057 !important}.mdl-color-text--pink-A700{color:#c51162 !important}.mdl-color--pink-A700{background-color:#c51162 !important}.mdl-color-text--purple{color:#9c27b0 !important}.mdl-color--purple{background-color:#9c27b0 !important}.mdl-color-text--purple-50{color:#f3e5f5 !important}.mdl-color--purple-50{background-color:#f3e5f5 !important}.mdl-color-text--purple-100{color:#e1bee7 !important}.mdl-color--purple-100{background-color:#e1bee7 !important}.mdl-color-text--purple-200{color:#ce93d8 !important}.mdl-color--purple-200{background-color:#ce93d8 !important}.mdl-color-text--purple-300{color:#ba68c8 !important}.mdl-color--purple-300{background-color:#ba68c8 !important}.mdl-color-text--purple-400{color:#ab47bc !important}.mdl-color--purple-400{background-color:#ab47bc !important}.mdl-color-text--purple-500{color:#9c27b0 !important}.mdl-color--purple-500{background-color:#9c27b0 !important}.mdl-color-text--purple-600{color:#8e24aa !important}.mdl-color--purple-600{background-color:#8e24aa !important}.mdl-color-text--purple-700{color:#7b1fa2 !important}.mdl-color--purple-700{background-color:#7b1fa2 !important}.mdl-color-text--purple-800{color:#6a1b9a !important}.mdl-color--purple-800{background-color:#6a1b9a !important}.mdl-color-text--purple-900{color:#4a148c !important}.mdl-color--purple-900{background-color:#4a148c !important}.mdl-color-text--purple-A100{color:#ea80fc !important}.mdl-color--purple-A100{background-color:#ea80fc !important}.mdl-color-text--purple-A200{color:#e040fb !important}.mdl-color--purple-A200{background-color:#e040fb !important}.mdl-color-text--purple-A400{color:#d500f9 !important}.mdl-color--purple-A400{background-color:#d500f9 !important}.mdl-color-text--purple-A700{color:#a0f !important}.mdl-color--purple-A700{background-color:#a0f !important}.mdl-color-text--deep-purple{color:#673ab7 !important}.mdl-color--deep-purple{background-color:#673ab7 !important}.mdl-color-text--deep-purple-50{color:#ede7f6 !important}.mdl-color--deep-purple-50{background-color:#ede7f6 !important}.mdl-color-text--deep-purple-100{color:#d1c4e9 !important}.mdl-color--deep-purple-100{background-color:#d1c4e9 !important}.mdl-color-text--deep-purple-200{color:#b39ddb !important}.mdl-color--deep-purple-200{background-color:#b39ddb !important}.mdl-color-text--deep-purple-300{color:#9575cd !important}.mdl-color--deep-purple-300{background-color:#9575cd !important}.mdl-color-text--deep-purple-400{color:#7e57c2 !important}.mdl-color--deep-purple-400{background-color:#7e57c2 !important}.mdl-color-text--deep-purple-500{color:#673ab7 !important}.mdl-color--deep-purple-500{background-color:#673ab7 !important}.mdl-color-text--deep-purple-600{color:#5e35b1 !important}.mdl-color--deep-purple-600{background-color:#5e35b1 !important}.mdl-color-text--deep-purple-700{color:#512da8 !important}.mdl-color--deep-purple-700{background-color:#512da8 !important}.mdl-color-text--deep-purple-800{color:#4527a0 !important}.mdl-color--deep-purple-800{background-color:#4527a0 !important}.mdl-color-text--deep-purple-900{color:#311b92 !important}.mdl-color--deep-purple-900{background-color:#311b92 !important}.mdl-color-text--deep-purple-A100{color:#b388ff !important}.mdl-color--deep-purple-A100{background-color:#b388ff !important}.mdl-color-text--deep-purple-A200{color:#7c4dff !important}.mdl-color--deep-purple-A200{background-color:#7c4dff !important}.mdl-color-text--deep-purple-A400{color:#651fff !important}.mdl-color--deep-purple-A400{background-color:#651fff !important}.mdl-color-text--deep-purple-A700{color:#6200ea !important}.mdl-color--deep-purple-A700{background-color:#6200ea !important}.mdl-color-text--indigo{color:#3f51b5 !important}.mdl-color--indigo{background-color:#3f51b5 !important}.mdl-color-text--indigo-50{color:#e8eaf6 !important}.mdl-color--indigo-50{background-color:#e8eaf6 !important}.mdl-color-text--indigo-100{color:#c5cae9 !important}.mdl-color--indigo-100{background-color:#c5cae9 !important}.mdl-color-text--indigo-200{color:#9fa8da !important}.mdl-color--indigo-200{background-color:#9fa8da !important}.mdl-color-text--indigo-300{color:#7986cb !important}.mdl-color--indigo-300{background-color:#7986cb !important}.mdl-color-text--indigo-400{color:#5c6bc0 !important}.mdl-color--indigo-400{background-color:#5c6bc0 !important}.mdl-color-text--indigo-500{color:#3f51b5 !important}.mdl-color--indigo-500{background-color:#3f51b5 !important}.mdl-color-text--indigo-600{color:#3949ab !important}.mdl-color--indigo-600{background-color:#3949ab !important}.mdl-color-text--indigo-700{color:#303f9f !important}.mdl-color--indigo-700{background-color:#303f9f !important}.mdl-color-text--indigo-800{color:#283593 !important}.mdl-color--indigo-800{background-color:#283593 !important}.mdl-color-text--indigo-900{color:#1a237e !important}.mdl-color--indigo-900{background-color:#1a237e !important}.mdl-color-text--indigo-A100{color:#8c9eff !important}.mdl-color--indigo-A100{background-color:#8c9eff !important}.mdl-color-text--indigo-A200{color:#536dfe !important}.mdl-color--indigo-A200{background-color:#536dfe !important}.mdl-color-text--indigo-A400{color:#3d5afe !important}.mdl-color--indigo-A400{background-color:#3d5afe !important}.mdl-color-text--indigo-A700{color:#304ffe !important}.mdl-color--indigo-A700{background-color:#304ffe !important}.mdl-color-text--blue{color:#2196f3 !important}.mdl-color--blue{background-color:#2196f3 !important}.mdl-color-text--blue-50{color:#e3f2fd !important}.mdl-color--blue-50{background-color:#e3f2fd !important}.mdl-color-text--blue-100{color:#bbdefb !important}.mdl-color--blue-100{background-color:#bbdefb !important}.mdl-color-text--blue-200{color:#90caf9 !important}.mdl-color--blue-200{background-color:#90caf9 !important}.mdl-color-text--blue-300{color:#64b5f6 !important}.mdl-color--blue-300{background-color:#64b5f6 !important}.mdl-color-text--blue-400{color:#42a5f5 !important}.mdl-color--blue-400{background-color:#42a5f5 !important}.mdl-color-text--blue-500{color:#2196f3 !important}.mdl-color--blue-500{background-color:#2196f3 !important}.mdl-color-text--blue-600{color:#1e88e5 !important}.mdl-color--blue-600{background-color:#1e88e5 !important}.mdl-color-text--blue-700{color:#1976d2 !important}.mdl-color--blue-700{background-color:#1976d2 !important}.mdl-color-text--blue-800{color:#1565c0 !important}.mdl-color--blue-800{background-color:#1565c0 !important}.mdl-color-text--blue-900{color:#0d47a1 !important}.mdl-color--blue-900{background-color:#0d47a1 !important}.mdl-color-text--blue-A100{color:#82b1ff !important}.mdl-color--blue-A100{background-color:#82b1ff !important}.mdl-color-text--blue-A200{color:#448aff !important}.mdl-color--blue-A200{background-color:#448aff !important}.mdl-color-text--blue-A400{color:#2979ff !important}.mdl-color--blue-A400{background-color:#2979ff !important}.mdl-color-text--blue-A700{color:#2962ff !important}.mdl-color--blue-A700{background-color:#2962ff !important}.mdl-color-text--light-blue{color:#03a9f4 !important}.mdl-color--light-blue{background-color:#03a9f4 !important}.mdl-color-text--light-blue-50{color:#e1f5fe !important}.mdl-color--light-blue-50{background-color:#e1f5fe !important}.mdl-color-text--light-blue-100{color:#b3e5fc !important}.mdl-color--light-blue-100{background-color:#b3e5fc !important}.mdl-color-text--light-blue-200{color:#81d4fa !important}.mdl-color--light-blue-200{background-color:#81d4fa !important}.mdl-color-text--light-blue-300{color:#4fc3f7 !important}.mdl-color--light-blue-300{background-color:#4fc3f7 !important}.mdl-color-text--light-blue-400{color:#29b6f6 !important}.mdl-color--light-blue-400{background-color:#29b6f6 !important}.mdl-color-text--light-blue-500{color:#03a9f4 !important}.mdl-color--light-blue-500{background-color:#03a9f4 !important}.mdl-color-text--light-blue-600{color:#039be5 !important}.mdl-color--light-blue-600{background-color:#039be5 !important}.mdl-color-text--light-blue-700{color:#0288d1 !important}.mdl-color--light-blue-700{background-color:#0288d1 !important}.mdl-color-text--light-blue-800{color:#0277bd !important}.mdl-color--light-blue-800{background-color:#0277bd !important}.mdl-color-text--light-blue-900{color:#01579b !important}.mdl-color--light-blue-900{background-color:#01579b !important}.mdl-color-text--light-blue-A100{color:#80d8ff !important}.mdl-color--light-blue-A100{background-color:#80d8ff !important}.mdl-color-text--light-blue-A200{color:#40c4ff !important}.mdl-color--light-blue-A200{background-color:#40c4ff !important}.mdl-color-text--light-blue-A400{color:#00b0ff !important}.mdl-color--light-blue-A400{background-color:#00b0ff !important}.mdl-color-text--light-blue-A700{color:#0091ea !important}.mdl-color--light-blue-A700{background-color:#0091ea !important}.mdl-color-text--cyan{color:#00bcd4 !important}.mdl-color--cyan{background-color:#00bcd4 !important}.mdl-color-text--cyan-50{color:#e0f7fa !important}.mdl-color--cyan-50{background-color:#e0f7fa !important}.mdl-color-text--cyan-100{color:#b2ebf2 !important}.mdl-color--cyan-100{background-color:#b2ebf2 !important}.mdl-color-text--cyan-200{color:#80deea !important}.mdl-color--cyan-200{background-color:#80deea !important}.mdl-color-text--cyan-300{color:#4dd0e1 !important}.mdl-color--cyan-300{background-color:#4dd0e1 !important}.mdl-color-text--cyan-400{color:#26c6da !important}.mdl-color--cyan-400{background-color:#26c6da !important}.mdl-color-text--cyan-500{color:#00bcd4 !important}.mdl-color--cyan-500{background-color:#00bcd4 !important}.mdl-color-text--cyan-600{color:#00acc1 !important}.mdl-color--cyan-600{background-color:#00acc1 !important}.mdl-color-text--cyan-700{color:#0097a7 !important}.mdl-color--cyan-700{background-color:#0097a7 !important}.mdl-color-text--cyan-800{color:#00838f !important}.mdl-color--cyan-800{background-color:#00838f !important}.mdl-color-text--cyan-900{color:#006064 !important}.mdl-color--cyan-900{background-color:#006064 !important}.mdl-color-text--cyan-A100{color:#84ffff !important}.mdl-color--cyan-A100{background-color:#84ffff !important}.mdl-color-text--cyan-A200{color:#18ffff !important}.mdl-color--cyan-A200{background-color:#18ffff !important}.mdl-color-text--cyan-A400{color:#00e5ff !important}.mdl-color--cyan-A400{background-color:#00e5ff !important}.mdl-color-text--cyan-A700{color:#00b8d4 !important}.mdl-color--cyan-A700{background-color:#00b8d4 !important}.mdl-color-text--teal{color:#009688 !important}.mdl-color--teal{background-color:#009688 !important}.mdl-color-text--teal-50{color:#e0f2f1 !important}.mdl-color--teal-50{background-color:#e0f2f1 !important}.mdl-color-text--teal-100{color:#b2dfdb !important}.mdl-color--teal-100{background-color:#b2dfdb !important}.mdl-color-text--teal-200{color:#80cbc4 !important}.mdl-color--teal-200{background-color:#80cbc4 !important}.mdl-color-text--teal-300{color:#4db6ac !important}.mdl-color--teal-300{background-color:#4db6ac !important}.mdl-color-text--teal-400{color:#26a69a !important}.mdl-color--teal-400{background-color:#26a69a !important}.mdl-color-text--teal-500{color:#009688 !important}.mdl-color--teal-500{background-color:#009688 !important}.mdl-color-text--teal-600{color:#00897b !important}.mdl-color--teal-600{background-color:#00897b !important}.mdl-color-text--teal-700{color:#00796b !important}.mdl-color--teal-700{background-color:#00796b !important}.mdl-color-text--teal-800{color:#00695c !important}.mdl-color--teal-800{background-color:#00695c !important}.mdl-color-text--teal-900{color:#004d40 !important}.mdl-color--teal-900{background-color:#004d40 !important}.mdl-color-text--teal-A100{color:#a7ffeb !important}.mdl-color--teal-A100{background-color:#a7ffeb !important}.mdl-color-text--teal-A200{color:#64ffda !important}.mdl-color--teal-A200{background-color:#64ffda !important}.mdl-color-text--teal-A400{color:#1de9b6 !important}.mdl-color--teal-A400{background-color:#1de9b6 !important}.mdl-color-text--teal-A700{color:#00bfa5 !important}.mdl-color--teal-A700{background-color:#00bfa5 !important}.mdl-color-text--green{color:#4caf50 !important}.mdl-color--green{background-color:#4caf50 !important}.mdl-color-text--green-50{color:#e8f5e9 !important}.mdl-color--green-50{background-color:#e8f5e9 !important}.mdl-color-text--green-100{color:#c8e6c9 !important}.mdl-color--green-100{background-color:#c8e6c9 !important}.mdl-color-text--green-200{color:#a5d6a7 !important}.mdl-color--green-200{background-color:#a5d6a7 !important}.mdl-color-text--green-300{color:#81c784 !important}.mdl-color--green-300{background-color:#81c784 !important}.mdl-color-text--green-400{color:#66bb6a !important}.mdl-color--green-400{background-color:#66bb6a !important}.mdl-color-text--green-500{color:#4caf50 !important}.mdl-color--green-500{background-color:#4caf50 !important}.mdl-color-text--green-600{color:#43a047 !important}.mdl-color--green-600{background-color:#43a047 !important}.mdl-color-text--green-700{color:#388e3c !important}.mdl-color--green-700{background-color:#388e3c !important}.mdl-color-text--green-800{color:#2e7d32 !important}.mdl-color--green-800{background-color:#2e7d32 !important}.mdl-color-text--green-900{color:#1b5e20 !important}.mdl-color--green-900{background-color:#1b5e20 !important}.mdl-color-text--green-A100{color:#b9f6ca !important}.mdl-color--green-A100{background-color:#b9f6ca !important}.mdl-color-text--green-A200{color:#69f0ae !important}.mdl-color--green-A200{background-color:#69f0ae !important}.mdl-color-text--green-A400{color:#00e676 !important}.mdl-color--green-A400{background-color:#00e676 !important}.mdl-color-text--green-A700{color:#00c853 !important}.mdl-color--green-A700{background-color:#00c853 !important}.mdl-color-text--light-green{color:#8bc34a !important}.mdl-color--light-green{background-color:#8bc34a !important}.mdl-color-text--light-green-50{color:#f1f8e9 !important}.mdl-color--light-green-50{background-color:#f1f8e9 !important}.mdl-color-text--light-green-100{color:#dcedc8 !important}.mdl-color--light-green-100{background-color:#dcedc8 !important}.mdl-color-text--light-green-200{color:#c5e1a5 !important}.mdl-color--light-green-200{background-color:#c5e1a5 !important}.mdl-color-text--light-green-300{color:#aed581 !important}.mdl-color--light-green-300{background-color:#aed581 !important}.mdl-color-text--light-green-400{color:#9ccc65 !important}.mdl-color--light-green-400{background-color:#9ccc65 !important}.mdl-color-text--light-green-500{color:#8bc34a !important}.mdl-color--light-green-500{background-color:#8bc34a !important}.mdl-color-text--light-green-600{color:#7cb342 !important}.mdl-color--light-green-600{background-color:#7cb342 !important}.mdl-color-text--light-green-700{color:#689f38 !important}.mdl-color--light-green-700{background-color:#689f38 !important}.mdl-color-text--light-green-800{color:#558b2f !important}.mdl-color--light-green-800{background-color:#558b2f !important}.mdl-color-text--light-green-900{color:#33691e !important}.mdl-color--light-green-900{background-color:#33691e !important}.mdl-color-text--light-green-A100{color:#ccff90 !important}.mdl-color--light-green-A100{background-color:#ccff90 !important}.mdl-color-text--light-green-A200{color:#b2ff59 !important}.mdl-color--light-green-A200{background-color:#b2ff59 !important}.mdl-color-text--light-green-A400{color:#76ff03 !important}.mdl-color--light-green-A400{background-color:#76ff03 !important}.mdl-color-text--light-green-A700{color:#64dd17 !important}.mdl-color--light-green-A700{background-color:#64dd17 !important}.mdl-color-text--lime{color:#cddc39 !important}.mdl-color--lime{background-color:#cddc39 !important}.mdl-color-text--lime-50{color:#f9fbe7 !important}.mdl-color--lime-50{background-color:#f9fbe7 !important}.mdl-color-text--lime-100{color:#f0f4c3 !important}.mdl-color--lime-100{background-color:#f0f4c3 !important}.mdl-color-text--lime-200{color:#e6ee9c !important}.mdl-color--lime-200{background-color:#e6ee9c !important}.mdl-color-text--lime-300{color:#dce775 !important}.mdl-color--lime-300{background-color:#dce775 !important}.mdl-color-text--lime-400{color:#d4e157 !important}.mdl-color--lime-400{background-color:#d4e157 !important}.mdl-color-text--lime-500{color:#cddc39 !important}.mdl-color--lime-500{background-color:#cddc39 !important}.mdl-color-text--lime-600{color:#c0ca33 !important}.mdl-color--lime-600{background-color:#c0ca33 !important}.mdl-color-text--lime-700{color:#afb42b !important}.mdl-color--lime-700{background-color:#afb42b !important}.mdl-color-text--lime-800{color:#9e9d24 !important}.mdl-color--lime-800{background-color:#9e9d24 !important}.mdl-color-text--lime-900{color:#827717 !important}.mdl-color--lime-900{background-color:#827717 !important}.mdl-color-text--lime-A100{color:#f4ff81 !important}.mdl-color--lime-A100{background-color:#f4ff81 !important}.mdl-color-text--lime-A200{color:#eeff41 !important}.mdl-color--lime-A200{background-color:#eeff41 !important}.mdl-color-text--lime-A400{color:#c6ff00 !important}.mdl-color--lime-A400{background-color:#c6ff00 !important}.mdl-color-text--lime-A700{color:#aeea00 !important}.mdl-color--lime-A700{background-color:#aeea00 !important}.mdl-color-text--yellow{color:#ffeb3b !important}.mdl-color--yellow{background-color:#ffeb3b !important}.mdl-color-text--yellow-50{color:#fffde7 !important}.mdl-color--yellow-50{background-color:#fffde7 !important}.mdl-color-text--yellow-100{color:#fff9c4 !important}.mdl-color--yellow-100{background-color:#fff9c4 !important}.mdl-color-text--yellow-200{color:#fff59d !important}.mdl-color--yellow-200{background-color:#fff59d !important}.mdl-color-text--yellow-300{color:#fff176 !important}.mdl-color--yellow-300{background-color:#fff176 !important}.mdl-color-text--yellow-400{color:#ffee58 !important}.mdl-color--yellow-400{background-color:#ffee58 !important}.mdl-color-text--yellow-500{color:#ffeb3b !important}.mdl-color--yellow-500{background-color:#ffeb3b !important}.mdl-color-text--yellow-600{color:#fdd835 !important}.mdl-color--yellow-600{background-color:#fdd835 !important}.mdl-color-text--yellow-700{color:#fbc02d !important}.mdl-color--yellow-700{background-color:#fbc02d !important}.mdl-color-text--yellow-800{color:#f9a825 !important}.mdl-color--yellow-800{background-color:#f9a825 !important}.mdl-color-text--yellow-900{color:#f57f17 !important}.mdl-color--yellow-900{background-color:#f57f17 !important}.mdl-color-text--yellow-A100{color:#ffff8d !important}.mdl-color--yellow-A100{background-color:#ffff8d !important}.mdl-color-text--yellow-A200{color:#ff0 !important}.mdl-color--yellow-A200{background-color:#ff0 !important}.mdl-color-text--yellow-A400{color:#ffea00 !important}.mdl-color--yellow-A400{background-color:#ffea00 !important}.mdl-color-text--yellow-A700{color:#ffd600 !important}.mdl-color--yellow-A700{background-color:#ffd600 !important}.mdl-color-text--amber{color:#ffc107 !important}.mdl-color--amber{background-color:#ffc107 !important}.mdl-color-text--amber-50{color:#fff8e1 !important}.mdl-color--amber-50{background-color:#fff8e1 !important}.mdl-color-text--amber-100{color:#ffecb3 !important}.mdl-color--amber-100{background-color:#ffecb3 !important}.mdl-color-text--amber-200{color:#ffe082 !important}.mdl-color--amber-200{background-color:#ffe082 !important}.mdl-color-text--amber-300{color:#ffd54f !important}.mdl-color--amber-300{background-color:#ffd54f !important}.mdl-color-text--amber-400{color:#ffca28 !important}.mdl-color--amber-400{background-color:#ffca28 !important}.mdl-color-text--amber-500{color:#ffc107 !important}.mdl-color--amber-500{background-color:#ffc107 !important}.mdl-color-text--amber-600{color:#ffb300 !important}.mdl-color--amber-600{background-color:#ffb300 !important}.mdl-color-text--amber-700{color:#ffa000 !important}.mdl-color--amber-700{background-color:#ffa000 !important}.mdl-color-text--amber-800{color:#ff8f00 !important}.mdl-color--amber-800{background-color:#ff8f00 !important}.mdl-color-text--amber-900{color:#ff6f00 !important}.mdl-color--amber-900{background-color:#ff6f00 !important}.mdl-color-text--amber-A100{color:#ffe57f !important}.mdl-color--amber-A100{background-color:#ffe57f !important}.mdl-color-text--amber-A200{color:#ffd740 !important}.mdl-color--amber-A200{background-color:#ffd740 !important}.mdl-color-text--amber-A400{color:#ffc400 !important}.mdl-color--amber-A400{background-color:#ffc400 !important}.mdl-color-text--amber-A700{color:#ffab00 !important}.mdl-color--amber-A700{background-color:#ffab00 !important}.mdl-color-text--orange{color:#ff9800 !important}.mdl-color--orange{background-color:#ff9800 !important}.mdl-color-text--orange-50{color:#fff3e0 !important}.mdl-color--orange-50{background-color:#fff3e0 !important}.mdl-color-text--orange-100{color:#ffe0b2 !important}.mdl-color--orange-100{background-color:#ffe0b2 !important}.mdl-color-text--orange-200{color:#ffcc80 !important}.mdl-color--orange-200{background-color:#ffcc80 !important}.mdl-color-text--orange-300{color:#ffb74d !important}.mdl-color--orange-300{background-color:#ffb74d !important}.mdl-color-text--orange-400{color:#ffa726 !important}.mdl-color--orange-400{background-color:#ffa726 !important}.mdl-color-text--orange-500{color:#ff9800 !important}.mdl-color--orange-500{background-color:#ff9800 !important}.mdl-color-text--orange-600{color:#fb8c00 !important}.mdl-color--orange-600{background-color:#fb8c00 !important}.mdl-color-text--orange-700{color:#f57c00 !important}.mdl-color--orange-700{background-color:#f57c00 !important}.mdl-color-text--orange-800{color:#ef6c00 !important}.mdl-color--orange-800{background-color:#ef6c00 !important}.mdl-color-text--orange-900{color:#e65100 !important}.mdl-color--orange-900{background-color:#e65100 !important}.mdl-color-text--orange-A100{color:#ffd180 !important}.mdl-color--orange-A100{background-color:#ffd180 !important}.mdl-color-text--orange-A200{color:#ffab40 !important}.mdl-color--orange-A200{background-color:#ffab40 !important}.mdl-color-text--orange-A400{color:#ff9100 !important}.mdl-color--orange-A400{background-color:#ff9100 !important}.mdl-color-text--orange-A700{color:#ff6d00 !important}.mdl-color--orange-A700{background-color:#ff6d00 !important}.mdl-color-text--deep-orange{color:#ff5722 !important}.mdl-color--deep-orange{background-color:#ff5722 !important}.mdl-color-text--deep-orange-50{color:#fbe9e7 !important}.mdl-color--deep-orange-50{background-color:#fbe9e7 !important}.mdl-color-text--deep-orange-100{color:#ffccbc !important}.mdl-color--deep-orange-100{background-color:#ffccbc !important}.mdl-color-text--deep-orange-200{color:#ffab91 !important}.mdl-color--deep-orange-200{background-color:#ffab91 !important}.mdl-color-text--deep-orange-300{color:#ff8a65 !important}.mdl-color--deep-orange-300{background-color:#ff8a65 !important}.mdl-color-text--deep-orange-400{color:#ff7043 !important}.mdl-color--deep-orange-400{background-color:#ff7043 !important}.mdl-color-text--deep-orange-500{color:#ff5722 !important}.mdl-color--deep-orange-500{background-color:#ff5722 !important}.mdl-color-text--deep-orange-600{color:#f4511e !important}.mdl-color--deep-orange-600{background-color:#f4511e !important}.mdl-color-text--deep-orange-700{color:#e64a19 !important}.mdl-color--deep-orange-700{background-color:#e64a19 !important}.mdl-color-text--deep-orange-800{color:#d84315 !important}.mdl-color--deep-orange-800{background-color:#d84315 !important}.mdl-color-text--deep-orange-900{color:#bf360c !important}.mdl-color--deep-orange-900{background-color:#bf360c !important}.mdl-color-text--deep-orange-A100{color:#ff9e80 !important}.mdl-color--deep-orange-A100{background-color:#ff9e80 !important}.mdl-color-text--deep-orange-A200{color:#ff6e40 !important}.mdl-color--deep-orange-A200{background-color:#ff6e40 !important}.mdl-color-text--deep-orange-A400{color:#ff3d00 !important}.mdl-color--deep-orange-A400{background-color:#ff3d00 !important}.mdl-color-text--deep-orange-A700{color:#dd2c00 !important}.mdl-color--deep-orange-A700{background-color:#dd2c00 !important}.mdl-color-text--brown{color:#795548 !important}.mdl-color--brown{background-color:#795548 !important}.mdl-color-text--brown-50{color:#efebe9 !important}.mdl-color--brown-50{background-color:#efebe9 !important}.mdl-color-text--brown-100{color:#d7ccc8 !important}.mdl-color--brown-100{background-color:#d7ccc8 !important}.mdl-color-text--brown-200{color:#bcaaa4 !important}.mdl-color--brown-200{background-color:#bcaaa4 !important}.mdl-color-text--brown-300{color:#a1887f !important}.mdl-color--brown-300{background-color:#a1887f !important}.mdl-color-text--brown-400{color:#8d6e63 !important}.mdl-color--brown-400{background-color:#8d6e63 !important}.mdl-color-text--brown-500{color:#795548 !important}.mdl-color--brown-500{background-color:#795548 !important}.mdl-color-text--brown-600{color:#6d4c41 !important}.mdl-color--brown-600{background-color:#6d4c41 !important}.mdl-color-text--brown-700{color:#5d4037 !important}.mdl-color--brown-700{background-color:#5d4037 !important}.mdl-color-text--brown-800{color:#4e342e !important}.mdl-color--brown-800{background-color:#4e342e !important}.mdl-color-text--brown-900{color:#3e2723 !important}.mdl-color--brown-900{background-color:#3e2723 !important}.mdl-color-text--grey{color:#9e9e9e !important}.mdl-color--grey{background-color:#9e9e9e !important}.mdl-color-text--grey-50{color:#fafafa !important}.mdl-color--grey-50{background-color:#fafafa !important}.mdl-color-text--grey-100{color:#f5f5f5 !important}.mdl-color--grey-100{background-color:#f5f5f5 !important}.mdl-color-text--grey-200{color:#eee !important}.mdl-color--grey-200{background-color:#eee !important}.mdl-color-text--grey-300{color:#e0e0e0 !important}.mdl-color--grey-300{background-color:#e0e0e0 !important}.mdl-color-text--grey-400{color:#bdbdbd !important}.mdl-color--grey-400{background-color:#bdbdbd !important}.mdl-color-text--grey-500{color:#9e9e9e !important}.mdl-color--grey-500{background-color:#9e9e9e !important}.mdl-color-text--grey-600{color:#757575 !important}.mdl-color--grey-600{background-color:#757575 !important}.mdl-color-text--grey-700{color:#616161 !important}.mdl-color--grey-700{background-color:#616161 !important}.mdl-color-text--grey-800{color:#424242 !important}.mdl-color--grey-800{background-color:#424242 !important}.mdl-color-text--grey-900{color:#212121 !important}.mdl-color--grey-900{background-color:#212121 !important}.mdl-color-text--blue-grey{color:#607d8b !important}.mdl-color--blue-grey{background-color:#607d8b !important}.mdl-color-text--blue-grey-50{color:#eceff1 !important}.mdl-color--blue-grey-50{background-color:#eceff1 !important}.mdl-color-text--blue-grey-100{color:#cfd8dc !important}.mdl-color--blue-grey-100{background-color:#cfd8dc !important}.mdl-color-text--blue-grey-200{color:#b0bec5 !important}.mdl-color--blue-grey-200{background-color:#b0bec5 !important}.mdl-color-text--blue-grey-300{color:#90a4ae !important}.mdl-color--blue-grey-300{background-color:#90a4ae !important}.mdl-color-text--blue-grey-400{color:#78909c !important}.mdl-color--blue-grey-400{background-color:#78909c !important}.mdl-color-text--blue-grey-500{color:#607d8b !important}.mdl-color--blue-grey-500{background-color:#607d8b !important}.mdl-color-text--blue-grey-600{color:#546e7a !important}.mdl-color--blue-grey-600{background-color:#546e7a !important}.mdl-color-text--blue-grey-700{color:#455a64 !important}.mdl-color--blue-grey-700{background-color:#455a64 !important}.mdl-color-text--blue-grey-800{color:#37474f !important}.mdl-color--blue-grey-800{background-color:#37474f !important}.mdl-color-text--blue-grey-900{color:#263238 !important}.mdl-color--blue-grey-900{background-color:#263238 !important}.mdl-color--black{background-color:#000 !important}.mdl-color-text--black{color:#000 !important}.mdl-color--white{background-color:#fff !important}.mdl-color-text--white{color:#fff !important}.mdl-color--primary{background-color:#3f51b5 !important}.mdl-color--primary-contrast{background-color:#fff !important}.mdl-color--primary-dark{background-color:#303f9f !important}.mdl-color--accent{background-color:#ff4081 !important}.mdl-color--accent-contrast{background-color:#fff !important}.mdl-color-text--primary{color:#3f51b5 !important}.mdl-color-text--primary-contrast{color:#fff !important}.mdl-color-text--primary-dark{color:#303f9f !important}.mdl-color-text--accent{color:#ff4081 !important}.mdl-color-text--accent-contrast{color:#fff !important}.mdl-ripple{background:#000;border-radius:50%;height:50px;left:0;opacity:0;pointer-events:none;position:absolute;top:0;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);width:50px;overflow:hidden}.mdl-ripple.is-animating{transition:transform .3s cubic-bezier(0,0,.2,1),width .3s cubic-bezier(0,0,.2,1),height .3s cubic-bezier(0,0,.2,1),opacity .6s cubic-bezier(0,0,.2,1);transition:transform .3s cubic-bezier(0,0,.2,1),width .3s cubic-bezier(0,0,.2,1),height .3s cubic-bezier(0,0,.2,1),opacity .6s cubic-bezier(0,0,.2,1),-webkit-transform .3s cubic-bezier(0,0,.2,1)}.mdl-ripple.is-visible{opacity:.3}.mdl-animation--default,.mdl-animation--fast-out-slow-in{transition-timing-function:cubic-bezier(.4,0,.2,1)}.mdl-animation--linear-out-slow-in{transition-timing-function:cubic-bezier(0,0,.2,1)}.mdl-animation--fast-out-linear-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.mdl-badge{position:relative;white-space:nowrap;margin-right:24px}.mdl-badge:not([data-badge]){margin-right:auto}.mdl-badge[data-badge]:after{content:attr(data-badge);display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;-webkit-align-content:center;-ms-flex-line-pack:center;align-content:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;position:absolute;top:-11px;right:-24px;font-family:"Roboto","Helvetica","Arial",sans-serif;font-weight:600;font-size:12px;width:22px;height:22px;border-radius:50%;background:#ff4081;color:#fff}.mdl-button .mdl-badge[data-badge]:after{top:-10px;right:-5px}.mdl-badge.mdl-badge--no-background[data-badge]:after{color:#ff4081;background:rgba(255,255,255,.2);box-shadow:0 0 1px gray}.mdl-badge.mdl-badge--overlap{margin-right:10px}.mdl-badge.mdl-badge--overlap:after{right:-10px}.mdl-button{background:0 0;border:none;border-radius:2px;color:#000;position:relative;height:36px;margin:0;min-width:64px;padding:0 16px;display:inline-block;font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:500;text-transform:uppercase;letter-spacing:0;overflow:hidden;will-change:box-shadow;transition:box-shadow .2s cubic-bezier(.4,0,1,1),background-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1);outline:none;cursor:pointer;text-decoration:none;text-align:center;line-height:36px;vertical-align:middle}.mdl-button::-moz-focus-inner{border:0}.mdl-button:hover{background-color:rgba(158,158,158,.2)}.mdl-button:focus:not(:active){background-color:rgba(0,0,0,.12)}.mdl-button:active{background-color:rgba(158,158,158,.4)}.mdl-button.mdl-button--colored{color:#3f51b5}.mdl-button.mdl-button--colored:focus:not(:active){background-color:rgba(0,0,0,.12)}input.mdl-button[type="submit"]{-webkit-appearance:none}.mdl-button--raised{background:rgba(158,158,158,.2);box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12)}.mdl-button--raised:active{box-shadow:0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12),0 2px 4px -1px rgba(0,0,0,.2);background-color:rgba(158,158,158,.4)}.mdl-button--raised:focus:not(:active){box-shadow:0 0 8px rgba(0,0,0,.18),0 8px 16px rgba(0,0,0,.36);background-color:rgba(158,158,158,.4)}.mdl-button--raised.mdl-button--colored{background:#3f51b5;color:#fff}.mdl-button--raised.mdl-button--colored:hover{background-color:#3f51b5}.mdl-button--raised.mdl-button--colored:active{background-color:#3f51b5}.mdl-button--raised.mdl-button--colored:focus:not(:active){background-color:#3f51b5}.mdl-button--raised.mdl-button--colored .mdl-ripple{background:#fff}.mdl-button--fab{border-radius:50%;font-size:24px;height:56px;margin:auto;min-width:56px;width:56px;padding:0;overflow:hidden;background:rgba(158,158,158,.2);box-shadow:0 1px 1.5px 0 rgba(0,0,0,.12),0 1px 1px 0 rgba(0,0,0,.24);position:relative;line-height:normal}.mdl-button--fab .material-icons{position:absolute;top:50%;left:50%;-webkit-transform:translate(-12px,-12px);transform:translate(-12px,-12px);line-height:24px;width:24px}.mdl-button--fab.mdl-button--mini-fab{height:40px;min-width:40px;width:40px}.mdl-button--fab .mdl-button__ripple-container{border-radius:50%;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-button--fab:active{box-shadow:0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12),0 2px 4px -1px rgba(0,0,0,.2);background-color:rgba(158,158,158,.4)}.mdl-button--fab:focus:not(:active){box-shadow:0 0 8px rgba(0,0,0,.18),0 8px 16px rgba(0,0,0,.36);background-color:rgba(158,158,158,.4)}.mdl-button--fab.mdl-button--colored{background:#ff4081;color:#fff}.mdl-button--fab.mdl-button--colored:hover{background-color:#ff4081}.mdl-button--fab.mdl-button--colored:focus:not(:active){background-color:#ff4081}.mdl-button--fab.mdl-button--colored:active{background-color:#ff4081}.mdl-button--fab.mdl-button--colored .mdl-ripple{background:#fff}.mdl-button--icon{border-radius:50%;font-size:24px;height:32px;margin-left:0;margin-right:0;min-width:32px;width:32px;padding:0;overflow:hidden;color:inherit;line-height:normal}.mdl-button--icon .material-icons{position:absolute;top:50%;left:50%;-webkit-transform:translate(-12px,-12px);transform:translate(-12px,-12px);line-height:24px;width:24px}.mdl-button--icon.mdl-button--mini-icon{height:24px;min-width:24px;width:24px}.mdl-button--icon.mdl-button--mini-icon .material-icons{top:0;left:0}.mdl-button--icon .mdl-button__ripple-container{border-radius:50%;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-button__ripple-container{display:block;height:100%;left:0;position:absolute;top:0;width:100%;z-index:0;overflow:hidden}.mdl-button[disabled] .mdl-button__ripple-container .mdl-ripple,.mdl-button.mdl-button--disabled .mdl-button__ripple-container .mdl-ripple{background-color:transparent}.mdl-button--primary.mdl-button--primary{color:#3f51b5}.mdl-button--primary.mdl-button--primary .mdl-ripple{background:#fff}.mdl-button--primary.mdl-button--primary.mdl-button--raised,.mdl-button--primary.mdl-button--primary.mdl-button--fab{color:#fff;background-color:#3f51b5}.mdl-button--accent.mdl-button--accent{color:#ff4081}.mdl-button--accent.mdl-button--accent .mdl-ripple{background:#fff}.mdl-button--accent.mdl-button--accent.mdl-button--raised,.mdl-button--accent.mdl-button--accent.mdl-button--fab{color:#fff;background-color:#ff4081}.mdl-button[disabled][disabled],.mdl-button.mdl-button--disabled.mdl-button--disabled{color:rgba(0,0,0,.26);cursor:default;background-color:transparent}.mdl-button--fab[disabled][disabled],.mdl-button--fab.mdl-button--disabled.mdl-button--disabled{background-color:rgba(0,0,0,.12);color:rgba(0,0,0,.26)}.mdl-button--raised[disabled][disabled],.mdl-button--raised.mdl-button--disabled.mdl-button--disabled{background-color:rgba(0,0,0,.12);color:rgba(0,0,0,.26);box-shadow:none}.mdl-button--colored[disabled][disabled],.mdl-button--colored.mdl-button--disabled.mdl-button--disabled{color:rgba(0,0,0,.26)}.mdl-button .material-icons{vertical-align:middle}.mdl-card{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;font-size:16px;font-weight:400;min-height:200px;overflow:hidden;width:330px;z-index:1;position:relative;background:#fff;border-radius:2px;box-sizing:border-box}.mdl-card__media{background-color:#ff4081;background-repeat:repeat;background-position:50% 50%;background-size:cover;background-origin:padding-box;background-attachment:scroll;box-sizing:border-box}.mdl-card__title{-webkit-align-items:center;-ms-flex-align:center;align-items:center;color:#000;display:block;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-justify-content:stretch;-ms-flex-pack:stretch;justify-content:stretch;line-height:normal;padding:16px;-webkit-perspective-origin:165px 56px;perspective-origin:165px 56px;-webkit-transform-origin:165px 56px;transform-origin:165px 56px;box-sizing:border-box}.mdl-card__title.mdl-card--border{border-bottom:1px solid rgba(0,0,0,.1)}.mdl-card__title-text{-webkit-align-self:flex-end;-ms-flex-item-align:end;align-self:flex-end;color:inherit;display:block;display:-webkit-flex;display:-ms-flexbox;display:flex;font-size:24px;font-weight:300;line-height:normal;overflow:hidden;-webkit-transform-origin:149px 48px;transform-origin:149px 48px;margin:0}.mdl-card__subtitle-text{font-size:14px;color:rgba(0,0,0,.54);margin:0}.mdl-card__supporting-text{color:rgba(0,0,0,.54);font-size:1rem;line-height:18px;overflow:hidden;padding:16px;width:90%}.mdl-card__actions{font-size:16px;line-height:normal;width:100%;background-color:transparent;padding:8px;box-sizing:border-box}.mdl-card__actions.mdl-card--border{border-top:1px solid rgba(0,0,0,.1)}.mdl-card--expand{-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1}.mdl-card__menu{position:absolute;right:16px;top:16px}.mdl-checkbox{position:relative;z-index:1;vertical-align:middle;display:inline-block;box-sizing:border-box;width:100%;height:24px;margin:0;padding:0}.mdl-checkbox.is-upgraded{padding-left:24px}.mdl-checkbox__input{line-height:24px}.mdl-checkbox.is-upgraded .mdl-checkbox__input{position:absolute;width:0;height:0;margin:0;padding:0;opacity:0;-ms-appearance:none;-moz-appearance:none;-webkit-appearance:none;appearance:none;border:none}.mdl-checkbox__box-outline{position:absolute;top:3px;left:0;display:inline-block;box-sizing:border-box;width:16px;height:16px;margin:0;cursor:pointer;overflow:hidden;border:2px solid rgba(0,0,0,.54);border-radius:2px;z-index:2}.mdl-checkbox.is-checked .mdl-checkbox__box-outline{border:2px solid #3f51b5}fieldset[disabled] .mdl-checkbox .mdl-checkbox__box-outline,.mdl-checkbox.is-disabled .mdl-checkbox__box-outline{border:2px solid rgba(0,0,0,.26);cursor:auto}.mdl-checkbox__focus-helper{position:absolute;top:3px;left:0;display:inline-block;box-sizing:border-box;width:16px;height:16px;border-radius:50%;background-color:transparent}.mdl-checkbox.is-focused .mdl-checkbox__focus-helper{box-shadow:0 0 0 8px rgba(0,0,0,.1);background-color:rgba(0,0,0,.1)}.mdl-checkbox.is-focused.is-checked .mdl-checkbox__focus-helper{box-shadow:0 0 0 8px rgba(63,81,181,.26);background-color:rgba(63,81,181,.26)}.mdl-checkbox__tick-outline{position:absolute;top:0;left:0;height:100%;width:100%;-webkit-mask:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMSAxIgogICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWluWU1pbiBtZWV0Ij4KICA8ZGVmcz4KICAgIDxjbGlwUGF0aCBpZD0iY2xpcCI+CiAgICAgIDxwYXRoCiAgICAgICAgIGQ9Ik0gMCwwIDAsMSAxLDEgMSwwIDAsMCB6IE0gMC44NTM0Mzc1LDAuMTY3MTg3NSAwLjk1OTY4NzUsMC4yNzMxMjUgMC40MjkzNzUsMC44MDM0Mzc1IDAuMzIzMTI1LDAuOTA5Njg3NSAwLjIxNzE4NzUsMC44MDM0Mzc1IDAuMDQwMzEyNSwwLjYyNjg3NSAwLjE0NjU2MjUsMC41MjA2MjUgMC4zMjMxMjUsMC42OTc1IDAuODUzNDM3NSwwLjE2NzE4NzUgeiIKICAgICAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZjtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgLz4KICAgIDwvY2xpcFBhdGg+CiAgICA8bWFzayBpZD0ibWFzayIgbWFza1VuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgbWFza0NvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giPgogICAgICA8cGF0aAogICAgICAgICBkPSJNIDAsMCAwLDEgMSwxIDEsMCAwLDAgeiBNIDAuODUzNDM3NSwwLjE2NzE4NzUgMC45NTk2ODc1LDAuMjczMTI1IDAuNDI5Mzc1LDAuODAzNDM3NSAwLjMyMzEyNSwwLjkwOTY4NzUgMC4yMTcxODc1LDAuODAzNDM3NSAwLjA0MDMxMjUsMC42MjY4NzUgMC4xNDY1NjI1LDAuNTIwNjI1IDAuMzIzMTI1LDAuNjk3NSAwLjg1MzQzNzUsMC4xNjcxODc1IHoiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmUiIC8+CiAgICA8L21hc2s+CiAgPC9kZWZzPgogIDxyZWN0CiAgICAgd2lkdGg9IjEiCiAgICAgaGVpZ2h0PSIxIgogICAgIHg9IjAiCiAgICAgeT0iMCIKICAgICBjbGlwLXBhdGg9InVybCgjY2xpcCkiCiAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgLz4KPC9zdmc+Cg==");mask:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMSAxIgogICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWluWU1pbiBtZWV0Ij4KICA8ZGVmcz4KICAgIDxjbGlwUGF0aCBpZD0iY2xpcCI+CiAgICAgIDxwYXRoCiAgICAgICAgIGQ9Ik0gMCwwIDAsMSAxLDEgMSwwIDAsMCB6IE0gMC44NTM0Mzc1LDAuMTY3MTg3NSAwLjk1OTY4NzUsMC4yNzMxMjUgMC40MjkzNzUsMC44MDM0Mzc1IDAuMzIzMTI1LDAuOTA5Njg3NSAwLjIxNzE4NzUsMC44MDM0Mzc1IDAuMDQwMzEyNSwwLjYyNjg3NSAwLjE0NjU2MjUsMC41MjA2MjUgMC4zMjMxMjUsMC42OTc1IDAuODUzNDM3NSwwLjE2NzE4NzUgeiIKICAgICAgICAgc3R5bGU9ImZpbGw6I2ZmZmZmZjtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgLz4KICAgIDwvY2xpcFBhdGg+CiAgICA8bWFzayBpZD0ibWFzayIgbWFza1VuaXRzPSJvYmplY3RCb3VuZGluZ0JveCIgbWFza0NvbnRlbnRVbml0cz0ib2JqZWN0Qm91bmRpbmdCb3giPgogICAgICA8cGF0aAogICAgICAgICBkPSJNIDAsMCAwLDEgMSwxIDEsMCAwLDAgeiBNIDAuODUzNDM3NSwwLjE2NzE4NzUgMC45NTk2ODc1LDAuMjczMTI1IDAuNDI5Mzc1LDAuODAzNDM3NSAwLjMyMzEyNSwwLjkwOTY4NzUgMC4yMTcxODc1LDAuODAzNDM3NSAwLjA0MDMxMjUsMC42MjY4NzUgMC4xNDY1NjI1LDAuNTIwNjI1IDAuMzIzMTI1LDAuNjk3NSAwLjg1MzQzNzUsMC4xNjcxODc1IHoiCiAgICAgICAgIHN0eWxlPSJmaWxsOiNmZmZmZmY7ZmlsbC1vcGFjaXR5OjE7c3Ryb2tlOm5vbmUiIC8+CiAgICA8L21hc2s+CiAgPC9kZWZzPgogIDxyZWN0CiAgICAgd2lkdGg9IjEiCiAgICAgaGVpZ2h0PSIxIgogICAgIHg9IjAiCiAgICAgeT0iMCIKICAgICBjbGlwLXBhdGg9InVybCgjY2xpcCkiCiAgICAgc3R5bGU9ImZpbGw6IzAwMDAwMDtmaWxsLW9wYWNpdHk6MTtzdHJva2U6bm9uZSIgLz4KPC9zdmc+Cg==");background:0 0;transition-duration:.28s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:background}.mdl-checkbox.is-checked .mdl-checkbox__tick-outline{background:#3f51b5 url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMSAxIgogICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWluWU1pbiBtZWV0Ij4KICA8cGF0aAogICAgIGQ9Ik0gMC4wNDAzODA1OSwwLjYyNjc3NjcgMC4xNDY0NDY2MSwwLjUyMDcxMDY4IDAuNDI5Mjg5MzIsMC44MDM1NTMzOSAwLjMyMzIyMzMsMC45MDk2MTk0MSB6IE0gMC4yMTcxNTcyOSwwLjgwMzU1MzM5IDAuODUzNTUzMzksMC4xNjcxNTcyOSAwLjk1OTYxOTQxLDAuMjczMjIzMyAwLjMyMzIyMzMsMC45MDk2MTk0MSB6IgogICAgIGlkPSJyZWN0Mzc4MCIKICAgICBzdHlsZT0iZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lIiAvPgo8L3N2Zz4K")}fieldset[disabled] .mdl-checkbox.is-checked .mdl-checkbox__tick-outline,.mdl-checkbox.is-checked.is-disabled .mdl-checkbox__tick-outline{background:rgba(0,0,0,.26)url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+CjxzdmcKICAgeG1sbnM6ZGM9Imh0dHA6Ly9wdXJsLm9yZy9kYy9lbGVtZW50cy8xLjEvIgogICB4bWxuczpjYz0iaHR0cDovL2NyZWF0aXZlY29tbW9ucy5vcmcvbnMjIgogICB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiCiAgIHhtbG5zOnN2Zz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciCiAgIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIKICAgdmVyc2lvbj0iMS4xIgogICB2aWV3Qm94PSIwIDAgMSAxIgogICBwcmVzZXJ2ZUFzcGVjdFJhdGlvPSJ4TWluWU1pbiBtZWV0Ij4KICA8cGF0aAogICAgIGQ9Ik0gMC4wNDAzODA1OSwwLjYyNjc3NjcgMC4xNDY0NDY2MSwwLjUyMDcxMDY4IDAuNDI5Mjg5MzIsMC44MDM1NTMzOSAwLjMyMzIyMzMsMC45MDk2MTk0MSB6IE0gMC4yMTcxNTcyOSwwLjgwMzU1MzM5IDAuODUzNTUzMzksMC4xNjcxNTcyOSAwLjk1OTYxOTQxLDAuMjczMjIzMyAwLjMyMzIyMzMsMC45MDk2MTk0MSB6IgogICAgIGlkPSJyZWN0Mzc4MCIKICAgICBzdHlsZT0iZmlsbDojZmZmZmZmO2ZpbGwtb3BhY2l0eToxO3N0cm9rZTpub25lIiAvPgo8L3N2Zz4K")}.mdl-checkbox__label{position:relative;cursor:pointer;font-size:16px;line-height:24px;margin:0}fieldset[disabled] .mdl-checkbox .mdl-checkbox__label,.mdl-checkbox.is-disabled .mdl-checkbox__label{color:rgba(0,0,0,.26);cursor:auto}.mdl-checkbox__ripple-container{position:absolute;z-index:2;top:-6px;left:-10px;box-sizing:border-box;width:36px;height:36px;border-radius:50%;cursor:pointer;overflow:hidden;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-checkbox__ripple-container .mdl-ripple{background:#3f51b5}fieldset[disabled] .mdl-checkbox .mdl-checkbox__ripple-container,.mdl-checkbox.is-disabled .mdl-checkbox__ripple-container{cursor:auto}fieldset[disabled] .mdl-checkbox .mdl-checkbox__ripple-container .mdl-ripple,.mdl-checkbox.is-disabled .mdl-checkbox__ripple-container .mdl-ripple{background:0 0}.mdl-data-table{position:relative;border:1px solid rgba(0,0,0,.12);border-collapse:collapse;white-space:nowrap;font-size:13px;background-color:#fff}.mdl-data-table thead{padding-bottom:3px}.mdl-data-table thead .mdl-data-table__select{margin-top:0}.mdl-data-table tbody tr{position:relative;height:48px;transition-duration:.28s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:background-color}.mdl-data-table tbody tr.is-selected{background-color:#e0e0e0}.mdl-data-table tbody tr:hover{background-color:#eee}.mdl-data-table td{text-align:right}.mdl-data-table th{padding:0 18px 12px 18px;text-align:right}.mdl-data-table td:first-of-type,.mdl-data-table th:first-of-type{padding-left:24px}.mdl-data-table td:last-of-type,.mdl-data-table th:last-of-type{padding-right:24px}.mdl-data-table td{position:relative;height:48px;border-top:1px solid rgba(0,0,0,.12);border-bottom:1px solid rgba(0,0,0,.12);padding:12px 18px;box-sizing:border-box}.mdl-data-table td,.mdl-data-table td .mdl-data-table__select{vertical-align:middle}.mdl-data-table th{position:relative;vertical-align:bottom;text-overflow:ellipsis;font-weight:700;line-height:24px;letter-spacing:0;height:48px;font-size:12px;color:rgba(0,0,0,.54);padding-bottom:8px;box-sizing:border-box}.mdl-data-table th.mdl-data-table__header--sorted-ascending,.mdl-data-table th.mdl-data-table__header--sorted-descending{color:rgba(0,0,0,.87)}.mdl-data-table th.mdl-data-table__header--sorted-ascending:before,.mdl-data-table th.mdl-data-table__header--sorted-descending:before{font-family:'Material Icons';font-weight:400;font-style:normal;line-height:1;letter-spacing:normal;text-transform:none;display:inline-block;word-wrap:normal;-moz-font-feature-settings:'liga';font-feature-settings:'liga';-webkit-font-feature-settings:'liga';-webkit-font-smoothing:antialiased;font-size:16px;content:"\e5d8";margin-right:5px;vertical-align:sub}.mdl-data-table th.mdl-data-table__header--sorted-ascending:hover,.mdl-data-table th.mdl-data-table__header--sorted-descending:hover{cursor:pointer}.mdl-data-table th.mdl-data-table__header--sorted-ascending:hover:before,.mdl-data-table th.mdl-data-table__header--sorted-descending:hover:before{color:rgba(0,0,0,.26)}.mdl-data-table th.mdl-data-table__header--sorted-descending:before{content:"\e5db"}.mdl-data-table__select{width:16px}.mdl-data-table__cell--non-numeric.mdl-data-table__cell--non-numeric{text-align:left}.mdl-dialog{border:none;box-shadow:0 9px 46px 8px rgba(0,0,0,.14),0 11px 15px -7px rgba(0,0,0,.12),0 24px 38px 3px rgba(0,0,0,.2);width:280px}.mdl-dialog__title{padding:24px 24px 0;margin:0;font-size:2.5rem}.mdl-dialog__actions{padding:8px 8px 8px 24px;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row-reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse;-webkit-flex-wrap:wrap;-ms-flex-wrap:wrap;flex-wrap:wrap}.mdl-dialog__actions>*{margin-right:8px;height:36px}.mdl-dialog__actions>*:first-child{margin-right:0}.mdl-dialog__actions--full-width{padding:0 0 8px}.mdl-dialog__actions--full-width>*{height:48px;-webkit-flex:0 0 100%;-ms-flex:0 0 100%;flex:0 0 100%;padding-right:16px;margin-right:0;text-align:right}.mdl-dialog__content{padding:20px 24px 24px;color:rgba(0,0,0,.54)}.mdl-mega-footer{padding:16px 40px;color:#9e9e9e;background-color:#424242}.mdl-mega-footer--top-section:after,.mdl-mega-footer--middle-section:after,.mdl-mega-footer--bottom-section:after,.mdl-mega-footer__top-section:after,.mdl-mega-footer__middle-section:after,.mdl-mega-footer__bottom-section:after{content:'';display:block;clear:both}.mdl-mega-footer--left-section,.mdl-mega-footer__left-section,.mdl-mega-footer--right-section,.mdl-mega-footer__right-section{margin-bottom:16px}.mdl-mega-footer--right-section a,.mdl-mega-footer__right-section a{display:block;margin-bottom:16px;color:inherit;text-decoration:none}@media screen and (min-width:760px){.mdl-mega-footer--left-section,.mdl-mega-footer__left-section{float:left}.mdl-mega-footer--right-section,.mdl-mega-footer__right-section{float:right}.mdl-mega-footer--right-section a,.mdl-mega-footer__right-section a{display:inline-block;margin-left:16px;line-height:36px;vertical-align:middle}}.mdl-mega-footer--social-btn,.mdl-mega-footer__social-btn{width:36px;height:36px;padding:0;margin:0;background-color:#9e9e9e;border:none}.mdl-mega-footer--drop-down-section,.mdl-mega-footer__drop-down-section{display:block;position:relative}@media screen and (min-width:760px){.mdl-mega-footer--drop-down-section,.mdl-mega-footer__drop-down-section{width:33%}.mdl-mega-footer--drop-down-section:nth-child(1),.mdl-mega-footer--drop-down-section:nth-child(2),.mdl-mega-footer__drop-down-section:nth-child(1),.mdl-mega-footer__drop-down-section:nth-child(2){float:left}.mdl-mega-footer--drop-down-section:nth-child(3),.mdl-mega-footer__drop-down-section:nth-child(3){float:right}.mdl-mega-footer--drop-down-section:nth-child(3):after,.mdl-mega-footer__drop-down-section:nth-child(3):after{clear:right}.mdl-mega-footer--drop-down-section:nth-child(4),.mdl-mega-footer__drop-down-section:nth-child(4){clear:right;float:right}.mdl-mega-footer--middle-section:after,.mdl-mega-footer__middle-section:after{content:'';display:block;clear:both}.mdl-mega-footer--bottom-section,.mdl-mega-footer__bottom-section{padding-top:0}}@media screen and (min-width:1024px){.mdl-mega-footer--drop-down-section,.mdl-mega-footer--drop-down-section:nth-child(3),.mdl-mega-footer--drop-down-section:nth-child(4),.mdl-mega-footer__drop-down-section,.mdl-mega-footer__drop-down-section:nth-child(3),.mdl-mega-footer__drop-down-section:nth-child(4){width:24%;float:left}}.mdl-mega-footer--heading-checkbox,.mdl-mega-footer__heading-checkbox{position:absolute;width:100%;height:55.8px;padding:32px;margin:-16px 0 0;cursor:pointer;z-index:1;opacity:0}.mdl-mega-footer--heading-checkbox+.mdl-mega-footer--heading:after,.mdl-mega-footer--heading-checkbox+.mdl-mega-footer__heading:after,.mdl-mega-footer__heading-checkbox+.mdl-mega-footer--heading:after,.mdl-mega-footer__heading-checkbox+.mdl-mega-footer__heading:after{font-family:'Material Icons';content:'\E5CE'}.mdl-mega-footer--heading-checkbox:checked~.mdl-mega-footer--link-list,.mdl-mega-footer--heading-checkbox:checked~.mdl-mega-footer__link-list,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer--heading+.mdl-mega-footer--link-list,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer__heading+.mdl-mega-footer__link-list,.mdl-mega-footer__heading-checkbox:checked~.mdl-mega-footer--link-list,.mdl-mega-footer__heading-checkbox:checked~.mdl-mega-footer__link-list,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer--heading+.mdl-mega-footer--link-list,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer__heading+.mdl-mega-footer__link-list{display:none}.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer--heading:after,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer__heading:after,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer--heading:after,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer__heading:after{font-family:'Material Icons';content:'\E5CF'}.mdl-mega-footer--heading,.mdl-mega-footer__heading{position:relative;width:100%;padding-right:39.8px;margin-bottom:16px;box-sizing:border-box;font-size:14px;line-height:23.8px;font-weight:500;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;color:#e0e0e0}.mdl-mega-footer--heading:after,.mdl-mega-footer__heading:after{content:'';position:absolute;top:0;right:0;display:block;width:23.8px;height:23.8px;background-size:cover}.mdl-mega-footer--link-list,.mdl-mega-footer__link-list{list-style:none;padding:0;margin:0 0 32px}.mdl-mega-footer--link-list:after,.mdl-mega-footer__link-list:after{clear:both;display:block;content:''}.mdl-mega-footer--link-list li,.mdl-mega-footer__link-list li{font-size:14px;font-weight:400;letter-spacing:0;line-height:20px}.mdl-mega-footer--link-list a,.mdl-mega-footer__link-list a{color:inherit;text-decoration:none;white-space:nowrap}@media screen and (min-width:760px){.mdl-mega-footer--heading-checkbox,.mdl-mega-footer__heading-checkbox{display:none}.mdl-mega-footer--heading-checkbox+.mdl-mega-footer--heading:after,.mdl-mega-footer--heading-checkbox+.mdl-mega-footer__heading:after,.mdl-mega-footer__heading-checkbox+.mdl-mega-footer--heading:after,.mdl-mega-footer__heading-checkbox+.mdl-mega-footer__heading:after{content:''}.mdl-mega-footer--heading-checkbox:checked~.mdl-mega-footer--link-list,.mdl-mega-footer--heading-checkbox:checked~.mdl-mega-footer__link-list,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer__heading+.mdl-mega-footer__link-list,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer--heading+.mdl-mega-footer--link-list,.mdl-mega-footer__heading-checkbox:checked~.mdl-mega-footer--link-list,.mdl-mega-footer__heading-checkbox:checked~.mdl-mega-footer__link-list,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer__heading+.mdl-mega-footer__link-list,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer--heading+.mdl-mega-footer--link-list{display:block}.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer--heading:after,.mdl-mega-footer--heading-checkbox:checked+.mdl-mega-footer__heading:after,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer--heading:after,.mdl-mega-footer__heading-checkbox:checked+.mdl-mega-footer__heading:after{content:''}}.mdl-mega-footer--bottom-section,.mdl-mega-footer__bottom-section{padding-top:16px;margin-bottom:16px}.mdl-logo{margin-bottom:16px;color:#fff}.mdl-mega-footer--bottom-section .mdl-mega-footer--link-list li,.mdl-mega-footer__bottom-section .mdl-mega-footer__link-list li{float:left;margin-bottom:0;margin-right:16px}@media screen and (min-width:760px){.mdl-logo{float:left;margin-bottom:0;margin-right:16px}}.mdl-mini-footer{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:row wrap;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;padding:32px 16px;color:#9e9e9e;background-color:#424242}.mdl-mini-footer:after{content:'';display:block}.mdl-mini-footer .mdl-logo{line-height:36px}.mdl-mini-footer--link-list,.mdl-mini-footer__link-list{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:row nowrap;-ms-flex-flow:row nowrap;flex-flow:row nowrap;list-style:none;margin:0;padding:0}.mdl-mini-footer--link-list li,.mdl-mini-footer__link-list li{margin-bottom:0;margin-right:16px}@media screen and (min-width:760px){.mdl-mini-footer--link-list li,.mdl-mini-footer__link-list li{line-height:36px}}.mdl-mini-footer--link-list a,.mdl-mini-footer__link-list a{color:inherit;text-decoration:none;white-space:nowrap}.mdl-mini-footer--left-section,.mdl-mini-footer__left-section{display:inline-block;-webkit-order:0;-ms-flex-order:0;order:0}.mdl-mini-footer--right-section,.mdl-mini-footer__right-section{display:inline-block;-webkit-order:1;-ms-flex-order:1;order:1}.mdl-mini-footer--social-btn,.mdl-mini-footer__social-btn{width:36px;height:36px;padding:0;margin:0;background-color:#9e9e9e;border:none}.mdl-icon-toggle{position:relative;z-index:1;vertical-align:middle;display:inline-block;height:32px;margin:0;padding:0}.mdl-icon-toggle__input{line-height:32px}.mdl-icon-toggle.is-upgraded .mdl-icon-toggle__input{position:absolute;width:0;height:0;margin:0;padding:0;opacity:0;-ms-appearance:none;-moz-appearance:none;-webkit-appearance:none;appearance:none;border:none}.mdl-icon-toggle__label{display:inline-block;position:relative;cursor:pointer;height:32px;width:32px;min-width:32px;color:#616161;border-radius:50%;padding:0;margin-left:0;margin-right:0;text-align:center;background-color:transparent;will-change:background-color;transition:background-color .2s cubic-bezier(.4,0,.2,1),color .2s cubic-bezier(.4,0,.2,1)}.mdl-icon-toggle__label.material-icons{line-height:32px;font-size:24px}.mdl-icon-toggle.is-checked .mdl-icon-toggle__label{color:#3f51b5}.mdl-icon-toggle.is-disabled .mdl-icon-toggle__label{color:rgba(0,0,0,.26);cursor:auto;transition:none}.mdl-icon-toggle.is-focused .mdl-icon-toggle__label{background-color:rgba(0,0,0,.12)}.mdl-icon-toggle.is-focused.is-checked .mdl-icon-toggle__label{background-color:rgba(63,81,181,.26)}.mdl-icon-toggle__ripple-container{position:absolute;z-index:2;top:-2px;left:-2px;box-sizing:border-box;width:36px;height:36px;border-radius:50%;cursor:pointer;overflow:hidden;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-icon-toggle__ripple-container .mdl-ripple{background:#616161}.mdl-icon-toggle.is-disabled .mdl-icon-toggle__ripple-container{cursor:auto}.mdl-icon-toggle.is-disabled .mdl-icon-toggle__ripple-container .mdl-ripple{background:0 0}.mdl-list{display:block;padding:8px 0;list-style:none}.mdl-list__item{font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:16px;font-weight:400;letter-spacing:.04em;line-height:1;min-height:48px;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;padding:16px;cursor:default;color:rgba(0,0,0,.87);overflow:hidden}.mdl-list__item,.mdl-list__item .mdl-list__item-primary-content{box-sizing:border-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-align-items:center;-ms-flex-align:center;align-items:center}.mdl-list__item .mdl-list__item-primary-content{-webkit-order:0;-ms-flex-order:0;order:0;-webkit-flex-grow:2;-ms-flex-positive:2;flex-grow:2;text-decoration:none}.mdl-list__item .mdl-list__item-primary-content .mdl-list__item-icon{margin-right:32px}.mdl-list__item .mdl-list__item-primary-content .mdl-list__item-avatar{margin-right:16px}.mdl-list__item .mdl-list__item-secondary-content{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:column;-ms-flex-flow:column;flex-flow:column;-webkit-align-items:flex-end;-ms-flex-align:end;align-items:flex-end;margin-left:16px}.mdl-list__item .mdl-list__item-secondary-content .mdl-list__item-secondary-action label{display:inline}.mdl-list__item .mdl-list__item-secondary-content .mdl-list__item-secondary-info{font-size:12px;font-weight:400;line-height:1;letter-spacing:0;color:rgba(0,0,0,.54)}.mdl-list__item .mdl-list__item-secondary-content .mdl-list__item-sub-header{padding:0 0 0 16px}.mdl-list__item-icon,.mdl-list__item-icon.material-icons{height:24px;width:24px;font-size:24px;box-sizing:border-box;color:#757575}.mdl-list__item-avatar,.mdl-list__item-avatar.material-icons{height:40px;width:40px;box-sizing:border-box;border-radius:50%;background-color:#757575;font-size:40px;color:#fff}.mdl-list__item--two-line{height:72px}.mdl-list__item--two-line .mdl-list__item-primary-content{height:36px;line-height:20px;display:block}.mdl-list__item--two-line .mdl-list__item-primary-content .mdl-list__item-avatar{float:left}.mdl-list__item--two-line .mdl-list__item-primary-content .mdl-list__item-icon{float:left;margin-top:6px}.mdl-list__item--two-line .mdl-list__item-primary-content .mdl-list__item-secondary-content{height:36px}.mdl-list__item--two-line .mdl-list__item-primary-content .mdl-list__item-sub-title{font-size:14px;font-weight:400;letter-spacing:0;line-height:18px;color:rgba(0,0,0,.54);display:block;padding:0}.mdl-list__item--three-line{height:88px}.mdl-list__item--three-line .mdl-list__item-primary-content{height:52px;line-height:20px;display:block}.mdl-list__item--three-line .mdl-list__item-primary-content .mdl-list__item-avatar,.mdl-list__item--three-line .mdl-list__item-primary-content .mdl-list__item-icon{float:left}.mdl-list__item--three-line .mdl-list__item-secondary-content{height:52px}.mdl-list__item--three-line .mdl-list__item-text-body{font-size:14px;font-weight:400;letter-spacing:0;line-height:18px;height:52px;color:rgba(0,0,0,.54);display:block;padding:0}.mdl-menu__container{display:block;margin:0;padding:0;border:none;position:absolute;overflow:visible;height:0;width:0;visibility:hidden;z-index:-1}.mdl-menu__container.is-visible,.mdl-menu__container.is-animating{z-index:999;visibility:visible}.mdl-menu__outline{display:block;background:#fff;margin:0;padding:0;border:none;border-radius:2px;position:absolute;top:0;left:0;overflow:hidden;opacity:0;-webkit-transform:scale(0);transform:scale(0);-webkit-transform-origin:0 0;transform-origin:0 0;box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);will-change:transform;transition:transform .3s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1);transition:transform .3s cubic-bezier(.4,0,.2,1),opacity .2s cubic-bezier(.4,0,.2,1),-webkit-transform .3s cubic-bezier(.4,0,.2,1);z-index:-1}.mdl-menu__container.is-visible .mdl-menu__outline{opacity:1;-webkit-transform:scale(1);transform:scale(1);z-index:999}.mdl-menu__outline.mdl-menu--bottom-right{-webkit-transform-origin:100% 0;transform-origin:100% 0}.mdl-menu__outline.mdl-menu--top-left{-webkit-transform-origin:0 100%;transform-origin:0 100%}.mdl-menu__outline.mdl-menu--top-right{-webkit-transform-origin:100% 100%;transform-origin:100% 100%}.mdl-menu{position:absolute;list-style:none;top:0;left:0;height:auto;width:auto;min-width:124px;padding:8px 0;margin:0;opacity:0;clip:rect(0 0 0 0);z-index:-1}.mdl-menu__container.is-visible .mdl-menu{opacity:1;z-index:999}.mdl-menu.is-animating{transition:opacity .2s cubic-bezier(.4,0,.2,1),clip .3s cubic-bezier(.4,0,.2,1)}.mdl-menu.mdl-menu--bottom-right{left:auto;right:0}.mdl-menu.mdl-menu--top-left{top:auto;bottom:0}.mdl-menu.mdl-menu--top-right{top:auto;left:auto;bottom:0;right:0}.mdl-menu.mdl-menu--unaligned{top:auto;left:auto}.mdl-menu__item{display:block;border:none;color:rgba(0,0,0,.87);background-color:transparent;text-align:left;margin:0;padding:0 16px;outline-color:#bdbdbd;position:relative;overflow:hidden;font-size:14px;font-weight:400;letter-spacing:0;text-decoration:none;cursor:pointer;height:48px;line-height:48px;white-space:nowrap;opacity:0;transition:opacity .2s cubic-bezier(.4,0,.2,1);-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mdl-menu__container.is-visible .mdl-menu__item{opacity:1}.mdl-menu__item::-moz-focus-inner{border:0}.mdl-menu__item--full-bleed-divider{border-bottom:1px solid rgba(0,0,0,.12)}.mdl-menu__item[disabled],.mdl-menu__item[data-mdl-disabled]{color:#bdbdbd;background-color:transparent;cursor:auto}.mdl-menu__item[disabled]:hover,.mdl-menu__item[data-mdl-disabled]:hover{background-color:transparent}.mdl-menu__item[disabled]:focus,.mdl-menu__item[data-mdl-disabled]:focus{background-color:transparent}.mdl-menu__item[disabled] .mdl-ripple,.mdl-menu__item[data-mdl-disabled] .mdl-ripple{background:0 0}.mdl-menu__item:hover{background-color:#eee}.mdl-menu__item:focus{outline:none;background-color:#eee}.mdl-menu__item:active{background-color:#e0e0e0}.mdl-menu__item--ripple-container{display:block;height:100%;left:0;position:absolute;top:0;width:100%;z-index:0;overflow:hidden}.mdl-progress{display:block;position:relative;height:4px;width:500px;max-width:100%}.mdl-progress>.bar{display:block;position:absolute;top:0;bottom:0;width:0%;transition:width .2s cubic-bezier(.4,0,.2,1)}.mdl-progress>.progressbar{background-color:#3f51b5;z-index:1;left:0}.mdl-progress>.bufferbar{background-image:linear-gradient(to right,rgba(255,255,255,.7),rgba(255,255,255,.7)),linear-gradient(to right,#3f51b5 ,#3f51b5);z-index:0;left:0}.mdl-progress>.auxbar{right:0}@supports (-webkit-appearance:none){.mdl-progress:not(.mdl-progress--indeterminate):not(.mdl-progress--indeterminate)>.auxbar,.mdl-progress:not(.mdl-progress__indeterminate):not(.mdl-progress__indeterminate)>.auxbar{background-image:linear-gradient(to right,rgba(255,255,255,.7),rgba(255,255,255,.7)),linear-gradient(to right,#3f51b5 ,#3f51b5);-webkit-mask:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+Cjxzdmcgd2lkdGg9IjEyIiBoZWlnaHQ9IjQiIHZpZXdQb3J0PSIwIDAgMTIgNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxlbGxpcHNlIGN4PSIyIiBjeT0iMiIgcng9IjIiIHJ5PSIyIj4KICAgIDxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9ImN4IiBmcm9tPSIyIiB0bz0iLTEwIiBkdXI9IjAuNnMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiAvPgogIDwvZWxsaXBzZT4KICA8ZWxsaXBzZSBjeD0iMTQiIGN5PSIyIiByeD0iMiIgcnk9IjIiIGNsYXNzPSJsb2FkZXIiPgogICAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iY3giIGZyb209IjE0IiB0bz0iMiIgZHVyPSIwLjZzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgLz4KICA8L2VsbGlwc2U+Cjwvc3ZnPgo=");mask:url("data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIj8+Cjxzdmcgd2lkdGg9IjEyIiBoZWlnaHQ9IjQiIHZpZXdQb3J0PSIwIDAgMTIgNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPgogIDxlbGxpcHNlIGN4PSIyIiBjeT0iMiIgcng9IjIiIHJ5PSIyIj4KICAgIDxhbmltYXRlIGF0dHJpYnV0ZU5hbWU9ImN4IiBmcm9tPSIyIiB0bz0iLTEwIiBkdXI9IjAuNnMiIHJlcGVhdENvdW50PSJpbmRlZmluaXRlIiAvPgogIDwvZWxsaXBzZT4KICA8ZWxsaXBzZSBjeD0iMTQiIGN5PSIyIiByeD0iMiIgcnk9IjIiIGNsYXNzPSJsb2FkZXIiPgogICAgPGFuaW1hdGUgYXR0cmlidXRlTmFtZT0iY3giIGZyb209IjE0IiB0bz0iMiIgZHVyPSIwLjZzIiByZXBlYXRDb3VudD0iaW5kZWZpbml0ZSIgLz4KICA8L2VsbGlwc2U+Cjwvc3ZnPgo=")}}.mdl-progress:not(.mdl-progress--indeterminate)>.auxbar,.mdl-progress:not(.mdl-progress__indeterminate)>.auxbar{background-image:linear-gradient(to right,rgba(255,255,255,.9),rgba(255,255,255,.9)),linear-gradient(to right,#3f51b5 ,#3f51b5)}.mdl-progress.mdl-progress--indeterminate>.bar1,.mdl-progress.mdl-progress__indeterminate>.bar1{-webkit-animation-name:indeterminate1;animation-name:indeterminate1}.mdl-progress.mdl-progress--indeterminate>.bar1,.mdl-progress.mdl-progress__indeterminate>.bar1,.mdl-progress.mdl-progress--indeterminate>.bar3,.mdl-progress.mdl-progress__indeterminate>.bar3{background-color:#3f51b5;-webkit-animation-duration:2s;animation-duration:2s;-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite;-webkit-animation-timing-function:linear;animation-timing-function:linear}.mdl-progress.mdl-progress--indeterminate>.bar3,.mdl-progress.mdl-progress__indeterminate>.bar3{background-image:none;-webkit-animation-name:indeterminate2;animation-name:indeterminate2}@-webkit-keyframes indeterminate1{0%{left:0%;width:0%}50%{left:25%;width:75%}75%{left:100%;width:0%}}@keyframes indeterminate1{0%{left:0%;width:0%}50%{left:25%;width:75%}75%{left:100%;width:0%}}@-webkit-keyframes indeterminate2{0%,50%{left:0%;width:0%}75%{left:0%;width:25%}100%{left:100%;width:0%}}@keyframes indeterminate2{0%,50%{left:0%;width:0%}75%{left:0%;width:25%}100%{left:100%;width:0%}}.mdl-navigation{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;box-sizing:border-box}.mdl-navigation__link{color:#424242;text-decoration:none;margin:0;font-size:14px;font-weight:400;line-height:24px;letter-spacing:0;opacity:.87}.mdl-navigation__link .material-icons{vertical-align:middle}.mdl-layout{width:100%;height:100%;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;overflow-y:auto;overflow-x:hidden;position:relative;-webkit-overflow-scrolling:touch}.mdl-layout.is-small-screen .mdl-layout--large-screen-only{display:none}.mdl-layout:not(.is-small-screen) .mdl-layout--small-screen-only{display:none}.mdl-layout__container{position:absolute;width:100%;height:100%}.mdl-layout__title,.mdl-layout-title{display:block;position:relative;font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:20px;line-height:1;letter-spacing:.02em;font-weight:400;box-sizing:border-box}.mdl-layout-spacer{-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1}.mdl-layout__drawer{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;width:240px;height:100%;max-height:100%;position:absolute;top:0;left:0;box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);box-sizing:border-box;border-right:1px solid #e0e0e0;background:#fafafa;-webkit-transform:translateX(-250px);transform:translateX(-250px);-webkit-transform-style:preserve-3d;transform-style:preserve-3d;will-change:transform;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:transform;transition-property:transform,-webkit-transform;color:#424242;overflow:visible;overflow-y:auto;z-index:5}.mdl-layout__drawer.is-visible{-webkit-transform:translateX(0);transform:translateX(0)}.mdl-layout__drawer.is-visible~.mdl-layout__content.mdl-layout__content{overflow:hidden}.mdl-layout__drawer>*{-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.mdl-layout__drawer>.mdl-layout__title,.mdl-layout__drawer>.mdl-layout-title{line-height:64px;padding-left:40px}@media screen and (max-width:1024px){.mdl-layout__drawer>.mdl-layout__title,.mdl-layout__drawer>.mdl-layout-title{line-height:56px;padding-left:16px}}.mdl-layout__drawer .mdl-navigation{-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-align-items:stretch;-ms-flex-align:stretch;-ms-grid-row-align:stretch;align-items:stretch;padding-top:16px}.mdl-layout__drawer .mdl-navigation .mdl-navigation__link{display:block;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;padding:16px 40px;margin:0;color:#757575}@media screen and (max-width:1024px){.mdl-layout__drawer .mdl-navigation .mdl-navigation__link{padding:16px}}.mdl-layout__drawer .mdl-navigation .mdl-navigation__link:hover{background-color:#e0e0e0}.mdl-layout__drawer .mdl-navigation .mdl-navigation__link--current{background-color:#000;color:#e0e0e0}@media screen and (min-width:1025px){.mdl-layout--fixed-drawer>.mdl-layout__drawer{-webkit-transform:translateX(0);transform:translateX(0)}}.mdl-layout__drawer-button{display:block;position:absolute;height:48px;width:48px;border:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;overflow:hidden;text-align:center;cursor:pointer;font-size:26px;line-height:50px;font-family:Helvetica,Arial,sans-serif;margin:10px 12px;top:0;left:0;color:#fff;z-index:4}.mdl-layout__header .mdl-layout__drawer-button{position:absolute;color:#fff;background-color:inherit}@media screen and (max-width:1024px){.mdl-layout__header .mdl-layout__drawer-button{margin:4px}}@media screen and (max-width:1024px){.mdl-layout__drawer-button{margin:4px;color:rgba(0,0,0,.5)}}@media screen and (min-width:1025px){.mdl-layout--fixed-drawer>.mdl-layout__drawer-button,.mdl-layout--no-desktop-drawer-button .mdl-layout__drawer-button{display:none}}.mdl-layout--no-drawer-button .mdl-layout__drawer-button{display:none}.mdl-layout__header{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-justify-content:flex-start;-ms-flex-pack:start;justify-content:flex-start;box-sizing:border-box;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;width:100%;margin:0;padding:0;border:none;min-height:64px;max-height:1000px;z-index:3;background-color:#3f51b5;color:#fff;box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:max-height,box-shadow}@media screen and (max-width:1024px){.mdl-layout__header{min-height:56px}}.mdl-layout--fixed-drawer.is-upgraded:not(.is-small-screen)>.mdl-layout__header{margin-left:240px;width:calc(100% - 240px)}@media screen and (min-width:1025px){.mdl-layout--fixed-drawer>.mdl-layout__header .mdl-layout__header-row{padding-left:40px}}.mdl-layout__header>.mdl-layout-icon{position:absolute;left:40px;top:16px;height:32px;width:32px;overflow:hidden;z-index:3;display:block}@media screen and (max-width:1024px){.mdl-layout__header>.mdl-layout-icon{left:16px;top:12px}}.mdl-layout.has-drawer .mdl-layout__header>.mdl-layout-icon{display:none}.mdl-layout__header.is-compact{max-height:64px}@media screen and (max-width:1024px){.mdl-layout__header.is-compact{max-height:56px}}.mdl-layout__header.is-compact.has-tabs{height:112px}@media screen and (max-width:1024px){.mdl-layout__header.is-compact.has-tabs{min-height:104px}}@media screen and (max-width:1024px){.mdl-layout__header{display:none}.mdl-layout--fixed-header>.mdl-layout__header{display:-webkit-flex;display:-ms-flexbox;display:flex}}.mdl-layout__header--transparent.mdl-layout__header--transparent{background-color:transparent;box-shadow:none}.mdl-layout__header--seamed,.mdl-layout__header--scroll{box-shadow:none}.mdl-layout__header--waterfall{box-shadow:none;overflow:hidden}.mdl-layout__header--waterfall.is-casting-shadow{box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12)}.mdl-layout__header--waterfall.mdl-layout__header--waterfall-hide-top{-webkit-justify-content:flex-end;-ms-flex-pack:end;justify-content:flex-end}.mdl-layout__header-row{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;box-sizing:border-box;-webkit-align-self:stretch;-ms-flex-item-align:stretch;align-self:stretch;-webkit-align-items:center;-ms-flex-align:center;align-items:center;height:64px;margin:0;padding:0 40px 0 80px}.mdl-layout--no-drawer-button .mdl-layout__header-row{padding-left:40px}@media screen and (min-width:1025px){.mdl-layout--no-desktop-drawer-button .mdl-layout__header-row{padding-left:40px}}@media screen and (max-width:1024px){.mdl-layout__header-row{height:56px;padding:0 16px 0 72px}.mdl-layout--no-drawer-button .mdl-layout__header-row{padding-left:16px}}.mdl-layout__header-row>*{-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.mdl-layout__header--scroll .mdl-layout__header-row{width:100%}.mdl-layout__header-row .mdl-navigation{margin:0;padding:0;height:64px;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-align-items:center;-ms-flex-align:center;-ms-grid-row-align:center;align-items:center}@media screen and (max-width:1024px){.mdl-layout__header-row .mdl-navigation{height:56px}}.mdl-layout__header-row .mdl-navigation__link{display:block;color:#fff;line-height:64px;padding:0 24px}@media screen and (max-width:1024px){.mdl-layout__header-row .mdl-navigation__link{line-height:56px;padding:0 16px}}.mdl-layout__obfuscator{background-color:transparent;position:absolute;top:0;left:0;height:100%;width:100%;z-index:4;visibility:hidden;transition-property:background-color;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.mdl-layout__obfuscator.is-visible{background-color:rgba(0,0,0,.5);visibility:visible}@supports (pointer-events:auto){.mdl-layout__obfuscator{background-color:rgba(0,0,0,.5);opacity:0;transition-property:opacity;visibility:visible;pointer-events:none}.mdl-layout__obfuscator.is-visible{pointer-events:auto;opacity:1}}.mdl-layout__content{-ms-flex:0 1 auto;position:relative;display:inline-block;overflow-y:auto;overflow-x:hidden;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;z-index:1;-webkit-overflow-scrolling:touch}.mdl-layout--fixed-drawer>.mdl-layout__content{margin-left:240px}.mdl-layout__container.has-scrolling-header .mdl-layout__content{overflow:visible}@media screen and (max-width:1024px){.mdl-layout--fixed-drawer>.mdl-layout__content{margin-left:0}.mdl-layout__container.has-scrolling-header .mdl-layout__content{overflow-y:auto;overflow-x:hidden}}.mdl-layout__tab-bar{height:96px;margin:0;width:calc(100% - 112px);padding:0 0 0 56px;display:-webkit-flex;display:-ms-flexbox;display:flex;background-color:#3f51b5;overflow-y:hidden;overflow-x:scroll}.mdl-layout__tab-bar::-webkit-scrollbar{display:none}.mdl-layout--no-drawer-button .mdl-layout__tab-bar{padding-left:16px;width:calc(100% - 32px)}@media screen and (min-width:1025px){.mdl-layout--no-desktop-drawer-button .mdl-layout__tab-bar{padding-left:16px;width:calc(100% - 32px)}}@media screen and (max-width:1024px){.mdl-layout__tab-bar{width:calc(100% - 60px);padding:0 0 0 60px}.mdl-layout--no-drawer-button .mdl-layout__tab-bar{width:calc(100% - 8px);padding-left:4px}}.mdl-layout--fixed-tabs .mdl-layout__tab-bar{padding:0;overflow:hidden;width:100%}.mdl-layout__tab-bar-container{position:relative;height:48px;width:100%;border:none;margin:0;z-index:2;-webkit-flex-grow:0;-ms-flex-positive:0;flex-grow:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;overflow:hidden}.mdl-layout__container>.mdl-layout__tab-bar-container{position:absolute;top:0;left:0}.mdl-layout__tab-bar-button{display:inline-block;position:absolute;top:0;height:48px;width:56px;z-index:4;text-align:center;background-color:#3f51b5;color:transparent;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mdl-layout--no-desktop-drawer-button .mdl-layout__tab-bar-button,.mdl-layout--no-drawer-button .mdl-layout__tab-bar-button{width:16px}.mdl-layout--no-desktop-drawer-button .mdl-layout__tab-bar-button .material-icons,.mdl-layout--no-drawer-button .mdl-layout__tab-bar-button .material-icons{position:relative;left:-4px}@media screen and (max-width:1024px){.mdl-layout__tab-bar-button{display:none;width:60px}}.mdl-layout--fixed-tabs .mdl-layout__tab-bar-button{display:none}.mdl-layout__tab-bar-button .material-icons{line-height:48px}.mdl-layout__tab-bar-button.is-active{color:#fff}.mdl-layout__tab-bar-left-button{left:0}.mdl-layout__tab-bar-right-button{right:0}.mdl-layout__tab{margin:0;border:none;padding:0 24px;float:left;position:relative;display:block;-webkit-flex-grow:0;-ms-flex-positive:0;flex-grow:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;text-decoration:none;height:48px;line-height:48px;text-align:center;font-weight:500;font-size:14px;text-transform:uppercase;color:rgba(255,255,255,.6);overflow:hidden}@media screen and (max-width:1024px){.mdl-layout__tab{padding:0 12px}}.mdl-layout--fixed-tabs .mdl-layout__tab{float:none;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;padding:0}.mdl-layout.is-upgraded .mdl-layout__tab.is-active{color:#fff}.mdl-layout.is-upgraded .mdl-layout__tab.is-active::after{height:2px;width:100%;display:block;content:" ";bottom:0;left:0;position:absolute;background:#ff4081;-webkit-animation:border-expand .2s cubic-bezier(.4,0,.4,1).01s alternate forwards;animation:border-expand .2s cubic-bezier(.4,0,.4,1).01s alternate forwards;transition:all 1s cubic-bezier(.4,0,1,1)}.mdl-layout__tab .mdl-layout__tab-ripple-container{display:block;position:absolute;height:100%;width:100%;left:0;top:0;z-index:1;overflow:hidden}.mdl-layout__tab .mdl-layout__tab-ripple-container .mdl-ripple{background-color:#fff}.mdl-layout__tab-panel{display:block}.mdl-layout.is-upgraded .mdl-layout__tab-panel{display:none}.mdl-layout.is-upgraded .mdl-layout__tab-panel.is-active{display:block}.mdl-radio{position:relative;font-size:16px;line-height:24px;display:inline-block;box-sizing:border-box;margin:0;padding-left:0}.mdl-radio.is-upgraded{padding-left:24px}.mdl-radio__button{line-height:24px}.mdl-radio.is-upgraded .mdl-radio__button{position:absolute;width:0;height:0;margin:0;padding:0;opacity:0;-ms-appearance:none;-moz-appearance:none;-webkit-appearance:none;appearance:none;border:none}.mdl-radio__outer-circle{position:absolute;top:4px;left:0;display:inline-block;box-sizing:border-box;width:16px;height:16px;margin:0;cursor:pointer;border:2px solid rgba(0,0,0,.54);border-radius:50%;z-index:2}.mdl-radio.is-checked .mdl-radio__outer-circle{border:2px solid #3f51b5}.mdl-radio__outer-circle fieldset[disabled] .mdl-radio,.mdl-radio.is-disabled .mdl-radio__outer-circle{border:2px solid rgba(0,0,0,.26);cursor:auto}.mdl-radio__inner-circle{position:absolute;z-index:1;margin:0;top:8px;left:4px;box-sizing:border-box;width:8px;height:8px;cursor:pointer;transition-duration:.28s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:transform;transition-property:transform,-webkit-transform;-webkit-transform:scale3d(0,0,0);transform:scale3d(0,0,0);border-radius:50%;background:#3f51b5}.mdl-radio.is-checked .mdl-radio__inner-circle{-webkit-transform:scale3d(1,1,1);transform:scale3d(1,1,1)}fieldset[disabled] .mdl-radio .mdl-radio__inner-circle,.mdl-radio.is-disabled .mdl-radio__inner-circle{background:rgba(0,0,0,.26);cursor:auto}.mdl-radio.is-focused .mdl-radio__inner-circle{box-shadow:0 0 0 10px rgba(0,0,0,.1)}.mdl-radio__label{cursor:pointer}fieldset[disabled] .mdl-radio .mdl-radio__label,.mdl-radio.is-disabled .mdl-radio__label{color:rgba(0,0,0,.26);cursor:auto}.mdl-radio__ripple-container{position:absolute;z-index:2;top:-9px;left:-13px;box-sizing:border-box;width:42px;height:42px;border-radius:50%;cursor:pointer;overflow:hidden;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000)}.mdl-radio__ripple-container .mdl-ripple{background:#3f51b5}fieldset[disabled] .mdl-radio .mdl-radio__ripple-container,.mdl-radio.is-disabled .mdl-radio__ripple-container{cursor:auto}fieldset[disabled] .mdl-radio .mdl-radio__ripple-container .mdl-ripple,.mdl-radio.is-disabled .mdl-radio__ripple-container .mdl-ripple{background:0 0}_:-ms-input-placeholder,:root .mdl-slider.mdl-slider.is-upgraded{-ms-appearance:none;height:32px;margin:0}.mdl-slider{width:calc(100% - 40px);margin:0 20px}.mdl-slider.is-upgraded{-webkit-appearance:none;-moz-appearance:none;appearance:none;height:2px;background:0 0;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;outline:0;padding:0;color:#3f51b5;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center;z-index:1;cursor:pointer}.mdl-slider.is-upgraded::-moz-focus-outer{border:0}.mdl-slider.is-upgraded::-ms-tooltip{display:none}.mdl-slider.is-upgraded::-webkit-slider-runnable-track{background:0 0}.mdl-slider.is-upgraded::-moz-range-track{background:0 0;border:none}.mdl-slider.is-upgraded::-ms-track{background:0 0;color:transparent;height:2px;width:100%;border:none}.mdl-slider.is-upgraded::-ms-fill-lower{padding:0;background:linear-gradient(to right,transparent,transparent 16px,#3f51b5 16px,#3f51b5 0)}.mdl-slider.is-upgraded::-ms-fill-upper{padding:0;background:linear-gradient(to left,transparent,transparent 16px,rgba(0,0,0,.26)16px,rgba(0,0,0,.26)0)}.mdl-slider.is-upgraded::-webkit-slider-thumb{-webkit-appearance:none;width:12px;height:12px;box-sizing:border-box;border-radius:50%;background:#3f51b5;border:none;transition:transform .18s cubic-bezier(.4,0,.2,1),border .18s cubic-bezier(.4,0,.2,1),box-shadow .18s cubic-bezier(.4,0,.2,1),background .28s cubic-bezier(.4,0,.2,1);transition:transform .18s cubic-bezier(.4,0,.2,1),border .18s cubic-bezier(.4,0,.2,1),box-shadow .18s cubic-bezier(.4,0,.2,1),background .28s cubic-bezier(.4,0,.2,1),-webkit-transform .18s cubic-bezier(.4,0,.2,1)}.mdl-slider.is-upgraded::-moz-range-thumb{-moz-appearance:none;width:12px;height:12px;box-sizing:border-box;border-radius:50%;background-image:none;background:#3f51b5;border:none}.mdl-slider.is-upgraded:focus:not(:active)::-webkit-slider-thumb{box-shadow:0 0 0 10px rgba(63,81,181,.26)}.mdl-slider.is-upgraded:focus:not(:active)::-moz-range-thumb{box-shadow:0 0 0 10px rgba(63,81,181,.26)}.mdl-slider.is-upgraded:active::-webkit-slider-thumb{background-image:none;background:#3f51b5;-webkit-transform:scale(1.5);transform:scale(1.5)}.mdl-slider.is-upgraded:active::-moz-range-thumb{background-image:none;background:#3f51b5;transform:scale(1.5)}.mdl-slider.is-upgraded::-ms-thumb{width:32px;height:32px;border:none;border-radius:50%;background:#3f51b5;transform:scale(.375);transition:transform .18s cubic-bezier(.4,0,.2,1),background .28s cubic-bezier(.4,0,.2,1);transition:transform .18s cubic-bezier(.4,0,.2,1),background .28s cubic-bezier(.4,0,.2,1),-webkit-transform .18s cubic-bezier(.4,0,.2,1)}.mdl-slider.is-upgraded:focus:not(:active)::-ms-thumb{background:radial-gradient(circle closest-side,#3f51b5 0%,#3f51b5 37.5%,rgba(63,81,181,.26)37.5%,rgba(63,81,181,.26)100%);transform:scale(1)}.mdl-slider.is-upgraded:active::-ms-thumb{background:#3f51b5;transform:scale(.5625)}.mdl-slider.is-upgraded.is-lowest-value::-webkit-slider-thumb{border:2px solid rgba(0,0,0,.26);background:0 0}.mdl-slider.is-upgraded.is-lowest-value::-moz-range-thumb{border:2px solid rgba(0,0,0,.26);background:0 0}.mdl-slider.is-upgraded.is-lowest-value+.mdl-slider__background-flex>.mdl-slider__background-upper{left:6px}.mdl-slider.is-upgraded.is-lowest-value:focus:not(:active)::-webkit-slider-thumb{box-shadow:0 0 0 10px rgba(0,0,0,.12);background:rgba(0,0,0,.12)}.mdl-slider.is-upgraded.is-lowest-value:focus:not(:active)::-moz-range-thumb{box-shadow:0 0 0 10px rgba(0,0,0,.12);background:rgba(0,0,0,.12)}.mdl-slider.is-upgraded.is-lowest-value:active::-webkit-slider-thumb{border:1.6px solid rgba(0,0,0,.26);-webkit-transform:scale(1.5);transform:scale(1.5)}.mdl-slider.is-upgraded.is-lowest-value:active+.mdl-slider__background-flex>.mdl-slider__background-upper{left:9px}.mdl-slider.is-upgraded.is-lowest-value:active::-moz-range-thumb{border:1.5px solid rgba(0,0,0,.26);transform:scale(1.5)}.mdl-slider.is-upgraded.is-lowest-value::-ms-thumb{background:radial-gradient(circle closest-side,transparent 0%,transparent 66.67%,rgba(0,0,0,.26)66.67%,rgba(0,0,0,.26)100%)}.mdl-slider.is-upgraded.is-lowest-value:focus:not(:active)::-ms-thumb{background:radial-gradient(circle closest-side,rgba(0,0,0,.12)0%,rgba(0,0,0,.12)25%,rgba(0,0,0,.26)25%,rgba(0,0,0,.26)37.5%,rgba(0,0,0,.12)37.5%,rgba(0,0,0,.12)100%);transform:scale(1)}.mdl-slider.is-upgraded.is-lowest-value:active::-ms-thumb{transform:scale(.5625);background:radial-gradient(circle closest-side,transparent 0%,transparent 77.78%,rgba(0,0,0,.26)77.78%,rgba(0,0,0,.26)100%)}.mdl-slider.is-upgraded.is-lowest-value::-ms-fill-lower{background:0 0}.mdl-slider.is-upgraded.is-lowest-value::-ms-fill-upper{margin-left:6px}.mdl-slider.is-upgraded.is-lowest-value:active::-ms-fill-upper{margin-left:9px}.mdl-slider.is-upgraded:disabled:focus::-webkit-slider-thumb,.mdl-slider.is-upgraded:disabled:active::-webkit-slider-thumb,.mdl-slider.is-upgraded:disabled::-webkit-slider-thumb{-webkit-transform:scale(.667);transform:scale(.667);background:rgba(0,0,0,.26)}.mdl-slider.is-upgraded:disabled:focus::-moz-range-thumb,.mdl-slider.is-upgraded:disabled:active::-moz-range-thumb,.mdl-slider.is-upgraded:disabled::-moz-range-thumb{transform:scale(.667);background:rgba(0,0,0,.26)}.mdl-slider.is-upgraded:disabled+.mdl-slider__background-flex>.mdl-slider__background-lower{background-color:rgba(0,0,0,.26);left:-6px}.mdl-slider.is-upgraded:disabled+.mdl-slider__background-flex>.mdl-slider__background-upper{left:6px}.mdl-slider.is-upgraded.is-lowest-value:disabled:focus::-webkit-slider-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled:active::-webkit-slider-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled::-webkit-slider-thumb{border:3px solid rgba(0,0,0,.26);background:0 0;-webkit-transform:scale(.667);transform:scale(.667)}.mdl-slider.is-upgraded.is-lowest-value:disabled:focus::-moz-range-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled:active::-moz-range-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled::-moz-range-thumb{border:3px solid rgba(0,0,0,.26);background:0 0;transform:scale(.667)}.mdl-slider.is-upgraded.is-lowest-value:disabled:active+.mdl-slider__background-flex>.mdl-slider__background-upper{left:6px}.mdl-slider.is-upgraded:disabled:focus::-ms-thumb,.mdl-slider.is-upgraded:disabled:active::-ms-thumb,.mdl-slider.is-upgraded:disabled::-ms-thumb{transform:scale(.25);background:rgba(0,0,0,.26)}.mdl-slider.is-upgraded.is-lowest-value:disabled:focus::-ms-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled:active::-ms-thumb,.mdl-slider.is-upgraded.is-lowest-value:disabled::-ms-thumb{transform:scale(.25);background:radial-gradient(circle closest-side,transparent 0%,transparent 50%,rgba(0,0,0,.26)50%,rgba(0,0,0,.26)100%)}.mdl-slider.is-upgraded:disabled::-ms-fill-lower{margin-right:6px;background:linear-gradient(to right,transparent,transparent 25px,rgba(0,0,0,.26)25px,rgba(0,0,0,.26)0)}.mdl-slider.is-upgraded:disabled::-ms-fill-upper{margin-left:6px}.mdl-slider.is-upgraded.is-lowest-value:disabled:active::-ms-fill-upper{margin-left:6px}.mdl-slider__ie-container{height:18px;overflow:visible;border:none;margin:none;padding:none}.mdl-slider__container{height:18px;position:relative;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row}.mdl-slider__container,.mdl-slider__background-flex{background:0 0;display:-webkit-flex;display:-ms-flexbox;display:flex}.mdl-slider__background-flex{position:absolute;height:2px;width:calc(100% - 52px);top:50%;left:0;margin:0 26px;overflow:hidden;border:0;padding:0;-webkit-transform:translate(0,-1px);transform:translate(0,-1px)}.mdl-slider__background-lower{background:#3f51b5}.mdl-slider__background-lower,.mdl-slider__background-upper{-webkit-flex:0;-ms-flex:0;flex:0;position:relative;border:0;padding:0}.mdl-slider__background-upper{background:rgba(0,0,0,.26);transition:left .18s cubic-bezier(.4,0,.2,1)}.mdl-snackbar{position:fixed;bottom:0;left:50%;cursor:default;background-color:#323232;z-index:3;display:block;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;font-family:"Roboto","Helvetica","Arial",sans-serif;will-change:transform;-webkit-transform:translate(0,80px);transform:translate(0,80px);transition:transform .25s cubic-bezier(.4,0,1,1);transition:transform .25s cubic-bezier(.4,0,1,1),-webkit-transform .25s cubic-bezier(.4,0,1,1);pointer-events:none}@media (max-width:479px){.mdl-snackbar{width:100%;left:0;min-height:48px;max-height:80px}}@media (min-width:480px){.mdl-snackbar{min-width:288px;max-width:568px;border-radius:2px;-webkit-transform:translate(-50%,80px);transform:translate(-50%,80px)}}.mdl-snackbar--active{-webkit-transform:translate(0,0);transform:translate(0,0);pointer-events:auto;transition:transform .25s cubic-bezier(0,0,.2,1);transition:transform .25s cubic-bezier(0,0,.2,1),-webkit-transform .25s cubic-bezier(0,0,.2,1)}@media (min-width:480px){.mdl-snackbar--active{-webkit-transform:translate(-50%,0);transform:translate(-50%,0)}}.mdl-snackbar__text{padding:14px 12px 14px 24px;vertical-align:middle;color:#fff;float:left}.mdl-snackbar__action{background:0 0;border:none;color:#ff4081;float:right;padding:14px 24px 14px 12px;font-family:"Roboto","Helvetica","Arial",sans-serif;font-size:14px;font-weight:500;text-transform:uppercase;line-height:1;letter-spacing:0;overflow:hidden;outline:none;opacity:0;pointer-events:none;cursor:pointer;text-decoration:none;text-align:center;-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.mdl-snackbar__action::-moz-focus-inner{border:0}.mdl-snackbar__action:not([aria-hidden]){opacity:1;pointer-events:auto}.mdl-spinner{display:inline-block;position:relative;width:28px;height:28px}.mdl-spinner:not(.is-upgraded).is-active:after{content:"Loading..."}.mdl-spinner.is-upgraded.is-active{-webkit-animation:mdl-spinner__container-rotate 1568.23529412ms linear infinite;animation:mdl-spinner__container-rotate 1568.23529412ms linear infinite}@-webkit-keyframes mdl-spinner__container-rotate{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes mdl-spinner__container-rotate{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.mdl-spinner__layer{position:absolute;width:100%;height:100%;opacity:0}.mdl-spinner__layer-1{border-color:#42a5f5}.mdl-spinner--single-color .mdl-spinner__layer-1{border-color:#3f51b5}.mdl-spinner.is-active .mdl-spinner__layer-1{-webkit-animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-1-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-1-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both}.mdl-spinner__layer-2{border-color:#f44336}.mdl-spinner--single-color .mdl-spinner__layer-2{border-color:#3f51b5}.mdl-spinner.is-active .mdl-spinner__layer-2{-webkit-animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-2-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-2-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both}.mdl-spinner__layer-3{border-color:#fdd835}.mdl-spinner--single-color .mdl-spinner__layer-3{border-color:#3f51b5}.mdl-spinner.is-active .mdl-spinner__layer-3{-webkit-animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-3-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-3-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both}.mdl-spinner__layer-4{border-color:#4caf50}.mdl-spinner--single-color .mdl-spinner__layer-4{border-color:#3f51b5}.mdl-spinner.is-active .mdl-spinner__layer-4{-webkit-animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-4-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__fill-unfill-rotate 5332ms cubic-bezier(.4,0,.2,1)infinite both,mdl-spinner__layer-4-fade-in-out 5332ms cubic-bezier(.4,0,.2,1)infinite both}@-webkit-keyframes mdl-spinner__fill-unfill-rotate{12.5%{-webkit-transform:rotate(135deg);transform:rotate(135deg)}25%{-webkit-transform:rotate(270deg);transform:rotate(270deg)}37.5%{-webkit-transform:rotate(405deg);transform:rotate(405deg)}50%{-webkit-transform:rotate(540deg);transform:rotate(540deg)}62.5%{-webkit-transform:rotate(675deg);transform:rotate(675deg)}75%{-webkit-transform:rotate(810deg);transform:rotate(810deg)}87.5%{-webkit-transform:rotate(945deg);transform:rotate(945deg)}to{-webkit-transform:rotate(1080deg);transform:rotate(1080deg)}}@keyframes mdl-spinner__fill-unfill-rotate{12.5%{-webkit-transform:rotate(135deg);transform:rotate(135deg)}25%{-webkit-transform:rotate(270deg);transform:rotate(270deg)}37.5%{-webkit-transform:rotate(405deg);transform:rotate(405deg)}50%{-webkit-transform:rotate(540deg);transform:rotate(540deg)}62.5%{-webkit-transform:rotate(675deg);transform:rotate(675deg)}75%{-webkit-transform:rotate(810deg);transform:rotate(810deg)}87.5%{-webkit-transform:rotate(945deg);transform:rotate(945deg)}to{-webkit-transform:rotate(1080deg);transform:rotate(1080deg)}}@-webkit-keyframes mdl-spinner__layer-1-fade-in-out{from,25%{opacity:.99}26%,89%{opacity:0}90%,100%{opacity:.99}}@keyframes mdl-spinner__layer-1-fade-in-out{from,25%{opacity:.99}26%,89%{opacity:0}90%,100%{opacity:.99}}@-webkit-keyframes mdl-spinner__layer-2-fade-in-out{from,15%{opacity:0}25%,50%{opacity:.99}51%{opacity:0}}@keyframes mdl-spinner__layer-2-fade-in-out{from,15%{opacity:0}25%,50%{opacity:.99}51%{opacity:0}}@-webkit-keyframes mdl-spinner__layer-3-fade-in-out{from,40%{opacity:0}50%,75%{opacity:.99}76%{opacity:0}}@keyframes mdl-spinner__layer-3-fade-in-out{from,40%{opacity:0}50%,75%{opacity:.99}76%{opacity:0}}@-webkit-keyframes mdl-spinner__layer-4-fade-in-out{from,65%{opacity:0}75%,90%{opacity:.99}100%{opacity:0}}@keyframes mdl-spinner__layer-4-fade-in-out{from,65%{opacity:0}75%,90%{opacity:.99}100%{opacity:0}}.mdl-spinner__gap-patch{position:absolute;box-sizing:border-box;top:0;left:45%;width:10%;height:100%;overflow:hidden;border-color:inherit}.mdl-spinner__gap-patch .mdl-spinner__circle{width:1000%;left:-450%}.mdl-spinner__circle-clipper{display:inline-block;position:relative;width:50%;height:100%;overflow:hidden;border-color:inherit}.mdl-spinner__circle-clipper .mdl-spinner__circle{width:200%}.mdl-spinner__circle{box-sizing:border-box;height:100%;border-width:3px;border-style:solid;border-color:inherit;border-bottom-color:transparent!important;border-radius:50%;-webkit-animation:none;animation:none;position:absolute;top:0;right:0;bottom:0;left:0}.mdl-spinner__left .mdl-spinner__circle{border-right-color:transparent!important;-webkit-transform:rotate(129deg);transform:rotate(129deg)}.mdl-spinner.is-active .mdl-spinner__left .mdl-spinner__circle{-webkit-animation:mdl-spinner__left-spin 1333ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__left-spin 1333ms cubic-bezier(.4,0,.2,1)infinite both}.mdl-spinner__right .mdl-spinner__circle{left:-100%;border-left-color:transparent!important;-webkit-transform:rotate(-129deg);transform:rotate(-129deg)}.mdl-spinner.is-active .mdl-spinner__right .mdl-spinner__circle{-webkit-animation:mdl-spinner__right-spin 1333ms cubic-bezier(.4,0,.2,1)infinite both;animation:mdl-spinner__right-spin 1333ms cubic-bezier(.4,0,.2,1)infinite both}@-webkit-keyframes mdl-spinner__left-spin{from{-webkit-transform:rotate(130deg);transform:rotate(130deg)}50%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(130deg);transform:rotate(130deg)}}@keyframes mdl-spinner__left-spin{from{-webkit-transform:rotate(130deg);transform:rotate(130deg)}50%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(130deg);transform:rotate(130deg)}}@-webkit-keyframes mdl-spinner__right-spin{from{-webkit-transform:rotate(-130deg);transform:rotate(-130deg)}50%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}to{-webkit-transform:rotate(-130deg);transform:rotate(-130deg)}}@keyframes mdl-spinner__right-spin{from{-webkit-transform:rotate(-130deg);transform:rotate(-130deg)}50%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}to{-webkit-transform:rotate(-130deg);transform:rotate(-130deg)}}.mdl-switch{position:relative;z-index:1;vertical-align:middle;display:inline-block;box-sizing:border-box;width:100%;height:24px;margin:0;padding:0;overflow:visible;-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mdl-switch.is-upgraded{padding-left:28px}.mdl-switch__input{line-height:24px}.mdl-switch.is-upgraded .mdl-switch__input{position:absolute;width:0;height:0;margin:0;padding:0;opacity:0;-ms-appearance:none;-moz-appearance:none;-webkit-appearance:none;appearance:none;border:none}.mdl-switch__track{background:rgba(0,0,0,.26);position:absolute;left:0;top:5px;height:14px;width:36px;border-radius:14px;cursor:pointer}.mdl-switch.is-checked .mdl-switch__track{background:rgba(63,81,181,.5)}.mdl-switch__track fieldset[disabled] .mdl-switch,.mdl-switch.is-disabled .mdl-switch__track{background:rgba(0,0,0,.12);cursor:auto}.mdl-switch__thumb{background:#fafafa;position:absolute;left:0;top:2px;height:20px;width:20px;border-radius:50%;cursor:pointer;box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);transition-duration:.28s;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-property:left}.mdl-switch.is-checked .mdl-switch__thumb{background:#3f51b5;left:16px;box-shadow:0 3px 4px 0 rgba(0,0,0,.14),0 3px 3px -2px rgba(0,0,0,.2),0 1px 8px 0 rgba(0,0,0,.12)}.mdl-switch__thumb fieldset[disabled] .mdl-switch,.mdl-switch.is-disabled .mdl-switch__thumb{background:#bdbdbd;cursor:auto}.mdl-switch__focus-helper{position:absolute;top:50%;left:50%;-webkit-transform:translate(-4px,-4px);transform:translate(-4px,-4px);display:inline-block;box-sizing:border-box;width:8px;height:8px;border-radius:50%;background-color:transparent}.mdl-switch.is-focused .mdl-switch__focus-helper{box-shadow:0 0 0 20px rgba(0,0,0,.1);background-color:rgba(0,0,0,.1)}.mdl-switch.is-focused.is-checked .mdl-switch__focus-helper{box-shadow:0 0 0 20px rgba(63,81,181,.26);background-color:rgba(63,81,181,.26)}.mdl-switch__label{position:relative;cursor:pointer;font-size:16px;line-height:24px;margin:0;left:24px}.mdl-switch__label fieldset[disabled] .mdl-switch,.mdl-switch.is-disabled .mdl-switch__label{color:#bdbdbd;cursor:auto}.mdl-switch__ripple-container{position:absolute;z-index:2;top:-12px;left:-14px;box-sizing:border-box;width:48px;height:48px;border-radius:50%;cursor:pointer;overflow:hidden;-webkit-mask-image:-webkit-radial-gradient(circle,#fff,#000);transition-duration:.4s;transition-timing-function:step-end;transition-property:left}.mdl-switch__ripple-container .mdl-ripple{background:#3f51b5}.mdl-switch__ripple-container fieldset[disabled] .mdl-switch,.mdl-switch.is-disabled .mdl-switch__ripple-container{cursor:auto}fieldset[disabled] .mdl-switch .mdl-switch__ripple-container .mdl-ripple,.mdl-switch.is-disabled .mdl-switch__ripple-container .mdl-ripple{background:0 0}.mdl-switch.is-checked .mdl-switch__ripple-container{left:2px}.mdl-tabs{display:block;width:100%}.mdl-tabs__tab-bar{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;-webkit-align-content:space-between;-ms-flex-line-pack:justify;align-content:space-between;-webkit-align-items:flex-start;-ms-flex-align:start;align-items:flex-start;height:48px;padding:0;margin:0;border-bottom:1px solid #e0e0e0}.mdl-tabs__tab{margin:0;border:none;padding:0 24px;float:left;position:relative;display:block;text-decoration:none;height:48px;line-height:48px;text-align:center;font-weight:500;font-size:14px;text-transform:uppercase;color:rgba(0,0,0,.54);overflow:hidden}.mdl-tabs.is-upgraded .mdl-tabs__tab.is-active{color:rgba(0,0,0,.87)}.mdl-tabs.is-upgraded .mdl-tabs__tab.is-active:after{height:2px;width:100%;display:block;content:" ";bottom:0;left:0;position:absolute;background:#3f51b5;-webkit-animation:border-expand .2s cubic-bezier(.4,0,.4,1).01s alternate forwards;animation:border-expand .2s cubic-bezier(.4,0,.4,1).01s alternate forwards;transition:all 1s cubic-bezier(.4,0,1,1)}.mdl-tabs__tab .mdl-tabs__ripple-container{display:block;position:absolute;height:100%;width:100%;left:0;top:0;z-index:1;overflow:hidden}.mdl-tabs__tab .mdl-tabs__ripple-container .mdl-ripple{background:#3f51b5}.mdl-tabs__panel{display:block}.mdl-tabs.is-upgraded .mdl-tabs__panel{display:none}.mdl-tabs.is-upgraded .mdl-tabs__panel.is-active{display:block}@-webkit-keyframes border-expand{0%{opacity:0;width:0}100%{opacity:1;width:100%}}@keyframes border-expand{0%{opacity:0;width:0}100%{opacity:1;width:100%}}.mdl-textfield{position:relative;font-size:16px;display:inline-block;box-sizing:border-box;width:300px;max-width:100%;margin:0;padding:20px 0}.mdl-textfield .mdl-button{position:absolute;bottom:20px}.mdl-textfield--align-right{text-align:right}.mdl-textfield--full-width{width:100%}.mdl-textfield--expandable{min-width:32px;width:auto;min-height:32px}.mdl-textfield__input{border:none;border-bottom:1px solid rgba(0,0,0,.12);display:block;font-size:16px;font-family:"Helvetica","Arial",sans-serif;margin:0;padding:4px 0;width:100%;background:0 0;text-align:left;color:inherit}.mdl-textfield__input[type="number"]{-moz-appearance:textfield}.mdl-textfield__input[type="number"]::-webkit-inner-spin-button,.mdl-textfield__input[type="number"]::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}.mdl-textfield.is-focused .mdl-textfield__input{outline:none}.mdl-textfield.is-invalid .mdl-textfield__input{border-color:#d50000;box-shadow:none}fieldset[disabled] .mdl-textfield .mdl-textfield__input,.mdl-textfield.is-disabled .mdl-textfield__input{background-color:transparent;border-bottom:1px dotted rgba(0,0,0,.12);color:rgba(0,0,0,.26)}.mdl-textfield textarea.mdl-textfield__input{display:block}.mdl-textfield__label{bottom:0;color:rgba(0,0,0,.26);font-size:16px;left:0;right:0;pointer-events:none;position:absolute;display:block;top:24px;width:100%;overflow:hidden;white-space:nowrap;text-align:left}.mdl-textfield.is-dirty .mdl-textfield__label,.mdl-textfield.has-placeholder .mdl-textfield__label{visibility:hidden}.mdl-textfield--floating-label .mdl-textfield__label{transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.mdl-textfield--floating-label.has-placeholder .mdl-textfield__label{transition:none}fieldset[disabled] .mdl-textfield .mdl-textfield__label,.mdl-textfield.is-disabled.is-disabled .mdl-textfield__label{color:rgba(0,0,0,.26)}.mdl-textfield--floating-label.is-focused .mdl-textfield__label,.mdl-textfield--floating-label.is-dirty .mdl-textfield__label,.mdl-textfield--floating-label.has-placeholder .mdl-textfield__label{color:#3f51b5;font-size:12px;top:4px;visibility:visible}.mdl-textfield--floating-label.is-focused .mdl-textfield__expandable-holder .mdl-textfield__label,.mdl-textfield--floating-label.is-dirty .mdl-textfield__expandable-holder .mdl-textfield__label,.mdl-textfield--floating-label.has-placeholder .mdl-textfield__expandable-holder .mdl-textfield__label{top:-16px}.mdl-textfield--floating-label.is-invalid .mdl-textfield__label{color:#d50000;font-size:12px}.mdl-textfield__label:after{background-color:#3f51b5;bottom:20px;content:'';height:2px;left:45%;position:absolute;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);visibility:hidden;width:10px}.mdl-textfield.is-focused .mdl-textfield__label:after{left:0;visibility:visible;width:100%}.mdl-textfield.is-invalid .mdl-textfield__label:after{background-color:#d50000}.mdl-textfield__error{color:#d50000;position:absolute;font-size:12px;margin-top:3px;visibility:hidden;display:block}.mdl-textfield.is-invalid .mdl-textfield__error{visibility:visible}.mdl-textfield__expandable-holder{display:inline-block;position:relative;margin-left:32px;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1);display:inline-block;max-width:.1px}.mdl-textfield.is-focused .mdl-textfield__expandable-holder,.mdl-textfield.is-dirty .mdl-textfield__expandable-holder{max-width:600px}.mdl-textfield__expandable-holder .mdl-textfield__label:after{bottom:0}.mdl-tooltip{-webkit-transform:scale(0);transform:scale(0);-webkit-transform-origin:top center;transform-origin:top center;will-change:transform;z-index:999;background:rgba(97,97,97,.9);border-radius:2px;color:#fff;display:inline-block;font-size:10px;font-weight:500;line-height:14px;max-width:170px;position:fixed;top:-500px;left:-500px;padding:8px;text-align:center}.mdl-tooltip.is-active{-webkit-animation:pulse 200ms cubic-bezier(0,0,.2,1)forwards;animation:pulse 200ms cubic-bezier(0,0,.2,1)forwards}.mdl-tooltip--large{line-height:14px;font-size:14px;padding:16px}@-webkit-keyframes pulse{0%{-webkit-transform:scale(0);transform:scale(0);opacity:0}50%{-webkit-transform:scale(.99);transform:scale(.99)}100%{-webkit-transform:scale(1);transform:scale(1);opacity:1;visibility:visible}}@keyframes pulse{0%{-webkit-transform:scale(0);transform:scale(0);opacity:0}50%{-webkit-transform:scale(.99);transform:scale(.99)}100%{-webkit-transform:scale(1);transform:scale(1);opacity:1;visibility:visible}}.mdl-shadow--2dp{box-shadow:0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12)}.mdl-shadow--3dp{box-shadow:0 3px 4px 0 rgba(0,0,0,.14),0 3px 3px -2px rgba(0,0,0,.2),0 1px 8px 0 rgba(0,0,0,.12)}.mdl-shadow--4dp{box-shadow:0 4px 5px 0 rgba(0,0,0,.14),0 1px 10px 0 rgba(0,0,0,.12),0 2px 4px -1px rgba(0,0,0,.2)}.mdl-shadow--6dp{box-shadow:0 6px 10px 0 rgba(0,0,0,.14),0 1px 18px 0 rgba(0,0,0,.12),0 3px 5px -1px rgba(0,0,0,.2)}.mdl-shadow--8dp{box-shadow:0 8px 10px 1px rgba(0,0,0,.14),0 3px 14px 2px rgba(0,0,0,.12),0 5px 5px -3px rgba(0,0,0,.2)}.mdl-shadow--16dp{box-shadow:0 16px 24px 2px rgba(0,0,0,.14),0 6px 30px 5px rgba(0,0,0,.12),0 8px 10px -5px rgba(0,0,0,.2)}.mdl-shadow--24dp{box-shadow:0 9px 46px 8px rgba(0,0,0,.14),0 11px 15px -7px rgba(0,0,0,.12),0 24px 38px 3px rgba(0,0,0,.2)}.mdl-grid{display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-flex-flow:row wrap;-ms-flex-flow:row wrap;flex-flow:row wrap;margin:0 auto;-webkit-align-items:stretch;-ms-flex-align:stretch;align-items:stretch}.mdl-grid.mdl-grid--no-spacing{padding:0}.mdl-cell{box-sizing:border-box}.mdl-cell--top{-webkit-align-self:flex-start;-ms-flex-item-align:start;align-self:flex-start}.mdl-cell--middle{-webkit-align-self:center;-ms-flex-item-align:center;align-self:center}.mdl-cell--bottom{-webkit-align-self:flex-end;-ms-flex-item-align:end;align-self:flex-end}.mdl-cell--stretch{-webkit-align-self:stretch;-ms-flex-item-align:stretch;align-self:stretch}.mdl-grid.mdl-grid--no-spacing>.mdl-cell{margin:0}.mdl-cell--order-1{-webkit-order:1;-ms-flex-order:1;order:1}.mdl-cell--order-2{-webkit-order:2;-ms-flex-order:2;order:2}.mdl-cell--order-3{-webkit-order:3;-ms-flex-order:3;order:3}.mdl-cell--order-4{-webkit-order:4;-ms-flex-order:4;order:4}.mdl-cell--order-5{-webkit-order:5;-ms-flex-order:5;order:5}.mdl-cell--order-6{-webkit-order:6;-ms-flex-order:6;order:6}.mdl-cell--order-7{-webkit-order:7;-ms-flex-order:7;order:7}.mdl-cell--order-8{-webkit-order:8;-ms-flex-order:8;order:8}.mdl-cell--order-9{-webkit-order:9;-ms-flex-order:9;order:9}.mdl-cell--order-10{-webkit-order:10;-ms-flex-order:10;order:10}.mdl-cell--order-11{-webkit-order:11;-ms-flex-order:11;order:11}.mdl-cell--order-12{-webkit-order:12;-ms-flex-order:12;order:12}@media (max-width:479px){.mdl-grid{padding:8px}.mdl-cell{margin:8px;width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell{width:100%}.mdl-cell--hide-phone{display:none!important}.mdl-cell--order-1-phone.mdl-cell--order-1-phone{-webkit-order:1;-ms-flex-order:1;order:1}.mdl-cell--order-2-phone.mdl-cell--order-2-phone{-webkit-order:2;-ms-flex-order:2;order:2}.mdl-cell--order-3-phone.mdl-cell--order-3-phone{-webkit-order:3;-ms-flex-order:3;order:3}.mdl-cell--order-4-phone.mdl-cell--order-4-phone{-webkit-order:4;-ms-flex-order:4;order:4}.mdl-cell--order-5-phone.mdl-cell--order-5-phone{-webkit-order:5;-ms-flex-order:5;order:5}.mdl-cell--order-6-phone.mdl-cell--order-6-phone{-webkit-order:6;-ms-flex-order:6;order:6}.mdl-cell--order-7-phone.mdl-cell--order-7-phone{-webkit-order:7;-ms-flex-order:7;order:7}.mdl-cell--order-8-phone.mdl-cell--order-8-phone{-webkit-order:8;-ms-flex-order:8;order:8}.mdl-cell--order-9-phone.mdl-cell--order-9-phone{-webkit-order:9;-ms-flex-order:9;order:9}.mdl-cell--order-10-phone.mdl-cell--order-10-phone{-webkit-order:10;-ms-flex-order:10;order:10}.mdl-cell--order-11-phone.mdl-cell--order-11-phone{-webkit-order:11;-ms-flex-order:11;order:11}.mdl-cell--order-12-phone.mdl-cell--order-12-phone{-webkit-order:12;-ms-flex-order:12;order:12}.mdl-cell--1-col,.mdl-cell--1-col-phone.mdl-cell--1-col-phone{width:calc(25% - 16px)}.mdl-grid--no-spacing>.mdl-cell--1-col,.mdl-grid--no-spacing>.mdl-cell--1-col-phone.mdl-cell--1-col-phone{width:25%}.mdl-cell--2-col,.mdl-cell--2-col-phone.mdl-cell--2-col-phone{width:calc(50% - 16px)}.mdl-grid--no-spacing>.mdl-cell--2-col,.mdl-grid--no-spacing>.mdl-cell--2-col-phone.mdl-cell--2-col-phone{width:50%}.mdl-cell--3-col,.mdl-cell--3-col-phone.mdl-cell--3-col-phone{width:calc(75% - 16px)}.mdl-grid--no-spacing>.mdl-cell--3-col,.mdl-grid--no-spacing>.mdl-cell--3-col-phone.mdl-cell--3-col-phone{width:75%}.mdl-cell--4-col,.mdl-cell--4-col-phone.mdl-cell--4-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--4-col,.mdl-grid--no-spacing>.mdl-cell--4-col-phone.mdl-cell--4-col-phone{width:100%}.mdl-cell--5-col,.mdl-cell--5-col-phone.mdl-cell--5-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--5-col,.mdl-grid--no-spacing>.mdl-cell--5-col-phone.mdl-cell--5-col-phone{width:100%}.mdl-cell--6-col,.mdl-cell--6-col-phone.mdl-cell--6-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--6-col,.mdl-grid--no-spacing>.mdl-cell--6-col-phone.mdl-cell--6-col-phone{width:100%}.mdl-cell--7-col,.mdl-cell--7-col-phone.mdl-cell--7-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--7-col,.mdl-grid--no-spacing>.mdl-cell--7-col-phone.mdl-cell--7-col-phone{width:100%}.mdl-cell--8-col,.mdl-cell--8-col-phone.mdl-cell--8-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--8-col,.mdl-grid--no-spacing>.mdl-cell--8-col-phone.mdl-cell--8-col-phone{width:100%}.mdl-cell--9-col,.mdl-cell--9-col-phone.mdl-cell--9-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--9-col,.mdl-grid--no-spacing>.mdl-cell--9-col-phone.mdl-cell--9-col-phone{width:100%}.mdl-cell--10-col,.mdl-cell--10-col-phone.mdl-cell--10-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--10-col,.mdl-grid--no-spacing>.mdl-cell--10-col-phone.mdl-cell--10-col-phone{width:100%}.mdl-cell--11-col,.mdl-cell--11-col-phone.mdl-cell--11-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--11-col,.mdl-grid--no-spacing>.mdl-cell--11-col-phone.mdl-cell--11-col-phone{width:100%}.mdl-cell--12-col,.mdl-cell--12-col-phone.mdl-cell--12-col-phone{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--12-col,.mdl-grid--no-spacing>.mdl-cell--12-col-phone.mdl-cell--12-col-phone{width:100%}.mdl-cell--1-offset,.mdl-cell--1-offset-phone.mdl-cell--1-offset-phone{margin-left:calc(25% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset-phone.mdl-cell--1-offset-phone{margin-left:25%}.mdl-cell--2-offset,.mdl-cell--2-offset-phone.mdl-cell--2-offset-phone{margin-left:calc(50% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset-phone.mdl-cell--2-offset-phone{margin-left:50%}.mdl-cell--3-offset,.mdl-cell--3-offset-phone.mdl-cell--3-offset-phone{margin-left:calc(75% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset-phone.mdl-cell--3-offset-phone{margin-left:75%}}@media (min-width:480px) and (max-width:839px){.mdl-grid{padding:8px}.mdl-cell{margin:8px;width:calc(50% - 16px)}.mdl-grid--no-spacing>.mdl-cell{width:50%}.mdl-cell--hide-tablet{display:none!important}.mdl-cell--order-1-tablet.mdl-cell--order-1-tablet{-webkit-order:1;-ms-flex-order:1;order:1}.mdl-cell--order-2-tablet.mdl-cell--order-2-tablet{-webkit-order:2;-ms-flex-order:2;order:2}.mdl-cell--order-3-tablet.mdl-cell--order-3-tablet{-webkit-order:3;-ms-flex-order:3;order:3}.mdl-cell--order-4-tablet.mdl-cell--order-4-tablet{-webkit-order:4;-ms-flex-order:4;order:4}.mdl-cell--order-5-tablet.mdl-cell--order-5-tablet{-webkit-order:5;-ms-flex-order:5;order:5}.mdl-cell--order-6-tablet.mdl-cell--order-6-tablet{-webkit-order:6;-ms-flex-order:6;order:6}.mdl-cell--order-7-tablet.mdl-cell--order-7-tablet{-webkit-order:7;-ms-flex-order:7;order:7}.mdl-cell--order-8-tablet.mdl-cell--order-8-tablet{-webkit-order:8;-ms-flex-order:8;order:8}.mdl-cell--order-9-tablet.mdl-cell--order-9-tablet{-webkit-order:9;-ms-flex-order:9;order:9}.mdl-cell--order-10-tablet.mdl-cell--order-10-tablet{-webkit-order:10;-ms-flex-order:10;order:10}.mdl-cell--order-11-tablet.mdl-cell--order-11-tablet{-webkit-order:11;-ms-flex-order:11;order:11}.mdl-cell--order-12-tablet.mdl-cell--order-12-tablet{-webkit-order:12;-ms-flex-order:12;order:12}.mdl-cell--1-col,.mdl-cell--1-col-tablet.mdl-cell--1-col-tablet{width:calc(12.5% - 16px)}.mdl-grid--no-spacing>.mdl-cell--1-col,.mdl-grid--no-spacing>.mdl-cell--1-col-tablet.mdl-cell--1-col-tablet{width:12.5%}.mdl-cell--2-col,.mdl-cell--2-col-tablet.mdl-cell--2-col-tablet{width:calc(25% - 16px)}.mdl-grid--no-spacing>.mdl-cell--2-col,.mdl-grid--no-spacing>.mdl-cell--2-col-tablet.mdl-cell--2-col-tablet{width:25%}.mdl-cell--3-col,.mdl-cell--3-col-tablet.mdl-cell--3-col-tablet{width:calc(37.5% - 16px)}.mdl-grid--no-spacing>.mdl-cell--3-col,.mdl-grid--no-spacing>.mdl-cell--3-col-tablet.mdl-cell--3-col-tablet{width:37.5%}.mdl-cell--4-col,.mdl-cell--4-col-tablet.mdl-cell--4-col-tablet{width:calc(50% - 16px)}.mdl-grid--no-spacing>.mdl-cell--4-col,.mdl-grid--no-spacing>.mdl-cell--4-col-tablet.mdl-cell--4-col-tablet{width:50%}.mdl-cell--5-col,.mdl-cell--5-col-tablet.mdl-cell--5-col-tablet{width:calc(62.5% - 16px)}.mdl-grid--no-spacing>.mdl-cell--5-col,.mdl-grid--no-spacing>.mdl-cell--5-col-tablet.mdl-cell--5-col-tablet{width:62.5%}.mdl-cell--6-col,.mdl-cell--6-col-tablet.mdl-cell--6-col-tablet{width:calc(75% - 16px)}.mdl-grid--no-spacing>.mdl-cell--6-col,.mdl-grid--no-spacing>.mdl-cell--6-col-tablet.mdl-cell--6-col-tablet{width:75%}.mdl-cell--7-col,.mdl-cell--7-col-tablet.mdl-cell--7-col-tablet{width:calc(87.5% - 16px)}.mdl-grid--no-spacing>.mdl-cell--7-col,.mdl-grid--no-spacing>.mdl-cell--7-col-tablet.mdl-cell--7-col-tablet{width:87.5%}.mdl-cell--8-col,.mdl-cell--8-col-tablet.mdl-cell--8-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--8-col,.mdl-grid--no-spacing>.mdl-cell--8-col-tablet.mdl-cell--8-col-tablet{width:100%}.mdl-cell--9-col,.mdl-cell--9-col-tablet.mdl-cell--9-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--9-col,.mdl-grid--no-spacing>.mdl-cell--9-col-tablet.mdl-cell--9-col-tablet{width:100%}.mdl-cell--10-col,.mdl-cell--10-col-tablet.mdl-cell--10-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--10-col,.mdl-grid--no-spacing>.mdl-cell--10-col-tablet.mdl-cell--10-col-tablet{width:100%}.mdl-cell--11-col,.mdl-cell--11-col-tablet.mdl-cell--11-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--11-col,.mdl-grid--no-spacing>.mdl-cell--11-col-tablet.mdl-cell--11-col-tablet{width:100%}.mdl-cell--12-col,.mdl-cell--12-col-tablet.mdl-cell--12-col-tablet{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--12-col,.mdl-grid--no-spacing>.mdl-cell--12-col-tablet.mdl-cell--12-col-tablet{width:100%}.mdl-cell--1-offset,.mdl-cell--1-offset-tablet.mdl-cell--1-offset-tablet{margin-left:calc(12.5% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset-tablet.mdl-cell--1-offset-tablet{margin-left:12.5%}.mdl-cell--2-offset,.mdl-cell--2-offset-tablet.mdl-cell--2-offset-tablet{margin-left:calc(25% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset-tablet.mdl-cell--2-offset-tablet{margin-left:25%}.mdl-cell--3-offset,.mdl-cell--3-offset-tablet.mdl-cell--3-offset-tablet{margin-left:calc(37.5% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset-tablet.mdl-cell--3-offset-tablet{margin-left:37.5%}.mdl-cell--4-offset,.mdl-cell--4-offset-tablet.mdl-cell--4-offset-tablet{margin-left:calc(50% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--4-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--4-offset-tablet.mdl-cell--4-offset-tablet{margin-left:50%}.mdl-cell--5-offset,.mdl-cell--5-offset-tablet.mdl-cell--5-offset-tablet{margin-left:calc(62.5% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--5-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--5-offset-tablet.mdl-cell--5-offset-tablet{margin-left:62.5%}.mdl-cell--6-offset,.mdl-cell--6-offset-tablet.mdl-cell--6-offset-tablet{margin-left:calc(75% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--6-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--6-offset-tablet.mdl-cell--6-offset-tablet{margin-left:75%}.mdl-cell--7-offset,.mdl-cell--7-offset-tablet.mdl-cell--7-offset-tablet{margin-left:calc(87.5% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--7-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--7-offset-tablet.mdl-cell--7-offset-tablet{margin-left:87.5%}}@media (min-width:840px){.mdl-grid{padding:8px}.mdl-cell{margin:8px;width:calc(33.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell{width:33.3333333333%}.mdl-cell--hide-desktop{display:none!important}.mdl-cell--order-1-desktop.mdl-cell--order-1-desktop{-webkit-order:1;-ms-flex-order:1;order:1}.mdl-cell--order-2-desktop.mdl-cell--order-2-desktop{-webkit-order:2;-ms-flex-order:2;order:2}.mdl-cell--order-3-desktop.mdl-cell--order-3-desktop{-webkit-order:3;-ms-flex-order:3;order:3}.mdl-cell--order-4-desktop.mdl-cell--order-4-desktop{-webkit-order:4;-ms-flex-order:4;order:4}.mdl-cell--order-5-desktop.mdl-cell--order-5-desktop{-webkit-order:5;-ms-flex-order:5;order:5}.mdl-cell--order-6-desktop.mdl-cell--order-6-desktop{-webkit-order:6;-ms-flex-order:6;order:6}.mdl-cell--order-7-desktop.mdl-cell--order-7-desktop{-webkit-order:7;-ms-flex-order:7;order:7}.mdl-cell--order-8-desktop.mdl-cell--order-8-desktop{-webkit-order:8;-ms-flex-order:8;order:8}.mdl-cell--order-9-desktop.mdl-cell--order-9-desktop{-webkit-order:9;-ms-flex-order:9;order:9}.mdl-cell--order-10-desktop.mdl-cell--order-10-desktop{-webkit-order:10;-ms-flex-order:10;order:10}.mdl-cell--order-11-desktop.mdl-cell--order-11-desktop{-webkit-order:11;-ms-flex-order:11;order:11}.mdl-cell--order-12-desktop.mdl-cell--order-12-desktop{-webkit-order:12;-ms-flex-order:12;order:12}.mdl-cell--1-col,.mdl-cell--1-col-desktop.mdl-cell--1-col-desktop{width:calc(8.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell--1-col,.mdl-grid--no-spacing>.mdl-cell--1-col-desktop.mdl-cell--1-col-desktop{width:8.3333333333%}.mdl-cell--2-col,.mdl-cell--2-col-desktop.mdl-cell--2-col-desktop{width:calc(16.6666666667% - 16px)}.mdl-grid--no-spacing>.mdl-cell--2-col,.mdl-grid--no-spacing>.mdl-cell--2-col-desktop.mdl-cell--2-col-desktop{width:16.6666666667%}.mdl-cell--3-col,.mdl-cell--3-col-desktop.mdl-cell--3-col-desktop{width:calc(25% - 16px)}.mdl-grid--no-spacing>.mdl-cell--3-col,.mdl-grid--no-spacing>.mdl-cell--3-col-desktop.mdl-cell--3-col-desktop{width:25%}.mdl-cell--4-col,.mdl-cell--4-col-desktop.mdl-cell--4-col-desktop{width:calc(33.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell--4-col,.mdl-grid--no-spacing>.mdl-cell--4-col-desktop.mdl-cell--4-col-desktop{width:33.3333333333%}.mdl-cell--5-col,.mdl-cell--5-col-desktop.mdl-cell--5-col-desktop{width:calc(41.6666666667% - 16px)}.mdl-grid--no-spacing>.mdl-cell--5-col,.mdl-grid--no-spacing>.mdl-cell--5-col-desktop.mdl-cell--5-col-desktop{width:41.6666666667%}.mdl-cell--6-col,.mdl-cell--6-col-desktop.mdl-cell--6-col-desktop{width:calc(50% - 16px)}.mdl-grid--no-spacing>.mdl-cell--6-col,.mdl-grid--no-spacing>.mdl-cell--6-col-desktop.mdl-cell--6-col-desktop{width:50%}.mdl-cell--7-col,.mdl-cell--7-col-desktop.mdl-cell--7-col-desktop{width:calc(58.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell--7-col,.mdl-grid--no-spacing>.mdl-cell--7-col-desktop.mdl-cell--7-col-desktop{width:58.3333333333%}.mdl-cell--8-col,.mdl-cell--8-col-desktop.mdl-cell--8-col-desktop{width:calc(66.6666666667% - 16px)}.mdl-grid--no-spacing>.mdl-cell--8-col,.mdl-grid--no-spacing>.mdl-cell--8-col-desktop.mdl-cell--8-col-desktop{width:66.6666666667%}.mdl-cell--9-col,.mdl-cell--9-col-desktop.mdl-cell--9-col-desktop{width:calc(75% - 16px)}.mdl-grid--no-spacing>.mdl-cell--9-col,.mdl-grid--no-spacing>.mdl-cell--9-col-desktop.mdl-cell--9-col-desktop{width:75%}.mdl-cell--10-col,.mdl-cell--10-col-desktop.mdl-cell--10-col-desktop{width:calc(83.3333333333% - 16px)}.mdl-grid--no-spacing>.mdl-cell--10-col,.mdl-grid--no-spacing>.mdl-cell--10-col-desktop.mdl-cell--10-col-desktop{width:83.3333333333%}.mdl-cell--11-col,.mdl-cell--11-col-desktop.mdl-cell--11-col-desktop{width:calc(91.6666666667% - 16px)}.mdl-grid--no-spacing>.mdl-cell--11-col,.mdl-grid--no-spacing>.mdl-cell--11-col-desktop.mdl-cell--11-col-desktop{width:91.6666666667%}.mdl-cell--12-col,.mdl-cell--12-col-desktop.mdl-cell--12-col-desktop{width:calc(100% - 16px)}.mdl-grid--no-spacing>.mdl-cell--12-col,.mdl-grid--no-spacing>.mdl-cell--12-col-desktop.mdl-cell--12-col-desktop{width:100%}.mdl-cell--1-offset,.mdl-cell--1-offset-desktop.mdl-cell--1-offset-desktop{margin-left:calc(8.3333333333% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--1-offset-desktop.mdl-cell--1-offset-desktop{margin-left:8.3333333333%}.mdl-cell--2-offset,.mdl-cell--2-offset-desktop.mdl-cell--2-offset-desktop{margin-left:calc(16.6666666667% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--2-offset-desktop.mdl-cell--2-offset-desktop{margin-left:16.6666666667%}.mdl-cell--3-offset,.mdl-cell--3-offset-desktop.mdl-cell--3-offset-desktop{margin-left:calc(25% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--3-offset-desktop.mdl-cell--3-offset-desktop{margin-left:25%}.mdl-cell--4-offset,.mdl-cell--4-offset-desktop.mdl-cell--4-offset-desktop{margin-left:calc(33.3333333333% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--4-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--4-offset-desktop.mdl-cell--4-offset-desktop{margin-left:33.3333333333%}.mdl-cell--5-offset,.mdl-cell--5-offset-desktop.mdl-cell--5-offset-desktop{margin-left:calc(41.6666666667% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--5-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--5-offset-desktop.mdl-cell--5-offset-desktop{margin-left:41.6666666667%}.mdl-cell--6-offset,.mdl-cell--6-offset-desktop.mdl-cell--6-offset-desktop{margin-left:calc(50% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--6-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--6-offset-desktop.mdl-cell--6-offset-desktop{margin-left:50%}.mdl-cell--7-offset,.mdl-cell--7-offset-desktop.mdl-cell--7-offset-desktop{margin-left:calc(58.3333333333% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--7-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--7-offset-desktop.mdl-cell--7-offset-desktop{margin-left:58.3333333333%}.mdl-cell--8-offset,.mdl-cell--8-offset-desktop.mdl-cell--8-offset-desktop{margin-left:calc(66.6666666667% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--8-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--8-offset-desktop.mdl-cell--8-offset-desktop{margin-left:66.6666666667%}.mdl-cell--9-offset,.mdl-cell--9-offset-desktop.mdl-cell--9-offset-desktop{margin-left:calc(75% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--9-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--9-offset-desktop.mdl-cell--9-offset-desktop{margin-left:75%}.mdl-cell--10-offset,.mdl-cell--10-offset-desktop.mdl-cell--10-offset-desktop{margin-left:calc(83.3333333333% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--10-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--10-offset-desktop.mdl-cell--10-offset-desktop{margin-left:83.3333333333%}.mdl-cell--11-offset,.mdl-cell--11-offset-desktop.mdl-cell--11-offset-desktop{margin-left:calc(91.6666666667% + 8px)}.mdl-grid.mdl-grid--no-spacing>.mdl-cell--11-offset,.mdl-grid.mdl-grid--no-spacing>.mdl-cell--11-offset-desktop.mdl-cell--11-offset-desktop{margin-left:91.6666666667%}} -/*# sourceMappingURL=material.min.css.map */ diff --git a/etc/cli.angular.io/theme.css b/etc/cli.angular.io/theme.css deleted file mode 100644 index b6a336e98b0c..000000000000 --- a/etc/cli.angular.io/theme.css +++ /dev/null @@ -1 +0,0 @@ -.console{width:360px;max-width:92vw;margin-left:15px;margin-right:40px;text-align:left;border-radius:5px;margin-bottom:10px}@media (max-width:830px){.console{margin-right:auto;margin-left:auto}}.console__head{overflow:hidden;background-color:#d5d5d5;padding:8px 15px;border-top-left-radius:5px;border-top-right-radius:5px}.console__dot{float:left;width:12px;height:12px;border-radius:50%;margin-right:7px;box-shadow:0 1px 1px 0 rgba(0,0,0,.2)}.console__dot--red{background-color:#ff6057}.console__dot--yellow{background-color:#ffc22e}.console__dot--green{background-color:#28ca40}.console__body{background-color:#1e1e1e;padding:30px 17px 20px;border-bottom-left-radius:5px;border-bottom-right-radius:5px}.console__prompt{display:block;margin-bottom:15px;font-family:"Source Code Pro",monospace;font-size:15px}.console__prompt::before{content:">";padding-right:15px;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.mdl-base{height:100vh} diff --git a/etc/rules/README.md b/etc/rules/README.md deleted file mode 100644 index 616923160a2b..000000000000 --- a/etc/rules/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# TsLint Rules - -This folder contains custom TsLint rules specific to this repository. diff --git a/etc/rules/Rule.ts b/etc/rules/Rule.ts deleted file mode 100644 index 743f98a584ec..000000000000 --- a/etc/rules/Rule.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -// tslint:disable-next-line: no-global-tslint-disable -// tslint:disable: no-implicit-dependencies -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - - -// An empty rule so that tslint does not error on rules '//' (which are comments). -export class Rule extends Lint.Rules.AbstractRule { - public static metadata: Lint.IRuleMetadata = { - ruleName: '//', - type: 'typescript', - description: ``, - rationale: '', - options: null, - optionsDescription: `Not configurable.`, - typescriptOnly: false, - }; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return []; - } -} diff --git a/etc/rules/defocusRule.ts b/etc/rules/defocusRule.ts deleted file mode 100644 index 1b7871cc0dbd..000000000000 --- a/etc/rules/defocusRule.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -/* - * Taken from https://github.com/Sergiioo/tslint-defocus - * Copyright (c) 2016 Sergio Annecchiarico - * MIT - https://github.com/Sergiioo/tslint-defocus/blob/master/LICENSE - */ - -// tslint:disable-next-line: no-global-tslint-disable -// tslint:disable: no-implicit-dependencies -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - -export class Rule extends Lint.Rules.AbstractRule { - - public static metadata: Lint.IRuleMetadata = { - ruleName: 'defocus', - description: "Bans the use of `fdescribe` and 'fit' Jasmine functions.", - rationale: 'It is all too easy to mistakenly commit a focussed Jasmine test suite or spec.', - options: null, - optionsDescription: 'Not configurable.', - type: 'functionality', - typescriptOnly: false, - }; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return this.applyWithFunction(sourceFile, walk); - } -} - -function walk(ctx: Lint.WalkContext) { - return ts.forEachChild(ctx.sourceFile, function cb(node: ts.Node): void { - if (node.kind === ts.SyntaxKind.CallExpression) { - const expression = (node as ts.CallExpression).expression; - const functionName = expression.getText(); - bannedFunctions.forEach((banned) => { - if (banned === functionName) { - ctx.addFailureAtNode(expression, failureMessage(functionName)); - } - }); - } - - return ts.forEachChild(node, cb); - }); -} - -const bannedFunctions: ReadonlyArray = ['fdescribe', 'fit']; - -const failureMessage = (functionName: string) => { - return `Calls to '${functionName}' are not allowed.`; -}; diff --git a/etc/rules/importGroupsRule.ts b/etc/rules/importGroupsRule.ts deleted file mode 100644 index 684fafbf4706..000000000000 --- a/etc/rules/importGroupsRule.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -// tslint:disable-next-line: no-global-tslint-disable -// tslint:disable: no-implicit-dependencies -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - - -export class Rule extends Lint.Rules.AbstractRule { - public static metadata: Lint.IRuleMetadata = { - ruleName: 'import-groups', - type: 'style', - description: `Ensure imports are grouped.`, - rationale: `Imports can be grouped or not depending on a project. A group is a sequence of - import statements separated by blank lines.`, - options: null, - optionsDescription: `Not configurable.`, - typescriptOnly: false, - }; - - public static FAILURE_STRING = 'You need to keep imports grouped.'; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return this.applyWithWalker(new Walker(sourceFile, this.getOptions())); - } -} - - -class Walker extends Lint.RuleWalker { - walk(sourceFile: ts.SourceFile) { - super.walk(sourceFile); - - const statements = sourceFile.statements; - const imports = statements.filter(s => s.kind == ts.SyntaxKind.ImportDeclaration); - const nonImports = statements.filter(s => s.kind != ts.SyntaxKind.ImportDeclaration); - - for (let i = 1; i < imports.length; i++) { - const node = imports[i]; - const previous = imports[i - 1]; - - if (previous && previous.kind == ts.SyntaxKind.ImportDeclaration) { - const nodeLine = sourceFile.getLineAndCharacterOfPosition(node.getStart()); - const previousLine = sourceFile.getLineAndCharacterOfPosition(previous.getEnd()); - - if (previousLine.line < nodeLine.line - 1) { - if (nonImports.some(s => s.getStart() > previous.getEnd() - && s.getStart() < node.getStart())) { - // Ignore imports with non-imports statements in between. - continue; - } - - this.addFailureAt( - node.getStart(), - node.getWidth(), - Rule.FAILURE_STRING, - Lint.Replacement.deleteFromTo(previous.getEnd() + 1, node.getStart()), - ); - } - } - } - } -} diff --git a/etc/rules/noGlobalTslintDisableRule.ts b/etc/rules/noGlobalTslintDisableRule.ts deleted file mode 100644 index 1e136b5486a8..000000000000 --- a/etc/rules/noGlobalTslintDisableRule.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -// tslint:disable-next-line: no-global-tslint-disable -// tslint:disable: no-implicit-dependencies -import * as path from 'path'; -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - - -export class Rule extends Lint.Rules.AbstractRule { - public static metadata: Lint.IRuleMetadata = { - ruleName: 'no-global-tslint-disable', - type: 'style', - description: `Ensure global tslint disable are only used for unit tests.`, - rationale: `Some projects want to disallow tslint disable and only use per-line ones.`, - options: null, - optionsDescription: `Not configurable.`, - typescriptOnly: false, - }; - - public static FAILURE_STRING = 'tslint:disable is not allowed in this context.'; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - return this.applyWithWalker(new Walker(sourceFile, this.getOptions())); - } -} - - -class Walker extends Lint.RuleWalker { - private _findComments(node: ts.Node): ts.CommentRange[] { - return ([] as ts.CommentRange[]).concat( - ts.getLeadingCommentRanges(node.getFullText(), 0) || [], - ts.getTrailingCommentRanges(node.getFullText(), 0) || [], - node.getChildren().reduce((acc, n) => { - return acc.concat(this._findComments(n)); - }, [] as ts.CommentRange[]), - ); - } - - walk(sourceFile: ts.SourceFile) { - super.walk(sourceFile); - - // Ignore spec files. - if (sourceFile.fileName.match(/_spec(_large)?.ts$/)) { - return; - } - // Ignore benchmark files. - if (sourceFile.fileName.match(/_benchmark.ts$/)) { - return; - } - - // TODO(filipesilva): remove this once the files are cleaned up. - // Ignore Angular CLI files files. - if (sourceFile.fileName.includes('/angular-cli-files/')) { - return; - } - - const scriptsPath = path.join(process.cwd(), 'scripts').replace(/\\/g, '/'); - if (sourceFile.fileName.startsWith(scriptsPath)) { - return; - } - - // Find all comment nodes. - const ranges = this._findComments(sourceFile); - ranges.forEach(range => { - const text = sourceFile.getFullText().substring(range.pos, range.end); - let i = text.indexOf('tslint:disable:'); - - while (i != -1) { - this.addFailureAt(range.pos + i + 1, range.pos + i + 15, Rule.FAILURE_STRING); - i = text.indexOf('tslint:disable:', i + 1); - } - }); - } -} diff --git a/etc/rules/singleEofLineRule.ts b/etc/rules/singleEofLineRule.ts deleted file mode 100644 index 36026e7baea5..000000000000 --- a/etc/rules/singleEofLineRule.ts +++ /dev/null @@ -1,61 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -// tslint:disable-next-line: no-global-tslint-disable -// tslint:disable: no-implicit-dependencies -import * as Lint from 'tslint'; -import * as ts from 'typescript'; - - -export class Rule extends Lint.Rules.AbstractRule { - public static metadata: Lint.IRuleMetadata = { - ruleName: 'single-eof-line', - type: 'style', - description: `Ensure the file ends with a single new line.`, - rationale: `This is similar to eofline, but ensure an exact count instead of just any new - line.`, - options: null, - optionsDescription: `Two integers indicating minimum and maximum number of new lines.`, - typescriptOnly: false, - }; - - public static FAILURE_STRING = 'You need to have a single blank line at end of file.'; - - public apply(sourceFile: ts.SourceFile): Lint.RuleFailure[] { - const length = sourceFile.text.length; - if (length === 0) { - // Allow empty files. - return []; - } - - const matchEof = /\r?\n((\r?\n)*)$/.exec(sourceFile.text); - if (!matchEof) { - const lines = sourceFile.getLineStarts(); - const fix = Lint.Replacement.appendText( - length, - sourceFile.text[lines[1] - 2] === '\r' ? '\r\n' : '\n', - ); - - return [ - new Lint.RuleFailure(sourceFile, length, length, Rule.FAILURE_STRING, this.ruleName, fix), - ]; - } else if (matchEof[1]) { - const lines = sourceFile.getLineStarts(); - const fix = Lint.Replacement.replaceFromTo( - matchEof.index, - length, - sourceFile.text[lines[1] - 2] === '\r' ? '\r\n' : '\n', - ); - - return [ - new Lint.RuleFailure(sourceFile, length, length, Rule.FAILURE_STRING, this.ruleName, fix), - ]; - } - - return []; - } -} diff --git a/etc/rules/tsconfig.json b/etc/rules/tsconfig.json deleted file mode 100644 index dce8cbca6f6f..000000000000 --- a/etc/rules/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - // This tsconfig is to help only build the tslint rules instead of the whole project. Makes - // things faster. - "extends": "../tsconfig.json", - "compilerOptions": { - "outDir": "../../dist/etc/rules", - "baseUrl": "" - }, - "exclude": [ - ] -} diff --git a/goldens/BUILD.bazel b/goldens/BUILD.bazel new file mode 100644 index 000000000000..711154f524b1 --- /dev/null +++ b/goldens/BUILD.bazel @@ -0,0 +1,10 @@ +load("@bazel_lib//lib:copy_to_bin.bzl", "copy_to_bin") + +package(default_visibility = ["//visibility:public"]) + +copy_to_bin( + name = "public-api", + srcs = glob([ + "public-api/**/*.md", + ]), +) diff --git a/goldens/circular-deps/packages.json b/goldens/circular-deps/packages.json new file mode 100644 index 000000000000..fe51488c7066 --- /dev/null +++ b/goldens/circular-deps/packages.json @@ -0,0 +1 @@ +[] diff --git a/goldens/public-api/angular/build/index.api.md b/goldens/public-api/angular/build/index.api.md new file mode 100644 index 000000000000..b08d70c07e64 --- /dev/null +++ b/goldens/public-api/angular/build/index.api.md @@ -0,0 +1,255 @@ +## API Report File for "@angular/build" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BuilderContext } from '@angular-devkit/architect'; +import { BuilderOutput } from '@angular-devkit/architect'; +import type { ConfigOptions } from 'karma'; +import type http from 'node:http'; +import type { OutputFile } from 'esbuild'; +import type { Plugin as Plugin_2 } from 'esbuild'; + +// @public (undocumented) +export interface ApplicationBuilderExtensions { + // (undocumented) + codePlugins?: Plugin_2[]; + // (undocumented) + indexHtmlTransformer?: IndexHtmlTransform; +} + +// @public +export type ApplicationBuilderOptions = { + allowedCommonJsDependencies?: string[]; + aot?: boolean; + appShell?: boolean; + assets?: AssetPattern[]; + baseHref?: string; + browser?: string; + budgets?: Budget[]; + clearScreen?: boolean; + conditions?: string[]; + crossOrigin?: CrossOrigin; + define?: { + [key: string]: string; + }; + deleteOutputPath?: boolean; + deployUrl?: string; + externalDependencies?: string[]; + extractLicenses?: boolean; + fileReplacements?: FileReplacement[]; + i18nDuplicateTranslation?: I18NTranslation; + i18nMissingTranslation?: I18NTranslation; + index?: IndexUnion; + inlineStyleLanguage?: InlineStyleLanguage; + loader?: { + [key: string]: any; + }; + localize?: Localize; + namedChunks?: boolean; + optimization?: OptimizationUnion; + outputHashing?: OutputHashing; + outputMode?: OutputMode; + outputPath?: OutputPathUnion; + poll?: number; + polyfills?: string[]; + prerender?: PrerenderUnion; + preserveSymlinks?: boolean; + progress?: boolean; + scripts?: ScriptElement[]; + security?: Security; + server?: Serv; + serviceWorker?: Serv; + sourceMap?: SourceMapUnion; + ssr?: SsrUnion; + statsJson?: boolean; + stylePreprocessorOptions?: StylePreprocessorOptions; + styles?: StyleElement[]; + subresourceIntegrity?: boolean; + tsConfig: string; + verbose?: boolean; + watch?: boolean; + webWorkerTsConfig?: string; +}; + +// @public +export function buildApplication(options: ApplicationBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): AsyncIterable; + +// @public (undocumented) +export interface BuildOutputAsset { + // (undocumented) + destination: string; + // (undocumented) + source: string; +} + +// @public (undocumented) +export interface BuildOutputFile extends OutputFile { + // (undocumented) + clone: () => BuildOutputFile; + // (undocumented) + readonly size: number; + // (undocumented) + type: BuildOutputFileType; +} + +// @public (undocumented) +export enum BuildOutputFileType { + // (undocumented) + Browser = 0, + // (undocumented) + Media = 1, + // (undocumented) + Root = 4, + // (undocumented) + ServerApplication = 2, + // (undocumented) + ServerRoot = 3 +} + +// @public +export type DevServerBuilderOptions = { + allowedHosts?: AllowedHosts; + buildTarget: string; + define?: { + [key: string]: string; + }; + headers?: { + [key: string]: string; + }; + hmr?: boolean; + host?: string; + inspect?: Inspect; + liveReload?: boolean; + open?: boolean; + poll?: number; + port?: number; + prebundle?: PrebundleUnion; + proxyConfig?: string; + servePath?: string; + ssl?: boolean; + sslCert?: string; + sslKey?: string; + verbose?: boolean; + watch?: boolean; +}; + +// @public +export interface DevServerBuilderOutput extends BuilderOutput { + // (undocumented) + address?: string; + // (undocumented) + baseUrl: string; + // (undocumented) + port?: number; +} + +// @public +export function executeDevServerBuilder(options: DevServerBuilderOptions, context: BuilderContext, extensions?: { + buildPlugins?: Plugin_2[]; + middleware?: ((req: http.IncomingMessage, res: http.ServerResponse, next: (err?: unknown) => void) => void)[]; + indexHtmlTransformer?: IndexHtmlTransform; +}): AsyncIterable; + +// @public +export function executeExtractI18nBuilder(options: ExtractI18nBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): Promise; + +// @public +export function executeKarmaBuilder(options: KarmaBuilderOptions, context: BuilderContext, transforms?: KarmaBuilderTransformsOptions): AsyncIterable; + +// @public +export function executeNgPackagrBuilder(options: NgPackagrBuilderOptions, context: BuilderContext): AsyncIterableIterator; + +// @public +export function executeUnitTestBuilder(options: UnitTestBuilderOptions, context: BuilderContext, extensions?: ApplicationBuilderExtensions): AsyncIterable; + +// @public +export type ExtractI18nBuilderOptions = { + buildTarget?: string; + format?: Format; + i18nDuplicateTranslation?: I18NDuplicateTranslation; + outFile?: string; + outputPath?: string; + progress?: boolean; +}; + +// @public +export type KarmaBuilderOptions = { + aot?: boolean; + assets?: AssetPattern_2[]; + browsers?: Browsers; + codeCoverage?: boolean; + codeCoverageExclude?: string[]; + define?: { + [key: string]: string; + }; + exclude?: string[]; + externalDependencies?: string[]; + fileReplacements?: FileReplacement_2[]; + include?: string[]; + inlineStyleLanguage?: InlineStyleLanguage_2; + karmaConfig?: string; + loader?: { + [key: string]: any; + }; + main?: string; + poll?: number; + polyfills?: string[]; + preserveSymlinks?: boolean; + progress?: boolean; + reporters?: string[]; + scripts?: ScriptElement_2[]; + sourceMap?: SourceMapUnion_2; + stylePreprocessorOptions?: StylePreprocessorOptions_2; + styles?: StyleElement_2[]; + tsConfig: string; + watch?: boolean; + webWorkerTsConfig?: string; +}; + +// @public +export type NgPackagrBuilderOptions = { + poll?: number; + project?: string; + tsConfig?: string; + watch?: boolean; +}; + +// @public +export type UnitTestBuilderOptions = { + browserViewport?: string; + browsers?: [string, ...string[]]; + buildTarget?: string; + coverage?: boolean; + coverageExclude?: string[]; + coverageInclude?: string[]; + coverageReporters?: SchemaCoverageReporter[]; + coverageThresholds?: CoverageThresholds; + coverageWatermarks?: CoverageWatermarks; + debug?: boolean; + dumpVirtualFiles?: boolean; + exclude?: string[]; + filter?: string; + headless?: boolean; + include?: string[]; + isolate?: boolean; + listTests?: boolean; + outputFile?: string; + polyfills?: string[]; + progress?: boolean; + providersFile?: string; + quiet?: boolean; + reporters?: SchemaReporter[]; + runner?: Runner; + runnerConfig?: RunnerConfig; + setupFiles?: string[]; + splitting?: boolean; + tsConfig?: string; + ui?: boolean; + watch?: boolean; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular/ssr/index.api.md b/goldens/public-api/angular/ssr/index.api.md new file mode 100644 index 000000000000..852187d5290b --- /dev/null +++ b/goldens/public-api/angular/ssr/index.api.md @@ -0,0 +1,102 @@ +## API Report File for "@angular/ssr" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { DefaultExport } from '@angular/router'; +import { EnvironmentProviders } from '@angular/core'; +import { InjectionToken } from '@angular/core'; +import { Provider } from '@angular/core'; +import { Type } from '@angular/core'; + +// @public +export class AngularAppEngine { + constructor(options?: AngularAppEngineOptions); + handle(request: Request, requestContext?: unknown): Promise; + static ɵallowStaticRouteRender: boolean; + static ɵdisableAllowedHostsCheck: boolean; + static ɵhooks: Hooks; +} + +// @public +export interface AngularAppEngineOptions { + allowedHosts?: readonly string[]; + trustProxyHeaders?: boolean | readonly string[]; +} + +// @public +export function createRequestHandler(handler: RequestHandlerFunction): RequestHandlerFunction; + +// @public +export const IS_DISCOVERING_ROUTES: InjectionToken; + +// @public +export enum PrerenderFallback { + Client = 1, + None = 2, + Server = 0 +} + +// @public +export function provideServerRendering(...features: ServerRenderingFeature[]): EnvironmentProviders; + +// @public +export function provideServerRendering(options: ServerRenderingOptions, ...features: ServerRenderingFeature[]): EnvironmentProviders; + +// @public +export enum RenderMode { + Client = 1, + Prerender = 2, + Server = 0 +} + +// @public +export type RequestHandlerFunction = (request: Request) => Promise | null | Response; + +// @public +export interface ServerRenderingOptions { + maxResponseBodySize: number; +} + +// @public +export type ServerRoute = ServerRouteClient | ServerRoutePrerender | ServerRoutePrerenderWithParams | ServerRouteServer; + +// @public +export interface ServerRouteClient extends ServerRouteCommon { + renderMode: RenderMode.Client; +} + +// @public +export interface ServerRouteCommon { + headers?: Record; + path: string; + status?: number; +} + +// @public +export interface ServerRoutePrerender extends Omit { + fallback?: never; + renderMode: RenderMode.Prerender; +} + +// @public +export interface ServerRoutePrerenderWithParams extends Omit { + fallback?: PrerenderFallback; + getPrerenderParams: () => Promise[]>; +} + +// @public +export interface ServerRouteServer extends ServerRouteCommon { + renderMode: RenderMode.Server; +} + +// @public +export function withAppShell(component: Type | (() => Promise | DefaultExport>>)): ServerRenderingFeature; + +// @public +export function withRoutes(routes: ServerRoute[]): ServerRenderingFeature; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular/ssr/node/index.api.md b/goldens/public-api/angular/ssr/node/index.api.md new file mode 100644 index 000000000000..06b61ab1fdcb --- /dev/null +++ b/goldens/public-api/angular/ssr/node/index.api.md @@ -0,0 +1,71 @@ +## API Report File for "@angular/ssr_node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApplicationRef } from '@angular/core'; +import { BootstrapContext } from '@angular/platform-browser'; +import { Http2ServerRequest } from 'node:http2'; +import { Http2ServerResponse } from 'node:http2'; +import { IncomingMessage } from 'node:http'; +import { ServerResponse } from 'node:http'; +import { StaticProvider } from '@angular/core'; +import { Type } from '@angular/core'; + +// @public +export class AngularNodeAppEngine { + constructor(options?: AngularNodeAppEngineOptions); + handle(request: IncomingMessage | Http2ServerRequest | Request, requestContext?: unknown): Promise; +} + +// @public +export interface AngularNodeAppEngineOptions extends AngularAppEngineOptions { +} + +// @public @deprecated +export class CommonEngine { + constructor(options?: CommonEngineOptions | undefined); + render(opts: CommonEngineRenderOptions): Promise; +} + +// @public @deprecated (undocumented) +export interface CommonEngineOptions { + allowedHosts?: readonly string[]; + bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise); + enablePerformanceProfiler?: boolean; + providers?: StaticProvider[]; +} + +// @public @deprecated (undocumented) +export interface CommonEngineRenderOptions { + bootstrap?: Type<{}> | ((context: BootstrapContext) => Promise); + // (undocumented) + document?: string; + // (undocumented) + documentFilePath?: string; + inlineCriticalCss?: boolean; + providers?: StaticProvider[]; + publicPath?: string; + // (undocumented) + url?: string; +} + +// @public +export function createNodeRequestHandler(handler: T): T; + +// @public +export function createWebRequestFromNodeRequest(nodeRequest: IncomingMessage | Http2ServerRequest, trustProxyHeaders?: boolean | readonly string[]): Request; + +// @public +export function isMainModule(url: string): boolean; + +// @public +export type NodeRequestHandlerFunction = (req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void) => Promise | void; + +// @public +export function writeResponseToNodeResponse(source: Response, destination: ServerResponse | Http2ServerResponse): Promise; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/architect/index.api.md b/goldens/public-api/angular_devkit/architect/index.api.md new file mode 100644 index 000000000000..747b7010f580 --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/index.api.md @@ -0,0 +1,553 @@ +## API Report File for "@angular-devkit/architect" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { ObservableInput } from 'rxjs'; +import { Observer } from 'rxjs'; +import { schema } from '@angular-devkit/core'; + +// @public (undocumented) +export class Architect { + constructor(_host: ArchitectHost, registry?: json.schema.SchemaRegistry, additionalJobRegistry?: Registry); + // (undocumented) + has(name: JobName): Observable; + // (undocumented) + scheduleBuilder(name: string, options: json.JsonObject, scheduleOptions?: ScheduleOptions): Promise; + // (undocumented) + scheduleTarget(target: Target, overrides?: json.JsonObject, scheduleOptions?: ScheduleOptions): Promise; +} + +// @public +export interface Builder { + // (undocumented) + [BuilderSymbol]: true; + // (undocumented) + [BuilderVersionSymbol]: string; + // (undocumented) + __OptionT: OptionT; + // (undocumented) + handler: JobHandler; +} + +// @public +export interface BuilderContext { + addTeardown(teardown: () => Promise | void): void; + builder: BuilderInfo; + currentDirectory: string; + getBuilderNameForTarget(target: Target): Promise; + // (undocumented) + getProjectMetadata(projectName: string): Promise; + // (undocumented) + getProjectMetadata(target: Target): Promise; + getTargetOptions(target: Target): Promise; + id: number; + logger: logging.LoggerApi; + reportProgress(current: number, total?: number, status?: string): void; + reportRunning(): void; + reportStatus(status: string): void; + scheduleBuilder(builderName: string, options?: json.JsonObject, scheduleOptions?: ScheduleOptions_2): Promise; + scheduleTarget(target: Target, overrides?: json.JsonObject, scheduleOptions?: ScheduleOptions_2): Promise; + target?: Target; + validateOptions(options: json.JsonObject, builderName: string): Promise; + workspaceRoot: string; +} + +// @public +export interface BuilderHandlerFn { + (input: A, context: BuilderContext): BuilderOutputLike; +} + +// @public +export type BuilderInfo = json.JsonObject & { + builderName: string; + description: string; + optionSchema: json.schema.JsonSchema; +}; + +// @public +export type BuilderInput = json.JsonObject & Schema; + +// @public (undocumented) +export type BuilderOutput = json.JsonObject & Schema_2; + +// @public +export type BuilderOutputLike = ObservableInput | BuilderOutput; + +// @public (undocumented) +export type BuilderProgress = json.JsonObject & Schema_3 & TypedBuilderProgress; + +// @public +export type BuilderProgressReport = BuilderProgress & { + target?: Target; + builder: BuilderInfo; +}; + +// @public (undocumented) +export enum BuilderProgressState { + // (undocumented) + Error = "error", + // (undocumented) + Running = "running", + // (undocumented) + Stopped = "stopped", + // (undocumented) + Waiting = "waiting" +} + +// @public (undocumented) +export type BuilderRegistry = Registry; + +// @public +export interface BuilderRun { + id: number; + info: BuilderInfo; + lastOutput: Promise; + output: Observable; + progress: Observable; + result: Promise; + stop(): Promise; +} + +// @public (undocumented) +class ChannelAlreadyExistException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export function createBuilder(fn: BuilderHandlerFn): Builder; + +// @public +function createDispatcher(options?: Partial>): JobDispatcher; + +// @public +function createJobFactory(loader: () => Promise>, options?: Partial): JobHandler; + +// @public +function createJobHandler(fn: SimpleJobHandlerFn, options?: Partial): JobHandler; + +// @public +function createLoggerJob(job: JobHandler, logger: logging.LoggerApi): JobHandler; + +// @public +class FallbackRegistry implements Registry { + constructor(_fallbacks?: Registry[]); + // (undocumented) + addFallback(registry: Registry): void; + // (undocumented) + protected _fallbacks: Registry[]; + // (undocumented) + get(name: JobName): Observable | null>; +} + +// @public (undocumented) +export function fromAsyncIterable(iterable: AsyncIterable): Observable; + +// @public (undocumented) +export function isBuilderOutput(obj: any): obj is BuilderOutput; + +// @public (undocumented) +function isJobHandler(value: unknown): value is JobHandler; + +// @public +interface Job { + readonly argument: ArgumentT; + readonly description: Observable; + getChannel(name: string, schema?: schema.JsonSchema): Observable; + readonly inboundBus: Observer>; + readonly input: Observer; + readonly outboundBus: Observable>; + readonly output: Observable; + ping(): Observable; + readonly state: JobState; + stop(): void; +} + +// @public (undocumented) +class JobArgumentSchemaValidationError extends schema.SchemaValidationException { + constructor(errors?: schema.SchemaValidatorError[]); +} + +// @public +interface JobDescription extends JsonObject { + // (undocumented) + readonly argument: DeepReadonly; + // (undocumented) + readonly input: DeepReadonly; + // (undocumented) + readonly name: JobName; + // (undocumented) + readonly output: DeepReadonly; +} + +// @public +interface JobDispatcher extends JobHandler { + addConditionalJob(predicate: (args: A) => boolean, name: string): void; + setDefaultJob(name: JobName | null | JobHandler): void; +} + +// @public (undocumented) +class JobDoesNotExistException extends BaseException { + constructor(name: JobName); +} + +// @public +interface JobHandler { + // (undocumented) + (argument: ArgT, context: JobHandlerContext): Observable>; + // (undocumented) + jobDescription: Partial; +} + +// @public +interface JobHandlerContext { + // (undocumented) + readonly dependencies: Job[]; + // (undocumented) + readonly description: JobDescription; + // (undocumented) + readonly inboundBus: Observable>; + // (undocumented) + readonly scheduler: Scheduler; +} + +// @public (undocumented) +type JobInboundMessage = JobInboundMessagePing | JobInboundMessageStop | JobInboundMessageInput; + +// @public +interface JobInboundMessageBase extends JsonObject { + readonly kind: JobInboundMessageKind; +} + +// @public +interface JobInboundMessageInput extends JobInboundMessageBase { + // (undocumented) + readonly kind: JobInboundMessageKind.Input; + readonly value: InputT; +} + +// @public +enum JobInboundMessageKind { + // (undocumented) + Input = "in", + // (undocumented) + Ping = "ip", + // (undocumented) + Stop = "is" +} + +// @public +interface JobInboundMessagePing extends JobInboundMessageBase { + readonly id: number; + // (undocumented) + readonly kind: JobInboundMessageKind.Ping; +} + +// @public (undocumented) +class JobInboundMessageSchemaValidationError extends schema.SchemaValidationException { + constructor(errors?: schema.SchemaValidatorError[]); +} + +// @public +interface JobInboundMessageStop extends JobInboundMessageBase { + // (undocumented) + readonly kind: JobInboundMessageKind.Stop; +} + +// @public +type JobName = string; + +// @public (undocumented) +class JobNameAlreadyRegisteredException extends BaseException { + constructor(name: JobName); +} + +// @public +type JobOutboundMessage = JobOutboundMessageOnReady | JobOutboundMessageStart | JobOutboundMessageOutput | JobOutboundMessageChannelCreate | JobOutboundMessageChannelMessage | JobOutboundMessageChannelError | JobOutboundMessageChannelComplete | JobOutboundMessageEnd | JobOutboundMessagePong; + +// @public +interface JobOutboundMessageBase { + readonly description: JobDescription; + readonly kind: JobOutboundMessageKind; +} + +// @public +interface JobOutboundMessageChannelBase extends JobOutboundMessageBase { + readonly name: string; +} + +// @public +interface JobOutboundMessageChannelComplete extends JobOutboundMessageChannelBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.ChannelComplete; +} + +// @public +interface JobOutboundMessageChannelCreate extends JobOutboundMessageChannelBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.ChannelCreate; +} + +// @public +interface JobOutboundMessageChannelError extends JobOutboundMessageChannelBase { + readonly error: JsonValue; + // (undocumented) + readonly kind: JobOutboundMessageKind.ChannelError; +} + +// @public +interface JobOutboundMessageChannelMessage extends JobOutboundMessageChannelBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.ChannelMessage; + readonly message: JsonValue; +} + +// @public +interface JobOutboundMessageEnd extends JobOutboundMessageBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.End; +} + +// @public +enum JobOutboundMessageKind { + // (undocumented) + ChannelComplete = "cc", + // (undocumented) + ChannelCreate = "cn", + // (undocumented) + ChannelError = "ce", + // (undocumented) + ChannelMessage = "cm", + // (undocumented) + End = "e", + // (undocumented) + OnReady = "c", + // (undocumented) + Output = "o", + // (undocumented) + Pong = "p", + // (undocumented) + Start = "s" +} + +// @public +interface JobOutboundMessageOnReady extends JobOutboundMessageBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.OnReady; +} + +// @public +interface JobOutboundMessageOutput extends JobOutboundMessageBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.Output; + readonly value: OutputT; +} + +// @public +interface JobOutboundMessagePong extends JobOutboundMessageBase { + readonly id: number; + // (undocumented) + readonly kind: JobOutboundMessageKind.Pong; +} + +// @public +interface JobOutboundMessageStart extends JobOutboundMessageBase { + // (undocumented) + readonly kind: JobOutboundMessageKind.Start; +} + +// @public (undocumented) +class JobOutputSchemaValidationError extends schema.SchemaValidationException { + constructor(errors?: schema.SchemaValidatorError[]); +} + +declare namespace jobs { + export { + strategy, + isJobHandler, + JobName, + JobHandler, + JobHandlerContext, + JobDescription, + JobInboundMessageKind, + JobInboundMessageBase, + JobInboundMessagePing, + JobInboundMessageStop, + JobInboundMessageInput, + JobInboundMessage, + JobOutboundMessageKind, + JobOutboundMessageBase, + JobOutboundMessageOnReady, + JobOutboundMessageStart, + JobOutboundMessageOutput, + JobOutboundMessageChannelBase, + JobOutboundMessageChannelMessage, + JobOutboundMessageChannelError, + JobOutboundMessageChannelCreate, + JobOutboundMessageChannelComplete, + JobOutboundMessageEnd, + JobOutboundMessagePong, + JobOutboundMessage, + JobState, + Job, + ScheduleJobOptions, + Registry, + Scheduler, + createJobHandler, + createJobFactory, + createLoggerJob, + ChannelAlreadyExistException, + SimpleJobHandlerContext, + SimpleJobHandlerFn, + JobNameAlreadyRegisteredException, + JobDoesNotExistException, + createDispatcher, + JobDispatcher, + FallbackRegistry, + RegisterJobOptions, + SimpleJobRegistry, + JobArgumentSchemaValidationError, + JobInboundMessageSchemaValidationError, + JobOutputSchemaValidationError, + SimpleScheduler + } +} +export { jobs } + +// @public +enum JobState { + Ended = "ended", + Errored = "errored", + Queued = "queued", + Ready = "ready", + Started = "started" +} + +// @public (undocumented) +type JobStrategy = (handler: JobHandler, options?: Partial>) => JobHandler; + +// @public +function memoize(replayMessages?: boolean): JobStrategy; + +// @public +interface RegisterJobOptions extends Partial { +} + +// @public (undocumented) +interface Registry { + get(name: JobName): Observable | null>; +} + +// @public +function reuse(replayMessages?: boolean): JobStrategy; + +// @public +interface ScheduleJobOptions { + dependencies?: Job | Job[]; +} + +// @public (undocumented) +export interface ScheduleOptions { + // (undocumented) + logger?: logging.Logger; +} + +// @public +interface Scheduler { + getDescription(name: JobName): Observable; + has(name: JobName): Observable; + pause(): () => void; + schedule(name: JobName, argument: A, options?: ScheduleJobOptions): Job; +} + +// @public +export function scheduleTargetAndForget(context: BuilderContext, target: Target, overrides?: json.JsonObject, scheduleOptions?: ScheduleOptions_2): Observable; + +// @public +function serialize(): JobStrategy; + +// @public +interface SimpleJobHandlerContext extends JobHandlerContext { + // (undocumented) + addTeardown(teardown: () => Promise | void): void; + // (undocumented) + createChannel: (name: string) => Observer; + // (undocumented) + input: Observable; +} + +// @public +type SimpleJobHandlerFn = (input: A, context: SimpleJobHandlerContext) => O | Promise | Observable; + +// @public +class SimpleJobRegistry implements Registry { + // (undocumented) + get(name: JobName): Observable | null>; + getJobNames(): JobName[]; + register(name: JobName, handler: JobHandler, options?: RegisterJobOptions): void; + register(handler: JobHandler, options?: RegisterJobOptions & { + name: string; + }): void; + // (undocumented) + protected _register(name: JobName, handler: JobHandler, options: RegisterJobOptions): void; +} + +// @public +class SimpleScheduler implements Scheduler { + constructor(_jobRegistry: Registry, _schemaRegistry?: schema.SchemaRegistry); + getDescription(name: JobName): Observable; + has(name: JobName): Observable; + // (undocumented) + protected _jobRegistry: Registry; + pause(): () => void; + schedule(name: JobName, argument: A, options?: ScheduleJobOptions): Job; + // (undocumented) + protected _scheduleJob(name: JobName, argument: A, options: ScheduleJobOptions, waitable: Observable): Job; + // (undocumented) + protected _schemaRegistry: schema.SchemaRegistry; +} + +declare namespace strategy { + export { + serialize, + reuse, + memoize, + JobStrategy + } +} + +// @public (undocumented) +export type Target = json.JsonObject & Target_2; + +// @public +export function targetFromTargetString(specifier: string, abbreviatedProjectName?: string, abbreviatedTargetName?: string): Target; + +// @public +export function targetStringFromTarget(input: Target): string; + +// @public +export type TypedBuilderProgress = { + state: BuilderProgressState.Stopped; +} | { + state: BuilderProgressState.Error; + error: json.JsonValue; +} | { + state: BuilderProgressState.Waiting; + status?: string; +} | { + state: BuilderProgressState.Running; + status?: string; + current: number; + total?: number; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/architect/node/index.api.md b/goldens/public-api/angular_devkit/architect/node/index.api.md new file mode 100644 index 000000000000..059423576f71 --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/node/index.api.md @@ -0,0 +1,73 @@ +## API Report File for "@angular-devkit/architect_node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Observer } from 'rxjs'; +import { schema } from '@angular-devkit/core'; +import { workspaces } from '@angular-devkit/core'; + +declare namespace jobs { + export { + NodeModuleJobRegistry + } +} +export { jobs } + +// @public (undocumented) +class NodeModuleJobRegistry implements jobs_2.Registry { + get(name: jobs_2.JobName): Observable | null>; + // (undocumented) + protected _resolve(name: string): string | null; +} + +// @public (undocumented) +export type NodeModulesBuilderInfo = BuilderInfo & { + import: string; +}; + +// @public (undocumented) +export interface WorkspaceHost { + // (undocumented) + getBuilderName(project: string, target: string): Promise; + // (undocumented) + getDefaultConfigurationName(project: string, target: string): Promise; + // (undocumented) + getMetadata(project: string): Promise; + // (undocumented) + getOptions(project: string, target: string, configuration?: string): Promise; + // (undocumented) + hasTarget(project: string, target: string): Promise; +} + +// @public (undocumented) +export class WorkspaceNodeModulesArchitectHost implements ArchitectHost { + constructor(workspaceHost: WorkspaceHost, _root: string); + constructor(workspace: workspaces.WorkspaceDefinition, _root: string); + // (undocumented) + getBuilderNameForTarget(target: Target_2): Promise; + // (undocumented) + getCurrentDirectory(): Promise; + // (undocumented) + getOptionsForTarget(target: Target_2): Promise; + // (undocumented) + getProjectMetadata(target: Target_2 | string): Promise; + // (undocumented) + getWorkspaceRoot(): Promise; + // (undocumented) + loadBuilder(info: NodeModulesBuilderInfo): Promise; + resolveBuilder(builderStr: string, basePath?: string, seenBuilders?: Set): Promise; + // (undocumented) + protected _root: string; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/architect/testing/index.api.md b/goldens/public-api/angular_devkit/architect/testing/index.api.md new file mode 100644 index 000000000000..23cc6fe8cd7a --- /dev/null +++ b/goldens/public-api/angular_devkit/architect/testing/index.api.md @@ -0,0 +1,76 @@ +## API Report File for "@angular-devkit/architect_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { json } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { NodeJsSyncHost } from '@angular-devkit/core/node'; +import { Observable } from 'rxjs'; +import { Observer } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; +import { Stats } from 'node:fs'; +import { virtualFs } from '@angular-devkit/core'; + +// @public (undocumented) +export class TestingArchitectHost implements ArchitectHost { + constructor(workspaceRoot?: string, currentDirectory?: string, _backendHost?: ArchitectHost | null); + // (undocumented) + addBuilder(builderName: string, builder: Builder, description?: string, optionSchema?: json.schema.JsonSchema): void; + // (undocumented) + addBuilderFromPackage(packageName: string): Promise; + // (undocumented) + addTarget(target: Target, builderName: string, options?: json.JsonObject): void; + // (undocumented) + currentDirectory: string; + // (undocumented) + getBuilderNameForTarget(target: Target): Promise; + // (undocumented) + getCurrentDirectory(): Promise; + // (undocumented) + getOptionsForTarget(target: Target): Promise; + // (undocumented) + getProjectMetadata(target: Target | string): Promise; + // (undocumented) + getWorkspaceRoot(): Promise; + // (undocumented) + loadBuilder(info: BuilderInfo): Promise; + resolveBuilder(builderName: string): Promise; + // (undocumented) + workspaceRoot: string; +} + +// @public @deprecated (undocumented) +export class TestProjectHost extends NodeJsSyncHost { + constructor(_templateRoot: Path); + // (undocumented) + appendToFile(path: string, str: string): void; + // (undocumented) + copyFile(from: string, to: string): void; + // (undocumented) + fileMatchExists(dir: string, regex: RegExp): PathFragment | undefined; + // (undocumented) + initialize(): Observable; + // (undocumented) + replaceInFile(path: string, match: RegExp | string, replacement: string): void; + // (undocumented) + restore(): Observable; + // (undocumented) + root(): Path; + // (undocumented) + scopedSync(): virtualFs.SyncDelegateHost; + // (undocumented) + protected _templateRoot: Path; + // (undocumented) + writeMultipleFiles(files: { + [path: string]: string | ArrayBufferLike | Buffer; + }): void; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/build_angular/index.api.md b/goldens/public-api/angular_devkit/build_angular/index.api.md new file mode 100644 index 000000000000..a7747bda9da2 --- /dev/null +++ b/goldens/public-api/angular_devkit/build_angular/index.api.md @@ -0,0 +1,355 @@ +## API Report File for "@angular-devkit/build-angular" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ApplicationBuilderOptions } from '@angular/build'; +import { buildApplication } from '@angular/build'; +import { BuilderContext } from '@angular-devkit/architect'; +import { BuilderOutput } from '@angular-devkit/architect'; +import type { ConfigOptions } from 'karma'; +import { Configuration } from 'webpack'; +import { DevServerBuilderOutput } from '@angular/build'; +import type http from 'node:http'; +import { IndexHtmlTransform } from '@angular/build/private'; +import { Observable } from 'rxjs'; +import type { Plugin as Plugin_2 } from 'esbuild'; +import webpack from 'webpack'; +import { WebpackLoggingCallback } from '@angular-devkit/build-webpack'; + +export { ApplicationBuilderOptions } + +// @public (undocumented) +export type AssetPattern = AssetPatternObject | string; + +// @public (undocumented) +export type AssetPatternObject = { + followSymlinks?: boolean; + glob: string; + ignore?: string[]; + input: string; + output?: string; +}; + +// @public +export type BrowserBuilderOptions = { + allowedCommonJsDependencies?: string[]; + aot?: boolean; + assets?: AssetPattern[]; + baseHref?: string; + budgets?: Budget[]; + buildOptimizer?: boolean; + commonChunk?: boolean; + crossOrigin?: CrossOrigin; + deleteOutputPath?: boolean; + deployUrl?: string; + extractLicenses?: boolean; + fileReplacements?: FileReplacement[]; + i18nDuplicateTranslation?: I18NTranslation; + i18nMissingTranslation?: I18NTranslation; + index: IndexUnion; + inlineStyleLanguage?: InlineStyleLanguage_2; + localize?: Localize; + main: string; + namedChunks?: boolean; + ngswConfigPath?: string; + optimization?: OptimizationUnion; + outputHashing?: OutputHashing; + outputPath: string; + poll?: number; + polyfills?: Polyfills_2; + preserveSymlinks?: boolean; + progress?: boolean; + resourcesOutputPath?: string; + scripts?: ScriptElement_2[]; + serviceWorker?: boolean; + sourceMap?: SourceMapUnion; + statsJson?: boolean; + stylePreprocessorOptions?: StylePreprocessorOptions; + styles?: StyleElement_2[]; + subresourceIntegrity?: boolean; + tsConfig: string; + vendorChunk?: boolean; + verbose?: boolean; + watch?: boolean; + webWorkerTsConfig?: string; +}; + +// @public +export type BrowserBuilderOutput = BuilderOutput & { + stats: BuildEventStats; + baseOutputPath: string; + outputs: { + locale?: string; + path: string; + baseHref?: string; + }[]; +}; + +// @public (undocumented) +export type Budget = { + baseline?: string; + error?: string; + maximumError?: string; + maximumWarning?: string; + minimumError?: string; + minimumWarning?: string; + name?: string; + type: Type; + warning?: string; +}; + +export { buildApplication } + +// @public +export enum CrossOrigin { + // (undocumented) + Anonymous = "anonymous", + // (undocumented) + None = "none", + // (undocumented) + UseCredentials = "use-credentials" +} + +// @public +export type DevServerBuilderOptions = { + allowedHosts?: string[]; + buildTarget: string; + disableHostCheck?: boolean; + forceEsbuild?: boolean; + headers?: { + [key: string]: string; + }; + hmr?: boolean; + host?: string; + inspect?: Inspect; + liveReload?: boolean; + open?: boolean; + poll?: number; + port?: number; + prebundle?: PrebundleUnion; + proxyConfig?: string; + publicHost?: string; + servePath?: string; + ssl?: boolean; + sslCert?: string; + sslKey?: string; + verbose?: boolean; + watch?: boolean; +}; + +export { DevServerBuilderOutput } + +// @public +export function executeBrowserBuilder(options: BrowserBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; + logging?: WebpackLoggingCallback; + indexHtml?: IndexHtmlTransform; +}): Observable; + +// @public +export function executeDevServerBuilder(options: DevServerBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; + logging?: WebpackLoggingCallback; + indexHtml?: IndexHtmlTransform; +}, extensions?: { + buildPlugins?: Plugin_2[]; + middleware?: ((req: http.IncomingMessage, res: http.ServerResponse, next: (err?: unknown) => void) => void)[]; + builderSelector?: (info: BuilderSelectorInfo, logger: BuilderContext['logger']) => string; +}): Observable; + +// @public +export function executeExtractI18nBuilder(options: ExtractI18nBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; +}): Promise; + +// @public +export function executeKarmaBuilder(options: KarmaBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; + karmaOptions?: (options: KarmaConfigOptions) => KarmaConfigOptions; +}): Observable; + +// @public +export function executeNgPackagrBuilder(options: NgPackagrBuilderOptions, context: BuilderContext): Observable; + +// @public +export function executeServerBuilder(options: ServerBuilderOptions, context: BuilderContext, transforms?: { + webpackConfiguration?: ExecutionTransformer; +}): Observable; + +// @public (undocumented) +export function executeSSRDevServerBuilder(options: SSRDevServerBuilderOptions, context: BuilderContext): Observable; + +// @public +export type ExecutionTransformer = (input: T) => T | Promise; + +// @public +export type ExtractI18nBuilderOptions = { + buildTarget?: string; + format?: Format; + i18nDuplicateTranslation?: I18NDuplicateTranslation; + outFile?: string; + outputPath?: string; + progress?: boolean; +}; + +// @public (undocumented) +export type FileReplacement = { + replace?: string; + replaceWith?: string; + src?: string; + with?: string; +}; + +// @public +export type KarmaBuilderOptions = { + aot?: boolean; + assets?: AssetPattern_2[]; + browsers?: Browsers; + builderMode?: BuilderMode; + codeCoverage?: boolean; + codeCoverageExclude?: string[]; + exclude?: string[]; + fileReplacements?: FileReplacement_2[]; + include?: string[]; + inlineStyleLanguage?: InlineStyleLanguage; + karmaConfig?: string; + main?: string; + poll?: number; + polyfills?: Polyfills; + preserveSymlinks?: boolean; + progress?: boolean; + reporters?: string[]; + scripts?: ScriptElement[]; + sourceMap?: SourceMapUnion_2; + stylePreprocessorOptions?: StylePreprocessorOptions_2; + styles?: StyleElement[]; + tsConfig: string; + watch?: boolean; + webWorkerTsConfig?: string; +}; + +// @public (undocumented) +export type KarmaConfigOptions = ConfigOptions & { + buildWebpack?: unknown; + configFile?: string; +}; + +// @public +export type NgPackagrBuilderOptions = { + poll?: number; + project: string; + tsConfig?: string; + watch?: boolean; +}; + +// @public (undocumented) +export type OptimizationObject = { + fonts?: FontsUnion; + scripts?: boolean; + styles?: StylesUnion; +}; + +// @public +export type OptimizationUnion = boolean | OptimizationObject; + +// @public +export enum OutputHashing { + // (undocumented) + All = "all", + // (undocumented) + Bundles = "bundles", + // (undocumented) + Media = "media", + // (undocumented) + None = "none" +} + +// @public (undocumented) +export type ServerBuilderOptions = { + assets?: AssetPattern_3[]; + buildOptimizer?: boolean; + deleteOutputPath?: boolean; + deployUrl?: string; + externalDependencies?: string[]; + extractLicenses?: boolean; + fileReplacements?: FileReplacement_3[]; + i18nDuplicateTranslation?: I18NTranslation_2; + i18nMissingTranslation?: I18NTranslation_2; + inlineStyleLanguage?: InlineStyleLanguage_3; + localize?: Localize_2; + main: string; + namedChunks?: boolean; + optimization?: OptimizationUnion_2; + outputHashing?: OutputHashing_2; + outputPath: string; + poll?: number; + preserveSymlinks?: boolean; + progress?: boolean; + resourcesOutputPath?: string; + sourceMap?: SourceMapUnion_3; + statsJson?: boolean; + stylePreprocessorOptions?: StylePreprocessorOptions_3; + tsConfig: string; + vendorChunk?: boolean; + verbose?: boolean; + watch?: boolean; +}; + +// @public +export type ServerBuilderOutput = BuilderOutput & { + baseOutputPath: string; + outputPath: string; + outputs: { + locale?: string; + path: string; + }[]; +}; + +// @public (undocumented) +export type SourceMapObject = { + hidden?: boolean; + scripts?: boolean; + styles?: boolean; + vendor?: boolean; +}; + +// @public +export type SourceMapUnion = boolean | SourceMapObject; + +// @public (undocumented) +export type SSRDevServerBuilderOptions = Schema; + +// @public (undocumented) +export type SSRDevServerBuilderOutput = BuilderOutput & { + baseUrl?: string; + port?: string; +}; + +// @public +export type StylePreprocessorOptions = { + includePaths?: string[]; +}; + +// @public +export enum Type { + // (undocumented) + All = "all", + // (undocumented) + AllScript = "allScript", + // (undocumented) + Any = "any", + // (undocumented) + AnyComponentStyle = "anyComponentStyle", + // (undocumented) + AnyScript = "anyScript", + // (undocumented) + Bundle = "bundle", + // (undocumented) + Initial = "initial" +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/build_webpack/index.api.md b/goldens/public-api/angular_devkit/build_webpack/index.api.md new file mode 100644 index 000000000000..3e7ebf07873b --- /dev/null +++ b/goldens/public-api/angular_devkit/build_webpack/index.api.md @@ -0,0 +1,80 @@ +## API Report File for "@angular-devkit/build-webpack" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BuilderContext } from '@angular-devkit/architect'; +import { BuilderOutput } from '@angular-devkit/architect'; +import type { Configuration } from 'webpack-dev-server'; +import { Observable } from 'rxjs'; +import type webpack from 'webpack'; +import type WebpackDevServer from 'webpack-dev-server'; + +// @public (undocumented) +export type BuildResult = BuilderOutput & { + emittedFiles?: EmittedFiles[]; + webpackStats?: webpack.StatsCompilation; + outputPath: string; +}; + +// @public (undocumented) +export type DevServerBuildOutput = BuildResult & { + port: number; + family: string; + address: string; +}; + +// @public (undocumented) +export interface EmittedFiles { + // (undocumented) + asset?: boolean; + // (undocumented) + extension: string; + // (undocumented) + file: string; + // (undocumented) + id?: string; + // (undocumented) + initial: boolean; + // (undocumented) + name?: string; +} + +// @public @deprecated (undocumented) +export function runWebpack(config: webpack.Configuration, context: BuilderContext, options?: { + logging?: WebpackLoggingCallback; + webpackFactory?: WebpackFactory; + shouldProvideStats?: boolean; +}): Observable; + +// @public @deprecated (undocumented) +export function runWebpackDevServer(config: webpack.Configuration, context: BuilderContext, options?: { + shouldProvideStats?: boolean; + devServerConfig?: Configuration; + logging?: WebpackLoggingCallback; + webpackFactory?: WebpackFactory; + webpackDevServerFactory?: WebpackDevServerFactory; +}): Observable; + +// @public (undocumented) +export type WebpackBuilderSchema = Schema; + +// @public (undocumented) +export type WebpackDevServerFactory = typeof WebpackDevServer; + +// @public (undocumented) +export interface WebpackFactory { + // (undocumented) + (config: webpack.Configuration): Observable | webpack.Compiler | null; +} + +// @public (undocumented) +export interface WebpackLoggingCallback { + // (undocumented) + (stats: webpack.Stats, config: webpack.Configuration): void; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/core/index.api.md b/goldens/public-api/angular_devkit/core/index.api.md new file mode 100644 index 000000000000..d86a826c566f --- /dev/null +++ b/goldens/public-api/angular_devkit/core/index.api.md @@ -0,0 +1,1400 @@ +## API Report File for "@angular-devkit/core" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { ErrorObject } from 'ajv'; +import { Format } from 'ajv'; +import { Observable } from 'rxjs'; +import { ObservableInput } from 'rxjs'; +import { Operator } from 'rxjs'; +import { PartialObserver } from 'rxjs'; +import { Position } from 'source-map'; +import { Subject } from 'rxjs'; +import { Subscription } from 'rxjs'; +import { ValidateFunction } from 'ajv'; + +// @public (undocumented) +function addUndefinedDefaults(value: JsonValue, _pointer: JsonPointer, schema?: JsonSchema): JsonValue; + +// @public (undocumented) +function addUndefinedObjectDefaults(value: JsonValue, _pointer: JsonPointer, schema?: JsonSchema): JsonValue; + +// @public +class AliasHost extends ResolverHost { + // (undocumented) + get aliases(): Map; + // (undocumented) + protected _aliases: Map; + // (undocumented) + protected _resolve(path: Path): Path; +} + +// @public (undocumented) +export function asPosixPath(path: Path): PosixPath; + +// @public (undocumented) +export function asWindowsPath(path: Path): WindowsPath; + +// @public +export class BaseException extends Error { + constructor(message?: string); +} + +// @public +export function basename(path: Path): PathFragment; + +// @public (undocumented) +function buildJsonPointer(fragments: string[]): JsonPointer; + +// @public +function camelize(str: string): string; + +// @public +function capitalize(str: string): string; + +// @public (undocumented) +export class CircularDependencyFoundException extends BaseException { + constructor(); +} + +// @public +function classify(str: string): string; + +// @public +class CordHost extends SimpleMemoryHost { + constructor(_back: ReadonlyHost); + // (undocumented) + protected _back: ReadonlyHost; + // (undocumented) + get backend(): ReadonlyHost; + // (undocumented) + get capabilities(): HostCapabilities; + clone(): CordHost; + commit(host: Host, force?: boolean): Observable; + create(path: Path, content: FileBuffer): Observable; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + protected _filesToCreate: Set; + // (undocumented) + protected _filesToDelete: Set; + // (undocumented) + protected _filesToOverwrite: Set; + // (undocumented) + protected _filesToRename: Map; + // (undocumented) + protected _filesToRenameRevert: Map; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + overwrite(path: Path, content: FileBuffer): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + records(): CordHostRecord[]; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + stat(path: Path): Observable | null; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): null; + // (undocumented) + willCreate(path: Path): boolean; + // (undocumented) + willDelete(path: Path): boolean; + // (undocumented) + willOverwrite(path: Path): boolean; + // (undocumented) + willRename(path: Path): boolean; + // (undocumented) + willRenameTo(path: Path, to: Path): boolean; + // (undocumented) + write(path: Path, content: FileBuffer): Observable; +} + +// @public (undocumented) +interface CordHostCreate { + // (undocumented) + content: FileBuffer; + // (undocumented) + kind: 'create'; + // (undocumented) + path: Path; +} + +// @public (undocumented) +interface CordHostDelete { + // (undocumented) + kind: 'delete'; + // (undocumented) + path: Path; +} + +// @public (undocumented) +interface CordHostOverwrite { + // (undocumented) + content: FileBuffer; + // (undocumented) + kind: 'overwrite'; + // (undocumented) + path: Path; +} + +// @public (undocumented) +type CordHostRecord = CordHostCreate | CordHostOverwrite | CordHostRename | CordHostDelete; + +// @public (undocumented) +interface CordHostRename { + // (undocumented) + from: Path; + // (undocumented) + kind: 'rename'; + // (undocumented) + to: Path; +} + +// @public (undocumented) +class CoreSchemaRegistry implements SchemaRegistry { + constructor(formats?: SchemaFormat[]); + // (undocumented) + addFormat(format: SchemaFormat): void; + addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + // (undocumented) + addSmartDefaultProvider(source: string, provider: SmartDefaultProvider): void; + compile(schema: JsonSchema): Promise; + // (undocumented) + registerUriHandler(handler: UriHandler): void; + // (undocumented) + protected _resolver(ref: string, validate?: ValidateFunction): { + context?: ValidateFunction; + schema?: JsonObject; + }; + // (undocumented) + usePromptProvider(provider: PromptProvider): void; + // (undocumented) + useXDeprecatedProvider(onUsage: (message: string) => void): void; + ɵflatten(schema: JsonObject): Promise; +} + +// @public (undocumented) +function createSyncHost(handler: SyncHostHandler): Host; + +// @public (undocumented) +function createWorkspaceHost(host: virtualFs.Host): WorkspaceHost; + +// @public +function dasherize(str: string): string; + +// @public +function decamelize(str: string): string; + +// @public @deprecated (undocumented) +export function deepCopy(value: T): T; + +// @public (undocumented) +type DefinitionCollectionListener = (name: string, newValue: V | undefined, collection: DefinitionCollection) => void; + +// @public (undocumented) +export class DependencyNotFoundException extends BaseException { + constructor(); +} + +// @public +export function dirname(path: Path): Path; + +// @public (undocumented) +class Empty implements ReadonlyHost { + // (undocumented) + readonly capabilities: HostCapabilities; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + stat(path: Path): Observable | null>; +} + +// @public (undocumented) +export function extname(path: Path): string; + +// @public (undocumented) +export class FileAlreadyExistException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +type FileBuffer = ArrayBuffer; + +// @public (undocumented) +type FileBufferLike = ArrayBufferLike; + +// @public @deprecated (undocumented) +function fileBufferToString(fileBuffer: FileBuffer): string; + +// @public (undocumented) +export class FileDoesNotExistException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export function fragment(path: string): PathFragment; + +// @public (undocumented) +export function getSystemPath(path: Path): string; + +// @public (undocumented) +function getTypesOfSchema(schema: JsonSchema): Set; + +// @public (undocumented) +interface Host extends ReadonlyHost { + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: FileBufferLike): Observable; +} + +// @public (undocumented) +interface HostCapabilities { + // (undocumented) + synchronous: boolean; +} + +// @public (undocumented) +interface HostWatchEvent { + // (undocumented) + readonly path: Path; + // (undocumented) + readonly time: Date; + // (undocumented) + readonly type: HostWatchEventType; +} + +// @public (undocumented) +enum HostWatchEventType { + // (undocumented) + Changed = 0, + // (undocumented) + Created = 1, + // (undocumented) + Deleted = 2, + // (undocumented) + Renamed = 3 +} + +// @public (undocumented) +interface HostWatchOptions { + // (undocumented) + readonly persistent?: boolean; + // (undocumented) + readonly recursive?: boolean; +} + +// @public (undocumented) +function indentBy(indentations: number): TemplateTag; + +// @public (undocumented) +class IndentLogger extends Logger { + constructor(name: string, parent?: Logger | null, indentation?: string); +} + +// @public (undocumented) +export class InvalidPathException extends BaseException { + constructor(path: string); +} + +// @public +export function isAbsolute(p: Path): boolean; + +// @public (undocumented) +export function isJsonArray(value: JsonValue): value is JsonArray; + +// @public (undocumented) +export function isJsonObject(value: JsonValue): value is JsonObject; + +// @public (undocumented) +function isJsonSchema(value: unknown): value is JsonSchema; + +// @public @deprecated +export function isPromise(obj: any): obj is Promise; + +// @public +export function join(p1: Path, ...others: string[]): Path; + +// @public (undocumented) +function joinJsonPointer(root: JsonPointer, ...others: string[]): JsonPointer; + +declare namespace json { + export { + schema, + isJsonObject, + isJsonArray, + JsonArray, + JsonObject, + JsonValue + } +} +export { json } + +// @public +export interface JsonArray extends Array { +} + +// @public (undocumented) +export interface JsonObject { + // (undocumented) + [prop: string]: JsonValue; +} + +// @public (undocumented) +type JsonPointer = string & { + __PRIVATE_DEVKIT_JSON_POINTER: void; +}; + +// @public +type JsonSchema = JsonObject | boolean; + +// @public (undocumented) +interface JsonSchemaVisitor { + // (undocumented) + (current: JsonObject | JsonArray, pointer: JsonPointer, parentSchema?: JsonObject | JsonArray, index?: string): void; +} + +// @public (undocumented) +export type JsonValue = boolean | string | number | JsonArray | JsonObject | null; + +// @public (undocumented) +interface JsonVisitor { + // (undocumented) + (value: JsonValue, pointer: JsonPointer, schema?: JsonObject, root?: JsonObject | JsonArray): Observable | JsonValue; +} + +// @public (undocumented) +class LevelCapLogger extends LevelTransformLogger { + constructor(name: string, parent: Logger | null, levelCap: LogLevel); + // (undocumented) + readonly levelCap: LogLevel; + // (undocumented) + static levelMap: { + [cap: string]: { + [level: string]: string; + }; + }; + // (undocumented) + readonly name: string; + // (undocumented) + readonly parent: Logger | null; +} + +// @public (undocumented) +class LevelTransformLogger extends Logger { + constructor(name: string, parent: Logger | null, levelTransform: (level: LogLevel) => LogLevel); + // (undocumented) + createChild(name: string): Logger; + // (undocumented) + readonly levelTransform: (level: LogLevel) => LogLevel; + // (undocumented) + log(level: LogLevel, message: string, metadata?: JsonObject): void; + // (undocumented) + readonly name: string; + // (undocumented) + readonly parent: Logger | null; +} + +// @public +function levenshtein(a: string, b: string): number; + +// @public (undocumented) +interface LogEntry extends LoggerMetadata { + // (undocumented) + level: LogLevel; + // (undocumented) + message: string; + // (undocumented) + timestamp: number; +} + +// @public (undocumented) +class Logger extends Observable implements LoggerApi { + constructor(name: string, parent?: Logger | null); + // (undocumented) + asApi(): LoggerApi; + // (undocumented) + complete(): void; + // (undocumented) + createChild(name: string): Logger; + // (undocumented) + debug(message: string, metadata?: JsonObject): void; + // (undocumented) + error(message: string, metadata?: JsonObject): void; + // (undocumented) + fatal(message: string, metadata?: JsonObject): void; + // (undocumented) + forEach(next: (value: LogEntry) => void, promiseCtor?: PromiseConstructorLike): Promise; + // (undocumented) + info(message: string, metadata?: JsonObject): void; + // (undocumented) + lift(operator: Operator): Observable; + // (undocumented) + log(level: LogLevel, message: string, metadata?: JsonObject): void; + // (undocumented) + protected _metadata: LoggerMetadata; + // (undocumented) + readonly name: string; + // (undocumented) + next(entry: LogEntry): void; + // (undocumented) + protected get _observable(): Observable; + protected set _observable(v: Observable); + // (undocumented) + readonly parent: Logger | null; + // (undocumented) + protected readonly _subject: Subject; + // (undocumented) + subscribe(): Subscription; + // (undocumented) + subscribe(observer: PartialObserver): Subscription; + // (undocumented) + subscribe(next?: (value: LogEntry) => void, error?: (error: Error) => void, complete?: () => void): Subscription; + // (undocumented) + toString(): string; + // (undocumented) + warn(message: string, metadata?: JsonObject): void; +} + +// @public (undocumented) +interface LoggerApi { + // (undocumented) + createChild(name: string): Logger; + // (undocumented) + debug(message: string, metadata?: JsonObject): void; + // (undocumented) + error(message: string, metadata?: JsonObject): void; + // (undocumented) + fatal(message: string, metadata?: JsonObject): void; + // (undocumented) + info(message: string, metadata?: JsonObject): void; + // (undocumented) + log(level: LogLevel, message: string, metadata?: JsonObject): void; + // (undocumented) + warn(message: string, metadata?: JsonObject): void; +} + +// @public (undocumented) +interface LoggerMetadata extends JsonObject { + // (undocumented) + name: string; + // (undocumented) + path: string[]; +} + +declare namespace logging { + export { + IndentLogger, + LevelTransformLogger, + LevelCapLogger, + LoggerMetadata, + LogEntry, + LoggerApi, + LogLevel, + Logger, + NullLogger, + TransformLogger + } +} +export { logging } + +// @public (undocumented) +type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'fatal'; + +// @public +function mergeSchemas(...schemas: (JsonSchema | undefined)[]): JsonSchema; + +// @public +export function noCacheNormalize(path: string): Path; + +// @public +export function normalize(path: string): Path; + +// @public +export const NormalizedRoot: Path; + +// @public +export const NormalizedSep: Path; + +// @public (undocumented) +class NullLogger extends Logger { + constructor(parent?: Logger | null); + // (undocumented) + asApi(): LoggerApi; +} + +// @public (undocumented) +function oneLine(strings: TemplateStringsArray, ...values: any[]): string; + +// @public (undocumented) +function parseJsonPointer(pointer: JsonPointer): string[]; + +// @public @deprecated (undocumented) +export class PartiallyOrderedSet { + // (undocumented) + [Symbol.iterator](): IterableIterator; + // (undocumented) + get [Symbol.toStringTag](): 'PartiallyOrderedSet'; + // (undocumented) + add(item: T, deps?: Set | T[]): this; + // (undocumented) + protected _checkCircularDependencies(item: T, deps: Set): void; + // (undocumented) + clear(): void; + // (undocumented) + delete(item: T): boolean; + entries(): IterableIterator<[T, T]>; + // (undocumented) + forEach(callbackfn: (value: T, value2: T, set: PartiallyOrderedSet) => void, thisArg?: any): void; + // (undocumented) + has(item: T): boolean; + keys(): IterableIterator; + // (undocumented) + get size(): number; + values(): IterableIterator; +} + +// @public +export type Path = string & { + __PRIVATE_DEVKIT_PATH: void; +}; + +// @public (undocumented) +export const path: TemplateTag; + +// @public (undocumented) +export class PathCannotBeFragmentException extends BaseException { + constructor(path: string); +} + +// @public +export type PathFragment = Path & { + __PRIVATE_DEVKIT_PATH_FRAGMENT: void; +}; + +// @public (undocumented) +export class PathIsDirectoryException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export class PathIsFileException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export class PathMustBeAbsoluteException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +class PatternMatchingHost extends ResolverHost { + // (undocumented) + addPattern(pattern: string | string[], replacementFn: ReplacementFunction): void; + // (undocumented) + protected _patterns: Map; + // (undocumented) + protected _resolve(path: Path): Path; +} + +// @public (undocumented) +export type PosixPath = string & { + __PRIVATE_DEVKIT_POSIX_PATH: void; +}; + +// @public +export class PriorityQueue { + constructor(_comparator: (x: T, y: T) => number); + // (undocumented) + clear(): void; + // (undocumented) + peek(): T | undefined; + // (undocumented) + pop(): T | undefined; + // (undocumented) + push(item: T): void; + // (undocumented) + get size(): number; + // (undocumented) + toArray(): Array; +} + +// @public (undocumented) +interface ProjectDefinition { + // (undocumented) + readonly extensions: Record; + // (undocumented) + prefix?: string; + // (undocumented) + root: string; + // (undocumented) + sourceRoot?: string; + // (undocumented) + readonly targets: TargetDefinitionCollection; +} + +// @public (undocumented) +class ProjectDefinitionCollection extends DefinitionCollection { + constructor(initial?: Record, listener?: DefinitionCollectionListener); + // (undocumented) + add(definition: { + name: string; + root: string; + sourceRoot?: string; + prefix?: string; + targets?: Record; + [key: string]: unknown; + }): ProjectDefinition; + // (undocumented) + set(name: string, value: ProjectDefinition): this; +} + +// @public (undocumented) +interface PromptDefinition { + // (undocumented) + default?: string | string[] | number | boolean | null; + // (undocumented) + id: string; + // (undocumented) + items?: Array; + // (undocumented) + message: string; + // (undocumented) + multiselect?: boolean; + // (undocumented) + propertyTypes: Set; + // (undocumented) + raw?: string | JsonObject; + // (undocumented) + type: string; + // (undocumented) + validator?: (value: JsonValue) => boolean | string | Promise; +} + +// @public (undocumented) +type PromptProvider = (definitions: Array) => ObservableInput<{ + [id: string]: JsonValue; +}>; + +// @public (undocumented) +interface ReadonlyHost { + // (undocumented) + readonly capabilities: HostCapabilities; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + stat(path: Path): Observable | null> | null; +} + +// @public +function readWorkspace(path: string, host: WorkspaceHost, format?: WorkspaceFormat): Promise<{ + workspace: WorkspaceDefinition; +}>; + +// @public (undocumented) +interface ReferenceResolver { + // (undocumented) + (ref: string, context?: ContextT): { + context?: ContextT; + schema?: JsonObject; + }; +} + +// @public +export function relative(from: Path, to: Path): Path; + +// @public (undocumented) +type ReplacementFunction = (path: Path) => Path; + +// @public +export function resetNormalizeCache(): void; + +// @public +export function resolve(p1: Path, p2: Path): Path; + +// @public +abstract class ResolverHost implements Host { + constructor(_delegate: Host); + // (undocumented) + get capabilities(): HostCapabilities; + // (undocumented) + protected _delegate: Host; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + protected abstract _resolve(path: Path): Path; + // (undocumented) + stat(path: Path): Observable | null> | null; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: FileBuffer): Observable; +} + +// @public +class SafeReadonlyHost implements ReadonlyHost { + constructor(_delegate: ReadonlyHost); + // (undocumented) + get capabilities(): HostCapabilities; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + stat(path: Path): Observable | null> | null; +} + +declare namespace schema { + export { + transforms, + JsonPointer, + SchemaValidatorResult, + SchemaValidatorError, + SchemaValidatorOptions, + SchemaValidator, + SchemaFormatter, + SchemaFormat, + SmartDefaultProvider, + SchemaKeywordValidator, + PromptDefinition, + PromptProvider, + SchemaRegistry, + JsonSchemaVisitor, + JsonVisitor, + buildJsonPointer, + joinJsonPointer, + parseJsonPointer, + UriHandler, + SchemaValidationException, + CoreSchemaRegistry, + isJsonSchema, + mergeSchemas, + JsonSchema, + visitJson, + visitJsonSchema, + ReferenceResolver, + getTypesOfSchema + } +} +export { schema } + +// @public (undocumented) +interface SchemaFormat { + // (undocumented) + formatter: SchemaFormatter; + // (undocumented) + name: string; +} + +// @public (undocumented) +type SchemaFormatter = Format; + +// @public (undocumented) +interface SchemaKeywordValidator { + // (undocumented) + (data: JsonValue, schema: JsonValue, parent: JsonObject | JsonArray | undefined, parentProperty: string | number | undefined, pointer: JsonPointer, rootData: JsonValue): boolean | Observable; +} + +// @public (undocumented) +interface SchemaRegistry { + // (undocumented) + addFormat(format: SchemaFormat): void; + addPostTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + addPreTransform(visitor: JsonVisitor, deps?: JsonVisitor[]): void; + // (undocumented) + addSmartDefaultProvider(source: string, provider: SmartDefaultProvider): void; + // (undocumented) + compile(schema: Object): Promise; + // (undocumented) + usePromptProvider(provider: PromptProvider): void; + // (undocumented) + useXDeprecatedProvider(onUsage: (message: string) => void): void; + // (undocumented) + ɵflatten(schema: JsonObject | string): Promise; +} + +// @public (undocumented) +class SchemaValidationException extends BaseException { + constructor(errors?: SchemaValidatorError[], baseMessage?: string); + // (undocumented) + static createMessages(errors?: SchemaValidatorError[]): string[]; + // (undocumented) + readonly errors: SchemaValidatorError[]; +} + +// @public (undocumented) +interface SchemaValidator { + // (undocumented) + (data: JsonValue, options?: SchemaValidatorOptions): Promise; +} + +// @public (undocumented) +type SchemaValidatorError = Partial; + +// @public (undocumented) +interface SchemaValidatorOptions { + // (undocumented) + applyPostTransforms?: boolean; + // (undocumented) + applyPreTransforms?: boolean; + // (undocumented) + withPrompts?: boolean; +} + +// @public (undocumented) +interface SchemaValidatorResult { + // (undocumented) + data: JsonValue; + // (undocumented) + errors?: SchemaValidatorError[]; + // (undocumented) + success: boolean; +} + +// @public (undocumented) +class ScopedHost extends ResolverHost { + constructor(delegate: Host, _root?: Path); + // (undocumented) + protected _resolve(path: Path): Path; + // (undocumented) + protected _root: Path; +} + +// @public (undocumented) +class SimpleMemoryHost implements Host<{}> { + constructor(); + // (undocumented) + protected _cache: Map>; + // (undocumented) + get capabilities(): HostCapabilities; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + protected _delete(path: Path): void; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + protected _exists(path: Path): boolean; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + protected _isDirectory(path: Path): boolean; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + protected _isFile(path: Path): boolean; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + protected _list(path: Path): PathFragment[]; + // (undocumented) + protected _newDirStats(): Stats; + // (undocumented) + protected _newFileStats(content: FileBuffer, oldStats?: Stats): Stats; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + protected _read(path: Path): FileBuffer; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + protected _rename(from: Path, to: Path): void; + // (undocumented) + reset(): void; + // (undocumented) + stat(path: Path): Observable | null> | null; + // (undocumented) + protected _stat(path: Path): Stats | null; + // (undocumented) + protected _toAbsolute(path: Path): Path; + // (undocumented) + protected _updateWatchers(path: Path, type: HostWatchEventType): void; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): Observable | null; + // (undocumented) + protected _watch(path: Path, options?: HostWatchOptions): Observable; + // (undocumented) + write(path: Path, content: FileBuffer): Observable; + protected _write(path: Path, content: FileBuffer): void; +} + +// @public (undocumented) +interface SimpleMemoryHostStats { + // (undocumented) + readonly content: FileBuffer | null; + // (undocumented) + inspect(): string; +} + +// @public (undocumented) +interface SmartDefaultProvider { + // (undocumented) + (schema: JsonObject): T | Observable; +} + +// @public +export function split(path: Path): PathFragment[]; + +// @public (undocumented) +type Stats = T & { + isFile(): boolean; + isDirectory(): boolean; + readonly size: number; + readonly atime: Date; + readonly mtime: Date; + readonly ctime: Date; + readonly birthtime: Date; +}; + +declare namespace strings { + export { + decamelize, + dasherize, + camelize, + classify, + underscore, + capitalize, + levenshtein + } +} +export { strings } + +// @public @deprecated (undocumented) +function stringToFileBuffer(str: string): FileBuffer; + +// @public (undocumented) +function stripIndent(strings: TemplateStringsArray, ...values: any[]): string; + +// @public (undocumented) +function stripIndents(strings: TemplateStringsArray, ...values: any[]): string; + +// @public +class SyncDelegateHost { + constructor(_delegate: Host); + // (undocumented) + get capabilities(): HostCapabilities; + // (undocumented) + get delegate(): Host; + // (undocumented) + protected _delegate: Host; + // (undocumented) + delete(path: Path): void; + // (undocumented) + protected _doSyncCall(observable: Observable): ResultT; + // (undocumented) + exists(path: Path): boolean; + // (undocumented) + isDirectory(path: Path): boolean; + // (undocumented) + isFile(path: Path): boolean; + // (undocumented) + list(path: Path): PathFragment[]; + // (undocumented) + read(path: Path): FileBuffer; + // (undocumented) + rename(from: Path, to: Path): void; + // (undocumented) + stat(path: Path): Stats | null; + // (undocumented) + watch(path: Path, options?: HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: FileBufferLike): void; +} + +// @public (undocumented) +interface SyncHostHandler { + // (undocumented) + delete(path: Path): void; + // (undocumented) + exists(path: Path): boolean; + // (undocumented) + isDirectory(path: Path): boolean; + // (undocumented) + isFile(path: Path): boolean; + // (undocumented) + list(path: Path): PathFragment[]; + // (undocumented) + read(path: Path): FileBuffer; + // (undocumented) + rename(from: Path, to: Path): void; + // (undocumented) + stat(path: Path): Stats | null; + // (undocumented) + write(path: Path, content: FileBufferLike): void; +} + +// @public (undocumented) +class SynchronousDelegateExpectedException extends BaseException { + constructor(); +} + +declare namespace tags { + export { + oneLine, + indentBy, + stripIndent, + stripIndents, + trimNewlines, + TemplateTag + } +} +export { tags } + +// @public (undocumented) +interface TargetDefinition { + // (undocumented) + builder: string; + // (undocumented) + configurations?: Record | undefined>; + // (undocumented) + defaultConfiguration?: string; + // (undocumented) + options?: Record; +} + +// @public (undocumented) +class TargetDefinitionCollection extends DefinitionCollection { + constructor(initial?: Record, listener?: DefinitionCollectionListener); + // (undocumented) + add(definition: { + name: string; + } & TargetDefinition): TargetDefinition; + // (undocumented) + set(name: string, value: TargetDefinition): this; +} + +// @public +export function template(content: string, options?: TemplateOptions): (input: T) => string; + +// @public +export interface TemplateAst { + // (undocumented) + children: TemplateAstNode[]; + // (undocumented) + content: string; + // (undocumented) + fileName: string; +} + +// @public +export interface TemplateAstBase { + // (undocumented) + end: Position; + // (undocumented) + start: Position; +} + +// @public +export interface TemplateAstComment extends TemplateAstBase { + // (undocumented) + kind: 'comment'; + // (undocumented) + text: string; +} + +// @public +export interface TemplateAstContent extends TemplateAstBase { + // (undocumented) + content: string; + // (undocumented) + kind: 'content'; +} + +// @public +export interface TemplateAstEscape extends TemplateAstBase { + // (undocumented) + expression: string; + // (undocumented) + kind: 'escape'; +} + +// @public +export interface TemplateAstEvaluate extends TemplateAstBase { + // (undocumented) + expression: string; + // (undocumented) + kind: 'evaluate'; +} + +// @public +export interface TemplateAstInterpolate extends TemplateAstBase { + // (undocumented) + expression: string; + // (undocumented) + kind: 'interpolate'; +} + +// @public (undocumented) +export type TemplateAstNode = TemplateAstContent | TemplateAstEvaluate | TemplateAstComment | TemplateAstEscape | TemplateAstInterpolate; + +// @public (undocumented) +export interface TemplateOptions { + // (undocumented) + fileName?: string; + // (undocumented) + module?: boolean | { + exports: {}; + }; + // (undocumented) + sourceMap?: boolean; + // (undocumented) + sourceRoot?: string; + // (undocumented) + sourceURL?: string; +} + +// @public +export function templateParser(sourceText: string, fileName: string): TemplateAst; + +// @public +interface TemplateTag { + // (undocumented) + (template: TemplateStringsArray, ...substitutions: any[]): R; +} + +declare namespace test { + export { + TestLogRecord, + TestHost + } +} + +// @public (undocumented) +class TestHost extends SimpleMemoryHost { + // (undocumented) + $exists(path: string): boolean; + // (undocumented) + $isDirectory(path: string): boolean; + // (undocumented) + $isFile(path: string): boolean; + // (undocumented) + $list(path: string): PathFragment[]; + // (undocumented) + $read(path: string): string; + // (undocumented) + $write(path: string, content: string): void; + constructor(map?: { + [path: string]: string; + }); + // (undocumented) + clearRecords(): void; + // (undocumented) + clone(): TestHost; + // (undocumented) + protected _delete(path: Path): void; + // (undocumented) + protected _exists(path: Path): boolean; + // (undocumented) + get files(): Path[]; + // (undocumented) + protected _isDirectory(path: Path): boolean; + // (undocumented) + protected _isFile(path: Path): boolean; + // (undocumented) + protected _list(path: Path): PathFragment[]; + // (undocumented) + protected _read(path: Path): ArrayBuffer; + // (undocumented) + get records(): TestLogRecord[]; + // (undocumented) + protected _records: TestLogRecord[]; + // (undocumented) + protected _rename(from: Path, to: Path): void; + // (undocumented) + protected _stat(path: Path): Stats | null; + // (undocumented) + get sync(): SyncDelegateHost<{}>; + // (undocumented) + protected _sync: SyncDelegateHost<{}> | null; + // (undocumented) + protected _watch(path: Path, options?: HostWatchOptions): Observable; + // (undocumented) + protected _write(path: Path, content: FileBuffer): void; +} + +// @public (undocumented) +type TestLogRecord = { + kind: 'write' | 'read' | 'delete' | 'list' | 'exists' | 'isDirectory' | 'isFile' | 'stat' | 'watch'; + path: Path; +} | { + kind: 'rename'; + from: Path; + to: Path; +}; + +// @public (undocumented) +class TransformLogger extends Logger { + constructor(name: string, transform: (stream: Observable) => Observable, parent?: Logger | null); +} + +declare namespace transforms { + export { + addUndefinedObjectDefaults, + addUndefinedDefaults + } +} + +// @public (undocumented) +function trimNewlines(strings: TemplateStringsArray, ...values: any[]): string; + +// @public +function underscore(str: string): string; + +// @public (undocumented) +export class UnknownException extends BaseException { + constructor(message: string); +} + +// @public (undocumented) +type UriHandler = (uri: string) => Observable | Promise | null | undefined; + +declare namespace virtualFs { + export { + test, + AliasHost, + stringToFileBuffer, + fileBufferToString, + createSyncHost, + SyncHostHandler, + Empty, + FileBuffer, + FileBufferLike, + HostWatchOptions, + HostWatchEventType, + Stats, + HostWatchEvent, + HostCapabilities, + ReadonlyHost, + Host, + SimpleMemoryHostStats, + SimpleMemoryHost, + ReplacementFunction, + PatternMatchingHost, + CordHostCreate, + CordHostOverwrite, + CordHostRename, + CordHostDelete, + CordHostRecord, + CordHost, + SafeReadonlyHost, + ScopedHost, + SynchronousDelegateExpectedException, + SyncDelegateHost, + ResolverHost + } +} +export { virtualFs } + +// @public +function visitJson(json: JsonValue, visitor: JsonVisitor, schema?: JsonSchema, refResolver?: ReferenceResolver, context?: ContextT): Observable; + +// @public (undocumented) +function visitJsonSchema(schema: JsonSchema, visitor: JsonSchemaVisitor): void; + +// @public (undocumented) +export type WindowsPath = string & { + __PRIVATE_DEVKIT_WINDOWS_PATH: void; +}; + +// @public (undocumented) +interface WorkspaceDefinition { + // (undocumented) + readonly extensions: Record; + // (undocumented) + readonly projects: ProjectDefinitionCollection; +} + +// @public +enum WorkspaceFormat { + // (undocumented) + JSON = 0 +} + +// @public (undocumented) +interface WorkspaceHost { + // (undocumented) + isDirectory(path: string): Promise; + // (undocumented) + isFile(path: string): Promise; + // (undocumented) + readFile(path: string): Promise; + // (undocumented) + writeFile(path: string, data: string): Promise; +} + +declare namespace workspaces { + export { + WorkspaceHost, + createWorkspaceHost, + WorkspaceFormat, + readWorkspace, + writeWorkspace, + WorkspaceDefinition, + ProjectDefinition, + TargetDefinition, + DefinitionCollectionListener, + ProjectDefinitionCollection, + TargetDefinitionCollection + } +} +export { workspaces } + +// @public +function writeWorkspace(workspace: WorkspaceDefinition, host: WorkspaceHost, path?: string, format?: WorkspaceFormat): Promise; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/core/node/index.api.md b/goldens/public-api/angular_devkit/core/node/index.api.md new file mode 100644 index 000000000000..9fcdee9770cb --- /dev/null +++ b/goldens/public-api/angular_devkit/core/node/index.api.md @@ -0,0 +1,77 @@ +## API Report File for "@angular-devkit/core_node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { Observable } from 'rxjs'; +import { Operator } from 'rxjs'; +import { PartialObserver } from 'rxjs'; +import { Stats as Stats_2 } from 'node:fs'; +import { Subject } from 'rxjs'; +import { Subscription } from 'rxjs'; + +// @public @deprecated +export function createConsoleLogger(verbose?: boolean, stdout?: ProcessOutput, stderr?: ProcessOutput, colors?: Partial string>>): logging.Logger; + +// @public +export class NodeJsAsyncHost implements virtualFs.Host { + // (undocumented) + get capabilities(): virtualFs.HostCapabilities; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + stat(path: Path): Observable>; + // (undocumented) + watch(path: Path, _options?: virtualFs.HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: virtualFs.FileBuffer): Observable; +} + +// @public +export class NodeJsSyncHost implements virtualFs.Host { + // (undocumented) + get capabilities(): virtualFs.HostCapabilities; + // (undocumented) + delete(path: Path): Observable; + // (undocumented) + exists(path: Path): Observable; + // (undocumented) + isDirectory(path: Path): Observable; + // (undocumented) + isFile(path: Path): Observable; + // (undocumented) + list(path: Path): Observable; + // (undocumented) + read(path: Path): Observable; + // (undocumented) + rename(from: Path, to: Path): Observable; + // (undocumented) + stat(path: Path): Observable>; + // (undocumented) + watch(path: Path, _options?: virtualFs.HostWatchOptions): Observable | null; + // (undocumented) + write(path: Path, content: virtualFs.FileBuffer): Observable; +} + +// @public (undocumented) +export interface ProcessOutput { + // (undocumented) + write(buffer: string | Buffer): boolean; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/core/node/testing/index.api.md b/goldens/public-api/angular_devkit/core/node/testing/index.api.md new file mode 100644 index 000000000000..116019d266a1 --- /dev/null +++ b/goldens/public-api/angular_devkit/core/node/testing/index.api.md @@ -0,0 +1,27 @@ +## API Report File for "@angular-devkit/core_node_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as fs from 'node:fs'; +import { Observable } from 'rxjs'; + +// @public +export class TempScopedNodeJsSyncHost extends virtualFs.ScopedHost { + constructor(); + // (undocumented) + get files(): Path[]; + // (undocumented) + get root(): Path; + // (undocumented) + protected _root: Path; + // (undocumented) + get sync(): virtualFs.SyncDelegateHost; + // (undocumented) + protected _sync?: virtualFs.SyncDelegateHost; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/schematics/index.api.md b/goldens/public-api/angular_devkit/schematics/index.api.md new file mode 100644 index 000000000000..efb5da7c8166 --- /dev/null +++ b/goldens/public-api/angular_devkit/schematics/index.api.md @@ -0,0 +1,1065 @@ +## API Report File for "@angular-devkit/schematics" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; +import { strings } from '@angular-devkit/core'; +import { Subject } from 'rxjs'; +import { Url } from 'node:url'; +import { virtualFs } from '@angular-devkit/core'; + +// @public (undocumented) +export type Action = CreateFileAction | OverwriteFileAction | RenameFileAction | DeleteFileAction; + +// @public (undocumented) +export interface ActionBase { + // (undocumented) + readonly id: number; + // (undocumented) + readonly parent: number; + // (undocumented) + readonly path: Path; +} + +// @public (undocumented) +export class ActionList implements Iterable { + // (undocumented) + [Symbol.iterator](): IterableIterator; + // (undocumented) + protected _action(action: Partial): void; + // (undocumented) + create(path: Path, content: Buffer): void; + // (undocumented) + delete(path: Path): void; + // (undocumented) + find(predicate: (value: Action) => boolean): Action | null; + // (undocumented) + forEach(fn: (value: Action, index: number, array: Action[]) => void, thisArg?: {}): void; + // (undocumented) + get(i: number): Action; + // (undocumented) + has(action: Action): boolean; + // (undocumented) + get length(): number; + // (undocumented) + optimize(): void; + // (undocumented) + overwrite(path: Path, content: Buffer): void; + // (undocumented) + push(action: Action): void; + // (undocumented) + rename(path: Path, to: Path): void; +} + +// @public +export function apply(source: Source, rules: Rule[]): Source; + +// @public (undocumented) +export function applyContentTemplate(options: T): FileOperator; + +// @public (undocumented) +export function applyPathTemplate(data: T, options?: PathTemplateOptions): FileOperator; + +// @public (undocumented) +export function applyTemplates(options: T): Rule; + +// @public (undocumented) +export function applyToSubtree(path: string, rules: Rule[]): Rule; + +// @public (undocumented) +export function asSource(rule: Rule): Source; + +// @public (undocumented) +export type AsyncFileOperator = (tree: FileEntry) => Observable; + +// @public +abstract class BaseWorkflow implements Workflow { + constructor(options: BaseWorkflowOptions); + // (undocumented) + get context(): Readonly; + // (undocumented) + protected _context: WorkflowExecutionContext[]; + // (undocumented) + protected _createSinks(): Sink[]; + // (undocumented) + protected _dryRun: boolean; + // (undocumented) + get engine(): Engine<{}, {}>; + // (undocumented) + protected _engine: Engine<{}, {}>; + // (undocumented) + get engineHost(): EngineHost<{}, {}>; + // (undocumented) + protected _engineHost: EngineHost<{}, {}>; + // (undocumented) + execute(options: Partial & RequiredWorkflowExecutionContext): Observable; + // (undocumented) + protected _force: boolean; + // (undocumented) + protected _host: virtualFs.Host; + // (undocumented) + get lifeCycle(): Observable; + // (undocumented) + protected _lifeCycle: Subject; + // (undocumented) + get registry(): schema.SchemaRegistry; + // (undocumented) + protected _registry: schema.CoreSchemaRegistry; + // (undocumented) + get reporter(): Observable; + // (undocumented) + protected _reporter: Subject; +} + +// @public (undocumented) +interface BaseWorkflowOptions { + // (undocumented) + dryRun?: boolean; + // (undocumented) + engineHost: EngineHost<{}, {}>; + // (undocumented) + force?: boolean; + // (undocumented) + host: virtualFs.Host; + // (undocumented) + registry?: schema.CoreSchemaRegistry; +} + +// @public (undocumented) +export function branchAndMerge(rule: Rule, strategy?: MergeStrategy): Rule; + +// @public (undocumented) +export function callRule(rule: Rule, input: Tree_2 | Observable, context: SchematicContext): Observable; + +// @public (undocumented) +export function callSource(source: Source, context: SchematicContext): Observable; + +// @public +export function chain(rules: Iterable | AsyncIterable): Rule; + +// @public (undocumented) +export class CircularCollectionException extends BaseException { + constructor(name: string); +} + +// @public +export interface Collection { + // (undocumented) + readonly baseDescriptions?: Array>; + // (undocumented) + createSchematic(name: string, allowPrivate?: boolean): Schematic; + // (undocumented) + readonly description: CollectionDescription; + // (undocumented) + listSchematicNames(includeHidden?: boolean): string[]; +} + +// @public +export type CollectionDescription = CollectionMetadataT & { + readonly name: string; + readonly extends?: string[]; +}; + +// @public (undocumented) +export class CollectionImpl implements Collection { + constructor(_description: CollectionDescription, _engine: SchematicEngine, baseDescriptions?: CollectionDescription[] | undefined); + // (undocumented) + readonly baseDescriptions?: CollectionDescription[] | undefined; + // (undocumented) + createSchematic(name: string, allowPrivate?: boolean): Schematic; + // (undocumented) + get description(): CollectionDescription; + // (undocumented) + listSchematicNames(includeHidden?: boolean): string[]; + // (undocumented) + get name(): string; +} + +// @public (undocumented) +export function composeFileOperators(operators: FileOperator[]): FileOperator; + +// @public (undocumented) +export class ContentHasMutatedException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export function contentTemplate(options: T): Rule; + +// @public (undocumented) +export interface CreateFileAction extends ActionBase { + // (undocumented) + readonly content: Buffer; + // (undocumented) + readonly kind: 'c'; +} + +// @public (undocumented) +export interface DelegateTree { +} + +// @public (undocumented) +export class DelegateTree implements Tree_2 { + // (undocumented) + [TreeSymbol]: () => DelegateTree; + constructor(_other: Tree_2); + // (undocumented) + get actions(): Action[]; + // (undocumented) + apply(action: Action, strategy?: MergeStrategy): void; + // (undocumented) + beginUpdate(path: string): UpdateRecorder; + // (undocumented) + branch(): Tree_2; + // (undocumented) + commitUpdate(record: UpdateRecorder): void; + // (undocumented) + create(path: string, content: Buffer | string): void; + // (undocumented) + delete(path: string): void; + // (undocumented) + exists(path: string): boolean; + // (undocumented) + get(path: string): FileEntry | null; + // (undocumented) + getDir(path: string): DirEntry; + // (undocumented) + merge(other: Tree_2, strategy?: MergeStrategy): void; + // (undocumented) + protected _other: Tree_2; + // (undocumented) + overwrite(path: string, content: Buffer | string): void; + // (undocumented) + read(path: string): Buffer | null; + // (undocumented) + readJson(path: string): JsonValue; + // (undocumented) + readText(path: string): string; + // (undocumented) + rename(from: string, to: string): void; + // (undocumented) + get root(): DirEntry; + // (undocumented) + visit(visitor: FileVisitor): void; +} + +// @public (undocumented) +export interface DeleteFileAction extends ActionBase { + // (undocumented) + readonly kind: 'd'; +} + +// @public (undocumented) +export interface DirEntry { + // (undocumented) + dir(name: PathFragment): DirEntry; + // (undocumented) + file(name: PathFragment): FileEntry | null; + // (undocumented) + readonly parent: DirEntry | null; + // (undocumented) + readonly path: Path; + // (undocumented) + readonly subdirs: PathFragment[]; + // (undocumented) + readonly subfiles: PathFragment[]; + // (undocumented) + visit(visitor: FileVisitor): void; +} + +// @public (undocumented) +export interface DryRunCreateEvent { + // (undocumented) + content: ArrayBufferLike; + // (undocumented) + kind: 'create'; + // (undocumented) + path: string; +} + +// @public (undocumented) +export interface DryRunDeleteEvent { + // (undocumented) + kind: 'delete'; + // (undocumented) + path: string; +} + +// @public (undocumented) +export interface DryRunErrorEvent { + // (undocumented) + description: 'alreadyExist' | 'doesNotExist'; + // (undocumented) + kind: 'error'; + // (undocumented) + path: string; +} + +// @public (undocumented) +export type DryRunEvent = DryRunErrorEvent | DryRunDeleteEvent | DryRunCreateEvent | DryRunUpdateEvent | DryRunRenameEvent; + +// @public (undocumented) +export interface DryRunRenameEvent { + // (undocumented) + kind: 'rename'; + // (undocumented) + path: string; + // (undocumented) + to: string; +} + +// @public (undocumented) +export class DryRunSink extends HostSink { + constructor(host: virtualFs.Host, force?: boolean); + // (undocumented) + _done(): Observable; + // (undocumented) + protected _fileAlreadyExistException(path: string): void; + // (undocumented) + protected _fileAlreadyExistExceptionSet: Set; + // (undocumented) + protected _fileDoesNotExistException(path: string): void; + // (undocumented) + protected _fileDoesNotExistExceptionSet: Set; + // (undocumented) + readonly reporter: Observable; + // (undocumented) + protected _subject: Subject; +} + +// @public (undocumented) +export interface DryRunUpdateEvent { + // (undocumented) + content: ArrayBufferLike; + // (undocumented) + kind: 'update'; + // (undocumented) + path: string; +} + +// @public +export function empty(): Source; + +// @public (undocumented) +export class EmptyTree extends HostTree { + constructor(); +} + +// @public +export interface Engine { + // (undocumented) + createCollection(name: string, requester?: Collection): Collection; + // (undocumented) + createContext(schematic: Schematic, parent?: Partial>, executionOptions?: Partial): TypedSchematicContext; + // (undocumented) + createSchematic(name: string, collection: Collection): Schematic; + // (undocumented) + createSourceFromUrl(url: Url, context: TypedSchematicContext): Source; + // (undocumented) + readonly defaultMergeStrategy: MergeStrategy; + // (undocumented) + executePostTasks(): Observable; + // (undocumented) + transformOptions(schematic: Schematic, options: OptionT, context?: TypedSchematicContext): Observable; + // (undocumented) + readonly workflow: Workflow | null; +} + +// @public +export interface EngineHost { + // (undocumented) + createCollectionDescription(name: string, requester?: CollectionDescription): CollectionDescription; + // (undocumented) + createSchematicDescription(name: string, collection: CollectionDescription): SchematicDescription | null; + // (undocumented) + createSourceFromUrl(url: Url, context: TypedSchematicContext): Source | null; + // (undocumented) + createTaskExecutor(name: string): Observable; + // (undocumented) + readonly defaultMergeStrategy?: MergeStrategy; + // (undocumented) + getSchematicRuleFactory(schematic: SchematicDescription, collection: CollectionDescription): RuleFactory; + // (undocumented) + hasTaskExecutor(name: string): boolean; + // (undocumented) + listSchematicNames(collection: CollectionDescription, includeHidden?: boolean): string[]; + // (undocumented) + transformContext(context: TypedSchematicContext): TypedSchematicContext | void; + // (undocumented) + transformOptions(schematic: SchematicDescription, options: OptionT, context?: TypedSchematicContext): Observable; +} + +// @public (undocumented) +export interface ExecutionOptions { + // (undocumented) + interactive: boolean; + // (undocumented) + scope: string; +} + +// @public +export function externalSchematic(collectionName: string, schematicName: string, options: OptionT, executionOptions?: Partial): Rule; + +// @public (undocumented) +export class FileAlreadyExistException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export class FileDoesNotExistException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export interface FileEntry { + // (undocumented) + readonly content: Buffer; + // (undocumented) + readonly path: Path; +} + +// @public +export type FileOperator = (entry: FileEntry) => FileEntry | null; + +// @public (undocumented) +export interface FilePredicate { + // (undocumented) + (path: Path, entry?: Readonly | null): T; +} + +// @public (undocumented) +export type FileVisitor = FilePredicate; + +// @public (undocumented) +export const FileVisitorCancelToken: symbol; + +// @public (undocumented) +export function filter(predicate: FilePredicate): Rule; + +// @public (undocumented) +export class FilterHostTree extends HostTree { + constructor(tree: HostTree, filter?: FilePredicate); +} + +// @public (undocumented) +export function forEach(operator: FileOperator): Rule; + +declare namespace formats { + export { + htmlSelectorFormat, + pathFormat, + standardFormats + } +} +export { formats } + +// @public (undocumented) +export class HostCreateTree extends HostTree { + constructor(host: virtualFs.ReadonlyHost); +} + +// @public (undocumented) +export class HostDirEntry implements DirEntry { + constructor(parent: DirEntry | null, path: Path, _host: virtualFs.SyncDelegateHost, _tree: Tree_2); + // (undocumented) + dir(name: PathFragment): DirEntry; + // (undocumented) + file(name: PathFragment): FileEntry | null; + // (undocumented) + protected _host: virtualFs.SyncDelegateHost; + // (undocumented) + readonly parent: DirEntry | null; + // (undocumented) + readonly path: Path; + // (undocumented) + get subdirs(): PathFragment[]; + // (undocumented) + get subfiles(): PathFragment[]; + // (undocumented) + protected _tree: Tree_2; + // (undocumented) + visit(visitor: FileVisitor): void; +} + +// @public (undocumented) +export class HostSink extends SimpleSinkBase { + constructor(_host: virtualFs.Host, _force?: boolean); + // (undocumented) + protected _createFile(path: Path, content: Buffer): Observable; + // (undocumented) + protected _deleteFile(path: Path): Observable; + // (undocumented) + _done(): Observable; + // (undocumented) + protected _filesToCreate: Map; + // (undocumented) + protected _filesToDelete: Set; + // (undocumented) + protected _filesToRename: Set<[Path, Path]>; + // (undocumented) + protected _filesToUpdate: Map; + // (undocumented) + protected _force: boolean; + // (undocumented) + protected _host: virtualFs.Host; + // (undocumented) + protected _overwriteFile(path: Path, content: Buffer): Observable; + // (undocumented) + protected _renameFile(from: Path, to: Path): Observable; + // (undocumented) + protected _validateCreateAction(action: CreateFileAction): Observable; + // (undocumented) + protected _validateFileExists(p: Path): Observable; +} + +// @public (undocumented) +export interface HostTree { +} + +// @public (undocumented) +export class HostTree implements Tree_2 { + // (undocumented) + [TreeSymbol]: () => HostTree; + constructor(_backend?: virtualFs.ReadonlyHost<{}>); + // (undocumented) + get actions(): Action[]; + // (undocumented) + apply(action: Action, strategy?: MergeStrategy): void; + // (undocumented) + protected _backend: virtualFs.ReadonlyHost<{}>; + // (undocumented) + beginUpdate(path: string): UpdateRecorder; + // (undocumented) + branch(): Tree_2; + // (undocumented) + commitUpdate(record: UpdateRecorder): void; + // (undocumented) + create(path: string, content: Buffer | string): void; + // (undocumented) + delete(path: string): void; + // (undocumented) + exists(path: string): boolean; + // (undocumented) + get(path: string): FileEntry | null; + // (undocumented) + getDir(path: string): DirEntry; + // (undocumented) + static isHostTree(tree: Tree_2): tree is HostTree; + // (undocumented) + merge(other: Tree_2, strategy?: MergeStrategy): void; + // (undocumented) + protected _normalizePath(path: string): Path; + // (undocumented) + overwrite(path: string, content: Buffer | string): void; + // (undocumented) + read(path: string): Buffer | null; + // (undocumented) + readJson(path: string): JsonValue; + // (undocumented) + readText(path: string): string; + // (undocumented) + rename(from: string, to: string): void; + // (undocumented) + get root(): DirEntry; + // (undocumented) + visit(visitor: FileVisitor): void; + // (undocumented) + protected _willCreate(path: Path): boolean; + // (undocumented) + protected _willDelete(path: Path): boolean; + // (undocumented) + protected _willOverwrite(path: Path): boolean; + // (undocumented) + protected _willRename(path: Path): boolean; +} + +// @public (undocumented) +const htmlSelectorFormat: schema.SchemaFormat; + +// @public (undocumented) +export class InvalidPipeException extends BaseException { + constructor(name: string); +} + +// @public +export class InvalidRuleResultException extends BaseException { + constructor(value?: {}); +} + +// @public (undocumented) +export class InvalidSchematicsNameException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class InvalidSourceResultException extends BaseException { + constructor(value?: {}); +} + +// @public (undocumented) +export class InvalidUpdateRecordException extends BaseException { + constructor(); +} + +// @public (undocumented) +export function isContentAction(action: Action): action is CreateFileAction | OverwriteFileAction; + +// @public (undocumented) +interface LifeCycleEvent { + // (undocumented) + kind: 'start' | 'end' | 'workflow-start' | 'workflow-end' | 'post-tasks-start' | 'post-tasks-end'; +} + +// @public (undocumented) +export class MergeConflictException extends BaseException { + constructor(path: string); +} + +// @public (undocumented) +export enum MergeStrategy { + // (undocumented) + AllowCreationConflict = 4, + // (undocumented) + AllowDeleteConflict = 8, + // (undocumented) + AllowOverwriteConflict = 2, + // (undocumented) + ContentOnly = 2, + // (undocumented) + Default = 0, + // (undocumented) + Error = 1, + // (undocumented) + Overwrite = 14 +} + +// @public +export function mergeWith(source: Source, strategy?: MergeStrategy): Rule; + +// @public (undocumented) +export function move(from: string, to: string): Rule; + +// @public (undocumented) +export function move(to: string): Rule; + +// @public (undocumented) +export function noop(): Rule; + +// @public (undocumented) +export class OptionIsNotDefinedException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export interface OverwriteFileAction extends ActionBase { + // (undocumented) + readonly content: Buffer; + // (undocumented) + readonly kind: 'o'; +} + +// @public (undocumented) +export function partitionApplyMerge(predicate: FilePredicate, ruleYes: Rule, ruleNo?: Rule): Rule; + +// @public (undocumented) +const pathFormat: schema.SchemaFormat; + +// @public (undocumented) +export function pathTemplate(options: T): Rule; + +// @public (undocumented) +export type PathTemplateData = { + [key: string]: PathTemplateValue | PathTemplateData | PathTemplatePipeFunction; +}; + +// @public (undocumented) +export interface PathTemplateOptions { + // (undocumented) + interpolationEnd: string; + // (undocumented) + interpolationStart: string; + // (undocumented) + pipeSeparator?: string; +} + +// @public (undocumented) +export type PathTemplatePipeFunction = (x: string) => PathTemplateValue; + +// @public (undocumented) +export type PathTemplateValue = boolean | string | number | undefined; + +// @public (undocumented) +export class PrivateSchematicException extends BaseException { + constructor(name: string, collection: CollectionDescription<{}>); +} + +// @public (undocumented) +export interface RandomOptions { + // (undocumented) + multi?: boolean | number; + // (undocumented) + multiFiles?: boolean | number; + // (undocumented) + root?: string; +} + +// @public (undocumented) +export interface RenameFileAction extends ActionBase { + // (undocumented) + readonly kind: 'r'; + // (undocumented) + readonly to: Path; +} + +// @public +export function renameTemplateFiles(): Rule; + +// @public (undocumented) +interface RequiredWorkflowExecutionContext { + // (undocumented) + collection: string; + // (undocumented) + options: object; + // (undocumented) + schematic: string; +} + +// @public (undocumented) +export type Rule = (tree: Tree_2, context: SchematicContext) => Tree_2 | Observable | Rule | Promise | void; + +// @public +export type RuleFactory = (options: T) => Rule; + +// @public +export interface Schematic { + // (undocumented) + call(options: OptionT, host: Observable, parentContext?: Partial>, executionOptions?: Partial): Observable; + // (undocumented) + readonly collection: Collection; + // (undocumented) + readonly description: SchematicDescription; +} + +// @public +export function schematic(schematicName: string, options: OptionT, executionOptions?: Partial): Rule; + +// @public +export type SchematicContext = TypedSchematicContext<{}, {}>; + +// @public +export type SchematicDescription = SchematicMetadataT & { + readonly collection: CollectionDescription; + readonly name: string; + readonly private?: boolean; + readonly hidden?: boolean; +}; + +// @public (undocumented) +export class SchematicEngine implements Engine { + constructor(_host: EngineHost, _workflow?: Workflow | undefined); + // (undocumented) + createCollection(name: string, requester?: Collection): Collection; + // (undocumented) + createContext(schematic: Schematic, parent?: Partial>, executionOptions?: Partial): TypedSchematicContext; + // (undocumented) + createSchematic(name: string, collection: Collection, allowPrivate?: boolean): Schematic; + // (undocumented) + createSourceFromUrl(url: Url, context: TypedSchematicContext): Source; + // (undocumented) + get defaultMergeStrategy(): MergeStrategy; + // (undocumented) + executePostTasks(): Observable; + // (undocumented) + listSchematicNames(collection: Collection, includeHidden?: boolean): string[]; + // (undocumented) + transformOptions(schematic: Schematic, options: OptionT, context?: TypedSchematicContext): Observable; + // (undocumented) + get workflow(): Workflow | null; + // (undocumented) + protected _workflow?: Workflow | undefined; +} + +// @public (undocumented) +export class SchematicEngineConflictingException extends BaseException { + constructor(); +} + +// @public (undocumented) +export class SchematicImpl implements Schematic { + constructor(_description: SchematicDescription, _factory: RuleFactory<{}>, _collection: Collection, _engine: Engine); + // (undocumented) + call(options: OptionT, host: Observable, parentContext?: Partial>, executionOptions?: Partial): Observable; + // (undocumented) + get collection(): Collection; + // (undocumented) + get description(): SchematicDescription; +} + +// @public (undocumented) +export class SchematicsException extends BaseException { +} + +// @public (undocumented) +export abstract class SimpleSinkBase implements Sink { + // (undocumented) + commit(tree: Tree_2): Observable; + // (undocumented) + commitSingleAction(action: Action): Observable; + // (undocumented) + protected abstract _createFile(path: string, content: Buffer): Observable; + // (undocumented) + protected abstract _deleteFile(path: string): Observable; + // (undocumented) + protected abstract _done(): Observable; + // (undocumented) + protected _fileAlreadyExistException(path: string): void; + // (undocumented) + protected _fileDoesNotExistException(path: string): void; + // (undocumented) + protected abstract _overwriteFile(path: string, content: Buffer): Observable; + // (undocumented) + postCommit: () => void | Observable; + // (undocumented) + postCommitAction: (action: Action) => void | Observable; + // (undocumented) + preCommit: () => void | Observable; + // (undocumented) + preCommitAction: (action: Action) => void | Action | PromiseLike | Observable; + // (undocumented) + protected abstract _renameFile(path: string, to: string): Observable; + // (undocumented) + protected _validateCreateAction(action: CreateFileAction): Observable; + // (undocumented) + protected _validateDeleteAction(action: DeleteFileAction): Observable; + // (undocumented) + protected abstract _validateFileExists(p: string): Observable; + // (undocumented) + protected _validateOverwriteAction(action: OverwriteFileAction): Observable; + // (undocumented) + protected _validateRenameAction(action: RenameFileAction): Observable; + // (undocumented) + validateSingleAction(action: Action): Observable; +} + +// @public (undocumented) +export interface Sink { + // (undocumented) + commit(tree: Tree_2): Observable; +} + +// @public +export type Source = (context: SchematicContext) => Tree_2 | Observable; + +// @public +export function source(tree: Tree_2): Source; + +// @public (undocumented) +const standardFormats: schema.SchemaFormat[]; + +export { strings } + +// @public (undocumented) +export interface TaskConfiguration { + // (undocumented) + dependencies?: Array; + // (undocumented) + name: string; + // (undocumented) + options?: T; +} + +// @public (undocumented) +export interface TaskConfigurationGenerator { + // (undocumented) + toConfiguration(): TaskConfiguration; +} + +// @public (undocumented) +export type TaskExecutor = (options: T | undefined, context: SchematicContext) => Promise | Observable; + +// @public (undocumented) +export interface TaskExecutorFactory { + // (undocumented) + create(options?: T): Promise | Observable; + // (undocumented) + readonly name: string; +} + +// @public (undocumented) +export interface TaskId { + // (undocumented) + readonly id: number; +} + +// @public (undocumented) +export interface TaskInfo { + // (undocumented) + readonly configuration: TaskConfiguration; + // (undocumented) + readonly context: SchematicContext; + // (undocumented) + readonly id: number; + // (undocumented) + readonly priority: number; +} + +// @public (undocumented) +export class TaskScheduler { + constructor(_context: SchematicContext); + // (undocumented) + finalize(): ReadonlyArray; + // (undocumented) + schedule(taskConfiguration: TaskConfiguration): TaskId; +} + +// @public (undocumented) +export function template(options: T): Rule; + +// @public (undocumented) +export const TEMPLATE_FILENAME_RE: RegExp; + +// @public (undocumented) +export type Tree = Tree_2; + +// @public (undocumented) +export const Tree: TreeConstructor; + +// @public (undocumented) +export interface TreeConstructor { + // (undocumented) + branch(tree: Tree_2): Tree_2; + // (undocumented) + empty(): Tree_2; + // (undocumented) + merge(tree: Tree_2, other: Tree_2, strategy?: MergeStrategy): Tree_2; + // (undocumented) + optimize(tree: Tree_2): Tree_2; + // (undocumented) + partition(tree: Tree_2, predicate: FilePredicate): [Tree_2, Tree_2]; +} + +// @public (undocumented) +export const TreeSymbol: symbol; + +// @public +export interface TypedSchematicContext { + // (undocumented) + addTask(task: TaskConfigurationGenerator, dependencies?: Array): TaskId; + // (undocumented) + readonly debug: boolean; + // (undocumented) + readonly engine: Engine; + // (undocumented) + readonly interactive: boolean; + // (undocumented) + readonly logger: logging.LoggerApi; + // (undocumented) + readonly schematic: Schematic; + // (undocumented) + readonly strategy: MergeStrategy; +} + +// @public (undocumented) +export class UnimplementedException extends BaseException { + constructor(); +} + +// @public (undocumented) +export class UnknownActionException extends BaseException { + constructor(action: Action); +} + +// @public (undocumented) +export class UnknownCollectionException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class UnknownPipeException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class UnknownSchematicException extends BaseException { + constructor(name: string, collection: CollectionDescription<{}>); +} + +// @public (undocumented) +export class UnknownTaskDependencyException extends BaseException { + constructor(id: TaskId); +} + +// @public (undocumented) +export class UnknownUrlSourceProtocol extends BaseException { + constructor(url: string); +} + +// @public (undocumented) +export class UnregisteredTaskException extends BaseException { + constructor(name: string, schematic?: SchematicDescription<{}, {}>); +} + +// @public (undocumented) +export class UnsuccessfulWorkflowExecution extends BaseException { + constructor(); +} + +// @public (undocumented) +export interface UpdateRecorder { + // (undocumented) + insertLeft(index: number, content: Buffer | string): UpdateRecorder; + // (undocumented) + insertRight(index: number, content: Buffer | string): UpdateRecorder; + // (undocumented) + remove(index: number, length: number): UpdateRecorder; +} + +// @public (undocumented) +export function url(urlString: string): Source; + +// @public (undocumented) +export function when(predicate: FilePredicate, operator: FileOperator): FileOperator; + +// @public (undocumented) +interface Workflow { + // (undocumented) + readonly context: Readonly; + // (undocumented) + execute(options: Partial & RequiredWorkflowExecutionContext): Observable; +} + +declare namespace workflow { + export { + BaseWorkflowOptions, + BaseWorkflow, + RequiredWorkflowExecutionContext, + WorkflowExecutionContext, + LifeCycleEvent, + Workflow + } +} +export { workflow } + +// @public (undocumented) +interface WorkflowExecutionContext extends RequiredWorkflowExecutionContext { + // (undocumented) + allowPrivate?: boolean; + // (undocumented) + debug: boolean; + // (undocumented) + logger: logging.Logger; + // (undocumented) + parentContext?: Readonly; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/schematics/tasks/index.api.md b/goldens/public-api/angular_devkit/schematics/tasks/index.api.md new file mode 100644 index 000000000000..ad9b7d8d7f0b --- /dev/null +++ b/goldens/public-api/angular_devkit/schematics/tasks/index.api.md @@ -0,0 +1,54 @@ +## API Report File for "@angular-devkit/schematics_tasks" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @public (undocumented) +export class NodePackageInstallTask implements TaskConfigurationGenerator { + constructor(workingDirectory?: string); + constructor(options: NodePackageInstallTaskOptions); + // (undocumented) + allowScripts: boolean; + // (undocumented) + hideOutput: boolean; + // (undocumented) + packageManager?: string; + // (undocumented) + packageName?: string; + // (undocumented) + quiet: boolean; + // (undocumented) + toConfiguration(): TaskConfiguration; + // (undocumented) + workingDirectory?: string; +} + +// @public (undocumented) +export class RepositoryInitializerTask implements TaskConfigurationGenerator { + constructor(workingDirectory?: string | undefined, commitOptions?: CommitOptions | undefined); + // (undocumented) + commitOptions?: CommitOptions | undefined; + // (undocumented) + toConfiguration(): TaskConfiguration; + // (undocumented) + workingDirectory?: string | undefined; +} + +// @public (undocumented) +export class RunSchematicTask implements TaskConfigurationGenerator> { + constructor(s: string, o: T); + constructor(c: string, s: string, o: T); + // (undocumented) + protected _collection: string | null; + // (undocumented) + protected _options: T; + // (undocumented) + protected _schematic: string; + // (undocumented) + toConfiguration(): TaskConfiguration>; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/schematics/testing/index.api.md b/goldens/public-api/angular_devkit/schematics/testing/index.api.md new file mode 100644 index 000000000000..abf89972c862 --- /dev/null +++ b/goldens/public-api/angular_devkit/schematics/testing/index.api.md @@ -0,0 +1,43 @@ +## API Report File for "@angular-devkit/schematics_testing" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { Url } from 'node:url'; + +// @public (undocumented) +export class SchematicTestRunner { + constructor(_collectionName: string, collectionPath: string); + // (undocumented) + callRule(rule: Rule, tree: Tree_2, parentContext?: Partial): Observable; + // (undocumented) + get engine(): SchematicEngine<{}, {}>; + // (undocumented) + get logger(): logging.Logger; + // (undocumented) + registerCollection(collectionName: string, collectionPath: string): void; + // (undocumented) + runExternalSchematic(collectionName: string, schematicName: string, opts?: SchematicSchemaT, tree?: Tree_2): Promise; + // (undocumented) + runSchematic(schematicName: string, opts?: SchematicSchemaT, tree?: Tree_2): Promise; + // (undocumented) + get tasks(): TaskConfiguration[]; +} + +// @public (undocumented) +export class UnitTestTree extends DelegateTree { + // (undocumented) + get files(): string[]; + // (undocumented) + readContent(path: string): string; +} + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/angular_devkit/schematics/tools/index.api.md b/goldens/public-api/angular_devkit/schematics/tools/index.api.md new file mode 100644 index 000000000000..c93e3ec27762 --- /dev/null +++ b/goldens/public-api/angular_devkit/schematics/tools/index.api.md @@ -0,0 +1,280 @@ +## API Report File for "@angular-devkit/schematics_tools" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { BaseException } from '@angular-devkit/core'; +import { JsonObject } from '@angular-devkit/core'; +import { JsonValue } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; +import { Observable } from 'rxjs'; +import { Path } from '@angular-devkit/core'; +import { PathFragment } from '@angular-devkit/core'; +import { schema } from '@angular-devkit/core'; +import { Subject } from 'rxjs'; +import { Url } from 'node:url'; +import { virtualFs } from '@angular-devkit/core'; + +// @public (undocumented) +export class CollectionCannotBeResolvedException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class CollectionMissingFieldsException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class CollectionMissingSchematicsMapException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export type ContextTransform = (context: FileSystemSchematicContext) => FileSystemSchematicContext; + +// @public +export class ExportStringRef { + constructor(ref: string, parentPath?: string, inner?: boolean); + // (undocumented) + get module(): string; + // (undocumented) + get path(): string; + // (undocumented) + get ref(): T | undefined; +} + +// @public (undocumented) +export class FactoryCannotBeResolvedException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export type FileSystemCollection = Collection; + +// @public (undocumented) +export type FileSystemCollectionDesc = CollectionDescription; + +// @public (undocumented) +export interface FileSystemCollectionDescription { + // (undocumented) + readonly encapsulation?: boolean; + // (undocumented) + readonly name: string; + // (undocumented) + readonly path: string; + // (undocumented) + readonly schematics: { + [name: string]: FileSystemSchematicDesc; + }; + // (undocumented) + readonly version?: string; +} + +// @public +export type FileSystemEngine = Engine; + +// @public +export class FileSystemEngineHost extends FileSystemEngineHostBase { + constructor(_root: string); + // (undocumented) + createTaskExecutor(name: string): Observable; + // (undocumented) + hasTaskExecutor(name: string): boolean; + // (undocumented) + protected _resolveCollectionPath(name: string): string; + // (undocumented) + protected _resolveReferenceString(refString: string, parentPath: string): { + ref: RuleFactory<{}>; + path: string; + } | null; + // (undocumented) + protected _root: string; + // (undocumented) + protected _transformCollectionDescription(name: string, desc: Partial): FileSystemCollectionDesc; + // (undocumented) + protected _transformSchematicDescription(name: string, _collection: FileSystemCollectionDesc, desc: Partial): FileSystemSchematicDesc; +} + +// @public +export abstract class FileSystemEngineHostBase implements FileSystemEngineHost_2 { + // (undocumented) + createCollectionDescription(name: string, requester?: FileSystemCollectionDesc): FileSystemCollectionDesc; + // (undocumented) + createSchematicDescription(name: string, collection: FileSystemCollectionDesc): FileSystemSchematicDesc | null; + // (undocumented) + createSourceFromUrl(url: Url): Source | null; + // (undocumented) + createTaskExecutor(name: string): Observable; + // (undocumented) + getSchematicRuleFactory(schematic: FileSystemSchematicDesc, _collection: FileSystemCollectionDesc): RuleFactory; + // (undocumented) + hasTaskExecutor(name: string): boolean; + // (undocumented) + listSchematicNames(collection: FileSystemCollectionDesc, includeHidden?: boolean): string[]; + // (undocumented) + registerContextTransform(t: ContextTransform): void; + // (undocumented) + registerOptionsTransform(t: OptionTransform): void; + // (undocumented) + registerTaskExecutor(factory: TaskExecutorFactory, options?: T): void; + // (undocumented) + protected abstract _resolveCollectionPath(name: string, requester?: string): string; + // (undocumented) + protected abstract _resolveReferenceString(name: string, parentPath: string, collectionDescription: FileSystemCollectionDesc): { + ref: RuleFactory<{}>; + path: string; + } | null; + // (undocumented) + protected abstract _transformCollectionDescription(name: string, desc: Partial): FileSystemCollectionDesc; + // (undocumented) + transformContext(context: FileSystemSchematicContext): FileSystemSchematicContext; + // (undocumented) + transformOptions(schematic: FileSystemSchematicDesc, options: OptionT, context?: FileSystemSchematicContext): Observable; + // (undocumented) + protected abstract _transformSchematicDescription(name: string, collection: FileSystemCollectionDesc, desc: Partial): FileSystemSchematicDesc; +} + +// @public (undocumented) +export type FileSystemSchematic = Schematic; + +// @public (undocumented) +export type FileSystemSchematicContext = TypedSchematicContext; + +// @public (undocumented) +export type FileSystemSchematicDesc = SchematicDescription; + +// @public (undocumented) +export interface FileSystemSchematicDescription extends FileSystemSchematicJsonDescription { + // (undocumented) + readonly factoryFn: RuleFactory<{}>; + // (undocumented) + readonly path: string; + // (undocumented) + readonly schemaJson?: JsonObject; +} + +// @public (undocumented) +export interface FileSystemSchematicJsonDescription { + // (undocumented) + readonly aliases?: string[]; + // (undocumented) + readonly collection: FileSystemCollectionDescription; + // (undocumented) + readonly description: string; + // (undocumented) + readonly extends?: string; + // (undocumented) + readonly factory: string; + // (undocumented) + readonly name: string; + // (undocumented) + readonly schema?: string; +} + +// @public (undocumented) +export class InvalidCollectionJsonException extends BaseException { + constructor(_name: string, path: string, jsonException?: Error); +} + +// @public +export class NodeModulesEngineHost extends FileSystemEngineHostBase { + constructor(paths?: string[] | undefined); + // (undocumented) + protected _resolveCollectionPath(name: string, requester?: string): string; + // (undocumented) + protected _resolveReferenceString(refString: string, parentPath: string, collectionDescription?: FileSystemCollectionDesc): { + ref: RuleFactory<{}>; + path: string; + } | null; + // (undocumented) + protected _transformCollectionDescription(name: string, desc: Partial): FileSystemCollectionDesc; + // (undocumented) + protected _transformSchematicDescription(name: string, _collection: FileSystemCollectionDesc, desc: Partial): FileSystemSchematicDesc; +} + +// @public +export class NodeModulesTestEngineHost extends NodeModulesEngineHost { + // (undocumented) + clearTasks(): void; + // (undocumented) + registerCollection(name: string, path: string): void; + // (undocumented) + protected _resolveCollectionPath(name: string, requester?: string): string; + // (undocumented) + get tasks(): TaskConfiguration[]; + // (undocumented) + transformContext(context: FileSystemSchematicContext): FileSystemSchematicContext; +} + +// @public (undocumented) +export class NodePackageDoesNotSupportSchematics extends BaseException { + constructor(name: string); +} + +// @public +export class NodeWorkflow extends workflow.BaseWorkflow { + constructor(root: string, options: NodeWorkflowOptions); + constructor(host: virtualFs.Host, options: NodeWorkflowOptions & { + root?: Path; + }); + // (undocumented) + get engine(): FileSystemEngine; + // (undocumented) + get engineHost(): NodeModulesEngineHost; +} + +// @public (undocumented) +export interface NodeWorkflowOptions { + // (undocumented) + dryRun?: boolean; + // (undocumented) + engineHostCreator?: (options: NodeWorkflowOptions) => NodeModulesEngineHost; + // (undocumented) + force?: boolean; + // (undocumented) + optionTransforms?: OptionTransform | null, object>[]; + // (undocumented) + packageManager?: string; + // (undocumented) + packageManagerForce?: boolean; + // (undocumented) + packageRegistry?: string; + // (undocumented) + registry?: schema.CoreSchemaRegistry; + // (undocumented) + resolvePaths?: string[]; + // (undocumented) + schemaValidation?: boolean; +} + +// @public (undocumented) +export type OptionTransform = (schematic: FileSystemSchematicDescription, options: T, context?: FileSystemSchematicContext) => Observable | PromiseLike | R; + +// @public (undocumented) +export class SchematicMissingDescriptionException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class SchematicMissingFactoryException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class SchematicMissingFieldsException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export class SchematicNameCollisionException extends BaseException { + constructor(name: string); +} + +// @public (undocumented) +export function validateOptionsWithSchema(registry: schema.SchemaRegistry): (schematic: FileSystemSchematicDescription, options: T, context?: FileSystemSchematicContext) => Observable; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/goldens/public-api/ngtools/webpack/index.api.md b/goldens/public-api/ngtools/webpack/index.api.md new file mode 100644 index 000000000000..a2c25e8c74f5 --- /dev/null +++ b/goldens/public-api/ngtools/webpack/index.api.md @@ -0,0 +1,56 @@ +## API Report File for "@ngtools/webpack" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { Compiler } from 'webpack'; +import type { CompilerOptions } from '@angular/compiler-cli'; +import type { LoaderContext } from 'webpack'; + +// @public @deprecated (undocumented) +function angularWebpackLoader(this: LoaderContext, content: string, map: string): void; +export default angularWebpackLoader; + +// @public @deprecated (undocumented) +export const AngularWebpackLoaderPath: string; + +// @public @deprecated (undocumented) +export class AngularWebpackPlugin { + constructor(options?: Partial); + // (undocumented) + apply(compiler: Compiler): void; + // (undocumented) + get options(): AngularWebpackPluginOptions; +} + +// @public @deprecated (undocumented) +export interface AngularWebpackPluginOptions { + // (undocumented) + compilerOptions?: CompilerOptions; + // (undocumented) + directTemplateLoading: boolean; + // (undocumented) + emitClassMetadata: boolean; + // (undocumented) + emitNgModuleScope: boolean; + // (undocumented) + emitSetClassDebugInfo?: boolean; + // (undocumented) + fileReplacements: Record; + // (undocumented) + inlineStyleFileExtension?: string; + // (undocumented) + jitMode: boolean; + // (undocumented) + substitutions: Record; + // (undocumented) + tsconfig: string; +} + +// @public @deprecated (undocumented) +export const imageDomains: Set; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/lib/README.md b/lib/README.md deleted file mode 100644 index a48231235dea..000000000000 --- a/lib/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# `/lib` Folder - -This folder includes bootstrap code for the various tools included in this repository. Also -included is the packages meta-information package in `packages.ts`. This is used to read and -understand all the monorepo information (contained in the `.monorepo.json` file, and `package.json` -files across the repo). - -`bootstrap-local.js` should be included when running files from this repository without compiling -first. It allows for compiling and loading packages in memory directly from the repo. Not only -does the `devkit-admin` scripts use this to include the library, but also all binaries linked -locally (like `schematics` and `ng`), when not using the npm published packages. diff --git a/lib/bootstrap-local.js b/lib/bootstrap-local.js deleted file mode 100644 index 3c3762655e61..000000000000 --- a/lib/bootstrap-local.js +++ /dev/null @@ -1,218 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -/* eslint-disable no-console */ -'use strict'; -const debug = require('debug'); -const debugLocal = debug('ng:local'); -const debugBuildEjs = debug('ng:local:build:ejs'); -const debugBuildSchema = debug('ng:local:build:schema'); -const debugBuildTs = debug('ng:local:build:ts'); - -const child_process = require('child_process'); -const fs = require('fs'); -const path = require('path'); -const temp = require('temp'); -const ts = require('typescript'); - -const tmpRoot = temp.mkdirSync('angular-devkit-'); - -debugLocal('starting bootstrap local'); - -// This processes any extended configs -const compilerOptions = ts.getParsedCommandLineOfConfigFile( - path.join(__dirname, '../tsconfig-test.json'), - { }, - ts.sys, -).options; - -// Check if we need to profile this CLI run. -let profiler = null; -if (process.env['DEVKIT_PROFILING']) { - debugLocal('setup profiling'); - try { - profiler = require('v8-profiler-node8'); - } catch (err) { - throw new Error(`Could not require 'v8-profiler-node8'. You must install it separetely with` + - `'npm install v8-profiler-node8 --no-save.\n\nOriginal error:\n\n${err}`); - } - - profiler.startProfiling(); - - function exitHandler(options, _err) { - if (options.cleanup) { - const cpuProfile = profiler.stopProfiling(); - const profileData = JSON.stringify(cpuProfile); - const filePath = path.resolve(process.cwd(), process.env.DEVKIT_PROFILING) + '.cpuprofile'; - - debugLocal('saving profiling data'); - console.log(`Profiling data saved in "${filePath}": ${profileData.length} bytes`); - fs.writeFileSync(filePath, profileData); - } - - if (options.exit) { - process.exit(); - } - } - - process.on('exit', exitHandler.bind(null, { cleanup: true })); - process.on('SIGINT', exitHandler.bind(null, { exit: true })); - process.on('uncaughtException', exitHandler.bind(null, { exit: true })); -} - -if (process.env['DEVKIT_LONG_STACK_TRACE']) { - debugLocal('setup long stack trace'); - Error.stackTraceLimit = Infinity; -} - -global._DevKitIsLocal = true; -global._DevKitRoot = path.resolve(__dirname, '..'); - - -const oldRequireTs = require.extensions['.ts']; -require.extensions['.ts'] = function (m, filename) { - // If we're in node module, either call the old hook or simply compile the - // file without transpilation. We do not touch node_modules/**. - // To account for Yarn workspaces symlinks, we much check the real path. - if (fs.realpathSync(filename).match(/node_modules/)) { - if (oldRequireTs) { - return oldRequireTs(m, filename); - } - return m._compile(fs.readFileSync(filename).toString(), filename); - } - - debugBuildTs(filename); - - // Node requires all require hooks to be sync. - const source = fs.readFileSync(filename).toString(); - - try { - let result = ts.transpile(source, compilerOptions, filename); - - debugBuildTs('done'); - - // Send it to node to execute. - return m._compile(result, filename); - } catch (err) { - console.error('Error while running script "' + filename + '":'); - console.error(err.stack); - throw err; - } -}; - - -require.extensions['.ejs'] = function (m, filename) { - debugBuildEjs(filename); - - const source = fs.readFileSync(filename).toString(); - const template = require('@angular-devkit/core').template; - const result = template(source, { sourceURL: filename, sourceMap: true }); - - debugBuildEjs('done'); - return m._compile(result.source.replace(/return/, 'module.exports.default = '), filename); -}; - -const builtinModules = Object.keys(process.binding('natives')); -const packages = require('./packages').packages; -// If we're running locally, meaning npm linked. This is basically "developer mode". -if (!__dirname.match(new RegExp(`\\${path.sep}node_modules\\${path.sep}`))) { - - // We mock the module loader so that we can fake our packages when running locally. - const Module = require('module'); - const oldLoad = Module._load; - const oldResolve = Module._resolveFilename; - - Module._resolveFilename = function (request, parent) { - let resolved = null; - let exception; - try { - resolved = oldResolve.call(this, request, parent); - } catch (e) { - exception = e; - } - - if (request in packages) { - return packages[request].main; - } else if (builtinModules.includes(request)) { - // It's a native Node module. - return oldResolve.call(this, request, parent); - } else if (resolved && resolved.match(/[\\\/]node_modules[\\\/]/)) { - return resolved; - } else { - const match = Object.keys(packages).find(pkgName => request.startsWith(pkgName + '/')); - if (match) { - const p = path.join(packages[match].root, request.substr(match.length)); - return oldResolve.call(this, p, parent); - } else if (!resolved) { - if (exception) { - throw exception; - } else { - return resolved; - } - } else { - // Because loading `.ts` ends up AFTER `.json` in the require() logic, requiring a file that has both `.json` - // and `.ts` versions will only get the `.json` content (which wouldn't happen if the .ts was compiled to - // JavaScript). We load `.ts` files first here to avoid this conflict. It's hacky, but so is the rest of this - // file. - const maybeTsPath = resolved.endsWith('.json') && resolved.replace(/\.json$/, '.ts'); - if (maybeTsPath && !request.endsWith('.json')) { - // If the file exists, return its path. If it doesn't, run the quicktype runner on it and return the content. - if (fs.existsSync(maybeTsPath)) { - return maybeTsPath; - } else { - debugBuildSchema('%s', resolved); - - // This script has the be synchronous, so we spawnSync instead of, say, requiring the runner and calling - // the method directly. - const tmpJsonSchemaPath = path.join(tmpRoot, maybeTsPath.replace(/[^0-9a-zA-Z.]/g, '_')); - try { - if (!fs.existsSync(tmpJsonSchemaPath)) { - const quicktypeRunnerPath = path.join(__dirname, '../tools/quicktype_runner.js'); - child_process.spawnSync('node', [quicktypeRunnerPath, resolved, tmpJsonSchemaPath]); - } - - debugBuildSchema('done'); - return tmpJsonSchemaPath; - } catch (_) { - // Just return resolvedPath and let Node deals with it. - console.log(_); - process.exit(99); - } - } - } - - return resolved; - } - } - }; -} - - -// Set the resolve hook to allow resolution of packages from a local dev environment. -require('@angular-devkit/core/node/resolve').setResolveHook(function(request, options) { - try { - if (request in packages) { - if (options.resolvePackageJson) { - return path.join(packages[request].root, 'package.json'); - } else { - return packages[request].main; - } - } else { - const match = Object.keys(packages).find(function(pkgName) { - return request.startsWith(pkgName + '/'); - }); - - if (match) { - return path.join(packages[match].root, request.substr(match[0].length)); - } else { - return null; - } - } - } catch (_) { - return null; - } -}); diff --git a/lib/packages.ts b/lib/packages.ts deleted file mode 100644 index 09ab1e719d84..000000000000 --- a/lib/packages.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -// tslint:disable-next-line: no-global-tslint-disable -// tslint:disable: no-implicit-dependencies -import { JsonObject } from '@angular-devkit/core'; -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as ts from 'typescript'; - -const distRoot = path.join(__dirname, '../dist'); -const { versions: monorepoVersions, packages: monorepoPackages } = require('../.monorepo.json'); - - -export interface PackageInfo { - name: string; - root: string; - bin: { [name: string]: string}; - relative: string; - main: string; - dist: string; - build: string; - tar: string; - private: boolean; - experimental: boolean; - packageJson: JsonObject; - dependencies: string[]; - reverseDependencies: string[]; - - snapshot: boolean; - snapshotRepo: string; - snapshotHash: string; - - version: string; -} -export type PackageMap = { [name: string]: PackageInfo }; - - -function loadPackageJson(p: string) { - const root = require('../package.json'); - const pkg = require(p); - - for (const key of Object.keys(root)) { - switch (key) { - // Keep the following keys from the package.json of the package itself. - case 'bin': - case 'description': - case 'dependencies': - case 'name': - case 'main': - case 'peerDependencies': - case 'optionalDependencies': - case 'typings': - case 'version': - case 'private': - case 'workspaces': - case 'resolutions': - case 'scripts': - continue; - - // Remove the following keys from the package.json. - case 'devDependencies': - delete pkg[key]; - continue; - - // Merge the following keys with the root package.json. - case 'keywords': - const a = pkg[key] || []; - const b = Object.keys( - root[key].concat(a).reduce((acc: {[k: string]: boolean}, curr: string) => { - acc[curr] = true; - - return acc; - }, {})); - pkg[key] = b; - break; - - // Overwrite engines to a common default. - case 'engines': - pkg['engines'] = { - 'node': '>= 10.13.0', - 'npm': '>= 6.11.0', - 'yarn': '>= 1.13.0', - }; - break; - - // Overwrite the package's key with to root one. - default: - pkg[key] = root[key]; - } - } - - return pkg; -} - - -function _findAllPackageJson(dir: string, exclude: RegExp): string[] { - const result: string[] = []; - fs.readdirSync(dir) - .forEach(fileName => { - const p = path.join(dir, fileName); - - if (exclude.test(p)) { - return; - } else if (/[\/\\]node_modules[\/\\]/.test(p)) { - return; - } else if (fileName == 'package.json') { - result.push(p); - } else if (fs.statSync(p).isDirectory() && fileName != 'node_modules') { - result.push(..._findAllPackageJson(p, exclude)); - } - }); - - return result; -} - - -const tsConfigPath = path.join(__dirname, '../tsconfig.json'); -const tsConfig = ts.readConfigFile(tsConfigPath, ts.sys.readFile); -const pattern = '^(' - + (tsConfig.config.exclude as string[]) - .map(ex => path.join(path.dirname(tsConfigPath), ex)) - .map(ex => '(' - + ex - .replace(/[\-\[\]{}()+?./\\^$|]/g, '\\$&') - .replace(/(\\\\|\\\/)\*\*/g, '((\/|\\\\).+?)?') - .replace(/\*/g, '[^/\\\\]*') - + ')') - .join('|') - + ')($|/|\\\\)'; -const excludeRe = new RegExp(pattern); - -// Find all the package.json that aren't excluded from tsconfig. -const packageJsonPaths = _findAllPackageJson(path.join(__dirname, '..'), excludeRe) - // Remove the root package.json. - .filter(p => p != path.join(__dirname, '../package.json')); - - -function _exec(cmd: string) { - return execSync(cmd).toString().trim(); -} - - -let gitShaCache: string; -function _getSnapshotHash(_pkg: PackageInfo): string { - if (!gitShaCache) { - gitShaCache = _exec('git log --format=%h -n1'); - } - - return gitShaCache; -} - - -let stableVersion = ''; -let experimentalVersion = ''; -function _getVersionFromGit(experimental: boolean): string { - if (stableVersion && experimentalVersion) { - return experimental ? experimentalVersion : stableVersion; - } - - const hasLocalChanges = _exec(`git status --porcelain`) != ''; - const scmVersionTagRaw = _exec(`git describe --match v[0-9]*.[0-9]*.[0-9]* --abbrev=7 --tags`) - .slice(1); - stableVersion = scmVersionTagRaw.replace(/-([0-9]+)-g/, '+$1.'); - if (hasLocalChanges) { - stableVersion += stableVersion.includes('+') ? '.with-local-changes' : '+with-local-changes'; - } - - experimentalVersion = `0.${stableVersion.replace(/^(\d+)\.(\d+)/, (_, major, minor) => { - return '' + (parseInt(major, 10) * 100 + parseInt(minor, 10)); - })}`; - - return experimental ? experimentalVersion : stableVersion; -} - - -// All the supported packages. Go through the packages directory and create a map of -// name => PackageInfo. This map is partial as it lacks some information that requires the -// map itself to finish building. -export const packages: PackageMap = - packageJsonPaths - .map(pkgPath => ({ root: path.dirname(pkgPath) })) - .reduce((packages: PackageMap, pkg) => { - const pkgRoot = pkg.root; - const packageJson = loadPackageJson(path.join(pkgRoot, 'package.json')); - const name = packageJson['name']; - if (!name) { - // Only build the entry if there's a package name. - return packages; - } - if (!(name in monorepoPackages)) { - throw new Error( - `Package ${name} found in ${JSON.stringify(pkg.root)}, not found in .monorepo.json.`, - ); - } - - const bin: {[name: string]: string} = {}; - Object.keys(packageJson['bin'] || {}).forEach(binName => { - let p = path.resolve(pkg.root, packageJson['bin'][binName]); - if (!fs.existsSync(p)) { - p = p.replace(/\.js$/, '.ts'); - } - bin[binName] = p; - }); - - const experimental = !!packageJson.private || !!packageJson.experimental; - - packages[name] = { - build: path.join(distRoot, pkgRoot.substr(path.dirname(__dirname).length)), - dist: path.join(distRoot, name), - root: pkgRoot, - relative: path.relative(path.dirname(__dirname), pkgRoot), - main: path.resolve(pkgRoot, 'src/index.ts'), - private: !!packageJson.private, - experimental, - // yarn doesn't take kindly to @ in tgz filenames - // https://github.com/yarnpkg/yarn/issues/6339 - tar: path.join(distRoot, name.replace(/\/|@/g, '_') + '.tgz'), - bin, - name, - packageJson, - - snapshot: !!monorepoPackages[name].snapshotRepo, - snapshotRepo: monorepoPackages[name].snapshotRepo, - get snapshotHash() { - return _getSnapshotHash(this); - }, - - dependencies: [], - reverseDependencies: [], - get version() { - return _getVersionFromGit(experimental); - }, - }; - - return packages; - }, {}); - - -// Update with dependencies. -for (const pkgName of Object.keys(packages)) { - const pkg = packages[pkgName]; - const pkgJson = require(path.join(pkg.root, 'package.json')); - pkg.dependencies = Object.keys(packages).filter(name => { - return name in (pkgJson.dependencies || {}) - || name in (pkgJson.devDependencies || {}); - }); - pkg.dependencies.forEach(depName => packages[depName].reverseDependencies.push(pkgName)); -} diff --git a/modules/testing/builder/BUILD.bazel b/modules/testing/builder/BUILD.bazel new file mode 100644 index 000000000000..45c39358c29c --- /dev/null +++ b/modules/testing/builder/BUILD.bazel @@ -0,0 +1,55 @@ +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "jasmine_test", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +npm_link_all_packages() + +ts_project( + name = "builder", + testonly = True, + srcs = glob( + include = [ + "src/**/*.ts", + ], + exclude = [ + "src/**/*_spec.ts", + ], + ), + data = [ + # Needed at runtime by some builder tests relying on SSR being + # resolvable in the test project. + ":node_modules/@angular/ssr", + ":node_modules/browser-sync", + ":node_modules/istanbul-lib-instrument", + ":node_modules/jsdom", + ":node_modules/ng-packagr", + ":node_modules/vitest", + ":node_modules/@vitest/coverage-v8", + ] + glob(["projects/**/*"]), + deps = [ + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + ":node_modules/rxjs", + "//:node_modules/@types/node", + ], +) + +ts_project( + name = "unit_test_lib", + testonly = True, + srcs = glob( + include = [ + "src/**/*_spec.ts", + ], + ), + deps = [ + ":builder", + ":node_modules/@angular-devkit/architect", + ], +) + +jasmine_test( + name = "test", + data = [":unit_test_lib"], +) diff --git a/modules/testing/builder/package.json b/modules/testing/builder/package.json new file mode 100644 index 000000000000..398a200cdf8a --- /dev/null +++ b/modules/testing/builder/package.json @@ -0,0 +1,15 @@ +{ + "devDependencies": { + "@angular-devkit/architect": "workspace:*", + "@angular-devkit/build-angular": "workspace:*", + "@angular-devkit/core": "workspace:*", + "@angular/ssr": "workspace:*", + "@vitest/coverage-v8": "5.0.4", + "browser-sync": "3.0.4", + "istanbul-lib-instrument": "6.0.3", + "jsdom": "30.1.2", + "ng-packagr": "22.3.0-next.2", + "rxjs": "7.8.2", + "vitest": "5.0.4" + } +} diff --git a/tests/angular_devkit/build_angular/hello-world-app-ve/.editorconfig b/modules/testing/builder/projects/hello-world-app/.editorconfig similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/.editorconfig rename to modules/testing/builder/projects/hello-world-app/.editorconfig diff --git a/tests/angular_devkit/build_angular/hello-world-app-ve/.gitignore b/modules/testing/builder/projects/hello-world-app/.gitignore similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/.gitignore rename to modules/testing/builder/projects/hello-world-app/.gitignore diff --git a/modules/testing/builder/projects/hello-world-app/README.md b/modules/testing/builder/projects/hello-world-app/README.md new file mode 100644 index 000000000000..c2795c8bcc94 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/README.md @@ -0,0 +1,27 @@ +# HelloWorldApp + +This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.7.0-beta.1. + +## Development server + +Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. + +## Code scaffolding + +Run `ng generate component component-name` to generate a new component. You can also use `ng generate --help` to see all the available schematics you can generate. + +## Build + +Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build. + +## Running unit tests + +Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). + +## Running end-to-end tests + +Run `ng e2e` to setup and run the end-to-end tests. + +## Further help + +To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/main/README.md). diff --git a/modules/testing/builder/projects/hello-world-app/angular.json b/modules/testing/builder/projects/hello-world-app/angular.json new file mode 100644 index 000000000000..9ab612b8b2c5 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/angular.json @@ -0,0 +1,177 @@ +{ + "$schema": "../../../../packages/angular_devkit/core/src/workspace/workspace-schema.json", + "version": 1, + "newProjectRoot": "./projects", + "cli": { + "cache": { + "enabled": false + } + }, + "schematics": {}, + "projects": { + "app": { + "root": "src", + "sourceRoot": "src", + "projectType": "application", + "prefix": "app", + "schematics": {}, + "targets": { + "build": { + "builder": "@angular-devkit/build-angular:browser", + "options": { + "outputPath": "dist", + "index": "src/index.html", + "main": "src/main.ts", + "polyfills": "src/polyfills.ts", + "tsConfig": "src/tsconfig.app.json", + "progress": false, + "sourceMap": false, + "aot": false, + "vendorChunk": true, + "buildOptimizer": false, + "optimization": false, + "extractLicenses": false, + "namedChunks": true, + "assets": [ + "src/favicon.ico", + "src/assets" + ], + "styles": [ + "src/styles.css" + ], + "scripts": [] + }, + "configurations": { + "production": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "aot": true, + "extractLicenses": true, + "vendorChunk": false, + "buildOptimizer": true + }, + "inline-critical-css": { + "optimization": { + "styles": { + "minify": true, + "inlineCritical": true + }, + "scripts": true, + "fonts": true + } + }, + "sw": { + "ngswConfigPath": "src/ngsw-config.json", + "serviceWorker": true + } + } + }, + "server": { + "builder": "@angular-devkit/build-angular:server", + "options": { + "outputPath": "dist-server", + "main": "src/main.server.ts", + "tsConfig": "src/tsconfig.server.json", + "progress": false, + "sourceMap": true, + "optimization": false, + "extractLicenses": false + }, + "configurations": { + "production": { + "outputHashing": "media", + "sourceMap": false, + "optimization": true + } + } + }, + "app-shell": { + "builder": "@angular-devkit/build-angular:app-shell", + "options": { + "browserTarget": "app:build", + "serverTarget": "app:server" + }, + "configurations": { + "production": { + "browserTarget": "app:build:production" + } + } + }, + "serve": { + "builder": "@angular-devkit/build-angular:dev-server", + "options": { + "buildTarget": "app:build", + "watch": false + }, + "configurations": { + "production": { + "buildTarget": "app:build:production" + } + } + }, + "extract-i18n": { + "builder": "@angular-devkit/build-angular:extract-i18n", + "options": { + "buildTarget": "app:build", + "progress": false, + "outputPath": "src" + } + }, + "test": { + "builder": "@angular-devkit/build-angular:karma", + "options": { + "polyfills": [ + "zone.js", + "zone.js/testing" + ], + "tsConfig": "src/tsconfig.spec.json", + "karmaConfig": "karma.conf.js", + "browsers": "ChromeHeadlessCI", + "progress": false, + "watch": false, + "styles": [ + { + "input": "src/styles.css" + } + ], + "scripts": [], + "assets": [ + "src/favicon.ico", + "src/assets" + ] + } + }, + "serve-ssr": { + "builder": "@angular-devkit/build-angular:ssr-dev-server", + "options": { + "browserTarget": "app:build", + "serverTarget": "app:server", + "watch": false, + "progress": false + }, + "configurations": { + "production": { + "browserTarget": "app:build:production", + "serverTarget": "app:server:production" + } + } + }, + "prerender": { + "builder": "@angular-devkit/build-angular:prerender", + "options": { + "browserTarget": "app:build", + "serverTarget": "app:server" + }, + "configurations": { + "production": { + "browserTarget": "app:build:production", + "serverTarget": "app:server:production" + } + } + } + } + } + } +} diff --git a/modules/testing/builder/projects/hello-world-app/karma.conf.js b/modules/testing/builder/projects/hello-world-app/karma.conf.js new file mode 100644 index 000000000000..d3206bd4cf87 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/karma.conf.js @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +const path = require('path'); + +// Resolve from relative paths to absolute paths within the bazel runfiles tree +// so subprocesses spawned in a different working directory can still find them. +process.env.CHROME_BIN = process.env.CHROME_BIN ? path.resolve(path.join(process.cwd(), '..', '..', '..'), process.env.CHROME_BIN) : require('puppeteer').executablePath(); + +module.exports = function(config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage'), + require('@angular-devkit/build-angular/plugins/karma'), + ], + jasmineHtmlReporter: { + suppressAll: true // removes the duplicated traces + }, + coverageReporter: { + dir: path.join(__dirname, './coverage'), + subdir: '.', + reporters: [ + {type: 'lcov'}, + ], + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + customLaunchers: { + ChromeHeadlessCI: { + base: 'ChromeHeadless', + flags: [ + '--disable-gpu', + '--no-sandbox' + ], + }, + }, + singleRun: false, + }); +}; diff --git a/tests/angular_devkit/build_angular/hello-world-app-ve/src/app/app.component.css b/modules/testing/builder/projects/hello-world-app/src/app/app.component.css similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/src/app/app.component.css rename to modules/testing/builder/projects/hello-world-app/src/app/app.component.css diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.component.html b/modules/testing/builder/projects/hello-world-app/src/app/app.component.html new file mode 100644 index 000000000000..fe14e2bc127c --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.component.html @@ -0,0 +1,22 @@ + +
+

+ Welcome to {{ title }}! +

+ Angular Logo +
+

Here are some links to help you start:

+
+ +

i18n test

+ diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.component.spec.ts b/modules/testing/builder/projects/hello-world-app/src/app/app.component.spec.ts new file mode 100644 index 000000000000..37e8de10ba72 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.component.spec.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { TestBed } from '@angular/core/testing'; +import { AppComponent } from './app.component'; +describe('AppComponent', () => { + beforeEach(() => TestBed.configureTestingModule({ + declarations: [AppComponent], + })); + it('should create the app', () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app).toBeTruthy(); + }); + it(`should have as title 'app'`, () => { + const fixture = TestBed.createComponent(AppComponent); + const app = fixture.debugElement.componentInstance; + expect(app.title).toEqual('app'); + }); + it('should render title in a h1 tag', () => { + const fixture = TestBed.createComponent(AppComponent); + fixture.detectChanges(); + const compiled = fixture.debugElement.nativeElement; + expect(compiled.querySelector('h1').textContent).toContain('Welcome to app!'); + }); +}); diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.component.ts b/modules/testing/builder/projects/hello-world-app/src/app/app.component.ts new file mode 100644 index 000000000000..93a041e8aec7 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.component.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-root', + standalone: false, + templateUrl: './app.component.html', + styleUrls: ['./app.component.css'], +}) +export class AppComponent { + title = 'app'; +} diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.module.server.ts b/modules/testing/builder/projects/hello-world-app/src/app/app.module.server.ts new file mode 100644 index 000000000000..76fd51151562 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.module.server.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { NgModule } from '@angular/core'; +import { ServerModule } from '@angular/platform-server'; + +import { AppModule } from './app.module'; +import { AppComponent } from './app.component'; + +@NgModule({ + imports: [ + AppModule, + ServerModule, + ], + bootstrap: [AppComponent], +}) +export class AppServerModule {} diff --git a/modules/testing/builder/projects/hello-world-app/src/app/app.module.ts b/modules/testing/builder/projects/hello-world-app/src/app/app.module.ts new file mode 100644 index 000000000000..d8060d5ac4c9 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/app/app.module.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BrowserModule } from '@angular/platform-browser'; +import { NgModule } from '@angular/core'; + + +import { AppComponent } from './app.component'; + + +@NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] +}) +export class AppModule { } diff --git a/tests/angular_devkit/build_angular/hello-world-app-ve/src/assets/.gitkeep b/modules/testing/builder/projects/hello-world-app/src/assets/.gitkeep similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/src/assets/.gitkeep rename to modules/testing/builder/projects/hello-world-app/src/assets/.gitkeep diff --git a/tests/angular_devkit/build_angular/hello-world-app-ve/src/favicon.ico b/modules/testing/builder/projects/hello-world-app/src/favicon.ico similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/src/favicon.ico rename to modules/testing/builder/projects/hello-world-app/src/favicon.ico diff --git a/tests/angular_devkit/build_angular/hello-world-app-ve/src/index.html b/modules/testing/builder/projects/hello-world-app/src/index.html similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/src/index.html rename to modules/testing/builder/projects/hello-world-app/src/index.html diff --git a/tests/angular_devkit/build_angular/hello-world-app-ve/src/src/locale/messages.xlf b/modules/testing/builder/projects/hello-world-app/src/locale/messages.xlf similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/src/src/locale/messages.xlf rename to modules/testing/builder/projects/hello-world-app/src/locale/messages.xlf diff --git a/modules/testing/builder/projects/hello-world-app/src/main.server.ts b/modules/testing/builder/projects/hello-world-app/src/main.server.ts new file mode 100644 index 000000000000..0c26cf60e980 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/main.server.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { AppServerModule } from './app/app.module.server'; +export default AppServerModule; +export { AppServerModule }; diff --git a/modules/testing/builder/projects/hello-world-app/src/main.ts b/modules/testing/builder/projects/hello-world-app/src/main.ts new file mode 100644 index 000000000000..d975b48d7808 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/main.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { platformBrowser } from '@angular/platform-browser'; + +import { AppModule } from './app/app.module'; + +platformBrowser() + .bootstrapModule(AppModule) + .catch(err => console.log(err)); diff --git a/modules/testing/builder/projects/hello-world-app/src/ngsw-config.json b/modules/testing/builder/projects/hello-world-app/src/ngsw-config.json new file mode 100644 index 000000000000..789b03bdf7ce --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/ngsw-config.json @@ -0,0 +1,30 @@ +{ + "$schema": "../node_modules/@angular/service-worker/config/schema.json", + "index": "/index.html", + "assetGroups": [ + { + "name": "app", + "installMode": "prefetch", + "resources": { + "files": [ + "/favicon.ico", + "/index.html", + "/manifest.webmanifest", + "/*.css", + "/*.js" + ] + } + }, + { + "name": "assets", + "installMode": "lazy", + "updateMode": "prefetch", + "resources": { + "files": [ + "/assets/**", + "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)" + ] + } + } + ] +} diff --git a/modules/testing/builder/projects/hello-world-app/src/polyfills.ts b/modules/testing/builder/projects/hello-world-app/src/polyfills.ts new file mode 100644 index 000000000000..eb77e392ee4e --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/polyfills.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * 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 Safari >= 10, Chrome >= 55 (including Opera), + * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. + * + * Learn more in https://angular.dev/reference/versions#browser-support + */ + +/*************************************************************************************************** + * BROWSER POLYFILLS + */ + +/** IE10 and IE11 requires the following for NgClass support on SVG elements */ +// import 'classlist.js'; // Run `npm install --save classlist.js`. + +/** + * Web Animations `@angular/platform-browser/animations` + * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. + * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). + */ +// import 'web-animations-js'; // Run `npm install --save web-animations-js`. + +/** + * 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.ts'; + * + * 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/tests/angular_devkit/build_angular/hello-world-app-ve/src/spectrum.png b/modules/testing/builder/projects/hello-world-app/src/spectrum.png similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/src/spectrum.png rename to modules/testing/builder/projects/hello-world-app/src/spectrum.png diff --git a/tests/angular_devkit/build_angular/hello-world-app-ve/src/styles.css b/modules/testing/builder/projects/hello-world-app/src/styles.css similarity index 100% rename from tests/angular_devkit/build_angular/hello-world-app-ve/src/styles.css rename to modules/testing/builder/projects/hello-world-app/src/styles.css diff --git a/modules/testing/builder/projects/hello-world-app/src/tsconfig.app.json b/modules/testing/builder/projects/hello-world-app/src/tsconfig.app.json new file mode 100644 index 000000000000..a76843b689fb --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/app", + "types": ["@angular/localize"] + }, + "files": [ + "main.ts", + "polyfills.ts" + ], + "include": [ + "**/*.d.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-app/src/tsconfig.server.json b/modules/testing/builder/projects/hello-world-app/src/tsconfig.server.json new file mode 100644 index 000000000000..3fd4df49c45d --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/tsconfig.server.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.app.json", + "compilerOptions": { + "outDir": "../dist-server", + "types": ["@angular/localize", "node", "@types/express"] + }, + "files": [ + "main.server.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-app/src/tsconfig.spec.json b/modules/testing/builder/projects/hello-world-app/src/tsconfig.spec.json new file mode 100644 index 000000000000..a42459f71db5 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/tsconfig.spec.json @@ -0,0 +1,17 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "outDir": "../out-tsc/spec", + "types": [ + "jasmine", + "@angular/localize" + ] + }, + "files": [ + "polyfills.ts" + ], + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-app/src/typings.d.ts b/modules/testing/builder/projects/hello-world-app/src/typings.d.ts new file mode 100644 index 000000000000..161056781dc0 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/src/typings.d.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* SystemJS module definition */ +declare var module: NodeModule; +interface NodeModule { + id: string; +} diff --git a/modules/testing/builder/projects/hello-world-app/tsconfig.json b/modules/testing/builder/projects/hello-world-app/tsconfig.json new file mode 100644 index 000000000000..4f9890cb8e7d --- /dev/null +++ b/modules/testing/builder/projects/hello-world-app/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "sourceMap": true, + "declaration": false, + "moduleResolution": "bundler", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "skipLibCheck": true, + "target": "es2022", + "module": "es2022", + "lib": [ + "es2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableIvy": true, + "disableTypeScriptVersionCheck": true, + "strictTemplates": true + } +} diff --git a/modules/testing/builder/projects/hello-world-lib/.gitignore b/modules/testing/builder/projects/hello-world-lib/.gitignore new file mode 100644 index 000000000000..e0406551d1ea --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/.gitignore @@ -0,0 +1,43 @@ +# See http://help.github.com/ignore-files/ for more about ignoring files. + +# compiled output +/dist +/tmp +/out-tsc + +# dependencies +/node_modules + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +# misc +/.angular/cache +/.sass-cache +/connect.lock +/coverage +/libpeerconnection.log +npm-debug.log +testem.log +/typings + +# e2e +/e2e/*.js +/e2e/*.map + +# System Files +.DS_Store +Thumbs.db diff --git a/modules/testing/builder/projects/hello-world-lib/angular.json b/modules/testing/builder/projects/hello-world-lib/angular.json new file mode 100644 index 000000000000..9925e3d08f14 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/angular.json @@ -0,0 +1,29 @@ +{ + "$schema": "../../../../packages/angular_devkit/core/src/workspace/workspace-schema.json", + "version": 1, + "cli": { + "cache": { + "enabled": false + } + }, + "projects": { + "lib": { + "root": "projects/lib", + "projectType": "library", + "targets": { + "build": { + "builder": "@angular-devkit/build-angular:ng-packagr", + "options": { + "project": "projects/lib/ng-package.json", + "tsConfig": "projects/lib/tsconfig.lib.json" + }, + "configurations": { + "production": { + "tsConfig": "projects/lib/tsconfig.lib.prod.json" + } + } + } + } + } + } +} \ No newline at end of file diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/karma.conf.js b/modules/testing/builder/projects/hello-world-lib/projects/lib/karma.conf.js new file mode 100644 index 000000000000..5573d103b26e --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/karma.conf.js @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +// Karma configuration file, see link for more information +// https://karma-runner.github.io/1.0/config/configuration-file.html + +module.exports = function (config) { + config.set({ + basePath: '', + frameworks: ['jasmine', '@angular-devkit/build-angular'], + plugins: [ + require('karma-jasmine'), + require('karma-chrome-launcher'), + require('karma-jasmine-html-reporter'), + require('karma-coverage-istanbul-reporter'), + require('@angular-devkit/build-angular/plugins/karma') + ], + jasmineHtmlReporter: { + suppressAll: true // removes the duplicated traces + }, + coverageIstanbulReporter: { + dir: require('path').join(__dirname, 'coverage'), + reports: ['html', 'lcovonly'], + fixWebpackSourcePaths: true + }, + reporters: ['progress', 'kjhtml'], + port: 9876, + colors: true, + logLevel: config.LOG_INFO, + autoWatch: true, + browsers: ['Chrome'], + customLaunchers: { + ChromeHeadlessCI: { + base: 'ChromeHeadless', + flags: [ + '--disable-gpu', + '--no-sandbox' + ], + } + }, + singleRun: false + }); +}; diff --git a/tests/angular_devkit/build_ng_packagr/ng-packaged-ve/projects/lib/ng-package.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/ng-package.json similarity index 100% rename from tests/angular_devkit/build_ng_packagr/ng-packaged-ve/projects/lib/ng-package.json rename to modules/testing/builder/projects/hello-world-lib/projects/lib/ng-package.json diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json new file mode 100644 index 000000000000..c8105b2c16b4 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/package.json @@ -0,0 +1,8 @@ +{ + "name": "lib", + "version": "0.0.1", + "peerDependencies": { + "@angular/common": "^22.2.0-next", + "@angular/core": "^22.2.0-next" + } +} \ No newline at end of file diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.spec.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.spec.ts new file mode 100644 index 000000000000..dbdec32d37c2 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.spec.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { LibComponent } from './lib.component'; + +describe('LibComponent', () => { + let component: LibComponent; + let fixture: ComponentFixture; + + beforeEach(() => TestBed.configureTestingModule({ + declarations: [LibComponent] + })); + + beforeEach(() => { + fixture = TestBed.createComponent(LibComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.ts new file mode 100644 index 000000000000..3e1ee2aebd2b --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.component.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Component } from '@angular/core'; + +@Component({ + selector: 'lib', + template: ` +

+ lib works! +

+ `, + styles: [] +}) +export class LibComponent { + +} diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.ts new file mode 100644 index 000000000000..1bc021e00abb --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.spec.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { TestBed, inject } from '@angular/core/testing'; + +import { LibService } from './lib.service'; + +describe('LibService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [LibService] + }); + }); + + it('should be created', inject([LibService], (service: LibService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.ts new file mode 100644 index 000000000000..20c9f8075e86 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/lib/lib.service.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Injectable } from '@angular/core'; + +@Injectable() +export class LibService { + + testEs2016() { + return ['foo', 'bar'].includes('foo'); + } + +} diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/src/public-api.ts b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/public-api.ts new file mode 100644 index 000000000000..b641e95d8900 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/src/public-api.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* + * Public API Surface of lib + */ + +export * from './lib/lib.service'; +export * from './lib/lib.component'; diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.json new file mode 100644 index 000000000000..23e851ac9c8b --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "inlineSources": true, + "types": [] + }, + "exclude": [ + "**/*.spec.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.prod.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.prod.json new file mode 100644 index 000000000000..2a2faa884cf3 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.lib.prod.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "declarationMap": false + }, + "angularCompilerOptions": { + "compilationMode": "partial" + } +} diff --git a/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.spec.json b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.spec.json new file mode 100644 index 000000000000..eae9b9ae9c29 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/projects/lib/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "../../out-tsc/spec", + "types": [ + "jasmine" + ] + }, + "include": [ + "**/*.spec.ts", + "**/*.d.ts" + ] +} diff --git a/modules/testing/builder/projects/hello-world-lib/tsconfig.json b/modules/testing/builder/projects/hello-world-lib/tsconfig.json new file mode 100644 index 000000000000..8ccae14cd594 --- /dev/null +++ b/modules/testing/builder/projects/hello-world-lib/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "outDir": "./dist/out-tsc", + "sourceMap": true, + "declaration": false, + "moduleResolution": "bundler", + "experimentalDecorators": true, + "target": "es2015", + "module": "es2022", + "useDefineForClassFields": false, + "lib": [ + "es2022", + "dom" + ] + }, + "angularCompilerOptions": { + "enableIvy": true, + "disableTypeScriptVersionCheck": true + } +} diff --git a/modules/testing/builder/src/builder-harness.ts b/modules/testing/builder/src/builder-harness.ts new file mode 100644 index 000000000000..570939cecd7f --- /dev/null +++ b/modules/testing/builder/src/builder-harness.ts @@ -0,0 +1,550 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { + BuilderContext, + BuilderHandlerFn, + BuilderInfo, + BuilderOutput, + BuilderOutputLike, + BuilderProgressReport, + BuilderRun, + ScheduleOptions, + Target, + fromAsyncIterable, + isBuilderOutput, +} from '@angular-devkit/architect'; +import { WorkspaceHost } from '@angular-devkit/architect/node'; +import { TestProjectHost } from '@angular-devkit/architect/testing'; +import { getSystemPath, json, logging } from '@angular-devkit/core'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { + Observable, + Subject, + catchError, + finalize, + firstValueFrom, + lastValueFrom, + map, + mergeMap, + from as observableFrom, + of as observableOf, + shareReplay, +} from 'rxjs'; +import { BuilderWatcherFactory, WatcherNotifier } from './file-watching'; + +export interface BuilderHarnessExecutionResult { + result?: T; + error?: Error; + logs: readonly logging.LogEntry[]; +} + +export interface BuilderHarnessExecutionOptions { + configuration: string; + outputLogsOnFailure: boolean; + outputLogsOnException: boolean; + useNativeFileWatching: boolean; + signal: AbortSignal; + additionalExecuteArguments: unknown[]; +} + +interface BuilderHandlerFnWithVarArgs extends BuilderHandlerFn { + (input: T, context: BuilderContext, ...args: unknown[]): BuilderOutputLike; +} + +/** + * The default set of fields provided to all builders executed via the BuilderHarness. + * `root` and `sourceRoot` are required for most Angular builders to function. + * `cli.cache.enabled` set to false provides improved test isolation guarantees by disabling + * the Webpack caching. + */ +const DEFAULT_PROJECT_METADATA = { + root: '.', + sourceRoot: 'src', + cli: { + cache: { + enabled: false, + }, + }, +}; + +export class BuilderHarness { + private readonly builderInfo: BuilderInfo; + private schemaRegistry = new json.schema.CoreSchemaRegistry(); + private projectName = 'test'; + private projectMetadata: Record = DEFAULT_PROJECT_METADATA; + private targetName?: string; + private options = new Map(); + private builderTargets = new Map< + string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { handler: BuilderHandlerFn; info: BuilderInfo; options: json.JsonObject } + >(); + private watcherNotifier?: WatcherNotifier; + + constructor( + private readonly builderHandler: BuilderHandlerFn, + private readonly host: TestProjectHost, + builderInfo?: Partial, + ) { + // Generate default pseudo builder info for test purposes + this.builderInfo = { + builderName: builderHandler.name, + description: '', + optionSchema: true, + ...builderInfo, + }; + + if (builderInfo?.builderName?.startsWith('@angular/build:')) { + this.schemaRegistry.addPostTransform(json.schema.transforms.addUndefinedObjectDefaults); + } else { + this.schemaRegistry.addPostTransform(json.schema.transforms.addUndefinedDefaults); + } + } + + resolvePath(path: string): string { + return join(getSystemPath(this.host.root()), path); + } + + resetProjectMetadata(): void { + this.projectMetadata = DEFAULT_PROJECT_METADATA; + } + + useProject(name: string, metadata: Record = {}): this { + if (!name) { + throw new Error('Project name cannot be an empty string.'); + } + + this.projectName = name; + this.projectMetadata = metadata; + + return this; + } + + useTarget(name: string, baseOptions: T): this { + if (!name) { + throw new Error('Target name cannot be an empty string.'); + } + + this.targetName = name; + this.options.set(null, baseOptions); + + return this; + } + + withConfiguration(configuration: string, options: T): this { + this.options.set(configuration, options); + + return this; + } + + withBuilderTarget( + target: string, + handler: BuilderHandlerFn, + options?: O, + info?: Partial, + ): this { + this.builderTargets.set(target, { + handler, + options: options || {}, + info: { builderName: handler.name, description: '', optionSchema: true, ...info }, + }); + + return this; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modifyTarget( + targetName: string, + modifier: (options: O) => O | void, + ): this { + const target = this.builderTargets.get(targetName); + if (!target) { + throw new Error(`Target "${targetName}" not found.`); + } + + const newOptions = modifier(target.options as O); + if (newOptions) { + target.options = newOptions as json.JsonObject; + } + + return this; + } + + execute( + options: Partial = {}, + ): Observable { + const { + configuration, + outputLogsOnException = true, + outputLogsOnFailure = true, + useNativeFileWatching = false, + } = options; + + const targetOptions = { + ...this.options.get(null), + ...((configuration && this.options.get(configuration)) ?? {}), + }; + + if (!useNativeFileWatching) { + if (this.watcherNotifier) { + throw new Error('Only one harness execution at a time is supported.'); + } + this.watcherNotifier = new WatcherNotifier(); + } + + const contextHost: ContextHost = { + findBuilderByTarget: async (project, target) => { + this.validateProjectName(project); + if (target === this.targetName) { + return { + info: this.builderInfo, + handler: this.builderHandler as BuilderHandlerFn, + }; + } + + const builderTarget = this.builderTargets.get(target); + if (builderTarget) { + return { info: builderTarget.info, handler: builderTarget.handler }; + } + + throw new Error('Project target does not exist.'); + }, + async getBuilderName(project, target) { + return (await this.findBuilderByTarget(project, target)).info.builderName; + }, + getMetadata: async (project) => { + this.validateProjectName(project); + + return this.projectMetadata as json.JsonObject; + }, + getOptions: async (project, target, configuration) => { + this.validateProjectName(project); + if (target === this.targetName) { + return (this.options.get(configuration ?? null) ?? {}) as json.JsonObject; + } else if (configuration !== undefined) { + // Harness builder targets currently do not support configurations + return {}; + } else { + return this.builderTargets.get(target)?.options || {}; + } + }, + hasTarget: async (project, target) => { + this.validateProjectName(project); + + return this.targetName === target || this.builderTargets.has(target); + }, + getDefaultConfigurationName: async (_project, _target) => { + return undefined; + }, + validate: async (options, builderName) => { + let schema; + if (builderName === this.builderInfo.builderName) { + schema = this.builderInfo.optionSchema; + } else { + for (const [, value] of this.builderTargets) { + if (value.info.builderName === builderName) { + schema = value.info.optionSchema; + break; + } + } + } + + const validator = await this.schemaRegistry.compile(schema ?? true); + const { data } = await validator(options); + + return data as json.JsonObject; + }, + }; + const context = new HarnessBuilderContext( + this.builderInfo, + this.resolvePath('.'), + contextHost, + options.signal, + useNativeFileWatching ? undefined : this.watcherNotifier, + ); + if (this.targetName !== undefined) { + context.target = { + project: this.projectName, + target: this.targetName, + configuration: configuration as string, + }; + } + + const logs: logging.LogEntry[] = []; + const logger$ = context.logger.subscribe((e) => logs.push(e)); + + return observableFrom(this.schemaRegistry.compile(this.builderInfo.optionSchema)).pipe( + mergeMap((validator) => validator(targetOptions)), + map((validationResult) => validationResult.data), + mergeMap((data) => + convertBuilderOutputToObservable( + (this.builderHandler as BuilderHandlerFnWithVarArgs)( + data as T & json.JsonObject, + context, + ...(options.additionalExecuteArguments ?? []), + ), + ), + ), + map((buildResult) => ({ result: buildResult, error: undefined })), + catchError((error) => { + if (outputLogsOnException) { + // eslint-disable-next-line no-console + console.error(logs.map((entry) => entry.message).join('\n')); + // eslint-disable-next-line no-console + console.error(error); + } + + return observableOf({ result: undefined, error }); + }), + map(({ result, error }) => { + if (outputLogsOnFailure && result?.success === false && logs.length > 0) { + // eslint-disable-next-line no-console + console.error(logs.map((entry) => entry.message).join('\n')); + } + + // Capture current logs and clear for next + const currentLogs = logs.slice(); + logs.length = 0; + + return { result, error, logs: currentLogs }; + }), + finalize(() => { + this.watcherNotifier = undefined; + logger$.unsubscribe(); + + for (const teardown of context.teardowns) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + teardown(); + } + }), + ); + } + + async executeOnce( + options?: Partial, + ): Promise { + // Return the first result + return firstValueFrom(this.execute(options)); + } + + async appendToFile(path: string, content: string): Promise { + await this.writeFile(path, this.readFile(path).concat(content)); + } + + async writeFile(path: string, content: string | Buffer): Promise { + const fullPath = this.resolvePath(path); + + await fs.mkdir(dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, 'utf-8'); + + this.watcherNotifier?.notify([{ path: fullPath, type: 'modified' }]); + } + + async writeFiles(files: Record): Promise { + const watchEvents = this.watcherNotifier + ? ([] as { path: string; type: 'modified' | 'deleted' }[]) + : undefined; + + for (const [path, content] of Object.entries(files)) { + const fullPath = this.resolvePath(path); + + await fs.mkdir(dirname(fullPath), { recursive: true }); + await fs.writeFile(fullPath, content, 'utf-8'); + + watchEvents?.push({ path: fullPath, type: 'modified' }); + } + + if (watchEvents) { + this.watcherNotifier?.notify(watchEvents); + } + } + + async removeFile(path: string): Promise { + const fullPath = this.resolvePath(path); + + await fs.unlink(fullPath); + + this.watcherNotifier?.notify([{ path: fullPath, type: 'deleted' }]); + } + + async modifyFile( + path: string, + modifier: (content: string) => string | Promise, + ): Promise { + const content = this.readFile(path); + await this.writeFile(path, await modifier(content)); + } + + hasFile(path: string): boolean { + const fullPath = this.resolvePath(path); + + return existsSync(fullPath); + } + + hasDirectory(path: string): boolean { + const fullPath = this.resolvePath(path); + + return statSync(fullPath, { throwIfNoEntry: false })?.isDirectory() ?? false; + } + + hasFileMatch(directory: string, pattern: RegExp): boolean { + const fullPath = this.resolvePath(directory); + + return readdirSync(fullPath).some((name) => pattern.test(name)); + } + + readFile(path: string): string { + const fullPath = this.resolvePath(path); + + return readFileSync(fullPath, 'utf-8'); + } + + private validateProjectName(name: string): void { + if (name !== this.projectName) { + throw new Error(`Project "${name}" does not exist.`); + } + } +} + +interface ContextHost extends WorkspaceHost { + findBuilderByTarget( + project: string, + target: string, + ): Promise<{ info: BuilderInfo; handler: BuilderHandlerFn }>; + validate(options: json.JsonObject, builderName: string): Promise; +} + +class HarnessBuilderContext implements BuilderContext { + id = Math.trunc(Math.random() * 1000000); + logger = new logging.Logger(`builder-harness-${this.id}`); + workspaceRoot: string; + currentDirectory: string; + target?: Target; + + teardowns: (() => Promise | void)[] = []; + + constructor( + public builder: BuilderInfo, + basePath: string, + private readonly contextHost: ContextHost, + public readonly signal: AbortSignal | undefined, + public readonly watcherFactory: BuilderWatcherFactory | undefined, + ) { + this.workspaceRoot = this.currentDirectory = basePath; + } + + addTeardown(teardown: () => Promise | void): void { + this.teardowns.push(teardown); + } + + async getBuilderNameForTarget(target: Target): Promise { + return this.contextHost.getBuilderName(target.project, target.target); + } + + async getProjectMetadata(targetOrName: Target | string): Promise { + const project = typeof targetOrName === 'string' ? targetOrName : targetOrName.project; + + return this.contextHost.getMetadata(project); + } + + async getTargetOptions(target: Target): Promise { + return this.contextHost.getOptions(target.project, target.target, target.configuration); + } + + // Unused by builders in this package + async scheduleBuilder( + builderName: string, + options?: json.JsonObject, + scheduleOptions?: ScheduleOptions, + ): Promise { + throw new Error('Not Implemented.'); + } + + async scheduleTarget( + target: Target, + overrides?: json.JsonObject, + scheduleOptions?: ScheduleOptions, + ): Promise { + const { info, handler } = await this.contextHost.findBuilderByTarget( + target.project, + target.target, + ); + const targetOptions = await this.validateOptions( + { + ...(await this.getTargetOptions(target)), + ...overrides, + }, + info.builderName, + ); + + const context = new HarnessBuilderContext( + info, + this.workspaceRoot, + this.contextHost, + this.signal, + this.watcherFactory, + ); + context.target = target; + context.logger = scheduleOptions?.logger || this.logger.createChild(''); + + const progressSubject = new Subject(); + const output = convertBuilderOutputToObservable(handler(targetOptions, context)); + + const run: BuilderRun = { + id: context.id, + info, + progress: progressSubject.asObservable(), + async stop() { + for (const teardown of context.teardowns) { + await teardown(); + } + progressSubject.complete(); + }, + output: output.pipe(shareReplay()), + get result() { + return firstValueFrom(this.output); + }, + get lastOutput() { + return lastValueFrom(this.output); + }, + }; + + return run; + } + + async validateOptions( + options: json.JsonObject, + builderName: string, + ): Promise { + return this.contextHost.validate(options, builderName) as unknown as T; + } + + // Unused report methods + reportRunning(): void {} + reportStatus(): void {} + reportProgress(): void {} +} + +function isAsyncIterable(obj: unknown): obj is AsyncIterable { + return !!obj && typeof (obj as AsyncIterable)[Symbol.asyncIterator] === 'function'; +} + +function convertBuilderOutputToObservable(output: BuilderOutputLike): Observable { + if (isBuilderOutput(output)) { + return observableOf(output); + } else if (isAsyncIterable(output)) { + return fromAsyncIterable(output); + } else { + return observableFrom(output); + } +} diff --git a/modules/testing/builder/src/builder-harness_spec.ts b/modules/testing/builder/src/builder-harness_spec.ts new file mode 100644 index 000000000000..860764f567f8 --- /dev/null +++ b/modules/testing/builder/src/builder-harness_spec.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { TestProjectHost } from '@angular-devkit/architect/testing'; +import { BuilderHarness } from './builder-harness'; + +describe('BuilderHarness', () => { + let mockHost: TestProjectHost; + + beforeEach(() => { + mockHost = jasmine.createSpyObj('TestProjectHost', ['root']); + (mockHost.root as jasmine.Spy).and.returnValue('.'); + }); + + it('uses the provided builder handler', async () => { + const mockHandler = jasmine.createSpy().and.returnValue({ success: true }); + + const harness = new BuilderHarness(mockHandler, mockHost); + + await harness.executeOnce(); + + expect(mockHandler).toHaveBeenCalled(); + }); + + it('provides the builder output result when executing', async () => { + const mockHandler = jasmine.createSpy().and.returnValue({ success: false, property: 'value' }); + + const harness = new BuilderHarness(mockHandler, mockHost); + const { result } = await harness.executeOnce(); + + expect(result).toBeDefined(); + expect(result?.success).toBeFalse(); + expect(result?.property).toBe('value'); + }); + + it('does not show builder logs on console when a builder succeeds', async () => { + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(async (_, context) => { + context.logger.warn('TEST WARNING'); + + return { success: true }; + }, mockHost); + + const { result } = await harness.executeOnce(); + + expect(result).toBeDefined(); + expect(result?.success).toBeTrue(); + + expect(consoleErrorMock).not.toHaveBeenCalledWith(jasmine.stringMatching('TEST WARNING')); + }); + + it('shows builder logs on console when a builder fails', async () => { + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(async (_, context) => { + context.logger.warn('TEST WARNING'); + + return { success: false }; + }, mockHost); + + const { result } = await harness.executeOnce(); + + expect(result).toBeDefined(); + expect(result?.success).toBeFalse(); + + expect(consoleErrorMock).toHaveBeenCalledWith(jasmine.stringMatching('TEST WARNING')); + }); + + it('does not show builder logs on console when a builder fails and outputLogsOnFailure: false', async () => { + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(async (_, context) => { + context.logger.warn('TEST WARNING'); + + return { success: false }; + }, mockHost); + + const { result } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result).toBeDefined(); + expect(result?.success).toBeFalse(); + + expect(consoleErrorMock).not.toHaveBeenCalledWith(jasmine.stringMatching('TEST WARNING')); + }); + + it('provides and logs the builder output exception when builder throws', async () => { + const mockHandler = jasmine.createSpy().and.throwError(new Error('Builder Error')); + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(mockHandler, mockHost); + const { result, error } = await harness.executeOnce(); + + expect(result).toBeUndefined(); + expect(error).toEqual(jasmine.objectContaining({ message: 'Builder Error' })); + expect(consoleErrorMock).toHaveBeenCalledWith(jasmine.stringMatching('Builder Error')); + }); + + it('does not log exception with outputLogsOnException false when builder throws', async () => { + const mockHandler = jasmine.createSpy().and.throwError(new Error('Builder Error')); + const consoleErrorMock = spyOn(console, 'error'); + + const harness = new BuilderHarness(mockHandler, mockHost); + const { result, error } = await harness.executeOnce({ outputLogsOnException: false }); + + expect(result).toBeUndefined(); + expect(error).toEqual(jasmine.objectContaining({ message: 'Builder Error' })); + expect(consoleErrorMock).not.toHaveBeenCalledWith(jasmine.stringMatching('Builder Error')); + }); + + it('supports executing a target from within a builder', async () => { + const mockHandler = jasmine.createSpy().and.returnValue({ success: true }); + + const harness = new BuilderHarness(async (_, context) => { + const run = await context.scheduleTarget({ project: 'test', target: 'another' }); + expect(await run.result).toEqual(jasmine.objectContaining({ success: true })); + await run.stop(); + + return { success: true }; + }, mockHost); + harness.withBuilderTarget('another', mockHandler); + + const { result } = await harness.executeOnce(); + + expect(result).toBeDefined(); + expect(result?.success).toBeTrue(); + + expect(mockHandler).toHaveBeenCalled(); + }); +}); diff --git a/modules/testing/builder/src/dev_prod_mode.ts b/modules/testing/builder/src/dev_prod_mode.ts new file mode 100644 index 000000000000..520de82d771b --- /dev/null +++ b/modules/testing/builder/src/dev_prod_mode.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderHarness } from './builder-harness'; + +export const GOOD_TARGET = './src/good.js'; +export const BAD_TARGET = './src/bad.js'; + +/** Setup project for use of conditional imports. */ +export async function setupConditionImport(harness: BuilderHarness) { + // Files that can be used as targets for the conditional import. + await harness.writeFile('src/good.ts', `export const VALUE = 'good-value';`); + await harness.writeFile('src/bad.ts', `export const VALUE = 'bad-value';`); + await harness.writeFile('src/wrong.ts', `export const VALUE = 1;`); + + // Simple application file that accesses conditional code. + await harness.writeFile( + 'src/main.ts', + `import {VALUE} from '#target'; +console.log(VALUE); +console.log(VALUE.length); +export default 42 as any; +`, + ); + + // Ensure that good/bad can be resolved from tsconfig. + const tsconfig = JSON.parse(harness.readFile('src/tsconfig.app.json')) as TypeScriptConfig; + tsconfig.compilerOptions.moduleResolution = 'bundler'; + tsconfig.files.push('good.ts', 'bad.ts', 'wrong.ts'); + await harness.writeFile('src/tsconfig.app.json', JSON.stringify(tsconfig)); +} + +/** Update package.json with the given mapping for #target. */ +export async function setTargetMapping(harness: BuilderHarness, mapping: unknown) { + await harness.writeFile( + 'package.json', + JSON.stringify({ + name: 'ng-test-app', + imports: { + '#target': mapping, + }, + }), + ); +} + +interface TypeScriptConfig { + compilerOptions: { + moduleResolution: string; + }; + files: string[]; +} diff --git a/modules/testing/builder/src/file-watching.ts b/modules/testing/builder/src/file-watching.ts new file mode 100644 index 000000000000..6d10706d6919 --- /dev/null +++ b/modules/testing/builder/src/file-watching.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +type BuilderWatcherCallback = ( + events: Array<{ path: string; type: 'created' | 'modified' | 'deleted'; time?: number }>, +) => void; + +interface BuilderWatcherFactory { + watch( + files: Iterable, + directories: Iterable, + callback: BuilderWatcherCallback, + ): { close(): void }; +} + +class WatcherDescriptor { + constructor( + readonly files: ReadonlySet, + readonly directories: ReadonlySet, + readonly callback: BuilderWatcherCallback, + ) {} + + shouldNotify(path: string): boolean { + return true; + } +} + +export class WatcherNotifier implements BuilderWatcherFactory { + private readonly descriptors = new Set(); + + notify(events: Iterable<{ path: string; type: 'modified' | 'deleted' }>): void { + for (const descriptor of this.descriptors) { + for (const { path } of events) { + if (descriptor.shouldNotify(path)) { + descriptor.callback([...events]); + break; + } + } + } + } + + watch( + files: Iterable, + directories: Iterable, + callback: BuilderWatcherCallback, + ): { close(): void } { + const descriptor = new WatcherDescriptor(new Set(files), new Set(directories), callback); + this.descriptors.add(descriptor); + + return { close: () => this.descriptors.delete(descriptor) }; + } +} + +export type { BuilderWatcherFactory }; diff --git a/modules/testing/builder/src/index.ts b/modules/testing/builder/src/index.ts new file mode 100644 index 000000000000..3ee4220781d9 --- /dev/null +++ b/modules/testing/builder/src/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export { + BuilderHarness, + type BuilderHarnessExecutionOptions, + type BuilderHarnessExecutionResult, +} from './builder-harness'; +export { + type HarnessFileMatchers, + JasmineBuilderHarness, + describeBuilder, + expectLog, + expectNoLog, +} from './jasmine-helpers'; +export * from './test-utils'; diff --git a/modules/testing/builder/src/jasmine-helpers.ts b/modules/testing/builder/src/jasmine-helpers.ts new file mode 100644 index 000000000000..bd1943ae3ea1 --- /dev/null +++ b/modules/testing/builder/src/jasmine-helpers.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderHandlerFn } from '@angular-devkit/architect'; +import { json, logging } from '@angular-devkit/core'; +import { readFileSync } from 'node:fs'; +import { concatMap, count, debounceTime, firstValueFrom, take, timeout } from 'rxjs'; +import { + BuilderHarness, + BuilderHarnessExecutionOptions, + BuilderHarnessExecutionResult, +} from './builder-harness'; +import { host } from './test-utils'; + +/** + * Maximum time for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +const optionSchemaCache = new Map(); + +let counter = 0; + +export function describeBuilder( + builderHandler: BuilderHandlerFn, + options: { name?: string; schemaPath: string }, + specDefinitions: (harness: JasmineBuilderHarness) => void, +): void { + let optionSchema = optionSchemaCache.get(options.schemaPath); + if (optionSchema === undefined) { + optionSchema = JSON.parse(readFileSync(options.schemaPath, 'utf8')) as json.schema.JsonSchema; + optionSchemaCache.set(options.schemaPath, optionSchema); + } + const harness = new JasmineBuilderHarness(builderHandler, host, { + builderName: options.name, + optionSchema, + }); + + // The counter is needed to avoid duplicate describe names as they are not allowed. + describe((options.name || builderHandler.name) + ` (Suite: ${counter++})`, () => { + beforeEach(async () => { + harness.resetProjectMetadata(); + + await host.initialize().toPromise(); + }); + + afterEach(() => host.restore().toPromise()); + + specDefinitions(harness); + }); +} + +export class JasmineBuilderHarness extends BuilderHarness { + expectFile(path: string): HarnessFileMatchers { + return expectFile(path, this); + } + expectDirectory(path: string): HarnessDirectoryMatchers { + return expectDirectory(path, this); + } + + async executeWithCases( + cases: (( + executionResult: BuilderHarnessExecutionResult, + index: number, + ) => void | Promise)[], + options?: Partial & { timeout?: number }, + ): Promise { + const executionCount = await firstValueFrom( + this.execute(options).pipe( + timeout(options?.timeout ?? BUILD_TIMEOUT), + debounceTime(100), // This is needed as sometimes 2 events for the same change fire with webpack. + concatMap(async (result, index) => await cases[index](result, index)), + take(cases.length), + count(), + ), + ); + + expect(executionCount).toBe(cases.length); + } +} + +export interface HarnessFileMatchers { + toExist(): boolean; + toNotExist(): boolean; + readonly content: jasmine.ArrayLikeMatchers; + readonly size: jasmine.Matchers; +} + +export interface HarnessDirectoryMatchers { + toExist(): boolean; + toNotExist(): boolean; +} + +/** + * Add a Jasmine expectation filter to an expectation that always fails with a message. + * @param base The base expectation (`expect(...)`) to use. + * @param message The message to provide in the expectation failure. + */ +function createFailureExpectation(base: T, message: string): T { + // Needed typings are not included in the Jasmine types + const expectation = base as T & { + expector: { + addFilter(filter: { + selectComparisonFunc(): () => { pass: boolean; message: string }; + }): typeof expectation.expector; + }; + }; + expectation.expector = expectation.expector.addFilter({ + selectComparisonFunc() { + return () => ({ + pass: false, + message, + }); + }, + }); + + return expectation; +} + +export function expectFile(path: string, harness: BuilderHarness): HarnessFileMatchers { + return { + toExist() { + const exists = harness.hasFile(path); + expect(exists) + .withContext('Expected file to exist: ' + path) + .toBeTrue(); + + return exists; + }, + toNotExist() { + const exists = harness.hasFile(path); + expect(exists) + .withContext('Expected file to exist: ' + path) + .toBeFalse(); + + return !exists; + }, + get content() { + try { + return expect(harness.readFile(path)).withContext(`With file content for '${path}'`); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + + // File does not exist so always fail the expectation + return createFailureExpectation( + expect(''), + `Expected file content but file does not exist: '${path}'`, + ); + } + }, + get size() { + try { + return expect(Buffer.byteLength(harness.readFile(path))).withContext( + `With file size for '${path}'`, + ); + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'ENOENT') { + throw e; + } + + // File does not exist so always fail the expectation + return createFailureExpectation( + expect(0), + `Expected file size but file does not exist: '${path}'`, + ); + } + }, + }; +} + +export function expectDirectory( + path: string, + harness: BuilderHarness, +): HarnessDirectoryMatchers { + return { + toExist() { + const exists = harness.hasDirectory(path); + expect(exists) + .withContext('Expected directory to exist: ' + path) + .toBeTrue(); + + return exists; + }, + toNotExist() { + const exists = harness.hasDirectory(path); + expect(exists) + .withContext('Expected directory to not exist: ' + path) + .toBeFalse(); + + return !exists; + }, + }; +} + +export function expectLog(logs: readonly logging.LogEntry[], message: string | RegExp) { + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(message), + }), + ); +} + +export function expectNoLog( + logs: readonly logging.LogEntry[], + message: string | RegExp, + failureMessage?: string, +) { + expect(logs) + .withContext(failureMessage ?? '') + .not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(message), + }), + ); +} diff --git a/modules/testing/builder/src/test-utils.ts b/modules/testing/builder/src/test-utils.ts new file mode 100644 index 000000000000..63ded0215c90 --- /dev/null +++ b/modules/testing/builder/src/test-utils.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { Architect, BuilderOutput, ScheduleOptions, Target } from '@angular-devkit/architect'; +import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; +import { TestProjectHost, TestingArchitectHost } from '@angular-devkit/architect/testing'; +import { + Path, + getSystemPath, + join, + json, + normalize, + schema, + virtualFs, + workspaces, +} from '@angular-devkit/core'; +import path from 'node:path'; +import { firstValueFrom } from 'rxjs'; + +// Default timeout for large specs is 60s. +jasmine.DEFAULT_TIMEOUT_INTERVAL = 60_000; + +export const workspaceRoot = join(normalize(__dirname), `../projects/hello-world-app/`); +export const host = new TestProjectHost(workspaceRoot); +export const outputPath: Path = normalize('dist'); + +export const browserTargetSpec = { project: 'app', target: 'build' }; +export const devServerTargetSpec = { project: 'app', target: 'serve' }; +export const extractI18nTargetSpec = { project: 'app', target: 'extract-i18n' }; +export const karmaTargetSpec = { project: 'app', target: 'test' }; +export const tslintTargetSpec = { project: 'app', target: 'lint' }; + +export async function createArchitect(workspaceRoot: Path) { + const registry = new schema.CoreSchemaRegistry(); + registry.addPostTransform(schema.transforms.addUndefinedDefaults); + const workspaceSysPath = getSystemPath(workspaceRoot); + + // The download path is relative (set from Starlark), so before potentially + // changing directories, or executing inside a temporary directory, ensure + // the path is absolute. + if (process.env['PUPPETEER_DOWNLOAD_PATH']) { + process.env.PUPPETEER_DOWNLOAD_PATH = path.resolve(process.env['PUPPETEER_DOWNLOAD_PATH']); + } + + const { workspace } = await workspaces.readWorkspace( + workspaceSysPath, + workspaces.createWorkspaceHost(host), + ); + const architectHost = new TestingArchitectHost( + workspaceSysPath, + workspaceSysPath, + new WorkspaceNodeModulesArchitectHost(workspace, workspaceSysPath), + ); + const architect = new Architect(architectHost, registry); + + return { + workspace, + architectHost, + architect, + }; +} + +export interface BrowserBuildOutput { + output: BuilderOutput; + files: { [file: string]: Promise }; +} + +export async function browserBuild( + architect: Architect, + host: virtualFs.Host, + target: Target, + overrides?: json.JsonObject, + scheduleOptions?: ScheduleOptions, +): Promise { + const run = await architect.scheduleTarget(target, overrides, scheduleOptions); + const output = await run.result; + expect(output.success).toBe(true); + + if (!output.success) { + await run.stop(); + + return { + output, + files: {}, + }; + } + + const [{ path }] = output.outputs; + expect(path).toBeTruthy(); + const outputPath = normalize(path); + + const fileNames = await firstValueFrom(host.list(outputPath)); + const files = fileNames.reduce((acc: { [name: string]: Promise }, path) => { + let cache: Promise | null = null; + Object.defineProperty(acc, path, { + enumerable: true, + get() { + if (cache) { + return cache; + } + if (!fileNames.includes(path)) { + return Promise.reject('No file named ' + path); + } + cache = firstValueFrom(host.read(join(outputPath, path))).then((content) => + virtualFs.fileBufferToString(content), + ); + + return cache; + }, + }); + + return acc; + }, {}); + + await run.stop(); + + return { + output, + files, + }; +} + +export const lazyModuleFiles: { [path: string]: string } = { + 'src/app/lazy/lazy-routing.module.ts': ` + import { NgModule } from '@angular/core'; + import { Routes, RouterModule } from '@angular/router'; + + const routes: Routes = []; + + @NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule] + }) + export class LazyRoutingModule { } + `, + 'src/app/lazy/lazy.module.ts': ` + import { NgModule } from '@angular/core'; + import { CommonModule } from '@angular/common'; + + import { LazyRoutingModule } from './lazy-routing.module'; + + @NgModule({ + imports: [ + CommonModule, + LazyRoutingModule + ], + declarations: [] + }) + export class LazyModule { } + `, +}; + +export const lazyModuleFnImport: { [path: string]: string } = { + 'src/app/app.module.ts': ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppComponent } from './app.component'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + RouterModule.forRoot([ + { path: 'lazy', loadChildren: () => import('./lazy/lazy.module').then(m => m.LazyModule) } + ]) + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } +`, +}; diff --git a/package.json b/package.json index caca385c35a0..911eea20e9f6 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,8 @@ { "name": "@angular/devkit-repo", - "version": "0.0.0", + "version": "22.3.0-next.2", "private": true, "description": "Software Development Kit for Angular", - "bin": { - "architect": "./bin/architect", - "benchmark": "./bin/benchmark", - "build-optimizer": "./bin/build-optimizer", - "devkit-admin": "./bin/devkit-admin", - "ng": "./bin/ng", - "schematics": "./bin/schematics" - }, "keywords": [ "angular", "Angular CLI", @@ -19,34 +11,30 @@ "Angular DevKit" ], "scripts": { - "admin": "node ./bin/devkit-admin", - "bazel:format": "find . -type f \\( -name \"*.bzl\" -or -name WORKSPACE -or -name BUILD -or -name BUILD.bazel \\) ! -path \"*/node_modules/*\" | xargs buildifier -v --warnings=attr-cfg,attr-license,attr-non-empty,attr-output-default,attr-single-file,constant-glob,ctx-args,depset-iteration,depset-union,dict-concatenation,duplicated-name,filetype,git-repository,http-archive,integer-division,load,load-on-top,native-build,native-package,output-group,package-name,package-on-top,positional-args,redefined-variable,repository-name,same-origin-load,string-iteration,unused-variable", - "bazel:lint": "yarn bazel:format --lint=warn", - "bazel:lint-fix": "yarn bazel:format --lint=fix", - "bazel:test": "bazel test //...", - "build": "node ./bin/devkit-admin build", - "build-tsc": "tsc -p tsconfig.json", - "debug:test": "node --inspect-brk ./bin/devkit-admin test", - "debug:test-large": "node --inspect-brk ./bin/devkit-admin test --large --spec-reporter", - "fix": "node ./bin/devkit-admin lint --fix", - "lint": "node ./bin/devkit-admin lint", - "templates": "node ./bin/devkit-admin templates", - "test": "node ./bin/devkit-admin test", - "test-large": "node ./bin/devkit-admin test --large --spec-reporter", - "validate": "node ./bin/devkit-admin validate", - "validate-commits": "./bin/devkit-admin validate-commits", - "preinstall": "node ./tools/yarn/check-yarn.js", - "//webdriver-update-README": "ChromeDriver version must match Puppeteer Chromium version, see https://github.com/GoogleChrome/puppeteer/releases http://chromedriver.chromium.org/downloads", - "webdriver-update": "webdriver-manager update --standalone false --gecko false --versions.chrome 79.0.3945.16", - "ngcc": "ngcc --properties es2015 browser module main --first-only --create-ivy-entry-points" + "admin": "node --no-warnings=ExperimentalWarning --experimental-transform-types ./scripts/devkit-admin.mts", + "bazel": "bazelisk", + "test": "bazel test //packages/...", + "build": "pnpm --silent admin build", + "benchmark": "node --no-warnings=ExperimentalWarning --experimental-transform-types --expose-gc ./scripts/devkit-admin.mts benchmark", + "build-schema": "bazel build //... --build_tag_filters schema --symlink_prefix dist-schema/", + "lint": "eslint --cache --max-warnings=0", + "templates": "pnpm --silent admin templates", + "validate": "pnpm --silent admin validate", + "postinstall": "husky", + "ts-circular-deps": "ng-dev ts-circular-deps --config ./scripts/circular-deps-test.conf.mjs", + "check-tooling-setup": "tsc --project .ng-dev/tsconfig.json", + "diff-release-package": "node scripts/diff-release-package.mts" }, "repository": { "type": "git", - "url": "https://github.com/angular/angular-cli.git" + "url": "git+https://github.com/angular/angular-cli.git" }, + "packageManager": "pnpm@12.11.2", "engines": { - "node": ">=10.13.0 <13.0.0", - "yarn": ">=1.0.0" + "node": "^22.22.3 || ^24.15.0 || >=26.0.0", + "npm": "Please use pnpm instead of NPM to install dependencies", + "yarn": "Please use pnpm instead of Yarn to install dependencies", + "pnpm": "12.11.2" }, "author": "Angular Authors", "license": "MIT", @@ -54,109 +42,102 @@ "url": "https://github.com/angular/angular-cli/issues" }, "homepage": "https://github.com/angular/angular-cli", - "workspaces": { - "packages": [ - "packages/angular/*", - "packages/angular_devkit/*", - "packages/ngtools/*", - "packages/schematics/*" - ], - "nohoist": [ - "@angular/compiler-cli" - ] + "dependencies": { + "@angular/compiler-cli": "22.3.0-next.2", + "typescript": "6.0.3" }, "devDependencies": { - "@angular/compiler": "9.1.1", - "@angular/compiler-cli": "9.1.1", - "@bazel/bazel": "2.1.0", - "@bazel/buildifier": "0.29.0", - "@bazel/jasmine": "1.3.0", - "@bazel/karma": "1.3.0", - "@bazel/labs": "1.3.0", - "@bazel/typescript": "1.3.0", - "@types/babel__core": "7.1.7", - "@types/browserslist": "^4.4.0", - "@types/caniuse-lite": "^1.0.0", - "@types/copy-webpack-plugin": "^5.0.0", - "@types/cssnano": "^4.0.0", - "@types/debug": "^4.1.2", - "@types/express": "^4.16.0", - "@types/find-cache-dir": "^3.0.0", - "@types/glob": "^7.0.3", - "@types/inquirer": "^0.0.44", - "@types/jasmine": "~3.5.0", - "@types/karma": "^4.0.0", - "@types/loader-utils": "^1.1.3", - "@types/minimist": "^1.2.0", - "@types/node": "10.12.30", - "@types/node-fetch": "^2.1.6", + "@angular/animations": "22.3.0-next.2", + "@angular/cdk": "22.3.0-next.2", + "@angular/common": "22.3.0-next.2", + "@angular/compiler": "22.3.0-next.2", + "@angular/core": "22.3.0-next.2", + "@angular/forms": "22.3.0-next.2", + "@angular/localize": "22.3.0-next.2", + "@angular/material": "22.3.0-next.2", + "@angular/ng-dev": "https://github.com/angular/dev-infra-private-ng-dev-builds.git#940c0beef0de4cb128e90962d47ff80e352a1cd8", + "@angular/platform-browser": "22.3.0-next.2", + "@angular/platform-server": "22.3.0-next.2", + "@angular/router": "22.3.0-next.2", + "@angular/service-worker": "22.3.0-next.2", + "@babel/core": "8.0.7", + "@bazel/bazelisk": "1.28.1", + "@bazel/buildifier": "8.2.1", + "@bazel/ibazel": "^0.28.0", + "@eslint/compat": "2.1.1", + "@eslint/eslintrc": "3.3.7", + "@eslint/js": "10.0.1", + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "16.0.3", + "@rollup/wasm-node": "4.64.6", + "@stylistic/eslint-plugin": "^5.0.0", + "@tony.ganchev/eslint-plugin-header": "~3.4.0", + "@types/babel__core": "7.20.5", + "@types/babel__generator": "^7.6.8", + "@types/browser-sync": "^2.27.0", + "@types/express": "~5.0.1", + "@types/http-proxy": "^1.17.4", + "@types/jasmine": "~6.0.0", + "@types/jasmine-reporters": "^2", + "@types/karma": "^6.3.0", + "@types/less": "^3.0.3", + "@types/loader-utils": "^3.0.0", + "@types/lodash": "^4.17.0", + "@types/node": "^22.12.0", "@types/npm-package-arg": "^6.1.0", + "@types/picomatch": "^4.0.0", "@types/progress": "^2.0.3", - "@types/request": "^2.47.1", - "@types/rimraf": "^3.0.0", - "@types/semver": "^7.0.0", - "@types/universal-analytics": "^0.4.2", - "@types/uuid": "^7.0.0", - "@types/webpack": "^4.32.1", - "@types/webpack-dev-server": "^3.1.7", - "@types/webpack-sources": "^0.1.5", - "@yarnpkg/lockfile": "1.1.0", - "ajv": "6.12.0", - "ansi-colors": "4.1.1", - "common-tags": "^1.8.0", - "conventional-changelog": "^3.0.0", - "conventional-commits-parser": "^3.0.0", - "debug": "^4.1.1", - "enhanced-resolve": "4.1.1", - "fast-json-stable-stringify": "2.1.0", - "gh-got": "^9.0.0", - "git-raw-commits": "^2.0.0", - "glob": "^7.0.3", - "husky": "^4.0.10", - "inquirer": "7.1.0", - "jasmine": "^3.3.1", - "jasmine-spec-reporter": "~5.0.0", - "karma": "~5.0.0", - "karma-jasmine": "~3.1.0", - "karma-jasmine-html-reporter": "^1.4.0", - "license-checker": "^25.0.0", - "minimatch": "^3.0.4", - "minimist": "^1.2.0", - "node-fetch": "^2.2.0", - "npm-registry-client": "8.6.0", - "ora": "^4.0.2", - "pacote": "11.1.4", - "parse5-html-rewriting-stream": "5.1.1", - "pidtree": "^0.3.0", - "pidusage": "^2.0.17", - "prettier": "^2.0.0", - "puppeteer": "2.1.1", - "quicktype-core": "^6.0.15", - "raw-loader": "4.0.0", - "rxjs": "6.5.4", - "sauce-connect-proxy": "https://saucelabs.com/downloads/sc-4.5.4-linux.tar.gz", - "semver": "7.1.3", - "source-map": "^0.7.3", - "source-map-support": "^0.5.0", - "spdx-satisfies": "^5.0.0", - "symbol-observable": "1.2.0", - "tar": "^6.0.0", - "temp": "^0.9.0", - "through2": "^3.0.0", - "tree-kill": "^1.2.2", - "ts-api-guardian": "0.5.0", - "ts-node": "^5.0.0", - "tslint": "^6.0.0", - "tslint-no-circular-imports": "^0.7.0", - "tslint-sonarts": "1.9.0", - "typescript": "3.8.3", - "verdaccio": "4.5.1", - "webpack": "4.42.0", - "webpack-sources": "1.4.3" + "@types/semver": "^7.3.12", + "@types/yargs": "^17.0.20", + "@types/yargs-parser": "^21.0.0", + "@typescript-eslint/eslint-plugin": "8.71.2", + "@typescript-eslint/parser": "8.71.2", + "ajv": "8.20.0", + "buffer": "6.0.3", + "esbuild": "0.28.2", + "esbuild-wasm": "0.28.2", + "eslint": "10.12.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import": "2.32.0", + "express": "5.3.0", + "fast-glob": "3.3.3", + "globals": "17.13.0", + "http-proxy": "^1.18.1", + "http-proxy-middleware": "4.2.0", + "husky": "9.1.7", + "jasmine": "~6.3.0", + "jasmine-core": "~6.3.0", + "jasmine-reporters": "^2.5.2", + "jasmine-spec-reporter": "~7.0.0", + "karma": "~6.4.0", + "karma-chrome-launcher": "~3.2.0", + "karma-coverage": "~2.2.0", + "karma-jasmine": "~5.1.0", + "karma-jasmine-html-reporter": "~2.3.0", + "karma-source-map-support": "1.4.0", + "lodash": "^4.17.21", + "magic-string": "1.4.3", + "prettier": "^3.0.0", + "puppeteer": "25.14.0", + "quicktype-core": "26.0.0", + "rollup": "4.64.6", + "rollup-plugin-dts": "6.5.1", + "rollup-plugin-sourcemaps2": "0.5.9", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tslib": "2.8.1", + "undici": "8.12.1", + "verdaccio": "6.10.5", + "verdaccio-auth-memory": "^13.0.0", + "zone.js": "^0.16.0" }, - "husky": { - "hooks": { - "pre-push": "node ./bin/devkit-admin hooks/pre-push" + "dependenciesMeta": { + "esbuild": { + "built": true + }, + "puppeteer": { + "built": true } } } diff --git a/packages/README.md b/packages/README.md index 82160f26a4f6..aab7eadc1c92 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,8 +2,7 @@ This folder is the root of all defined packages in this repository. -Packages that are marked as `private: true` will not be published to NPM. These are limited to the -`_` subfolder. +Packages that are marked as `private: true` will not be published to NPM. -This folder includes a directory for every scope in NPM, without the `@` sign. Then one folder +This folder includes a directory for every scope in NPM, without the `@` sign. Then one folder per package, which contains the `package.json`. diff --git a/packages/_/benchmark/package.json b/packages/_/benchmark/package.json deleted file mode 100644 index ba691f860a1a..000000000000 --- a/packages/_/benchmark/package.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "@_/benchmark", - "version": "0.0.0", - "description": "CLI tool for Angular", - "main": "src/index.js", - "typings": "src/index.d.ts", - "scripts": { - "preinstall": "echo DO NOT INSTALL THIS PROJECT, ONLY THE ROOT PROJECT. && exit 1" - }, - "private": true -} diff --git a/packages/_/benchmark/src/benchmark.ts b/packages/_/benchmark/src/benchmark.ts deleted file mode 100644 index 7c22abe0136f..000000000000 --- a/packages/_/benchmark/src/benchmark.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -declare const global: { - benchmarkReporter: { - reportBenchmark: Function, - }, -}; - - -const kNanosecondsPerSeconds = 1e9; -const kBenchmarkIterationMaxCount = 10000; -const kBenchmarkTimeoutInMsec = 5000; -const kWarmupIterationCount = 100; -const kTopMetricCount = 5; - - -function _run(fn: (i: number) => void, collector: number[]) { - const timeout = Date.now(); - // Gather the first 5 seconds runs, or kMaxNumberOfIterations runs whichever comes first - // (soft timeout). - for (let i = 0; - i < kBenchmarkIterationMaxCount && (Date.now() - timeout) < kBenchmarkTimeoutInMsec; - i++) { - // Start time. - const start = process.hrtime(); - fn(i); - // Get the stop difference time. - const diff = process.hrtime(start); - - // Add to metrics. - collector.push(diff[0] * kNanosecondsPerSeconds + diff[1]); - } -} - - -function _stats(metrics: number[]) { - metrics.sort((a, b) => a - b); - - const count = metrics.length; - const middle = count / 2; - const mean = Number.isInteger(middle) - ? metrics[middle] : ((metrics[middle - 0.5] + metrics[middle + 0.5]) / 2); - const total = metrics.reduce((acc, curr) => acc + curr, 0); - const average = total / count; - - return { - count: count, - fastest: metrics.slice(0, kTopMetricCount), - slowest: metrics.reverse().slice(0, kTopMetricCount), - mean, - average, - }; -} - - -export function benchmark(name: string, fn: (i: number) => void, base?: (i: number) => void) { - it(name + ' (time in nanoseconds)', (done) => { - process.nextTick(() => { - for (let i = 0; i < kWarmupIterationCount; i++) { - // Warm it up. - fn(i); - } - - const reporter = global.benchmarkReporter; - const metrics: number[] = []; - const baseMetrics: number[] = []; - - _run(fn, metrics); - if (base) { - _run(base, baseMetrics); - } - - reporter.reportBenchmark({ - ..._stats(metrics), - base: base ? _stats(baseMetrics) : null, - }); - - done(); - }); - }); -} diff --git a/packages/_/benchmark/src/index.ts b/packages/_/benchmark/src/index.ts deleted file mode 100644 index df56b2754b0b..000000000000 --- a/packages/_/benchmark/src/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -export * from './benchmark'; diff --git a/packages/_/devkit/collection.json b/packages/_/devkit/collection.json deleted file mode 100644 index 0353b644307c..000000000000 --- a/packages/_/devkit/collection.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "schematics": { - "package": { - "factory": "./package/factory", - "schema": "./package/schema.json", - "description": "Create an empty schematic project or add a blank schematic to the current project." - } - } -} diff --git a/packages/_/devkit/package.json b/packages/_/devkit/package.json deleted file mode 100644 index 23b4a671ac7e..000000000000 --- a/packages/_/devkit/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "devkit", - "version": "0.0.0", - "description": "Schematics specific to DevKit (used internally, not released)", - "scripts": { - "preinstall": "echo DO NOT INSTALL THIS PROJECT, ONLY THE ROOT PROJECT. && exit 1" - }, - "schematics": "./collection.json", - "private": true, - "dependencies": { - "@angular-devkit/core": "0.0.0", - "@angular-devkit/schematics": "0.0.0" - } -} diff --git a/packages/_/devkit/package/factory.ts b/packages/_/devkit/package/factory.ts deleted file mode 100644 index 9068a3d4ac24..000000000000 --- a/packages/_/devkit/package/factory.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { JsonAstObject, JsonValue, parseJsonAst } from '@angular-devkit/core'; -import { - Rule, - Tree, - UpdateRecorder, - apply, - chain, - mergeWith, - template, - url, -} from '@angular-devkit/schematics'; -import { Schema } from './schema'; - - -function appendPropertyInAstObject( - recorder: UpdateRecorder, - node: JsonAstObject, - propertyName: string, - value: JsonValue, - indent = 4, -) { - const indentStr = '\n' + new Array(indent + 1).join(' '); - - if (node.properties.length > 0) { - // Insert comma. - const last = node.properties[node.properties.length - 1]; - recorder.insertRight(last.start.offset + last.text.replace(/\s+$/, '').length, ','); - } - - recorder.insertLeft( - node.end.offset - 1, - ' ' - + `"${propertyName}": ${JSON.stringify(value, null, 2).replace(/\n/g, indentStr)}` - + indentStr.slice(0, -2), - ); -} - -function addPackageToMonorepo(options: Schema, path: string): Rule { - return (tree: Tree) => { - const collectionJsonContent = tree.read('/.monorepo.json'); - if (!collectionJsonContent) { - throw new Error('Could not find monorepo.json'); - } - const collectionJsonAst = parseJsonAst(collectionJsonContent.toString('utf-8')); - if (collectionJsonAst.kind !== 'object') { - throw new Error('Invalid monorepo content.'); - } - - const packages = collectionJsonAst.properties.find(x => x.key.value == 'packages'); - if (!packages) { - throw new Error('Cannot find packages key in monorepo.'); - } - if (packages.value.kind != 'object') { - throw new Error('Invalid packages key.'); - } - - const readmeUrl = `https://github.com/angular/angular-cli/blob/master/${path}/README.md`; - - const recorder = tree.beginUpdate('/.monorepo.json'); - appendPropertyInAstObject( - recorder, - packages.value, - options.name, - { - name: options.displayName, - links: [{ label: 'README', url: readmeUrl }], - version: '0.0.1', - hash: '', - }, - ); - tree.commitUpdate(recorder); - }; -} - - -export default function (options: Schema): Rule { - const path = 'packages/' - + options.name - .replace(/^@/, '') - .replace(/-/g, '_'); - - // Verify if we need to create a full project, or just add a new schematic. - const source = apply(url('./project-files'), [ - template({ - ...options as object, - dot: '.', - path, - }), - ]); - - return chain([ - mergeWith(source), - addPackageToMonorepo(options, path), - ]); -} diff --git a/packages/_/devkit/package/project-files/__path__/README.md b/packages/_/devkit/package/project-files/__path__/README.md deleted file mode 100644 index f4b425475360..000000000000 --- a/packages/_/devkit/package/project-files/__path__/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# <%= displayName %> - -Work in progress diff --git a/packages/_/devkit/package/project-files/__path__/package.json b/packages/_/devkit/package/project-files/__path__/package.json deleted file mode 100644 index 7126e5f40667..000000000000 --- a/packages/_/devkit/package/project-files/__path__/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "<%= name %>", - "version": "0.0.0", - "description": "<%= description %>", - "main": "src/index.js", - "typings": "src/index.d.ts", - "scripts": { - "preinstall": "echo DO NOT INSTALL THIS PROJECT, ONLY THE ROOT PROJECT. && exit 1" - }, - "keywords": [ - ], - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "0.0.0" - } -} diff --git a/packages/_/devkit/package/project-files/__path__/src/index.ts b/packages/_/devkit/package/project-files/__path__/src/index.ts deleted file mode 100644 index 258badcf91cd..000000000000 --- a/packages/_/devkit/package/project-files/__path__/src/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -// TODO: Make this useful (and awesome). -export default 1; diff --git a/packages/_/devkit/package/schema.json b/packages/_/devkit/package/schema.json deleted file mode 100644 index 4e8bca4c8627..000000000000 --- a/packages/_/devkit/package/schema.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "id": "SchematicsSchematicSchema", - "title": "DevKit Package Schematic Schema", - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The package name for the new schematic.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "description": { - "type": "string", - "description": "The description of the new package" - }, - "displayName": { - "type": "string", - "$default": { - "$source": "interpolation", - "value": "${name}" - }, - "description": "The human readable name." - } - }, - "required": [ - "name", - "displayName" - ] -} diff --git a/packages/angular/build/BUILD.bazel b/packages/angular/build/BUILD.bazel new file mode 100644 index 000000000000..d0ab55750e98 --- /dev/null +++ b/packages/angular/build/BUILD.bazel @@ -0,0 +1,403 @@ +load("@devinfra//bazel/api-golden:index.bzl", "api_golden_test_npm_package") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "copy_to_bin", "jasmine_test", "npm_package", "ts_project") +load("//tools:ts_json_schema.bzl", "ts_json_schema") + +licenses(["notice"]) + +package(default_visibility = ["//visibility:public"]) + +npm_link_all_packages() + +ts_json_schema( + name = "application_schema", + src = "src/builders/application/schema.json", +) + +ts_json_schema( + name = "dev-server_schema", + src = "src/builders/dev-server/schema.json", +) + +ts_json_schema( + name = "extract_i18n_schema", + src = "src/builders/extract-i18n/schema.json", +) + +ts_json_schema( + name = "library_schema", + src = "src/builders/library/schema.json", +) + +ts_json_schema( + name = "ng_karma_schema", + src = "src/builders/karma/schema.json", +) + +ts_json_schema( + name = "ng_packagr_schema", + src = "src/builders/ng-packagr/schema.json", +) + +ts_json_schema( + name = "unit_test_schema", + src = "src/builders/unit-test/schema.json", +) + +copy_to_bin( + name = "schemas", + srcs = glob(["**/schema.json"]), +) + +RUNTIME_ASSETS = glob( + include = [ + "src/**/schema.json", + "src/**/*.js", + ], +) + [ + "builders.json", + "package.json", +] + +ts_project( + name = "build", + srcs = glob( + include = [ + "src/**/*.ts", + ], + exclude = [ + "src/test-utils.ts", + "src/**/*_spec.ts", + "src/**/tests/**/*.ts", + "src/testing/**/*.ts", + ], + ) + [ + "index.ts", + "//packages/angular/build:src/builders/application/schema.ts", + "//packages/angular/build:src/builders/dev-server/schema.ts", + "//packages/angular/build:src/builders/extract-i18n/schema.ts", + "//packages/angular/build:src/builders/karma/schema.ts", + "//packages/angular/build:src/builders/library/schema.ts", + "//packages/angular/build:src/builders/ng-packagr/schema.ts", + "//packages/angular/build:src/builders/unit-test/schema.ts", + ], + data = RUNTIME_ASSETS, + deps = [ + ":node_modules/@ampproject/remapping", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + ":node_modules/@angular/ssr", + ":node_modules/@babel/core", + ":node_modules/@inquirer/confirm", + ":node_modules/@oxc-project/types", + ":node_modules/@parcel/watcher", + ":node_modules/@vitejs/plugin-basic-ssl", + ":node_modules/beasties", + ":node_modules/browserslist", + ":node_modules/chokidar", + ":node_modules/https-proxy-agent", + ":node_modules/istanbul-lib-instrument", + ":node_modules/jsonc-parser", + ":node_modules/less", + ":node_modules/listr2", + ":node_modules/lmdb", + ":node_modules/magic-string", + ":node_modules/mrmime", + ":node_modules/ng-packagr", + ":node_modules/oxc-parser", + ":node_modules/parse5-html-rewriting-stream", + ":node_modules/picomatch", + ":node_modules/piscina", + ":node_modules/postcss", + ":node_modules/rolldown", + ":node_modules/rolldown-plugin-dts", + ":node_modules/rollup", + ":node_modules/sass", + ":node_modules/sass-embedded", + ":node_modules/source-map-support", + ":node_modules/tinyglobby", + ":node_modules/vite", + ":node_modules/vitest", + ":node_modules/xxhash-wasm", + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/localize", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/platform-server", + "//:node_modules/@angular/service-worker", + "//:node_modules/@types/babel__core", + "//:node_modules/@types/karma", + "//:node_modules/@types/less", + "//:node_modules/@types/node", + "//:node_modules/@types/picomatch", + "//:node_modules/@types/semver", + "//:node_modules/esbuild", + "//:node_modules/esbuild-wasm", + "//:node_modules/karma", + "//:node_modules/semver", + "//:node_modules/tslib", + "//:node_modules/typescript", + ], +) + +ts_project( + name = "unit_test_lib", + testonly = True, + srcs = glob( + include = ["src/**/*_spec.ts"], + exclude = ["src/builders/**/tests/**"], + ), + deps = [ + ":build", + ":node_modules/@angular-devkit/core", + ":node_modules/@babel/core", + ":node_modules/@parcel/watcher", + ":node_modules/oxc-parser", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/router", + "//:node_modules/@types/jasmine", + "//:node_modules/esbuild", + "//:node_modules/prettier", + "//:node_modules/typescript", + "//packages/angular/build/private", + ], +) + +jasmine_test( + name = "test", + data = [":unit_test_lib"], + shard_count = 4, +) + +ts_project( + name = "application_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/application/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + + # Base dependencies for the application in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "dev-server_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/dev-server/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + + # dev server only test deps + "//:node_modules/@types/http-proxy", + "//:node_modules/@types/node", + "//:node_modules/http-proxy", + "//:node_modules/puppeteer", + + # Base dependencies for the application in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/ng-packagr", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "karma_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/karma/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # karma specific test deps + "//:node_modules/karma-chrome-launcher", + "//:node_modules/karma-coverage", + "//:node_modules/karma-jasmine", + "//:node_modules/karma-jasmine-html-reporter", + "//:node_modules/puppeteer", + + # Base dependencies for the karma in hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + "//:node_modules/buffer", + ], +) + +ts_project( + name = "unit-test_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/unit-test/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # unit test specific test deps + ":node_modules/@vitest/coverage-v8", + ":node_modules/vitest", + ":node_modules/jsdom", + + # Base dependencies for the hello-world-app. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], +) + +ts_project( + name = "library_integration_test_lib", + testonly = True, + srcs = glob(include = ["src/builders/library/tests/**/*.ts"]), + deps = [ + ":build", + "//packages/angular/build/private", + "//modules/testing/builder", + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + "//:node_modules/@types/node", + + # Base dependencies for the library in hello-world-lib. + "//:node_modules/@angular/common", + "//:node_modules/@angular/compiler", + "//:node_modules/@angular/compiler-cli", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//:node_modules/@angular/router", + ":node_modules/rxjs", + "//:node_modules/tslib", + "//:node_modules/typescript", + "//:node_modules/zone.js", + ], +) + +jasmine_test( + name = "application_integration_tests", + size = "medium", + data = [":application_integration_test_lib"], + shard_count = 25, +) + +jasmine_test( + name = "dev-server_integration_tests", + size = "medium", + data = [":dev-server_integration_test_lib"], + env = { + # Force IPv4 to resolve RBE resolution issues + "NODE_OPTIONS": "--dns-result-order=ipv4first", + }, + shard_count = 10, +) + +jasmine_test( + name = "karma_integration_tests", + size = "medium", + data = [":karma_integration_test_lib"], + env = { + # TODO: Replace Puppeteer downloaded browsers with Bazel-managed browsers, + # or standardize to avoid complex configuration like this! + "PUPPETEER_DOWNLOAD_PATH": "../../../node_modules/puppeteer/downloads", + }, + shard_count = 10, +) + +jasmine_test( + name = "unit-test_integration_tests", + size = "medium", + data = [":unit-test_integration_test_lib"], + shard_count = 10, +) + +jasmine_test( + name = "library_integration_tests", + size = "medium", + data = [":library_integration_test_lib"], + shard_count = 6, +) + +genrule( + name = "license", + srcs = ["//:LICENSE"], + outs = ["LICENSE"], + cmd = "cp $(execpath //:LICENSE) $@", +) + +npm_package( + name = "pkg", + pkg_deps = [ + "//packages/angular_devkit/architect:package.json", + ], + stamp_files = [ + "src/utils/version.js", + "src/tools/esbuild/target.js", + "src/utils/normalize-cache.js", + "src/utils/supported-browsers.js", + ], + tags = ["release-package"], + deps = RUNTIME_ASSETS + [ + ":README.md", + ":build", + ":license", + "//packages/angular/build/private", + ], +) + +api_golden_test_npm_package( + name = "api", + data = [ + ":npm_package", + "//goldens:public-api", + ], + golden_dir = "goldens/public-api/angular/build", + npm_package = "packages/angular/build/npm_package", +) diff --git a/packages/angular/build/README.md b/packages/angular/build/README.md new file mode 100644 index 000000000000..62249f7fe422 --- /dev/null +++ b/packages/angular/build/README.md @@ -0,0 +1,5 @@ +# Angular Build System for Applications and Libraries + +The sources for this package are in the [Angular CLI](https://github.com/angular/angular-cli) repository. Please file issues and pull requests against that repository. + +Usage information and reference details can be found in repository [README](https://github.com/angular/angular-cli/blob/main/README.md) file. diff --git a/packages/angular/build/builders.json b/packages/angular/build/builders.json new file mode 100644 index 000000000000..d73c7a18fe58 --- /dev/null +++ b/packages/angular/build/builders.json @@ -0,0 +1,39 @@ +{ + "builders": { + "application": { + "implementation": "./src/builders/application/index", + "schema": "./src/builders/application/schema.json", + "description": "Build an application." + }, + "dev-server": { + "implementation": "./src/builders/dev-server/index", + "schema": "./src/builders/dev-server/schema.json", + "description": "Execute a development server for an application." + }, + "extract-i18n": { + "implementation": "./src/builders/extract-i18n/index", + "schema": "./src/builders/extract-i18n/schema.json", + "description": "Extract i18n messages from an application." + }, + "karma": { + "implementation": "./src/builders/karma", + "schema": "./src/builders/karma/schema.json", + "description": "Run Karma unit tests." + }, + "library": { + "implementation": "./src/builders/library/index", + "schema": "./src/builders/library/schema.json", + "description": "Build an Angular library package conforming to the Angular Package Format (APF)." + }, + "ng-packagr": { + "implementation": "./src/builders/ng-packagr/index", + "schema": "./src/builders/ng-packagr/schema.json", + "description": "Build a library with ng-packagr." + }, + "unit-test": { + "implementation": "./src/builders/unit-test", + "schema": "./src/builders/unit-test/schema.json", + "description": "Run application unit tests." + } + } +} diff --git a/packages/angular/build/index.ts b/packages/angular/build/index.ts new file mode 100644 index 000000000000..e6da94cc7ded --- /dev/null +++ b/packages/angular/build/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './src/index'; diff --git a/packages/angular/build/package.json b/packages/angular/build/package.json new file mode 100644 index 000000000000..711ab5893f75 --- /dev/null +++ b/packages/angular/build/package.json @@ -0,0 +1,131 @@ +{ + "name": "@angular/build", + "version": "0.0.0-PLACEHOLDER", + "description": "Official build system for Angular", + "keywords": [ + "angular", + "angular-cli" + ], + "exports": { + ".": { + "types": "./src/index.d.ts", + "default": "./src/index.js" + }, + "./private": { + "default": "./src/private.js" + }, + "./package.json": "./package.json" + }, + "builders": "builders.json", + "dependencies": { + "@ampproject/remapping": "2.3.0", + "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", + "@babel/core": "8.0.7", + "@inquirer/confirm": "6.3.3", + "@parcel/watcher": "2.6.0", + "@vitejs/plugin-basic-ssl": "2.3.0", + "beasties": "0.5.5", + "browserslist": "^4.26.0", + "chokidar": "5.0.0", + "esbuild": "0.28.2", + "https-proxy-agent": "9.1.0", + "jsonc-parser": "3.3.1", + "listr2": "11.2.0", + "magic-string": "1.4.3", + "mrmime": "2.0.1", + "oxc-parser": "0.154.0", + "parse5-html-rewriting-stream": "8.0.1", + "picomatch": "4.0.7", + "piscina": "5.3.2", + "rolldown": "1.2.14", + "rolldown-plugin-dts": "0.28.8", + "sass": "1.105.1", + "sass-embedded": "1.105.1", + "semver": "7.8.5", + "source-map-support": "0.5.21", + "tinyglobby": "0.2.17", + "vite": "8.3.4", + "xxhash-wasm": "1.1.0" + }, + "optionalDependencies": { + "lmdb": "3.5.7" + }, + "devDependencies": { + "@angular-devkit/core": "workspace:*", + "@angular/ssr": "workspace:*", + "@oxc-project/types": "0.154.0", + "@vitest/coverage-v8": "5.0.4", + "istanbul-lib-instrument": "6.0.3", + "jsdom": "30.1.2", + "less": "4.9.1", + "ng-packagr": "22.3.0-next.2", + "postcss": "8.5.30", + "rollup": "4.64.6", + "rxjs": "7.8.2", + "vitest": "5.0.4" + }, + "peerDependencies": { + "@angular/compiler": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/compiler-cli": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/core": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/localize": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/platform-browser": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/platform-server": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/service-worker": "0.0.0-ANGULAR-FW-PEER-DEP", + "@angular/ssr": "^0.0.0-PLACEHOLDER", + "istanbul-lib-instrument": "^6.0.0", + "karma": "^6.4.0", + "less": "^4.2.0", + "ng-packagr": "0.0.0-NG-PACKAGR-PEER-DEP", + "postcss": "^8.4.0", + "rollup": "^4.0.0", + "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "tslib": "^2.3.0", + "typescript": ">=6.0 <6.1", + "vitest": "^4.0.8 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@angular/localize": { + "optional": true + }, + "@angular/platform-browser": { + "optional": true + }, + "@angular/platform-server": { + "optional": true + }, + "@angular/service-worker": { + "optional": true + }, + "@angular/ssr": { + "optional": true + }, + "istanbul-lib-instrument": { + "optional": true + }, + "karma": { + "optional": true + }, + "less": { + "optional": true + }, + "ng-packagr": { + "optional": true + }, + "postcss": { + "optional": true + }, + "rollup": { + "optional": true + }, + "tailwindcss": { + "optional": true + }, + "vitest": { + "optional": true + } + } +} diff --git a/packages/angular/build/private/BUILD.bazel b/packages/angular/build/private/BUILD.bazel new file mode 100644 index 000000000000..c3a100de897f --- /dev/null +++ b/packages/angular/build/private/BUILD.bazel @@ -0,0 +1,11 @@ +load("//tools:defaults.bzl", "ts_project") + +package(default_visibility = ["//visibility:public"]) + +ts_project( + name = "private", + srcs = ["index.ts"], + deps = [ + "//packages/angular/build", + ], +) diff --git a/packages/angular/build/private/index.ts b/packages/angular/build/private/index.ts new file mode 100644 index 000000000000..1c2b76656baf --- /dev/null +++ b/packages/angular/build/private/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from '../src/private'; diff --git a/packages/angular/build/src/builders/application/build-action.ts b/packages/angular/build/src/builders/application/build-action.ts new file mode 100644 index 000000000000..eaae50848a48 --- /dev/null +++ b/packages/angular/build/src/builders/application/build-action.ts @@ -0,0 +1,443 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { + BuildOutputAsset, + ExecutionResult, + RebuildState, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { + resetSassWorkerPoolCaches, + shutdownSassWorkerPool, +} from '../../tools/esbuild/stylesheets/sass-language'; +import { logMessages, withNoProgress, withSpinner } from '../../tools/esbuild/utils'; +import { initializeHash } from '../../utils/hash'; +import { NormalizedCachedOptions } from '../../utils/normalize-cache'; +import { ChangedFiles } from '../../utils/watcher'; +import { NormalizedApplicationBuildOptions, NormalizedOutputOptions } from './options'; +import { + ComponentUpdateResult, + FullResult, + IncrementalResult, + Result, + ResultKind, + ResultMessage, +} from './results'; + +// eslint-disable-next-line max-lines-per-function +export async function* runEsBuildBuildAction( + action: (rebuildState?: RebuildState) => Promise, + options: { + workspaceRoot: string; + projectRoot: string; + outputOptions: NormalizedOutputOptions; + logger: BuilderContext['logger']; + cacheOptions: NormalizedCachedOptions; + watch?: boolean; + verbose?: boolean; + progress?: boolean; + poll?: number; + signal?: AbortSignal; + preserveSymlinks?: boolean; + clearScreen?: boolean; + colors?: boolean; + jsonLogs?: boolean; + incrementalResults?: boolean; + }, +): AsyncIterable { + const { + watch, + poll, + clearScreen, + logger, + cacheOptions, + outputOptions, + verbose, + projectRoot, + workspaceRoot, + progress, + preserveSymlinks, + colors, + jsonLogs, + incrementalResults, + } = options; + + await initializeHash(); + + const withProgress: typeof withSpinner = progress ? withSpinner : withNoProgress; + + // Initial build + let result: ExecutionResult | undefined; + try { + // Perform the build action + result = await withProgress('Building...', () => action()); + + // Log all diagnostic (error/warning/logs) messages + await logMessages(logger, result, colors, jsonLogs); + } finally { + // Ensure Sass workers are shutdown if not watching or if the initial build failed + if (!watch || !result) { + shutdownSassWorkerPool(); + } + } + + let watcher: import('../../utils/watcher').BuildWatcher | undefined; + let watchLoopStarted = false; + try { + // Setup watcher if watch mode enabled + if (watch) { + if (progress) { + logger.info('Watch mode enabled. Watching for file changes...'); + } + + // Setup a watcher + const { setupWatcher } = await import('../../utils/watcher'); + watcher = await setupWatcher({ + workspaceRoot, + projectRoot, + outputPath: outputOptions.base, + cacheOptions, + poll, + preserveSymlinks, + signal: options.signal, + watchFiles: result.watchFiles, + }); + } + + // Output the first build results after setting up the watcher to ensure that any code executed + // higher in the iterator call stack will trigger the watcher. This is particularly relevant for + // unit tests which execute the builder and modify the file system programmatically. + const outputResults = [...emitOutputResults(result, outputOptions)]; + if (!watch) { + await result.dispose(); + // Set to true to prevent double-disposal of the result context in the finally block on generator exit + watchLoopStarted = true; + } + yield* outputResults; + + // Finish if watch mode is not enabled + if (!watcher) { + return; + } + + watchLoopStarted = true; + } finally { + if (!watchLoopStarted && result) { + await result.dispose(); + } + } + + // Used to force a full result on next rebuild if there were initial errors. + // This ensures at least one full result is emitted. + let hasInitialErrors = result.errors.length > 0; + + // Wait for changes and rebuild as needed + const currentWatchFiles = new Set(result.watchFiles); + try { + for await (const changes of watcher) { + if (options.signal?.aborted) { + break; + } + + if (clearScreen) { + // eslint-disable-next-line no-console + console.clear(); + } + + if (verbose) { + logger.info(changes.toDebugString()); + } + + // Clear removed files from current watch files + changes.removed.forEach((removedPath) => currentWatchFiles.delete(removedPath)); + + const hasSassChanges = changes.all.some((f) => /\.(scss|sass|css)$/i.test(f)); + if (hasSassChanges) { + resetSassWorkerPoolCaches(); + } + + const rebuildState = result.createRebuildState(changes); + result = await withProgress('Changes detected. Rebuilding...', () => action(rebuildState)); + + // Log all diagnostic (error/warning/logs) messages + await logMessages(logger, result, colors, jsonLogs); + + // Update watched locations provided by the new build result. + // Keep watching all previous files if there are any errors; otherwise consider all + // files stale until confirmed present in the new result's watch files. + const staleWatchFiles = result.errors.length > 0 ? undefined : new Set(currentWatchFiles); + for (const watchFile of result.watchFiles) { + if (!currentWatchFiles.has(watchFile)) { + // Add new watch location + watcher.add(watchFile); + currentWatchFiles.add(watchFile); + } + + // Present so remove from stale locations + staleWatchFiles?.delete(watchFile); + } + // Remove any stale locations if the build was successful + if (staleWatchFiles?.size) { + watcher.remove([...staleWatchFiles]); + for (const staleFile of staleWatchFiles) { + currentWatchFiles.delete(staleFile); + } + } + + for (const outputResult of emitOutputResults( + result, + outputOptions, + changes, + incrementalResults && !hasInitialErrors ? rebuildState : undefined, + )) { + yield outputResult; + } + + // Clear initial build errors flag if no errors are now present + hasInitialErrors &&= result.errors.length > 0; + } + } finally { + // Stop the watcher and cleanup incremental rebuild state + await Promise.allSettled([watcher.close(), result.dispose()]); + + shutdownSassWorkerPool(); + } +} + +function* emitOutputResults( + { + outputFiles, + assetFiles, + errors, + warnings, + externalMetadata, + htmlIndexPath, + htmlBaseHref, + templateUpdates, + }: ExecutionResult, + outputOptions: NormalizedApplicationBuildOptions['outputOptions'], + changes?: ChangedFiles, + rebuildState?: RebuildState, +): Iterable { + if (errors.length > 0) { + yield { + kind: ResultKind.Failure, + errors: errors as ResultMessage[], + warnings: warnings as ResultMessage[], + detail: { + outputOptions, + }, + }; + + return; + } + + // Use a full result if there is no rebuild state (no prior build result) + if (!rebuildState || !changes) { + yield createFullResult( + outputFiles, + assetFiles, + warnings, + outputOptions, + externalMetadata, + htmlIndexPath, + htmlBaseHref, + ); + + return; + } + + // Template updates only exist if no other JS changes have occurred. + // A full page reload may be required based on the following incremental output change analysis. + const hasTemplateUpdates = !!templateUpdates?.size; + + // Use an incremental result if previous output information is available + const { previousAssetsInfo, previousOutputInfo } = rebuildState; + + const incrementalResult: IncrementalResult = { + kind: ResultKind.Incremental, + warnings: warnings as ResultMessage[], + // Initially attempt to use a background update of files to support component updates. + background: hasTemplateUpdates, + added: [], + removed: [], + modified: [], + files: [], + detail: { + externalMetadata, + htmlIndexPath, + htmlBaseHref, + outputOptions, + }, + }; + + let hasCssUpdates = false; + + // Initially assume all previous output files have been removed + const removedOutputFiles = new Map(previousOutputInfo); + for (const file of outputFiles) { + const key = `${file.type}:${file.path}`; + removedOutputFiles.delete(key); + + const previousHash = previousOutputInfo.get(key)?.hash; + let needFile = false; + if (previousHash === undefined) { + needFile = true; + incrementalResult.added.push(file.path); + } else if (previousHash !== file.hash) { + needFile = true; + incrementalResult.modified.push(file.path); + } + + if (needFile) { + if (file.path.endsWith('.css')) { + hasCssUpdates = true; + } else if (!canBackgroundUpdate(file)) { + incrementalResult.background = false; + } + + incrementalResult.files.push({ + path: file.path, + type: file.type, + contents: file.contents, + origin: 'memory', + hash: file.hash, + }); + } + } + + // Initially assume all previous assets files have been removed + const removedAssetFiles = new Map(previousAssetsInfo); + for (const { source, destination } of assetFiles) { + removedAssetFiles.delete(source); + + if (!previousAssetsInfo.has(source)) { + incrementalResult.added.push(destination); + incrementalResult.background = false; + } else if (changes.modified.has(source)) { + incrementalResult.modified.push(destination); + incrementalResult.background = false; + } else { + continue; + } + + hasCssUpdates ||= destination.endsWith('.css'); + + incrementalResult.files.push({ + path: destination, + type: BuildOutputFileType.Browser, + source, + origin: 'disk', + }); + } + + // Do not remove stale files yet if there are template updates. + // Component chunk files may still be referenced in running browser code. + // Module evaluation time component updates will update any of these files. + // This typically occurs when a lazy component is changed that has not yet + // been accessed at runtime. + if (hasTemplateUpdates && incrementalResult.background) { + removedOutputFiles.clear(); + } + + // Include the removed output and asset files + incrementalResult.removed.push( + ...Array.from(removedOutputFiles.values(), ({ type, path }) => ({ + path, + type, + })), + ...Array.from(removedAssetFiles.values(), (path) => ({ + path, + type: BuildOutputFileType.Browser, + })), + ); + + yield incrementalResult; + + // If there are template updates and the incremental update was background only, a component + // update is possible. + if (hasTemplateUpdates && incrementalResult.background) { + // Template changes may be accompanied by stylesheet changes and these should also be updated hot when possible. + if (hasCssUpdates) { + const styleResult: IncrementalResult = { + kind: ResultKind.Incremental, + added: incrementalResult.added.filter(isCssFilePath), + removed: incrementalResult.removed.filter(({ path }) => isCssFilePath(path)), + modified: incrementalResult.modified.filter(isCssFilePath), + files: incrementalResult.files.filter((file) => isCssFilePath(file.path)), + }; + + yield styleResult; + } + + const updateResult: ComponentUpdateResult = { + kind: ResultKind.ComponentUpdate, + updates: Array.from(templateUpdates, ([id, content]) => ({ + type: 'template', + id, + content, + })), + }; + + yield updateResult; + } +} + +function createFullResult( + outputFiles: readonly BuildOutputFile[], + assetFiles: readonly BuildOutputAsset[], + warnings: readonly unknown[], + outputOptions: NormalizedApplicationBuildOptions['outputOptions'], + externalMetadata: unknown, + htmlIndexPath: unknown, + htmlBaseHref: unknown, +): FullResult { + return { + kind: ResultKind.Full, + warnings: warnings as ResultMessage[], + files: [ + ...assetFiles.map(({ source, destination }) => ({ + path: destination, + type: BuildOutputFileType.Browser, + source, + origin: 'disk' as const, + })), + ...outputFiles.map((file) => ({ + path: file.path, + type: file.type, + contents: file.contents, + origin: 'memory' as const, + hash: file.hash, + })), + ], + detail: { + externalMetadata, + htmlIndexPath, + htmlBaseHref, + outputOptions, + }, + }; +} + +function isCssFilePath(filePath: string): boolean { + return /\.css(?:\.map)?$/i.test(filePath); +} + +function canBackgroundUpdate(file: BuildOutputFile): boolean { + // Files in the output root are not served and do not affect the + // application available with the development server. + if (file.type === BuildOutputFileType.Root) { + return true; + } + + // Updates to non-JS files must signal an update with the dev server + // except the service worker configuration which is special cased. + return /(?:\.m?js|\.map)$/.test(file.path) || file.path === 'ngsw.json'; +} diff --git a/packages/angular/build/src/builders/application/chunk-optimizer.ts b/packages/angular/build/src/builders/application/chunk-optimizer.ts new file mode 100644 index 000000000000..cd5e4e1814ec --- /dev/null +++ b/packages/angular/build/src/builders/application/chunk-optimizer.ts @@ -0,0 +1,432 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview This file provides a function to optimize JavaScript chunks using rolldown. + * It is designed to be used after an esbuild build to further optimize the output. + * The main function, `optimizeChunks`, takes the result of an esbuild build, + * identifies the main browser entry point, and then uses rolldown to rebundle + * and optimize the chunks. This process can result in smaller and more efficient + * code by combining and restructuring the original chunks. The file also includes + * helper functions to convert rolldown's output into an esbuild-compatible + * metafile, allowing for consistent analysis and reporting of the build output. + */ + +import type { Message, Metafile } from 'esbuild'; +import assert from 'node:assert'; +import type { Plugin } from 'rollup'; +import { BundleMergedContextResult } from '../../tools/esbuild/bundler-context'; +import { + type BuildOutputFile, + BuildOutputFileType, + type InitialFileRecord, + createOutputFile, +} from '../../tools/esbuild/bundler-files'; +import { useRolldownChunks } from '../../utils/environment-options'; +import { assertIsError } from '../../utils/error'; +import { loadRolldown } from '../../utils/load-rolldown'; +import { toPosixPath } from '../../utils/path'; + +/** + * Represents a minimal subset of a Rollup/Rolldown output asset. + * This is manually defined to avoid hard dependencies on both bundlers' types + * and to ensure compatibility since Rolldown and Rollup types have slight differences + * but share these core properties. + */ +interface OutputAsset { + type: 'asset'; + fileName: string; + source: string | Uint8Array; +} + +/** + * Represents a minimal subset of a Rollup/Rolldown output chunk. + * This is manually defined to avoid hard dependencies on both bundlers' types + * and to ensure compatibility since Rolldown and Rollup types have slight differences + * but share these core properties. + */ +interface OutputChunk { + type: 'chunk'; + fileName: string; + code: string; + modules: Record; + imports: string[]; + dynamicImports?: string[]; + exports: string[]; + isEntry: boolean; + facadeModuleId: string | null | undefined; + map?: { toString(): string } | null; + sourcemapFileName?: string | null; +} + +/** + * Converts the output of a bundle build into an esbuild-compatible metafile. + * @param bundleOutput The output of a bundle build. + * @param originalMetafile The original esbuild metafile from the build. + * @returns An esbuild-compatible metafile. + */ +function bundleOutputToEsbuildMetafile( + bundleOutput: (OutputChunk | OutputAsset)[], + originalMetafile: Metafile, +): Metafile { + const newMetafile: Metafile = { + inputs: originalMetafile.inputs, + outputs: {}, + }; + + const intermediateChunkSizes: Record = {}; + for (const [path, output] of Object.entries(originalMetafile.outputs)) { + intermediateChunkSizes[path] = Object.values(output.inputs).reduce( + (s, i) => s + i.bytesInOutput, + 0, + ); + } + + for (const chunk of bundleOutput) { + if (chunk.type === 'asset') { + newMetafile.outputs[chunk.fileName] = { + bytes: + typeof chunk.source === 'string' + ? Buffer.byteLength(chunk.source, 'utf8') + : chunk.source.length, + inputs: {}, + imports: [], + exports: [], + }; + continue; + } + + const newOutputInputs: Record = {}; + if (chunk.modules) { + for (const [moduleId, renderedModule] of Object.entries(chunk.modules)) { + const originalOutputEntry = originalMetafile.outputs[moduleId]; + if (!originalOutputEntry?.inputs) { + continue; + } + + const totalOriginalBytesInModule = intermediateChunkSizes[moduleId]; + if (totalOriginalBytesInModule === 0) { + continue; + } + + // Read once per module: in Rolldown, `renderedLength` is an uncached getter that + // copies the entire module code across the NAPI bridge on each access. + const { renderedLength } = renderedModule; + + for (const [originalInputPath, originalInputInfo] of Object.entries( + originalOutputEntry.inputs, + )) { + const proportion = originalInputInfo.bytesInOutput / totalOriginalBytesInModule; + const newBytesInOutput = Math.floor(renderedLength * proportion); + + const existing = newOutputInputs[originalInputPath]; + if (existing) { + existing.bytesInOutput += newBytesInOutput; + } else { + newOutputInputs[originalInputPath] = { bytesInOutput: newBytesInOutput }; + } + + if (!newMetafile.inputs[originalInputPath]) { + newMetafile.inputs[originalInputPath] = originalMetafile.inputs[originalInputPath]; + } + } + } + } + + const imports = [ + ...chunk.imports.map((path) => ({ path, kind: 'import-statement' as const })), + ...(chunk.dynamicImports?.map((path) => ({ path, kind: 'dynamic-import' as const })) ?? []), + ]; + + let entryPoint: string | undefined; + if (chunk.facadeModuleId) { + const posixFacadeModuleId = toPosixPath(chunk.facadeModuleId); + for (const [outputPath, output] of Object.entries(originalMetafile.outputs)) { + if (posixFacadeModuleId.endsWith(outputPath)) { + entryPoint = output.entryPoint; + break; + } + } + } + + newMetafile.outputs[chunk.fileName] = { + bytes: Buffer.byteLength(chunk.code, 'utf8'), + inputs: newOutputInputs, + imports, + exports: chunk.exports ?? [], + entryPoint, + }; + } + + return newMetafile; +} + +/** + * Creates an InitialFileRecord object with a specified depth. + * @param depth The depth of the file in the dependency graph. + * @returns An InitialFileRecord object. + */ +function createInitialFileRecord(depth: number): InitialFileRecord { + return { + type: 'script', + entrypoint: false, + external: false, + serverFile: false, + depth, + }; +} + +/** + * Creates an esbuild message object for a chunk optimization failure. + * @param message The error message detailing the cause of the failure. + * @returns A partial esbuild message object. + */ +function createChunkOptimizationFailureMessage(message: string): Message { + // Most of these fields are not actually needed for printing the error + return { + id: '', + text: 'Chunk optimization failed', + detail: undefined, + pluginName: '', + location: null, + notes: [ + { + text: message, + location: null, + }, + ], + }; +} + +/** + * Optimizes the chunks of a build result using rolldown. + * + * This function takes the output of an esbuild build, identifies the main browser entry point, + * and uses rolldown to bundle and optimize the JavaScript chunks. The optimized chunks + * replace the original ones in the build result, and the metafile is updated to reflect + * the changes. + * + * @param original The original build result from esbuild. + * @param sourcemap A boolean or 'hidden' to control sourcemap generation. + * @returns A promise that resolves to the updated build result with optimized chunks. + */ +// eslint-disable-next-line max-lines-per-function +export async function optimizeChunks( + original: BundleMergedContextResult, + sourcemap: boolean | 'hidden', +): Promise { + // Failed builds cannot be optimized + if (original.errors) { + return original; + } + + // Find the main browser entrypoint + let mainFile; + for (const [file, record] of original.initialFiles) { + if ( + record.name === 'main' && + record.entrypoint && + !record.serverFile && + record.type === 'script' + ) { + mainFile = file; + break; + } + } + + // No action required if no browser main entrypoint or metafile for stats + if (!mainFile || !original.metafiles.browser) { + return original; + } + + const chunks: Record = {}; + const maps: Record = {}; + for (const originalFile of original.outputFiles) { + if (originalFile.type !== BuildOutputFileType.Browser) { + continue; + } + + if (originalFile.path.endsWith('.js')) { + chunks[originalFile.path] = originalFile; + } else if (originalFile.path.endsWith('.js.map')) { + // Create mapping of JS file to sourcemap content + maps[originalFile.path.slice(0, -4)] = originalFile; + } + } + + const usedChunks = new Set(); + + let bundle; + let optimizedOutput: (OutputChunk | OutputAsset)[]; + try { + const plugins = [ + { + name: 'angular-bundle', + resolveId(source: string) { + // Remove leading `./` if present + const file = source[0] === '.' && source[1] === '/' ? source.slice(2) : source; + + if (chunks[file]) { + return file; + } + + // All other identifiers are considered external to maintain behavior + return { id: source, external: true }; + }, + load(id: string) { + assert( + chunks[id], + `Angular chunk content should always be present in chunk optimizer [${id}].`, + ); + + usedChunks.add(id); + + const result = { + code: chunks[id].text, + map: maps[id]?.text, + }; + + return result; + }, + }, + ]; + + if (useRolldownChunks) { + const { rolldown } = await loadRolldown(); + bundle = await rolldown({ + input: mainFile, + plugins, + }); + + const result = await bundle.generate({ + minify: { mangle: false, compress: false }, + sourcemap, + chunkFileNames: (chunkInfo) => + `${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`, + }); + optimizedOutput = result.output; + } else { + let rollup; + try { + rollup = (await import('rollup')).rollup; + } catch { + throw new Error( + `Rollup is required when 'NG_BUILD_CHUNKS_ROLLDOWN' is set to false. ` + + `Please install 'rollup' manually (e.g. 'npm install rollup --save-dev') to use this fallback.`, + ); + } + bundle = await rollup({ + input: mainFile, + plugins: plugins as Plugin[], + }); + + const result = await bundle.generate({ + compact: true, + sourcemap, + chunkFileNames: (chunkInfo) => + `${chunkInfo.name.replace(/-[a-zA-Z0-9_-]{8}$/, '')}-[hash].js`, + }); + optimizedOutput = result.output; + } + } catch (e) { + assertIsError(e); + + return { + errors: [createChunkOptimizationFailureMessage(e.message)], + warnings: original.warnings, + }; + } finally { + await bundle?.close(); + } + + // Update metafile + const newMetafile = bundleOutputToEsbuildMetafile(optimizedOutput, original.metafiles.browser); + // Add back the outputs that were not part of the optimization + for (const [path, output] of Object.entries(original.metafiles.browser.outputs)) { + if (usedChunks.has(path)) { + continue; + } + + newMetafile.outputs[path] = output; + for (const inputPath of Object.keys(output.inputs)) { + if (!newMetafile.inputs[inputPath]) { + newMetafile.inputs[inputPath] = original.metafiles.browser.inputs[inputPath]; + } + } + } + original.metafiles.browser = newMetafile; + + // Remove used chunks and associated sourcemaps from the original result + original.outputFiles = original.outputFiles.filter( + (file) => + !usedChunks.has(file.path) && + !(file.path.endsWith('.map') && usedChunks.has(file.path.slice(0, -4))), + ); + + // Add new optimized chunks + const importsPerFile: Record = {}; + for (const optimizedFile of optimizedOutput) { + if (optimizedFile.type !== 'chunk') { + continue; + } + + importsPerFile[optimizedFile.fileName] = optimizedFile.imports; + + original.outputFiles.push( + createOutputFile(optimizedFile.fileName, optimizedFile.code, BuildOutputFileType.Browser), + ); + if (optimizedFile.map && optimizedFile.sourcemapFileName) { + original.outputFiles.push( + createOutputFile( + optimizedFile.sourcemapFileName, + optimizedFile.map.toString(), + BuildOutputFileType.Browser, + ), + ); + } + } + + // Update initial files to reflect optimized chunks + const entriesToAnalyze: [string, InitialFileRecord][] = []; + for (const usedFile of usedChunks) { + // Leave the main file since its information did not change + if (usedFile === mainFile) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + entriesToAnalyze.push([mainFile, original.initialFiles.get(mainFile)!]); + continue; + } + + // Remove all other used chunks + original.initialFiles.delete(usedFile); + } + + // Analyze for transitive initial files + let currentEntry; + while ((currentEntry = entriesToAnalyze.pop())) { + const [entryPath, entryRecord] = currentEntry; + + for (const importPath of importsPerFile[entryPath]) { + const existingRecord = original.initialFiles.get(importPath); + if (existingRecord) { + // Store the smallest value depth + if (existingRecord.depth > entryRecord.depth + 1) { + existingRecord.depth = entryRecord.depth + 1; + } + + continue; + } + + const record = createInitialFileRecord(entryRecord.depth + 1); + + original.initialFiles.set(importPath, record); + entriesToAnalyze.push([importPath, record]); + } + } + + return original; +} diff --git a/packages/angular/build/src/builders/application/execute-build.ts b/packages/angular/build/src/builders/application/execute-build.ts new file mode 100644 index 000000000000..d3b628a169e2 --- /dev/null +++ b/packages/angular/build/src/builders/application/execute-build.ts @@ -0,0 +1,434 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { createAngularCompilation } from '../../tools/angular/compilation'; +import { + AngularCompilationContext, + PrimaryCompilationContext, +} from '../../tools/esbuild/angular/compilation-state'; +import { SourceFileCache } from '../../tools/esbuild/angular/source-file-cache'; +import { generateBudgetStats } from '../../tools/esbuild/budget-stats'; +import { BundleContextResult, BundlerContext } from '../../tools/esbuild/bundler-context'; +import { ExecutionResult, RebuildState } from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { checkCommonJSModules } from '../../tools/esbuild/commonjs-checker'; +import { LOCALE_DATA_BASE_MODULE } from '../../tools/esbuild/i18n-locale-plugin'; +import { extractLicenses } from '../../tools/esbuild/license-extractor'; +import { transformSupportedBrowsersToTargets } from '../../tools/esbuild/target'; +import { + calculateEstimatedTransferSizes, + isZonelessApp, + logBuildStats, +} from '../../tools/esbuild/utils'; +import { BudgetCalculatorResult, checkBudgets } from '../../utils/bundle-calculator'; +import { optimizeChunksThreshold } from '../../utils/environment-options'; +import { profileAsync } from '../../utils/profiling'; +import { resolveAssets } from '../../utils/resolve-assets'; +import { + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest, +} from '../../utils/server-rendering/manifest'; +import { getSupportedBrowsers } from '../../utils/supported-browsers'; +import { executePostBundleSteps } from './execute-post-bundle'; +import { inlineI18n, loadActiveTranslations } from './i18n'; +import { NormalizedApplicationBuildOptions } from './options'; +import { createComponentStyleBundler, setupBundlerContexts } from './setup-bundling'; + +/** The esbuild error text prefix used to detect top-level await errors. */ +const TOP_LEVEL_AWAIT_ERROR_TEXT = + 'Top-level await is not available in the configured target environment'; +// eslint-disable-next-line max-lines-per-function +export async function executeBuild( + options: NormalizedApplicationBuildOptions, + context: BuilderContext, + rebuildState?: RebuildState, +): Promise { + const { + projectRoot, + workspaceRoot, + i18nOptions, + optimizationOptions, + assets, + cacheOptions, + serverEntryPoint, + baseHref, + ssrOptions, + verbose, + colors, + jsonLogs, + security, + } = options; + + // TODO: Consider integrating into watch mode. Would require full rebuild on target changes. + const browsers = getSupportedBrowsers(projectRoot, context.logger); + + // Load active translations if inlining + // TODO: Integrate into watch mode and only load changed translations + if (i18nOptions.shouldInline) { + await loadActiveTranslations(context, i18nOptions); + } + + // Reuse rebuild state or create new bundle contexts for code and global stylesheets + let bundlerContexts; + let componentStyleBundler; + let codeBundleCache; + let bundlingIndividualResults: BundleContextResult[]; + let templateUpdates: Map | undefined; + let angularCompilationContext: AngularCompilationContext | undefined; + let executionResult: ExecutionResult | undefined; + try { + if (rebuildState) { + bundlerContexts = rebuildState.rebuildContexts; + componentStyleBundler = rebuildState.componentStyleBundler; + codeBundleCache = rebuildState.codeBundleCache; + templateUpdates = rebuildState.templateUpdates; + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); + // Reset template updates for new rebuild + templateUpdates?.clear(); + + const allFileChanges = rebuildState.fileChanges.all; + + // Bundle all contexts that do not require TypeScript changed file checks. + // These will automatically use cached results based on the changed files. + bundlingIndividualResults = await BundlerContext.bundleAll( + bundlerContexts.otherContexts, + allFileChanges, + ); + + // Check the TypeScript code bundling cache for changes. If invalid, force a rebundle of + // all TypeScript related contexts. + const forceTypeScriptRebuild = codeBundleCache?.invalidate(allFileChanges); + const typescriptResults: BundleContextResult[] = []; + for (const typescriptContext of bundlerContexts.typescriptContexts) { + typescriptContext.invalidate(allFileChanges); + const result = await typescriptContext.bundle(forceTypeScriptRebuild); + typescriptResults.push(result); + } + + bundlingIndividualResults.push(...typescriptResults); + } else { + const target = transformSupportedBrowsersToTargets(browsers); + codeBundleCache = new SourceFileCache(cacheOptions.enabled ? cacheOptions.path : undefined); + componentStyleBundler = createComponentStyleBundler(options, target); + if (options.templateUpdates) { + templateUpdates = new Map(); + } + const angularCompilation = await createAngularCompilation( + !!options.jit, + !options.serverEntryPoint, + ); + angularCompilationContext = new PrimaryCompilationContext(angularCompilation); + bundlerContexts = setupBundlerContexts( + options, + target, + codeBundleCache, + componentStyleBundler, + angularCompilationContext, + templateUpdates, + ); + executionResult = new ExecutionResult( + bundlerContexts, + componentStyleBundler, + codeBundleCache, + templateUpdates, + ); + + // Bundle everything on initial build + bundlingIndividualResults = await BundlerContext.bundleAll([ + ...bundlerContexts.typescriptContexts, + ...bundlerContexts.otherContexts, + ]); + } + + // Update any external component styles if enabled and rebuilding. + // TODO: Only attempt rebundling of invalidated styles once incremental build results are supported. + if (rebuildState && options.externalRuntimeStyles) { + componentStyleBundler.invalidate(rebuildState.fileChanges.all); + + const componentResults = await componentStyleBundler.bundleAllFiles(true, true); + bundlingIndividualResults.push(...componentResults); + } + + let bundlingResult = BundlerContext.mergeResults(bundlingIndividualResults); + + executionResult.addWarnings(bundlingResult.warnings); + + // Add used external component style referenced files to be watched + if (options.externalRuntimeStyles) { + executionResult.extraWatchFiles.push(...componentStyleBundler.collectReferencedFiles()); + } + + // In non-watch mode, the bundler rebuild contexts and Angular compilation + // are no longer needed once bundling completes. Dispose them early to free + // worker processes, TypeScript ASTs, and caches before post-bundling steps + // execute or before returning bundling errors. + // This is fire-and-forget so worker teardown runs concurrently in the background + // without blocking the critical path of post-bundle optimization steps. + // Any in-flight disposal is awaited in the builder action's finally block. + if (!options.watch) { + void angularCompilationContext?.dispose(); + void executionResult.dispose(); + } + + // Return if the bundling has errors + if (bundlingResult.errors) { + // If Zone.js is used, augment top-level await errors with a more helpful message. + // esbuild's default error mentions "target environment" with browser versions, but + // the actual reason is that async/await is downleveled for Zone.js compatibility. + if (!isZonelessApp(options.polyfills)) { + for (const error of bundlingResult.errors) { + if (error.text?.startsWith(TOP_LEVEL_AWAIT_ERROR_TEXT)) { + error.notes ??= []; + error.notes.push({ + text: + 'Top-level await is not supported in applications that use Zone.js. ' + + 'Consider removing Zone.js or moving this code into an async function. \n' + + 'For more information about zoneless Angular applications, visit: https://angular.dev/guide/zoneless', + location: null, + }); + } + } + } + + executionResult.addErrors(bundlingResult.errors); + + return executionResult; + } + + // Optimize chunks if enabled and threshold is met. + // This pass uses Rollup/Rolldown to further optimize chunks generated by esbuild. + if (options.optimizationOptions.scripts) { + // Count lazy chunks (files not needed for initial load). + // Advanced chunk optimization is most beneficial when there are multiple lazy chunks. + const { metafiles, initialFiles } = bundlingResult; + const lazyChunksCount = Object.keys(metafiles.browser.outputs || {}).filter( + (path) => path.endsWith('.js') && !initialFiles.has(path), + ).length; + + // Only run if the number of lazy chunks meets the configured threshold. + // This avoids overhead for small projects with few chunks. + if (lazyChunksCount >= optimizeChunksThreshold) { + const { optimizeChunks } = await import('./chunk-optimizer'); + const optimizationResult = await profileAsync('OPTIMIZE_CHUNKS', () => + optimizeChunks( + bundlingResult, + options.sourcemapOptions.scripts ? !options.sourcemapOptions.hidden || 'hidden' : false, + ), + ); + + if (optimizationResult.errors) { + executionResult.addErrors(optimizationResult.errors); + + return executionResult; + } + + bundlingResult = optimizationResult; + } + } + + // Analyze external imports if external options are enabled + if (options.externalPackages || bundlingResult.externalConfiguration) { + const { + externalConfiguration = [], + externalImports: { browser = [], server = [] }, + } = bundlingResult; + // Similar to esbuild, --external:@foo/bar automatically implies --external:@foo/bar/*, + // which matches import paths like @foo/bar/baz. + // This means all paths within the @foo/bar package are also marked as external. + const exclusionsPrefixes = externalConfiguration.map((exclusion) => exclusion + '/'); + const exclusions = new Set(externalConfiguration); + const explicitExternal = new Set(); + + const isExplicitExternal = (dep: string): boolean => { + // Locale-data imports are injected by the builder itself (see i18n-locale-plugin) and must stay resolvable + // even when the containing package is externalized by the user. + if (dep.startsWith(`${LOCALE_DATA_BASE_MODULE}/`)) { + return false; + } + + if (exclusions.has(dep)) { + return true; + } + + for (const prefix of exclusionsPrefixes) { + if (dep.startsWith(prefix)) { + return true; + } + } + + return false; + }; + + const implicitBrowser: string[] = []; + for (const dep of browser) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitBrowser.push(dep); + } + } + + const implicitServer: string[] = []; + for (const dep of server) { + if (isExplicitExternal(dep)) { + explicitExternal.add(dep); + } else { + implicitServer.push(dep); + } + } + + executionResult.setExternalMetadata(implicitBrowser, implicitServer, [...explicitExternal]); + } + + const { + metafiles: { browser: browserMetafile, server: serverMetafile }, + initialFiles, + outputFiles, + } = bundlingResult; + const metafiles = [browserMetafile, serverMetafile]; + + executionResult.outputFiles.push(...outputFiles); + + // Analyze files for bundle budget failures if present + let budgetFailures: BudgetCalculatorResult[] | undefined; + if (options.budgets) { + const compatStats = generateBudgetStats(browserMetafile, outputFiles, initialFiles); + budgetFailures = [...checkBudgets(options.budgets, compatStats, true)]; + for (const { message, severity } of budgetFailures) { + if (severity === 'error') { + executionResult.addError(message); + } else { + executionResult.addWarning(message); + } + } + } + + // Calculate estimated transfer size if scripts are optimized + let estimatedTransferSizes; + if (optimizationOptions.scripts || optimizationOptions.styles.minify) { + estimatedTransferSizes = await calculateEstimatedTransferSizes(executionResult.outputFiles); + } + + // Check metafile for CommonJS module usage if optimizing scripts + if (optimizationOptions.scripts) { + const messages = checkCommonJSModules(browserMetafile, options.allowedCommonJsDependencies); + executionResult.addWarnings(messages); + } + + // Copy assets + if (assets) { + executionResult.addAssets(await resolveAssets(assets, workspaceRoot)); + } + + // Extract and write licenses for used packages + if (options.extractLicenses) { + executionResult.addOutputFile( + '3rdpartylicenses.txt', + await extractLicenses(metafiles, workspaceRoot), + BuildOutputFileType.Root, + ); + } + + // Watch input index HTML file if configured + if (options.indexHtmlOptions) { + executionResult.extraWatchFiles.push(options.indexHtmlOptions.input); + executionResult.htmlIndexPath = options.indexHtmlOptions.output; + executionResult.htmlBaseHref = options.baseHref; + } + + // Create server app engine manifest + if (serverEntryPoint) { + executionResult.addOutputFile( + SERVER_APP_ENGINE_MANIFEST_FILENAME, + generateAngularServerAppEngineManifest(i18nOptions, security.allowedHosts, baseHref), + BuildOutputFileType.ServerRoot, + ); + } + + // Perform i18n translation inlining if enabled + if (i18nOptions.shouldInline) { + const result = await inlineI18n(browserMetafile, options, executionResult, initialFiles); + executionResult.addErrors(result.errors); + executionResult.addWarnings(result.warnings); + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + } else { + const result = await executePostBundleSteps( + browserMetafile, + options, + executionResult.outputFiles, + executionResult.assetFiles, + initialFiles, + // Set lang attribute to the defined source locale if present + i18nOptions.hasDefinedSourceLocale ? i18nOptions.sourceLocale : undefined, + ); + + // Deduplicate and add errors and warnings + executionResult.addErrors([...new Set(result.errors)]); + executionResult.addWarnings([...new Set(result.warnings)]); + + executionResult.addPrerenderedRoutes(result.prerenderedRoutes); + executionResult.outputFiles.push(...result.additionalOutputFiles); + executionResult.assetFiles.push(...result.additionalAssets); + } + + if (serverEntryPoint) { + executionResult.addOutputFile( + 'prerendered-routes.json', + JSON.stringify({ routes: executionResult.prerenderedRoutes }, null, 2), + BuildOutputFileType.Root, + ); + } + + // Write metafiles if stats option is enabled + if (options.stats) { + executionResult.addOutputFile( + 'browser-stats.json', + JSON.stringify(browserMetafile, null, 2), + BuildOutputFileType.Root, + ); + + if (serverEntryPoint) { + executionResult.addOutputFile( + 'server-stats.json', + JSON.stringify(serverMetafile, null, 2), + BuildOutputFileType.Root, + ); + } + } + + if (!jsonLogs && !options.quiet) { + const changedFiles = + rebuildState && executionResult.findChangedFiles(rebuildState.previousOutputInfo); + executionResult.addLog( + logBuildStats( + metafiles, + outputFiles, + initialFiles, + budgetFailures, + colors, + changedFiles, + estimatedTransferSizes, + !!ssrOptions, + verbose, + ), + ); + } + + return executionResult; + } catch (error) { + await Promise.allSettled([angularCompilationContext?.dispose(), executionResult?.dispose()]); + + throw error; + } +} diff --git a/packages/angular/build/src/builders/application/execute-post-bundle.ts b/packages/angular/build/src/builders/application/execute-post-bundle.ts new file mode 100644 index 000000000000..99e23bcb30c9 --- /dev/null +++ b/packages/angular/build/src/builders/application/execute-post-bundle.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import assert from 'node:assert'; +import { + type BuildOutputAsset, + type PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { + type BuildOutputFile, + BuildOutputFileType, + type InitialFileRecord, + createOutputFile, +} from '../../tools/esbuild/bundler-files'; +import { maxWorkers } from '../../utils/environment-options'; +import { + SERVER_APP_MANIFEST_FILENAME, + generateAngularServerAppManifest, +} from '../../utils/server-rendering/manifest'; +import { WritableSerializableRouteTreeNode } from '../../utils/server-rendering/models'; +import { prerenderPages } from '../../utils/server-rendering/prerender'; +import { augmentAppWithServiceWorkerEsbuild } from '../../utils/service-worker'; +import { generateIndexHtml } from './index-html-generator'; +import { injectDebugIds } from './inject-debug-ids'; +import { INDEX_HTML_CSR, INDEX_HTML_SERVER, NormalizedApplicationBuildOptions } from './options'; +import { OutputMode } from './schema'; + +/** + * Run additional builds steps including SSG, AppShell, Index HTML file and Service worker generation. + * @param browserMetafile An esbuild metafile object. + * @param options The normalized application builder options used to create the build. + * @param outputFiles The output files of an executed build. + * @param assetFiles The assets of an executed build. + * @param initialFiles A map containing initial file information for the executed build. + * @param locale A language locale to insert in the index.html. + */ +// eslint-disable-next-line max-lines-per-function +export async function executePostBundleSteps( + browserMetafile: Metafile, + options: NormalizedApplicationBuildOptions, + outputFiles: BuildOutputFile[], + assetFiles: BuildOutputAsset[], + initialFiles: Map, + locale: string | undefined, +): Promise<{ + errors: string[]; + warnings: string[]; + additionalOutputFiles: BuildOutputFile[]; + additionalAssets: BuildOutputAsset[]; + prerenderedRoutes: PrerenderedRoutesRecord; +}> { + const additionalAssets: BuildOutputAsset[] = []; + const additionalOutputFiles: BuildOutputFile[] = []; + const allErrors: string[] = []; + const allWarnings: string[] = []; + const prerenderedRoutes: PrerenderedRoutesRecord = {}; + + const { + baseHref = '/', + serviceWorker, + ssrOptions, + indexHtmlOptions, + optimizationOptions, + sourcemapOptions, + outputMode, + serverEntryPoint, + prerenderOptions, + appShellOptions, + publicPath, + workspaceRoot, + partialSSRBuild, + } = options; + + // Embed ECMA-426 Debug IDs into JS/source-map pairs before any consumer reads the bytes (in + // particular `generateIndexHtml` below, which computes SRI hashes from the on-disk content). + // Doing this here also covers the i18n path, where this function is invoked once per locale + // with locale-specific output files. Files without a source map sibling are skipped. + if (sourcemapOptions.scripts) { + injectDebugIds(outputFiles); + } + + // Index HTML content without CSS inlining to be used for server rendering (AppShell, SSG and SSR). + // NOTE: Critical CSS inlining is deliberately omitted here, as it will be handled during server rendering. + // Additionally, when using prerendering or AppShell, the index HTML file may be regenerated. + // To prevent generating duplicate files with the same filename, a `Map` is used to store and manage the files. + const additionalHtmlOutputFiles = new Map(); + + // Generate index HTML file + // If localization is enabled, index generation is handled in the inlining process. + if (indexHtmlOptions) { + const { csrContent, ssrContent, errors, warnings } = await generateIndexHtml( + initialFiles, + outputFiles, + options, + locale, + ); + + allErrors.push(...errors); + allWarnings.push(...warnings); + + additionalHtmlOutputFiles.set( + indexHtmlOptions.output, + createOutputFile(indexHtmlOptions.output, csrContent, BuildOutputFileType.Browser), + ); + + if (ssrContent) { + additionalHtmlOutputFiles.set( + INDEX_HTML_SERVER, + createOutputFile(INDEX_HTML_SERVER, ssrContent, BuildOutputFileType.ServerApplication), + ); + } + } + + // Create server manifest + const initialFilesPaths = new Set(initialFiles.keys()); + if (serverEntryPoint && (outputMode || prerenderOptions || appShellOptions || ssrOptions)) { + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtmlOutputFiles, + outputFiles, + optimizationOptions.styles.inlineCritical ?? false, + undefined, + locale, + baseHref, + initialFilesPaths, + browserMetafile, + publicPath, + ); + + additionalOutputFiles.push( + ...serverAssetsChunks, + createOutputFile( + SERVER_APP_MANIFEST_FILENAME, + manifestContent, + BuildOutputFileType.ServerApplication, + ), + ); + } + + // Pre-render (SSG) and App-shell + // If localization is enabled, prerendering is handled in the inlining process. + if ( + !partialSSRBuild && + (prerenderOptions || appShellOptions || (outputMode && serverEntryPoint)) && + !allErrors.length + ) { + assert( + indexHtmlOptions, + 'The "index" option is required when using the "ssg" or "appShell" options.', + ); + + const { + output, + warnings, + errors, + serializableRouteTreeNode, + prerenderedRoutes: generatedPrerenderedRoutes, + } = await prerenderPages( + workspaceRoot, + baseHref, + appShellOptions, + prerenderOptions, + [...outputFiles, ...additionalOutputFiles], + assetFiles, + outputMode, + sourcemapOptions.scripts, + maxWorkers, + ); + + allErrors.push(...errors); + allWarnings.push(...warnings); + Object.assign(prerenderedRoutes, generatedPrerenderedRoutes); + + const indexHasBeenPrerendered = output[indexHtmlOptions.output]; + for (const [path, { content, appShellRoute }] of Object.entries(output)) { + // Update the index contents with the app shell under these conditions: + // - Replace 'index.html' with the app shell only if it hasn't been prerendered yet. + // - Always replace 'index.csr.html' with the app shell. + let filePath = path; + if (appShellRoute && !indexHasBeenPrerendered) { + if (outputMode !== OutputMode.Server && indexHtmlOptions.output === INDEX_HTML_CSR) { + filePath = 'index.html'; + } else { + filePath = indexHtmlOptions.output; + } + } + + additionalHtmlOutputFiles.set( + filePath, + createOutputFile(filePath, content, BuildOutputFileType.Browser), + ); + } + + const serializableRouteTreeNodeForManifest: WritableSerializableRouteTreeNode = []; + for (const metadata of serializableRouteTreeNode) { + serializableRouteTreeNodeForManifest.push(metadata); + } + + if (outputMode === OutputMode.Server) { + // Regenerate the manifest to append route tree. This is only needed if SSR is enabled. + const manifest = additionalOutputFiles.find((f) => f.path === SERVER_APP_MANIFEST_FILENAME); + assert(manifest, `${SERVER_APP_MANIFEST_FILENAME} was not found in output files.`); + + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtmlOutputFiles, + outputFiles, + optimizationOptions.styles.inlineCritical ?? false, + serializableRouteTreeNodeForManifest, + locale, + baseHref, + initialFilesPaths, + browserMetafile, + publicPath, + ); + + for (const chunk of serverAssetsChunks) { + const idx = additionalOutputFiles.findIndex(({ path }) => path === chunk.path); + if (idx === -1) { + additionalOutputFiles.push(chunk); + } else { + additionalOutputFiles[idx] = chunk; + } + } + + manifest.contents = new TextEncoder().encode(manifestContent); + } + } + + additionalOutputFiles.push(...additionalHtmlOutputFiles.values()); + + // Augment the application with service worker support + // If localization is enabled, service worker is handled in the inlining process. + if (serviceWorker) { + try { + const serviceWorkerResult = await augmentAppWithServiceWorkerEsbuild( + workspaceRoot, + serviceWorker, + baseHref, + options.indexHtmlOptions?.output, + // Ensure additional files recently added are used + [...outputFiles, ...additionalOutputFiles], + assetFiles, + ); + + additionalOutputFiles.push( + createOutputFile('ngsw.json', serviceWorkerResult.manifest, BuildOutputFileType.Browser), + ); + additionalAssets.push(...serviceWorkerResult.assetFiles); + } catch (error) { + allErrors.push(error instanceof Error ? error.message : `${error}`); + } + } + + return { + errors: allErrors, + warnings: allWarnings, + additionalAssets, + prerenderedRoutes, + additionalOutputFiles, + }; +} diff --git a/packages/angular/build/src/builders/application/i18n.ts b/packages/angular/build/src/builders/application/i18n.ts new file mode 100644 index 000000000000..839384421f1f --- /dev/null +++ b/packages/angular/build/src/builders/application/i18n.ts @@ -0,0 +1,244 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import type { Metafile } from 'esbuild'; +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { + ExecutionResult, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFileType, InitialFileRecord } from '../../tools/esbuild/bundler-files'; +import { I18nInliner } from '../../tools/i18n'; +import { maxInlinerWorkers } from '../../utils/environment-options'; +import { loadTranslations } from '../../utils/i18n-options'; +import { createTranslationLoader } from '../../utils/load-translations'; +import { createProjectResolver } from '../../utils/resolve-project'; +import type { WorkerPool } from '../../utils/worker-pool'; +import { executePostBundleSteps } from './execute-post-bundle'; +import { NormalizedApplicationBuildOptions, getLocaleBaseHref } from './options'; + +/** + * Inlines all active locales as specified by the application build options into all + * application JavaScript files created during the build. + * @param metafile An esbuild metafile object. + * @param options The normalized application builder options used to create the build. + * @param executionResult The result of an executed build. + * @param initialFiles A map containing initial file information for the executed build. + * @param workerPool An optional worker pool to use for running transformation tasks. + */ +export async function inlineI18n( + metafile: Metafile, + options: NormalizedApplicationBuildOptions, + executionResult: ExecutionResult, + initialFiles: Map, + workerPool?: WorkerPool, +): Promise<{ + errors: string[]; + warnings: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; +}> { + const { i18nOptions, baseHref, cacheOptions } = options; + + // Create the multi-threaded inliner with common options. + const inliner = new I18nInliner( + { + missingTranslation: i18nOptions.missingTranslationBehavior ?? 'warning', + maxConcurrency: workerPool + ? Math.min(workerPool.maxThreads, maxInlinerWorkers) + : maxInlinerWorkers, + persistentCachePath: cacheOptions.enabled ? cacheOptions.path : undefined, + localizeVersion: i18nOptions.localizeVersion, + }, + workerPool, + ); + + const inlineResult: { + errors: string[]; + warnings: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; + } = { + errors: [], + warnings: [], + prerenderedRoutes: {}, + }; + + // For each active locale, use the inliner to process the output files of the build. + const updatedOutputFiles = []; + const updatedAssetFiles = []; + // Root and SSR entry files are not modified. + const unModifiedOutputFiles = executionResult.outputFiles.filter( + ({ type }) => type === BuildOutputFileType.Root || type === BuildOutputFileType.ServerRoot, + ); + + try { + const localesToInline = Array.from(i18nOptions.inlineLocales, (locale) => { + const localeDescription = i18nOptions.locales[locale]; + let translationIntegrity: string | undefined = ''; + for (const file of localeDescription.files) { + if (!file.integrity) { + translationIntegrity = undefined; + break; + } + translationIntegrity += (translationIntegrity ? '|' : '') + file.integrity; + } + + return { + locale, + translation: localeDescription.translation, + translationIntegrity, + }; + }); + + const inlinedLocales = await inliner.inlineAll(executionResult.outputFiles, localesToInline); + + for (const locale of i18nOptions.inlineLocales) { + const localeInlineResult = inlinedLocales.get(locale); + assert(localeInlineResult !== undefined, 'Inlined result must exist for locale: ' + locale); + const localeOutputFiles = localeInlineResult.outputFiles; + inlineResult.errors.push(...localeInlineResult.errors); + inlineResult.warnings.push(...localeInlineResult.warnings); + + const { + errors, + warnings, + additionalAssets, + additionalOutputFiles, + prerenderedRoutes: generatedRoutes, + } = await executePostBundleSteps( + metafile, + { + ...options, + baseHref: getLocaleBaseHref(baseHref, i18nOptions, locale) ?? baseHref, + }, + [...unModifiedOutputFiles, ...localeOutputFiles], + executionResult.assetFiles, + initialFiles, + locale, + ); + + localeOutputFiles.push(...additionalOutputFiles); + inlineResult.errors.push(...errors); + inlineResult.warnings.push(...warnings); + + // Update directory with locale base or subPath + const subPath = i18nOptions.locales[locale].subPath; + if (i18nOptions.flatOutput !== true) { + localeOutputFiles.forEach((file) => { + file.path = join(subPath, file.path); + }); + + for (const assetFile of [...executionResult.assetFiles, ...additionalAssets]) { + updatedAssetFiles.push({ + source: assetFile.source, + destination: join(subPath, assetFile.destination), + }); + } + } else { + executionResult.assetFiles.push(...additionalAssets); + } + + inlineResult.prerenderedRoutes = { ...inlineResult.prerenderedRoutes, ...generatedRoutes }; + updatedOutputFiles.push(...localeOutputFiles); + } + + // Update the result with all localized files. + executionResult.outputFiles = [ + // Root and SSR entry files are not modified. + ...unModifiedOutputFiles, + // Updated files for each locale. + ...updatedOutputFiles, + ]; + + // Assets are only changed if not using the flat output option + if (!i18nOptions.flatOutput) { + executionResult.assetFiles = updatedAssetFiles; + } + + // Inline any template updates if present + if (executionResult.templateUpdates?.size) { + // The development server only allows a single locale but issue a warning if used programmatically (experimental) + // with multiple locales and template HMR. + if (localesToInline.length > 1) { + inlineResult.warnings.push( + `Component HMR updates can only be inlined with a single locale. The first locale will be used.`, + ); + } + const targetLocale = localesToInline[0]; + + for (const [id, content] of executionResult.templateUpdates) { + const templateUpdateResult = await inliner.inlineTemplateUpdate( + targetLocale.locale, + targetLocale.translation, + content, + id, + targetLocale.translationIntegrity, + ); + executionResult.templateUpdates.set(id, templateUpdateResult.code); + inlineResult.errors.push(...templateUpdateResult.errors); + inlineResult.warnings.push(...templateUpdateResult.warnings); + } + } + + return inlineResult; + } finally { + await inliner.close(); + } +} + +/** + * Loads all active translations using the translation loaders from the `@angular/localize` package. + * @param context The architect builder context for the current build. + * @param i18n The normalized i18n options to use. + */ +export async function loadActiveTranslations( + context: BuilderContext, + i18n: NormalizedApplicationBuildOptions['i18nOptions'], +) { + if (!i18n.localizeVersion) { + try { + const projectResolve = createProjectResolver(context.workspaceRoot); + const manifestPath = projectResolve('@angular/localize/package.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf-8')) as { version?: string }; + i18n.localizeVersion = manifest.version; + } catch {} + } + + // Load locale data and translations (if present) + let loader; + for (const [locale, desc] of Object.entries(i18n.locales)) { + if (!i18n.inlineLocales.has(locale) && locale !== i18n.sourceLocale) { + continue; + } + + if (!desc.files.length) { + continue; + } + + loader ??= await createTranslationLoader(); + + loadTranslations( + locale, + desc, + context.workspaceRoot, + loader, + { + warn(message) { + context.logger.warn(message); + }, + error(message) { + throw new Error(message); + }, + }, + undefined, + i18n.duplicateTranslationBehavior, + ); + } +} diff --git a/packages/angular/build/src/builders/application/index-html-generator.ts b/packages/angular/build/src/builders/application/index-html-generator.ts new file mode 100644 index 000000000000..d89eadf8b9b0 --- /dev/null +++ b/packages/angular/build/src/builders/application/index-html-generator.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { createHash } from 'node:crypto'; +import path from 'node:path'; +import { + BuildOutputFile, + BuildOutputFileType, + InitialFileRecord, +} from '../../tools/esbuild/bundler-files'; +import { IndexHtmlGenerator } from '../../utils/index-file/index-html-generator'; +import { NormalizedApplicationBuildOptions } from './options'; + +/** + * The maximum number of module preload link elements that should be added for + * initial scripts. + */ +const MODULE_PRELOAD_MAX = 10; + +export async function generateIndexHtml( + initialFiles: Map, + outputFiles: BuildOutputFile[], + buildOptions: NormalizedApplicationBuildOptions, + lang?: string, +): Promise<{ + csrContent: string; + ssrContent?: string; + warnings: string[]; + errors: string[]; +}> { + // Analyze metafile for initial link-based hints. + // Skip if the internal externalPackages option is enabled since this option requires + // dev server cooperation to properly resolve and fetch imports. + const hints = []; + const { + indexHtmlOptions, + externalPackages, + optimizationOptions, + crossOrigin, + subresourceIntegrity, + baseHref, + } = buildOptions; + + assert(indexHtmlOptions, 'indexHtmlOptions cannot be undefined.'); + + if (!externalPackages && indexHtmlOptions.preloadInitial) { + const modulePreloads = []; + for (const [key, value] of initialFiles) { + if (value.entrypoint || value.serverFile) { + // Entry points are already referenced in the HTML + continue; + } + + if (value.type === 'script') { + modulePreloads.push({ url: key, mode: 'modulepreload' as const, depth: value.depth }); + } else if (value.type === 'style') { + // Provide an "as" value of "style" to ensure external URLs which may not have a + // file extension are treated as stylesheets. + hints.push({ url: key, mode: 'preload' as const, as: 'style' }); + } + } + + // Limit the number of module preloads with smallest depth given priority + modulePreloads.sort((a, b) => a.depth - b.depth); + hints.push(...modulePreloads.slice(0, MODULE_PRELOAD_MAX)); + } + + /** Virtual output path to support reading in-memory files. */ + const browserOutputFiles = outputFiles.filter(({ type }) => type === BuildOutputFileType.Browser); + const virtualOutputPath = '/'; + const readAsset = async function (filePath: string): Promise { + // Remove leading directory separator + const relativefilePath = path.relative(virtualOutputPath, filePath); + const file = browserOutputFiles.find((file) => file.path === relativefilePath); + if (file) { + return file.text; + } + + throw new Error(`Output file does not exist: ${relativefilePath}`); + }; + + // When SRI is enabled, build an integrity map for every browser JavaScript + // chunk so an `', + ); + indexFileContent.toContain(' { + describe('Behavior: "Esbuild for-await"', () => { + it('should properly downlevel for-await loops with optimization enabled', async () => { + // Setup a for-await loop that triggers the esbuild minification bug when async/await is downleveled. + await harness.writeFile( + 'src/main.ts', + ` + async function test() { + const someAsyncIterable = { + [Symbol.asyncIterator]() { + return { + next() { + return Promise.resolve({ done: true, value: undefined }); + } + }; + } + }; + for await(const item of someAsyncIterable) { + console.log(item); + } + } + test(); + `, + ); + + // Ensure target is ES2022 so that optional catch binding is supported natively. + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.compilerOptions.target = 'ES2022'; + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + polyfills: ['zone.js'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + // We expect the output to contain a catch block that captures the error in a variable, + // even if that variable is mangled. + // The pattern for the downleveled for-await catch block is roughly: + // } catch (temp) { error = [temp]; } + // + // With the bug, esbuild (when minifying) would optimize away the catch binding if it thought it was unused, + // resulting in: } catch { ... } which breaks the logic requiring the error object. + // + // The regex matches: + // catch \s* -> catch keyword and whitespace + // \( [a-zA-Z_$][\w$]* \) -> (variable) + // \s* { \s* -> { and whitespace + // [a-zA-Z_$][\w$]* -> error array variable + // \s* = \s* -> assignment + // \[ [a-zA-Z_$][\w$]* \] -> [variable] + harness + .expectFile('dist/browser/main.js') + .content.toMatch( + /catch\s*\([a-zA-Z_$][\w$]*\)\s*\{\s*[a-zA-Z_$][\w$]*\s*=\s*\[[a-zA-Z_$][\w$]*\]/, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts new file mode 100644 index 000000000000..0f87dd78a289 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/index-csr_spec.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "index.csr.html"', () => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + }); + + it(`should generate 'index.csr.html' instead of 'index.html' when ssr is enabled.`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + + it(`should generate 'index.csr.html' instead of 'index.html' when 'output' is 'index.html' and ssr is enabled.`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + index: { + input: 'src/index.html', + output: 'index.html', + }, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts new file mode 100644 index 000000000000..7f6b9711790b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/index-preload-hints_spec.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Preload hints"', () => { + it('should add preload hints for transitive global style imports', async () => { + await harness.writeFile( + 'src/styles.css', + ` + @import url('https://fonts.googleapis.com/css2?family=Roboto+Mono&family=Roboto:wght@300;400;500;700&display=swap'); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + harness + .expectFile('dist/browser/index.html') + .content.toContain( + '', + ); + }); + + it('should not add preload hints for ssr files', async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/server/main.server.mjs').toExist(); + + harness + .expectFile('dist/browser/index.csr.html') + .content.not.toMatch(//); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts new file mode 100644 index 000000000000..91c4cafc571a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "loader import attribute"', () => { + beforeEach(async () => { + await harness.modifyFile('tsconfig.json', (content) => { + return content.replace('"module": "ES2022"', '"module": "esnext"'); + }); + }); + + it('should inline text content for loader attribute set to "text"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "text" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should inline binary content for loader attribute set to "binary"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "binary" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the binary encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('__toBinary("QUJD")'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline base64 content for file extension set to "base64"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "base64" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the base64 encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('QUJD'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should inline dataurl content for file extension set to "dataurl"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.svg', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.svg" with { loader: "dataurl" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the dataurl encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('data:image/svg+xml,ABC'); + }); + + it('should emit an output file for loader attribute set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + harness.expectFile('dist/browser/media/a.unknown').toExist(); + }); + + it('should emit an output file with hashing when enabled for loader attribute set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputHashing: 'media' as any, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + expect(harness.hasFileMatch('dist/browser/media', /a-[0-9A-Z]{8}\.unknown$/)).toBeTrue(); + }); + + it('should allow overriding default `.txt` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.txt" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.txt'); + harness.expectFile('dist/browser/media/a.txt').toExist(); + }); + + it('should allow overriding default `.js` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.js', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.js" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.js'); + harness.expectFile('dist/browser/media/a.js').toExist(); + }); + + it('should fail with an error if an invalid loader attribute value is used', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "invalid" };\n console.log(contents);', + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unsupported loader import attribute'), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-assets_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-assets_spec.ts new file mode 100644 index 000000000000..7bfcca94d242 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-assets_spec.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when input asset changes"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + await harness.writeFile('public/asset.txt', 'foo'); + }); + + it('emits updated asset', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*', + input: 'public', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset.txt').content.toContain('foo'); + + await harness.writeFile('public/asset.txt', 'bar'); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset.txt').content.toContain('bar'); + }, + ]); + }); + + it('remove deleted asset from output', async () => { + await Promise.all([ + harness.writeFile('public/asset-two.txt', 'bar'), + harness.writeFile('public/asset-one.txt', 'foo'), + ]); + + harness.useTarget('build', { + ...BASE_OPTIONS, + assets: [ + { + glob: '**/*', + input: 'public', + }, + ], + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset-one.txt').toExist(); + harness.expectFile('dist/browser/asset-two.txt').toExist(); + + await harness.removeFile('public/asset-two.txt'); + }, + + ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/asset-one.txt').toExist(); + harness.expectFile('dist/browser/asset-two.txt').toNotExist(); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts new file mode 100644 index 000000000000..08b683439684 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when component stylesheets change"', () => { + for (const aot of [true, false]) { + it(`updates component when imported sass changes with ${aot ? 'AOT' : 'JIT'}`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + aot, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('app.component.css', 'app.component.scss'), + ); + await harness.writeFile('src/app/app.component.scss', "@import './a';"); + await harness.writeFile('src/app/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: aqua'); + harness.expectFile('dist/browser/main.js').content.not.toContain('color: blue'); + + await harness.writeFile('src/app/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/main.js').content.toContain('color: blue'); + + await harness.writeFile('src/app/a.scss', '$primary: green;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/main.js').content.not.toContain('color: blue'); + harness.expectFile('dist/browser/main.js').content.toContain('color: green'); + }, + ]); + }); + } + + it('rebuilds component after error on rebuild from transitive import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('app.component.css', 'app.component.scss'), + ); + await harness.writeFile('src/app/app.component.scss', "@import './a';"); + await harness.writeFile('src/app/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: aqua'); + + // Introduce a syntax error + await harness.writeFile( + 'src/app/a.scss', + 'invalid-invalid-invalid\\nh1 { color: $primary; }', + ); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + // Fix the syntax error + await harness.writeFile('src/app/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: blue'); + }, + ]); + }); + + it('rebuilds component after error on rebuild from deep transitive import with partials', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('app.component.css', 'app.component.scss'), + ); + await harness.writeFile('src/app/app.component.scss', "@import './intermediary';"); + await harness.writeFile('src/app/_intermediary.scss', "@import './partial';"); + await harness.writeFile('src/app/_partial.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: aqua'); + + // Introduce a syntax error deeply + await harness.writeFile( + 'src/app/_partial.scss', + 'invalid-invalid-invalid\\nh1 { color: $primary; }', + ); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + // Fix the syntax error deeply + await harness.writeFile( + 'src/app/_partial.scss', + '$primary: blue;\\nh1 { color: $primary; }', + ); + }, + ({ result }) => { + expect(result?.success).toBe(true); + + harness.expectFile('dist/browser/main.js').content.toContain('color: blue'); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts new file mode 100644 index 000000000000..5e7bdeb3fd9c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts @@ -0,0 +1,470 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuild Error Detection"', () => { + it('detects template errors with no AOT codegen or TS emit differences', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const goodDirectiveContents = ` + import { Directive, Input } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir { + @Input() foo!: number; + } + `; + + const typeErrorText = `Type 'number' is not assignable to type 'string'.`; + + // Create a directive and add to application + await harness.writeFile('src/app/dir.ts', goodDirectiveContents); + await harness.writeFile( + 'src/app/app.module.ts', + ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + import { Dir } from './dir'; + @NgModule({ + declarations: [ + AppComponent, + Dir, + ], + imports: [ + BrowserModule + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + ); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + standalone: false, + template: '', + }) + export class AppComponent { } + `, + ); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update directive to use a different input type for 'foo' (number -> string) + // Should cause a template error + await harness.writeFile( + 'src/app/dir.ts', + ` + import { Directive, Input } from '@angular/core'; + @Directive({ selector: 'dir', standalone: false }) + export class Dir { + @Input() foo: string; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Make an unrelated change to verify error cache was updated + // Should persist error in the next rebuild + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Revert the directive change that caused the error + // Should remove the error + await harness.writeFile('src/app/dir.ts', goodDirectiveContents); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + + // Make an unrelated change to verify error cache was updated + // Should continue showing no error + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('detects template errors across components on watch rebuild when isolatedModules is enabled', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions = { + ...tsconfig.compilerOptions, + isolatedModules: true, + }; + + return JSON.stringify(tsconfig); + }); + + const goodChildComponentContents = ` + import { Component, Input } from '@angular/core'; + @Component({ + selector: 'child', + standalone: true, + template: '

{{ value }}

', + }) + export class ChildComponent { + @Input() value!: number; + } + `; + + const typeErrorText = `Type 'number' is not assignable to type 'string'.`; + + await harness.writeFiles({ + 'src/app/child.component.ts': goodChildComponentContents, + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + @NgModule({ + imports: [ + BrowserModule, + AppComponent, + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { ChildComponent } from './child.component'; + @Component({ + selector: 'app-root', + standalone: true, + imports: [ChildComponent], + template: '', + }) + export class AppComponent {} + `, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update child component to change input type from number to string + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, Input } from '@angular/core'; + @Component({ + selector: 'child', + standalone: true, + template: '

{{ value }}

', + }) + export class ChildComponent { + @Input() value!: string; + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Make an unrelated change to verify error persists + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, typeErrorText); + + // Revert back to number + await harness.writeFile('src/app/child.component.ts', goodChildComponentContents); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + + // Make an unrelated change to verify error cache cleared + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, typeErrorText); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('detects template errors when an input becomes required or a selector is renamed on watch rebuild', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions = { + ...tsconfig.compilerOptions, + isolatedModules: true, + }; + + return JSON.stringify(tsconfig); + }); + + const goodChildComponentContents = ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input(''); + } + `; + + const requiredInputErrorText = `Required input 'label' from component ChildComponent must be specified.`; + const unknownElementErrorText = `'app-child' is not a known element`; + + await harness.writeFiles({ + 'src/app/child.component.ts': goodChildComponentContents, + 'src/app/app.module.ts': ` + import { NgModule } from '@angular/core'; + import { BrowserModule } from '@angular/platform-browser'; + import { AppComponent } from './app.component'; + @NgModule({ + imports: [ + BrowserModule, + AppComponent, + ], + providers: [], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.component.ts': ` + import { Component } from '@angular/core'; + import { ChildComponent } from './child.component'; + @Component({ + selector: 'app-root', + standalone: true, + imports: [ChildComponent], + template: '', + }) + export class AppComponent {} + `, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Make input required using signal input.required() (which has the same TS .d.ts type InputSignal) + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input.required(); + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, requiredInputErrorText); + + // Revert required input and rename selector instead + await harness.writeFile( + 'src/app/child.component.ts', + ` + import { Component, input } from '@angular/core'; + @Component({ + selector: 'app-child-renamed', + standalone: true, + template: '

{{ label() }}

', + }) + export class ChildComponent { + label = input(''); + } + `, + ); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectNoLog(logs, requiredInputErrorText); + expectLog(logs, unknownElementErrorText); + + // Revert back to valid child component + await harness.writeFile('src/app/child.component.ts', goodChildComponentContents); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, requiredInputErrorText); + expectNoLog(logs, unknownElementErrorText); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('detects cumulative block syntax errors', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases( + [ + async () => { + // Add invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@if-one'); + }, + async ({ logs }) => { + expectLog(logs, '@if-one'); + + // Make an unrelated change to verify error cache was updated + // Should persist error in the next rebuild + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ logs }) => { + expectLog(logs, '@if-one'); + + // Add more invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@if-two'); + }, + async ({ logs }) => { + expectLog(logs, '@if-one'); + expectLog(logs, '@if-two'); + + // Add more invalid block syntax + await harness.appendToFile('src/app/app.component.html', '@if-three'); + }, + async ({ logs }) => { + expectLog(logs, '@if-one'); + expectLog(logs, '@if-two'); + expectLog(logs, '@if-three'); + + // Revert the changes that caused the error + // Should remove the error + await harness.writeFile('src/app/app.component.html', '

GOOD

'); + }, + ({ logs }) => { + expectNoLog(logs, '@if-one'); + expectNoLog(logs, '@if-two'); + expectNoLog(logs, '@if-three'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('recovers from component stylesheet error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + aot: false, + }); + + await harness.executeWithCases( + [ + async () => { + await harness.writeFile('src/app/app.component.css', 'invalid-css-content'); + }, + async ({ logs }) => { + expectLog(logs, 'invalid-css-content'); + + await harness.writeFile('src/app/app.component.css', 'p { color: green }'); + }, + ({ logs }) => { + expectNoLog(logs, 'invalid-css-content'); + + harness + .expectFile('dist/browser/main.js') + .content.toContain('p {\\n color: green;\\n}'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('recovers from component template error', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases( + [ + async () => { + // Missing ending `>` on the div will cause an error + await harness.appendToFile('src/app/app.component.html', '
Hello, world! { + expectLog(logs, 'Unexpected character "EOF"'); + + await harness.appendToFile('src/app/app.component.html', '>'); + }, + async ({ logs }) => { + expectNoLog(logs, 'Unexpected character "EOF"'); + + harness.expectFile('dist/browser/main.js').content.toContain('Hello, world!'); + + // Make an additional valid change to ensure that rebuilds still trigger + await harness.appendToFile('src/app/app.component.html', '
Guten Tag
'); + }, + ({ logs }) => { + expectNoLog(logs, 'Unexpected character "EOF"'); + + harness.expectFile('dist/browser/main.js').content.toContain('Hello, world!'); + harness.expectFile('dist/browser/main.js').content.toContain('Guten Tag'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts new file mode 100644 index 000000000000..d9ea8870f687 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts @@ -0,0 +1,97 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuild updates in general cases"', () => { + it('detects changes after a file was deleted and recreated', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const fileAContent = ` + console.log('FILE-A'); + export {}; + `; + + // Create a file and add to application + await harness.writeFile('src/app/file-a.ts', fileAContent); + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + import './file-a'; + @Component({ + selector: 'app-root', + standalone: false, + template: 'App component', + }) + export class AppComponent { } + `, + ); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/main.js').content.toContain('FILE-A'); + + // Delete the imported file + await harness.removeFile('src/app/file-a.ts'); + }, + async ({ result }) => { + // Should fail from missing import + expect(result?.success).toBeFalse(); + + // Remove the failing import + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace(`import './file-a';`, ''), + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.not.toContain('FILE-A'); + + // Recreate the file and the import + await harness.writeFile('src/app/file-a.ts', fileAContent); + await harness.modifyFile( + 'src/app/app.component.ts', + (content) => `import './file-a';\n` + content, + ); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toContain('FILE-A'); + + // Change the imported file + await harness.modifyFile('src/app/file-a.ts', (content) => + content.replace('FILE-A', 'FILE-B'), + ); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toContain('FILE-B'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts new file mode 100644 index 000000000000..2de5c5f229ca --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts @@ -0,0 +1,250 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when global stylesheets change"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + it('rebuilds Sass stylesheet after error on rebuild from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.scss'], + }); + + await harness.writeFile('src/styles.scss', "@import './a';"); + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile( + 'src/a.scss', + 'invalid-invalid-invalid\\nh1 { color: $primary; }', + ); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds Sass stylesheet after error on initial build from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.scss'], + }); + + await harness.writeFile('src/styles.scss', "@import './a';"); + await harness.writeFile('src/a.scss', 'invalid-invalid-invalid\\nh1 { color: $primary; }'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds dependent Sass stylesheets after error on initial build from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: [ + { bundleName: 'styles', input: 'src/styles.scss' }, + { bundleName: 'other', input: 'src/other.scss' }, + ], + }); + + await harness.writeFile('src/styles.scss', "@import './a';"); + await harness.writeFile('src/other.scss', "@import './a'; h1 { color: green; }"); + await harness.writeFile('src/a.scss', 'invalid-invalid-invalid\\nh1 { color: $primary; }'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.scss', '$primary: aqua;\\nh1 { color: $primary; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + harness.expectFile('dist/browser/other.css').content.toContain('color: green'); + harness.expectFile('dist/browser/other.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/other.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/a.scss', '$primary: blue;\\nh1 { color: $primary; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + + harness.expectFile('dist/browser/other.css').content.toContain('color: green'); + harness.expectFile('dist/browser/other.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/other.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds PostCSS stylesheet after error on rebuild from plugin dependency', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.css'], + }); + + await harness.writeFile( + 'test-plugin.js', + ` + const fs = require('fs'); + const path = require('path'); + module.exports = () => { + return { + postcssPlugin: 'test-plugin', + Once(root, { result }) { + const themePath = path.join(path.dirname(root.source.input.file), 'theme.json'); + result.messages.push({ + type: 'dependency', + file: themePath, + }); + const data = fs.readFileSync(themePath, 'utf-8'); + const json = JSON.parse(data); + root.append('body { color: ' + json.color + '; }'); + }, + }; + }; + module.exports.postcss = true; + `, + ); + await harness.writeFile( + '.postcssrc.json', + JSON.stringify({ + plugins: { + './test-plugin.js': {}, + }, + }), + ); + await harness.writeFile('src/styles.css', '/* base */'); + await harness.writeFile('src/theme.json', '{"color": "aqua"}'); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/theme.json', 'invalid-json'); + }, + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/theme.json', '{"color": "blue"}'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + + it('rebuilds PostCSS stylesheet after CSS syntax error on initial build from import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + styles: ['src/styles.css'], + }); + + await harness.writeFile( + 'noop-plugin.js', + ` + module.exports = () => ({ postcssPlugin: 'noop-plugin' }); + module.exports.postcss = true; + `, + ); + await harness.writeFile( + '.postcssrc.json', + JSON.stringify({ + plugins: { + './noop-plugin.js': {}, + }, + }), + ); + await harness.writeFile('src/styles.css', "@import './a.css';"); + await harness.writeFile('src/a.css', "a { ' }"); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBe(false); + + await harness.writeFile('src/a.css', 'body { color: aqua; }'); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: blue'); + + await harness.writeFile('src/a.css', 'body { color: blue; }'); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/styles.css').content.not.toContain('color: aqua'); + harness.expectFile('dist/browser/styles.css').content.toContain('color: blue'); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts new file mode 100644 index 000000000000..99603bc98cee --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when input index HTML changes"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + it('rebuilds output index HTML', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + await harness.executeWithCases([ + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('charset="utf-8"'); + + await harness.modifyFile('src/index.html', (content) => + content.replace('charset="utf-8"', 'abc'), + ); + }, + async ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.not.toContain('charset="utf-8"'); + + await harness.modifyFile('src/index.html', (content) => + content.replace('abc', 'charset="utf-8"'), + ); + }, + ({ result }) => { + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('charset="utf-8"'); + }, + ]); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts new file mode 100644 index 000000000000..2fdad10f8d8d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + expectLog, + expectNoLog, +} from '../setup'; + +/** + * A regular expression used to check if a built worker is correctly referenced in application code. + */ +const REFERENCED_WORKER_REGEXP = + /new Worker\(new URL\("worker-[A-Z0-9]{8}\.js", import\.meta\.url\)/; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when Web Worker files change"', () => { + it('Recovers from error when directly referenced worker file is changed', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const workerCodeFile = ` + console.log('WORKER FILE'); + `; + + const errorText = `Expected ";" but found "~"`; + + // Create a worker file + await harness.writeFile('src/app/worker.ts', workerCodeFile); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + standalone: false, + template: '

Worker Test

', + }) + export class AppComponent { + worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' }); + } + `, + ); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Ensure built worker is referenced in the application code + harness.expectFile('dist/browser/main.js').content.toMatch(REFERENCED_WORKER_REGEXP); + + // Update the worker file to be invalid syntax + await harness.writeFile('src/app/worker.ts', `asd;fj$3~kls;kd^(*fjlk;sdj---flk`); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, errorText); + + // Make an unrelated change to verify error cache was updated + // Should persist error in the next rebuild + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + async ({ logs }) => { + expectLog(logs, errorText); + + // Revert the change that caused the error + // Should remove the error + await harness.writeFile('src/app/worker.ts', workerCodeFile); + }, + async ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, errorText); + + // Make an unrelated change to verify error cache was updated + // Should continue showing no error + await harness.modifyFile('src/main.ts', (content) => content + '\n'); + }, + ({ result, logs }) => { + expect(result?.success).toBeTrue(); + expectNoLog(logs, errorText); + + // Ensure built worker is referenced in the application code + harness.expectFile('dist/browser/main.js').content.toMatch(REFERENCED_WORKER_REGEXP); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts new file mode 100644 index 000000000000..0adc77b5311a --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts @@ -0,0 +1,458 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Stylesheet url() Resolution"', () => { + it('should show a note when using tilde prefix in a directly referenced stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Preprocessor stylesheets may not show the exact'), + }), + ); + }); + + it('should show a note when using tilde prefix in an imported CSS stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + @import "a.css"; + `, + ); + await harness.writeFile( + 'src/a.css', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + }); + + it('should show a note when using tilde prefix in an imported Sass stylesheet', async () => { + await harness.writeFile( + 'src/styles.scss', + ` + @import "a"; + `, + ); + await harness.writeFile( + 'src/a.scss', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Preprocessor stylesheets may not show the exact'), + }), + ); + }); + + it('should show a note when using caret prefix in a directly referenced stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + .a { + background-image: url("^image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the caret and'), + }), + ); + }); + + it('should show a note when using caret prefix in an imported Sass stylesheet', async () => { + await harness.writeFile( + 'src/styles.scss', + ` + @import "a"; + `, + ); + await harness.writeFile( + 'src/a.scss', + ` + .a { + background-image: url("^image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the caret and'), + }), + ); + }); + + it('should not rebase a URL with a namespaced Sass variable reference that points to an absolute asset', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named; + .a { + background-image: url(named.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "https://example.com/example.png";`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain('url(https://example.com/example.png)'); + }); + + it('should not rebase a URL with a Sass variable reference that points to an absolute asset', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url($my-var) + } + `, + 'src/theme/b.scss': `$my-var: "https://example.com/example.png";`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain('url(https://example.com/example.png)'); + }); + + it('should rebase a URL with a namespaced Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named; + .a { + background-image: url(named.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with a hyphen-namespaced Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named-hyphen; + .a { + background-image: url(named-hyphen.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with a underscore-namespaced Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named_underscore; + .a { + background-image: url(named_underscore.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with a Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url($my-var) + } + `, + 'src/theme/b.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with an leading interpolation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url(#{$my-var}logo.svg) + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with interpolation using concatenation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + $extra-var: "2"; + $postfix-var: "xyz"; + .a { + background-image: url("#{$my-var}logo#{$extra-var+ "-" + $postfix-var}.svg") + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo2-xyz.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain(`url("./media/logo2-xyz.svg")`); + harness.expectFile('dist/browser/media/logo2-xyz.svg').toExist(); + }); + + it('should rebase a URL with an non-leading interpolation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url(./#{$my-var}logo.svg) + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should not rebase Sass function definition with name ending in "url"', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + $asset: my-function-url('logo'); + background-image: url($asset) + } + `, + 'src/theme/b.scss': `@function my-function-url($name) { @return "./images/" + $name + ".svg"; }`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should not process a URL that has been marked as external', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + .a { + background-image: url("assets/logo.svg") + } + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + externalDependencies: ['assets/*'], + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url(assets/logo.svg)`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts new file mode 100644 index 000000000000..41ae225e2d3d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const styleBaseContent: Record = Object.freeze({ + 'css': ` + @import url(imported-styles.css); + div { hyphens: none; } + `, +}); + +const styleImportedContent: Record = Object.freeze({ + 'css': 'section { hyphens: none; }', +}); + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Stylesheet autoprefixer"', () => { + for (const ext of ['css'] /* ['css', 'sass', 'scss', 'less'] */) { + it(`should add prefixes for listed browsers in global styles [${ext}]`, async () => { + await harness.writeFile( + '.browserslistrc', + ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + ); + + await harness.writeFiles({ + [`src/styles.${ext}`]: styleBaseContent[ext], + [`src/imported-styles.${ext}`]: styleImportedContent[ext], + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [`src/styles.${ext}`], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/section\s*{\s*-webkit-hyphens:\s*none;\s*hyphens:\s*none;\s*}/); + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/div\s*{\s*-webkit-hyphens:\s*none;\s*hyphens:\s*none;\s*}/); + }); + + it(`should not add prefixes if not required by browsers in global styles [${ext}]`, async () => { + await harness.writeFile( + '.browserslistrc', + ` + Edge 110 + `, + ); + + await harness.writeFiles({ + [`src/styles.${ext}`]: styleBaseContent[ext], + [`src/imported-styles.${ext}`]: styleImportedContent[ext], + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: [`src/styles.${ext}`], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/section\s*{\s*hyphens:\s*none;\s*}/); + harness + .expectFile('dist/browser/styles.css') + .content.toMatch(/div\s*{\s*hyphens:\s*none;\s*}/); + }); + + it(`should add prefixes for listed browsers in external component styles [${ext}]`, async () => { + await harness.writeFile( + '.browserslistrc', + ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + ); + + await harness.writeFiles({ + [`src/app/app.component.${ext}`]: styleBaseContent[ext], + [`src/app/imported-styles.${ext}`]: styleImportedContent[ext], + }); + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('./app.component.css', `./app.component.${ext}`), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.toMatch(/{\\n\s*-webkit-hyphens:\s*none;\\n\s*hyphens:\s*none;\\n\s*}/); + harness + .expectFile('dist/browser/main.js') + .content.toMatch(/{\\n\s*-webkit-hyphens:\s*none;\\n\s*hyphens:\s*none;\\n\s*}/); + }); + + it(`should not add prefixes if not required by browsers in external component styles [${ext}]`, async () => { + await harness.writeFile( + '.browserslistrc', + ` + Edge 110 + `, + ); + + await harness.writeFiles({ + [`src/app/app.component.${ext}`]: styleBaseContent[ext], + [`src/app/imported-styles.${ext}`]: styleImportedContent[ext], + }); + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace('./app.component.css', `./app.component.${ext}`), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + .content.toMatch(/{\\n\s*hyphens:\s*none;\\n\s*}/); + harness + .expectFile('dist/browser/main.js') + .content.toMatch(/{\\n\s*hyphens:\s*none;\\n\s*}/); + }); + } + + it('should add prefixes for listed browsers in inline component styles', async () => { + await harness.writeFile( + '.browserslistrc', + ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + ); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content + .replace('styleUrls', 'styles') + .replace('./app.component.css', 'div { hyphens: none; }'); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + // div[_ngcontent-%COMP%] {\n -webkit-hyphens: none;\n hyphens: none;\n}\n + .content.toMatch(/{\\n\s*-webkit-hyphens:\s*none;\\n\s*hyphens:\s*none;\\n\s*}/); + }); + + it('should not add prefixes if not required by browsers in inline component styles', async () => { + await harness.writeFile( + '.browserslistrc', + ` + Edge 110 + `, + ); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content + .replace('styleUrls', 'styles') + .replace('./app.component.css', 'div { hyphens: none; }'); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toMatch(/{\\n\s*hyphens:\s*none;\\n\s*}/); + }); + + it('should add prefixes for listed browsers in inline template styles', async () => { + await harness.writeFile( + '.browserslistrc', + ` + Safari 15.4 + Edge 104 + Firefox 91 + `, + ); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content.replace('styleUrls', 'styles').replace('./app.component.css', ''); + }); + await harness.modifyFile('src/app/app.component.html', (content) => { + return `\n${content}`; + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/main.js') + // div[_ngcontent-%COMP%] {\n -webkit-hyphens: none;\n hyphens: none;\n}\n + .content.toMatch(/{\\n\s*-webkit-hyphens:\s*none;\\n\s*hyphens:\s*none;\\n\s*}/); + }); + + it('should not add prefixes if not required by browsers in inline template styles', async () => { + await harness.writeFile( + '.browserslistrc', + ` + Edge 110 + `, + ); + + await harness.modifyFile('src/app/app.component.ts', (content) => { + return content.replace('styleUrls', 'styles').replace('./app.component.css', ''); + }); + await harness.modifyFile('src/app/app.component.html', (content) => { + return `\n${content}`; + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toMatch(/{\\n\s*hyphens:\s*none;\\n\s*}/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts new file mode 100644 index 000000000000..b0220529ee28 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/top-level-await-error_spec.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Top-level await error message"', () => { + it('should show a Zone.js-specific error when top-level await is used with Zone.js', async () => { + await harness.writeFile( + 'src/main.ts', + ` + // The export makes this file a module, which is required for top-level await. + export const value = await Promise.resolve('test'); + console.log(value); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'Top-level await is not supported in applications that use Zone.js', + ), + }), + ); + }); + + it('should not show a Zone.js-specific error when top-level await is used without Zone.js', async () => { + await harness.writeFile( + 'src/main.ts', + ` + // The export makes this file a module, which is required for top-level await. + export const value = await Promise.resolve('test'); + console.log(value); + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: [], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'error', + message: jasmine.stringContaining( + 'Top-level await is not supported in applications that use Zone.js', + ), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts new file mode 100644 index 000000000000..cc74b9549e9d --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript explicit incremental option usage"', () => { + it('should successfully build with incremental disabled', async () => { + // Disable tsconfig incremental option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.incremental = false; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts new file mode 100644 index 000000000000..bef97e545ad9 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript isolated modules direct transpilation"', () => { + it('should successfully build with isolated modules enabled and disabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('should successfully build with isolated modules enabled and enabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('supports TSX files with isolated modules enabled and enabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + tsconfig.compilerOptions.jsx = 'react-jsx'; + + // There's an implicit `any`, because we don't include the React typings. + tsconfig.compilerOptions.noImplicitAny = false; + + return JSON.stringify(tsconfig); + }); + + await harness.writeFile('src/types.d.ts', `declare module 'react/jsx-runtime' { jsx: any }`); + await harness.writeFile('src/abc.tsx', `export function hello() { return

Hello

; }`); + await harness.modifyFile( + 'src/main.ts', + (content) => content + `import { hello } from './abc'; console.log(hello());`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + externalDependencies: ['react'], + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts new file mode 100644 index 000000000000..416ee9c227bd --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript Path Mapping"', () => { + it('should resolve TS files when imported with a path mapping', async () => { + // Change main module import to use path mapping + await harness.modifyFile('src/main.ts', (content) => + content.replace(`'./app/app.module'`, `'@root/app.module'`), + ); + + // Add a path mapping for `@root` + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.paths = { + '@root/*': ['./src/app/*'], + }; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('should fail to resolve if no path mapping for an import is present', async () => { + // Change main module import to use path mapping + await harness.modifyFile('src/main.ts', (content) => + content.replace(`'./app/app.module'`, `'@root/app.module'`), + ); + + // Add a path mapping for `@not-root` + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.paths = { + '@not-root/*': ['./src/app/*'], + }; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Could not resolve "@root/app.module"'), + }), + ); + }); + + it('should resolve JS files when imported with a path mapping', async () => { + // Change main module import to use path mapping + await harness.modifyFile('src/main.ts', (content) => + content.replace(`'./app/app.module'`, `'app-module'`), + ); + + await harness.writeFiles({ + 'a.js': `export * from './src/app/app.module';\n\nconsole.log('A');`, + 'a.d.ts': `export * from './src/app/app.module';`, + }); + + // Add a path mapping for `@root` + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.paths = { + 'app-module': ['./a.js'], + }; + tsconfig.compilerOptions.allowJs = true; + + return JSON.stringify(tsconfig); + }); + + // app.module needs to be manually included since it is not referenced via a TS file + // with the test path mapping in place. + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.files.push('app/app.module.ts'); + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain(`console.log("A")`); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts new file mode 100644 index 000000000000..ba01e2a27dce --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder, expectLog } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files = ['main.server.ts', 'main.ts']; + + return JSON.stringify(tsConfig); + }); + + await harness.writeFiles({ + 'src/lazy.ts': `export const foo: number = 1;`, + 'src/main.ts': `export async function fn () { + const lazy = await import('./lazy'); + return lazy.foo; + }`, + 'src/main.server.ts': `export { fn as default } from './main';`, + }); + }); + + describe('Behavior: "Rebuild both server and browser bundles when using lazy loading"', () => { + it('detect changes and errors when expected', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + namedChunks: true, + outputHashing: OutputHashing.None, + server: 'src/main.server.ts', + ssr: true, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Add valid code + await harness.appendToFile('src/lazy.ts', `console.log('foo');`); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + + // Update type of 'foo' to invalid (number -> string) + await harness.writeFile('src/lazy.ts', `export const foo: string = 1;`); + }, + async ({ result, logs }) => { + expect(result?.success).toBeFalse(); + expectLog(logs, `Type 'number' is not assignable to type 'string'.`); + + // Fix TS error + await harness.writeFile('src/lazy.ts', `export const foo: string = "1";`); + }, + ({ result }) => { + expect(result?.success).toBeTrue(); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts new file mode 100644 index 000000000000..eeb160ebef47 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when touching file"', () => { + for (const aot of [true, false]) { + it(`Rebuild correctly when file is touched with ${aot ? 'AOT' : 'JIT'}`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + aot, + }); + + await harness.executeWithCases( + [ + async ({ result }) => { + expect(result?.success).toBeTrue(); + // Touch a file without doing any changes. + await harness.modifyFile('src/app/app.component.ts', (content) => content); + }, + async ({ result }) => { + expect(result?.success).toBeTrue(); + await harness.removeFile('src/app/app.component.ts'); + }, + ({ result }) => { + expect(result?.success).toBeFalse(); + }, + ], + { outputLogsOnFailure: false }, + ); + }); + } + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts new file mode 100644 index 000000000000..6ad5bbb3a9de --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript JSON module resolution"', () => { + it('should resolve JSON files when imported with resolveJsonModule enabled', async () => { + await harness.writeFiles({ + 'src/x.json': `{"a": 1}`, + 'src/main.ts': `import * as x from './x.json'; console.log(x);`, + }); + + // Enable tsconfig resolveJsonModule option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.moduleResolution = 'node16'; + tsconfig.compilerOptions.module = 'node16'; + tsconfig.compilerOptions.resolveJsonModule = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('should fail to resolve with TS if resolveJsonModule is not present', async () => { + await harness.writeFiles({ + 'src/x.json': `{"a": 1}`, + 'src/main.ts': `import * as x from './x.json'; console.log(x);`, + }); + + // Enable tsconfig resolveJsonModule option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.moduleResolution = 'node16'; + tsconfig.compilerOptions.module = 'node16'; + tsconfig.compilerOptions.resolveJsonModule = undefined; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(`Cannot find module './x.json'`), + }), + ); + }); + + it('should fail to resolve with TS if resolveJsonModule is disabled', async () => { + await harness.writeFiles({ + 'src/x.json': `{"a": 1}`, + 'src/main.ts': `import * as x from './x.json'; console.log(x);`, + }); + + // Enable tsconfig resolveJsonModule option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.moduleResolution = 'node16'; + tsconfig.compilerOptions.module = 'node16'; + tsconfig.compilerOptions.resolveJsonModule = false; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching(`Cannot find module './x.json'`), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts new file mode 100644 index 000000000000..5ae62f020c1c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (export "multiply" (func $multiply)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * ) + * ``` + */ +const exportWasmBase64 = + 'AGFzbQEAAAABBwFgAn9/AX8DAgEABwwBCG11bHRpcGx5AAAKCQEHACAAIAFsCwAXBG5hbWUBCwEACG11bHRpcGx5AgMBAAA='; +const exportWasmBytes = Buffer.from(exportWasmBase64, 'base64'); + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (import "./values" "getValue" (func $getvalue (result i32))) + * (export "multiply" (func $multiply)) + * (export "subtract1" (func $subtract)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * (func $subtract (param i32) (result i32) + * call $getvalue + * local.get 0 + * i32.sub + * ) + * ) + * ``` + */ +const importWasmBase64 = + 'AGFzbQEAAAABEANgAAF/YAJ/fwF/YAF/AX8CFQEILi92YWx1ZXMIZ2V0VmFsdWUAAAMDAgECBxgCCG11bHRpcGx5AAEJc3VidHJhY3QxAAIKEQIHACAAIAFsCwcAEAAgAGsLAC8EbmFtZQEfAwAIZ2V0dmFsdWUBCG11bHRpcGx5AghzdWJ0cmFjdAIHAwAAAQACAA=='; +const importWasmBytes = Buffer.from(importWasmBase64, 'base64'); + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Supports WASM/ES module integration"', () => { + it('should inject initialization code and add an export', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('multiply'); + }); + + it('should compile successfully with a provided type definition file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + await harness.writeFile( + 'src/multiply.wasm.d.ts', + 'export declare function multiply(a: number, b: number): number;', + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('multiply'); + }); + + it('should add WASM defined imports and include resolved TS file for import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/subtract.wasm', importWasmBytes); + + // Create TS file that is expect by WASM file + await harness.writeFile( + 'src/values.ts', + ` + export function getValue(): number { return 100; } + `, + ); + // The file is not imported into any actual TS files so it needs to be manually added to the TypeScript program + await harness.modifyFile('src/tsconfig.app.json', (content) => + content.replace('"main.ts",', '"main.ts","values.ts",'), + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { subtract1 } from './subtract.wasm'; + + console.log(subtract1(5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('subtract1'); + harness.expectFile('dist/browser/main.js').content.toContain('./values'); + harness.expectFile('dist/browser/main.js').content.toContain('getValue'); + }); + + it('should add WASM defined imports and include resolved JS file for import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/subtract.wasm', importWasmBytes); + + // Create JS file that is expect by WASM file + await harness.writeFile( + 'src/values.js', + ` + export function getValue() { return 100; } + `, + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { subtract1 } from './subtract.wasm'; + + console.log(subtract1(5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('subtract1'); + harness.expectFile('dist/browser/main.js').content.toContain('./values'); + harness.expectFile('dist/browser/main.js').content.toContain('getValue'); + }); + + it('should inline WASM files less than 10kb', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure WASM is present in output code + harness.expectFile('dist/browser/main.js').content.toContain(exportWasmBase64); + }); + + it('should show an error on invalid WASM file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', 'NOT_WASM'); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unable to analyze WASM file'), + }), + ); + }); + + it('should show an error if using Zone.js', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', importWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'WASM/ES module integration imports are not supported with Zone.js applications', + ), + }), + ); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts new file mode 100644 index 000000000000..135d5ff68165 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * A regular expression used to check if a built worker is correctly referenced in application code. + */ +const REFERENCED_WORKER_REGEXP = + /new Worker\(new URL\("worker-[A-Z0-9]{8}\.js", import\.meta\.url\)/; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Bundles web worker files within application code"', () => { + it('should use the worker entry point when worker lazy chunks are present', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const workerCodeFile = ` + addEventListener('message', () => { + import('./extra').then((m) => console.log(m.default)); + }); + `; + const extraWorkerCodeFile = ` + export default 'WORKER FILE'; + `; + + // Create a worker file + await harness.writeFile('src/app/worker.ts', workerCodeFile); + await harness.writeFile('src/app/extra.ts', extraWorkerCodeFile); + + // Create app component that uses the directive + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core' + @Component({ + selector: 'app-root', + standalone: false, + template: '

Worker Test

', + }) + export class AppComponent { + worker = new Worker(new URL('./worker', import.meta.url), { type: 'module' }); + } + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure built worker is referenced in the application code + harness.expectFile('dist/browser/main.js').content.toMatch(REFERENCED_WORKER_REGEXP); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts b/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts new file mode 100644 index 000000000000..7521b1a1a495 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + expectLog, + expectNoLog, +} from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "allowedCommonJsDependencies"', () => { + describe('given option is not set', () => { + for (const aot of [true, false]) { + it(`should show warning when depending on a Common JS bundle in ${ + aot ? 'AOT' : 'JIT' + } Mode`, async () => { + // Add a Common JS dependency + await harness.appendToFile('src/app/app.component.ts', `import 'buffer';`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + aot, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectLog(logs, /Module 'buffer' used by 'src\/app\/app\.component\.ts' is not ESM/); + expectLog(logs, /CommonJS or AMD dependencies/); + expectNoLog( + logs, + 'base64-js', + 'Should not warn on transitive CommonJS packages which parent is also CommonJS.', + ); + }); + } + }); + + it('should not show warning when depending on a Common JS bundle which is allowed', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'buffer'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: ['buffer', 'base64-js', 'ieee754'], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it('should not show warning when all dependencies are allowed by wildcard', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'buffer'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: ['*'], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it('should not show warning when depending on zone.js', async () => { + // Add a Common JS dependency + await harness.appendToFile( + 'src/app/app.component.ts', + ` + import 'zone.js'; + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it(`should not show warning when importing non global local data '@angular/common/locale/fr'`, async () => { + await harness.appendToFile( + 'src/app/app.component.ts', + `import '@angular/common/locales/fr';`, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it('should not show warning in JIT for templateUrl and styleUrl when using paths', async () => { + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions ??= {}; + tsconfig.compilerOptions.paths = { + '@app/*': ['./src/app/*'], + }; + + return JSON.stringify(tsconfig, null, 2); + }); + + await harness.modifyFile('src/app/app.module.ts', (content) => + content.replace('./app.component', '@app/app.component'), + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + aot: false, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + + it('should not show warning for relative imports', async () => { + await harness.appendToFile('src/main.ts', `import './abc';`); + await harness.writeFile('src/abc.ts', 'console.log("abc");'); + + harness.useTarget('build', { + ...BASE_OPTIONS, + allowedCommonJsDependencies: [], + optimization: true, + }); + + const { result, logs } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + expectNoLog(logs, /CommonJS or AMD dependencies/); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts new file mode 100644 index 000000000000..16f2a27b634c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts @@ -0,0 +1,180 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +const appShellRouteFiles: Record = { + 'src/styles.css': `p { color: #000 }`, + 'src/app/app-shell/app-shell.component.ts': ` + import { Component } from '@angular/core'; + + @Component({ + selector: 'app-app-shell', + standalone: false, + styles: ['div { color: #fff; }'], + template: '

app-shell works!

', + }) + export class AppShellComponent {}`, + 'src/main.server.ts': ` + import { AppServerModule } from './app/app.module.server'; + export default AppServerModule; + `, + 'src/app/app.module.ts': ` + import { BrowserModule } from '@angular/platform-browser'; + import { NgModule } from '@angular/core'; + + import { AppRoutingModule } from './app-routing.module'; + import { AppComponent } from './app.component'; + import { RouterModule } from '@angular/router'; + + @NgModule({ + declarations: [ + AppComponent + ], + imports: [ + BrowserModule, + AppRoutingModule, + RouterModule + ], + bootstrap: [AppComponent] + }) + export class AppModule { } + `, + 'src/app/app.module.server.ts': ` + import { NgModule } from '@angular/core'; + import { ServerModule } from '@angular/platform-server'; + + import { AppModule } from './app.module'; + import { AppComponent } from './app.component'; + import { Routes, RouterModule } from '@angular/router'; + import { AppShellComponent } from './app-shell/app-shell.component'; + + const routes: Routes = [ { path: 'shell', component: AppShellComponent }]; + + @NgModule({ + imports: [ + AppModule, + ServerModule, + RouterModule.forRoot(routes), + ], + bootstrap: [AppComponent], + declarations: [AppShellComponent], + }) + export class AppServerModule {} + `, + 'src/main.ts': ` + import { platformBrowser } from '@angular/platform-browser'; + import { AppModule } from './app/app.module'; + + platformBrowser().bootstrapModule(AppModule).catch(err => console.log(err)); + `, + 'src/app/app-routing.module.ts': ` + import { NgModule } from '@angular/core'; + import { Routes, RouterModule } from '@angular/router'; + + const routes: Routes = []; + + @NgModule({ + imports: [RouterModule.forRoot(routes)], + exports: [RouterModule] + }) + export class AppRoutingModule { } + `, + 'src/app/app.component.html': ``, +}; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + beforeEach(async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + await harness.writeFiles(appShellRouteFiles); + }); + + describe('Option: "appShell"', () => { + it('renders the application shell', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + appShell: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').toExist(); + const indexFileContent = harness.expectFile('dist/browser/index.html').content; + indexFileContent.toContain('app-shell works!'); + }); + + it('critical CSS is inlined', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + polyfills: ['zone.js'], + appShell: true, + styles: ['src/styles.css'], + optimization: { + styles: { + minify: true, + inlineCritical: true, + }, + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + const indexFileContent = harness.expectFile('dist/browser/index.html').content; + indexFileContent.toContain('app-shell works!'); + indexFileContent.toContain('p{color:#000}'); + indexFileContent.toContain( + ``, + ); + }); + + it('applies CSP nonce to critical CSS', async () => { + await harness.modifyFile('src/index.html', (content) => + content.replace(/`, + ); + indexFileContent.toContain(' + + +
+

Blocked request. This host ("${hostname}") is not allowed.

+

To allow this host, add it to allowedHosts under the serve target in angular.json.

+
{
+  "serve": {
+    "options": {
+      "allowedHosts": ["${hostname}"]
+    }
+  }
+}
+
+ + `; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts new file mode 100644 index 000000000000..dbef050ac4c0 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/html-fallback-middleware.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ServerResponse } from 'node:http'; +import type { Connect } from 'vite' with { + 'resolution-mode': 'import', +}; +import { lookupMimeTypeFromRequest } from '../utils'; + +const ALLOWED_FALLBACK_METHODS = Object.freeze(['GET', 'HEAD']); + +export function angularHtmlFallbackMiddleware( + req: Connect.IncomingMessage, + _res: ServerResponse, + next: Connect.NextFunction, +): void { + // Similar to how it is handled in vite + // https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/middlewares/htmlFallback.ts#L15C19-L15C45 + if (!req.method || !ALLOWED_FALLBACK_METHODS.includes(req.method)) { + // No fallback for unsupported request methods + next(); + + return; + } + + if (req.url) { + const mimeType = lookupMimeTypeFromRequest(req.url); + if ( + (mimeType === 'text/html' || mimeType === 'application/xhtml+xml') && + !/^\/index\.(?:csr\.)?html/.test(req.url) + ) { + // eslint-disable-next-line no-console + console.warn( + `Request for HTML file "${req.url}" was received but no asset found. Asset may be missing from build.`, + ); + } else if (mimeType) { + // No fallback for request of asset-like files + next(); + + return; + } + } + + if ( + !req.headers.accept || + req.headers.accept.includes('text/html') || + req.headers.accept.includes('text/*') || + req.headers.accept.includes('*/*') + ) { + req.url = '/index.html'; + } + + next(); +} diff --git a/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts new file mode 100644 index 000000000000..7767d970ee42 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/index-html-middleware.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { extname } from 'node:path'; +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; +import { AngularMemoryOutputFiles, pathnameWithoutBasePath } from '../utils'; + +export function createAngularIndexHtmlMiddleware( + server: ViteDevServer, + outputFiles: AngularMemoryOutputFiles, + resetComponentUpdates: () => void, + indexHtmlTransformer: ((content: string) => Promise) | undefined, +): Connect.NextHandleFunction { + return function angularIndexHtmlMiddleware(req, res, next) { + if (!req.url) { + next(); + + return; + } + + // Parse the incoming request. + // The base of the URL is unused but required to parse the URL. + const pathname = pathnameWithoutBasePath(req.url, server.config.base); + const extension = extname(pathname); + if (extension !== '.html') { + next(); + + return; + } + + const rawHtml = outputFiles.get(pathname)?.contents; + if (!rawHtml) { + next(); + + return; + } + + // A request for the index indicates a full page reload request. + resetComponentUpdates(); + + server + .transformIndexHtml(req.url, Buffer.from(rawHtml).toString('utf-8')) + .then(async (processedHtml) => { + if (indexHtmlTransformer) { + processedHtml = await indexHtmlTransformer(processedHtml); + } + + res.setHeader('Content-Type', 'text/html'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(processedHtml); + }) + .catch((error) => next(error)); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/index.ts b/packages/angular/build/src/tools/vite/middlewares/index.ts new file mode 100644 index 000000000000..807e739eed59 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export { type ComponentStyleRecord, createAngularAssetsMiddleware } from './assets-middleware'; +export { angularHtmlFallbackMiddleware } from './html-fallback-middleware'; +export { createAngularIndexHtmlMiddleware } from './index-html-middleware'; +export { + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, +} from './ssr-middleware'; +export { createAngularHeadersMiddleware } from './headers-middleware'; +export { createAngularComponentMiddleware } from './component-middleware'; +export { createChromeDevtoolsMiddleware } from './chrome-devtools-middleware'; +export { patchHostValidationMiddleware } from './host-check-middleware'; +export { patchBaseMiddleware } from './base-middleware'; diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts new file mode 100644 index 000000000000..c75c63e992bb --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware.ts @@ -0,0 +1,162 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { + AngularAppEngine as SSRAngularAppEngine, + ɵgetOrCreateAngularServerApp as getOrCreateAngularServerApp, +} from '@angular/ssr'; +import type { ServerResponse } from 'node:http'; +import type { Connect, ViteDevServer } from 'vite' with { + 'resolution-mode': 'import', +}; +import { + isSsrNodeRequestHandler, + isSsrRequestHandler, +} from '../../../utils/server-rendering/utils'; + +export function createAngularSsrInternalMiddleware( + server: ViteDevServer, + resetComponentUpdates: () => void, + indexHtmlTransformer?: (content: string) => Promise, +): Connect.NextHandleFunction { + let cachedAngularServerApp: ReturnType | undefined; + + return function angularSsrMiddleware( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, + ) { + if (req.url === undefined) { + return next(); + } + + (async () => { + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + const { writeResponseToNodeResponse, createWebRequestFromNodeRequest } = (await import( + '@angular/ssr/node' as string + )) as typeof import('@angular/ssr/node', { with: { 'resolution-mode': 'import' } }); + + const { ɵgetOrCreateAngularServerApp } = (await server.ssrLoadModule('/main.server.mjs')) as { + ɵgetOrCreateAngularServerApp: typeof getOrCreateAngularServerApp; + }; + + const angularServerApp = ɵgetOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + // Only Add the transform hook only if it's a different instance. + if (cachedAngularServerApp !== angularServerApp) { + angularServerApp.hooks.on('html:transform:pre', async ({ html, url }) => { + resetComponentUpdates(); + const processedHtml = await server.transformIndexHtml(url.pathname, html); + + return indexHtmlTransformer?.(processedHtml) ?? processedHtml; + }); + + cachedAngularServerApp = angularServerApp; + } + + const webReq = new Request(createWebRequestFromNodeRequest(req), { + signal: AbortSignal.timeout(30_000), + }); + const webRes = await angularServerApp.handle(webReq); + if (!webRes) { + return next(); + } + + return writeResponseToNodeResponse(webRes, res); + })().catch(next); + }; +} + +export async function createAngularSsrExternalMiddleware( + server: ViteDevServer, + resetComponentUpdates: () => void, + indexHtmlTransformer?: (content: string) => Promise, +): Promise { + let fallbackWarningShown = false; + let cachedAngularAppEngine: typeof SSRAngularAppEngine | undefined; + let angularSsrInternalMiddleware: + ReturnType | undefined; + + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + + const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } = (await import( + '@angular/ssr/node' as string + )) as typeof import('@angular/ssr/node', { with: { 'resolution-mode': 'import' } }); + + // Disable host check if allowed hosts is true meaning allow all hosts. + const { allowedHosts } = server.config.server; + const disableAllowedHostsCheck = allowedHosts === true; + + return function angularSsrExternalMiddleware( + req: Connect.IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction, + ) { + (async () => { + const { reqHandler, AngularAppEngine } = (await server.ssrLoadModule('./server.mjs')) as { + reqHandler?: unknown; + AngularAppEngine: typeof SSRAngularAppEngine; + }; + + if (!isSsrNodeRequestHandler(reqHandler) && !isSsrRequestHandler(reqHandler)) { + if (!fallbackWarningShown) { + // eslint-disable-next-line no-console + console.warn( + `The 'reqHandler' export in 'server.ts' is either undefined or does not provide a recognized request handler. ` + + 'Using the internal SSR middleware instead.', + ); + + fallbackWarningShown = true; + } + + angularSsrInternalMiddleware ??= createAngularSsrInternalMiddleware( + server, + resetComponentUpdates, + indexHtmlTransformer, + ); + + angularSsrInternalMiddleware(req, res, next); + + return; + } + + if (cachedAngularAppEngine !== AngularAppEngine) { + AngularAppEngine.ɵdisableAllowedHostsCheck = disableAllowedHostsCheck; + AngularAppEngine.ɵallowStaticRouteRender = true; + AngularAppEngine.ɵhooks.on('html:transform:pre', async ({ html, url }) => { + resetComponentUpdates(); + const processedHtml = await server.transformIndexHtml(url.pathname, html); + + return indexHtmlTransformer?.(processedHtml) ?? processedHtml; + }); + + cachedAngularAppEngine = AngularAppEngine; + } + + // Forward the request to the middleware in server.ts + if (isSsrNodeRequestHandler(reqHandler)) { + await reqHandler(req, res, next); + } else { + const webRes = await reqHandler(createWebRequestFromNodeRequest(req)); + if (!webRes) { + next(); + + return; + } + + await writeResponseToNodeResponse(webRes, res); + } + })().catch(next); + }; +} diff --git a/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts new file mode 100644 index 000000000000..a61011043a43 --- /dev/null +++ b/packages/angular/build/src/tools/vite/middlewares/ssr-middleware_spec.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, +} from './ssr-middleware'; + +type ViteDevServer = Parameters[0]; + +describe('SSR dev-server middleware', () => { + function createMockReqRes(): { req: IncomingMessage; res: ServerResponse } { + const req = { + url: '/', + method: 'GET', + headers: { host: 'localhost' }, + } as unknown as IncomingMessage; + + const res = { + statusCode: 200, + setHeader: jasmine.createSpy('setHeader'), + writeHead: jasmine.createSpy('writeHead'), + end: jasmine.createSpy('end'), + } as unknown as ServerResponse; + + return { req, res }; + } + + it('resets component updates when html:transform:pre executes in internal SSR middleware', async () => { + let hookCallback: ((args: { html: string; url: URL }) => Promise) | undefined; + const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates'); + let resolveHookRegistered!: () => void; + const hookRegistered = new Promise((resolve) => { + resolveHookRegistered = resolve; + }); + + const fakeAngularServerApp = { + hooks: { + on: (_name: string, cb: typeof hookCallback) => { + hookCallback = cb; + resolveHookRegistered(); + }, + }, + handle: jasmine.createSpy('handle').and.resolveTo(new Response('rendered')), + }; + + const server = { + config: { server: { allowedHosts: true }, base: '/' }, + ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({ + ɵgetOrCreateAngularServerApp: () => fakeAngularServerApp, + }), + transformIndexHtml: jasmine + .createSpy('transformIndexHtml') + .and.callFake(async (_path: string, html: string) => html), + } as unknown as ViteDevServer; + + const middleware = createAngularSsrInternalMiddleware( + server, + resetComponentUpdates, + async (html) => html + '', + ); + + const { req, res } = createMockReqRes(); + middleware(req, res, () => {}); + + await hookRegistered; + + expect(resetComponentUpdates).not.toHaveBeenCalled(); + expect(hookCallback).toBeDefined(); + const result = await hookCallback?.({ + html: '
test
', + url: new URL('http://localhost/'), + }); + expect(resetComponentUpdates).toHaveBeenCalledTimes(1); + expect(result).toBe('
test
'); + }); + + it('resets component updates when html:transform:pre executes in external SSR middleware', async () => { + let hookCallback: ((args: { html: string; url: URL }) => Promise) | undefined; + const resetComponentUpdates = jasmine.createSpy('resetComponentUpdates'); + let resolveHookRegistered!: () => void; + const hookRegistered = new Promise((resolve) => { + resolveHookRegistered = resolve; + }); + + class FakeAngularAppEngine { + static ɵhooks = { + on: (_name: string, cb: typeof hookCallback) => { + hookCallback = cb; + resolveHookRegistered(); + }, + }; + static ɵdisableAllowedHostsCheck: boolean; + static ɵallowStaticRouteRender: boolean; + } + + const server = { + config: { server: { allowedHosts: true }, base: '/' }, + ssrLoadModule: jasmine.createSpy('ssrLoadModule').and.resolveTo({ + reqHandler: Object.assign(() => new Response('ok'), { __ng_request_handler__: true }), + AngularAppEngine: FakeAngularAppEngine, + }), + transformIndexHtml: jasmine + .createSpy('transformIndexHtml') + .and.callFake(async (_path: string, html: string) => html + ''), + } as unknown as ViteDevServer; + + const middleware = await createAngularSsrExternalMiddleware(server, resetComponentUpdates); + + const { req, res } = createMockReqRes(); + middleware(req, res, () => {}); + + await hookRegistered; + + expect(resetComponentUpdates).not.toHaveBeenCalled(); + expect(hookCallback).toBeDefined(); + const result = await hookCallback?.({ + html: '
test
', + url: new URL('http://localhost/'), + }); + expect(resetComponentUpdates).toHaveBeenCalledTimes(1); + expect(result).toBe('
test
'); + }); +}); diff --git a/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts new file mode 100644 index 000000000000..c6dc243385a8 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/angular-memory-plugin.ts @@ -0,0 +1,154 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { readFile } from 'node:fs/promises'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type * as Vite from 'vite' with { + 'resolution-mode': 'import', +}; +import { removeSourceMappingURL } from '../../../utils/source-map'; +import { AngularMemoryOutputFiles } from '../utils'; + +interface AngularMemoryPluginOptions { + virtualProjectRoot: string; + outputFiles: AngularMemoryOutputFiles; + templateUpdates?: ReadonlyMap; + external?: string[]; + disableViteTransport?: boolean; +} + +const ANGULAR_PREFIX = '/@ng/'; +const VITE_FS_PREFIX = '/@fs/'; +const FILE_PROTOCOL = 'file:'; + +export async function createAngularMemoryPlugin( + options: AngularMemoryPluginOptions, +): Promise { + const { virtualProjectRoot, outputFiles, external } = options; + const { normalizePath } = (await import('vite' as string)) as typeof Vite; + + return { + name: 'vite:angular-memory', + // Ensures plugin hooks run before built-in Vite hooks + enforce: 'pre', + async resolveId(source, importer, { ssr }) { + if (source.startsWith(VITE_FS_PREFIX)) { + return; + } + + // For SSR with component HMR, pass through as a virtual module + if (ssr && source.startsWith(FILE_PROTOCOL) && source.includes(ANGULAR_PREFIX)) { + // Vite will resolve these these files example: + // `file:///@ng/component?c=src%2Fapp%2Fapp.component.ts%40AppComponent&t=1737017253850` + const sourcePath = fileURLToPath(source); + const sourceWithoutRoot = normalizePath('/' + relative(virtualProjectRoot, sourcePath)); + + if (sourceWithoutRoot.startsWith(ANGULAR_PREFIX)) { + const [, query] = source.split('?', 2); + + return `\0${sourceWithoutRoot}?${query}`; + } + } + + // Prevent vite from resolving an explicit external dependency (`externalDependencies` option) + if (external?.includes(source)) { + // This is still not ideal since Vite will still transform the import specifier to + // `/@id/${source}` but is currently closer to a raw external than a resolved file path. + return source; + } + + if (importer && source[0] === '.') { + const normalizedImporter = normalizePath(importer); + if (normalizedImporter.startsWith(virtualProjectRoot)) { + // Remove query if present + const [importerFile] = normalizedImporter.split('?', 1); + source = '/' + join(dirname(relative(virtualProjectRoot, importerFile)), source); + } + } + + const [file] = source.split('?', 1); + if (outputFiles.has(normalizePath(file))) { + return join(virtualProjectRoot, source); + } + }, + load(id, loadOptions) { + // For SSR component updates, return the component update module or empty if none + if (loadOptions?.ssr && id.startsWith(`\0${ANGULAR_PREFIX}`)) { + // Extract component identifier (first character is rollup virtual module null) + const requestUrl = new URL(id.slice(1), 'http://localhost'); + const componentId = requestUrl.searchParams.get('c'); + + return (componentId && options.templateUpdates?.get(encodeURIComponent(componentId))) ?? ''; + } + + const [file] = id.split('?', 1); + const relativeFile = '/' + normalizePath(relative(virtualProjectRoot, file)); + const codeContents = outputFiles.get(relativeFile)?.contents; + if (codeContents === undefined) { + if (relativeFile.endsWith('/node_modules/vite/dist/client/client.mjs')) { + return loadViteClientCode(file, options.disableViteTransport); + } + + return undefined; + } + + const code = Buffer.from(codeContents).toString('utf-8'); + const mapContents = outputFiles.get(relativeFile + '.map')?.contents; + + return { + // Remove source map URL comments from the code if a sourcemap is present. + // Vite will inline and add an additional sourcemap URL for the sourcemap. + code: mapContents ? removeSourceMappingURL(code) : code, + map: mapContents && Buffer.from(mapContents).toString('utf-8'), + }; + }, + }; +} + +/** + * Reads the resolved Vite client code from disk and updates the content to remove + * an unactionable suggestion to update the Vite configuration file to disable the + * error overlay. The Vite configuration file is not present when used in the Angular + * CLI. + * @param file The absolute path to the Vite client code. + * @returns + */ +async function loadViteClientCode(file: string, disableViteTransport = false): Promise { + const originalContents = await readFile(file, 'utf-8'); + let updatedContents = originalContents.replace( + '"You can also disable this overlay by setting ", ' + + 'h("code", { part: "config-option-name" }, "server.hmr.overlay"), ' + + '" to ", ' + + 'h("code", { part: "config-option-value" }, "false"), ' + + '" in ", ' + + 'h("code", { part: "config-file-name" }, hmrConfigName), ' + + '"."', + '', + ); + + assert(originalContents !== updatedContents, 'Failed to update Vite client error overlay text.'); + + if (disableViteTransport) { + const previousUpdatedContents = updatedContents; + + updatedContents = updatedContents.replace( + 'transport.connect(createHMRHandler(handleMessage));', + '', + ); + assert( + previousUpdatedContents !== updatedContents, + 'Failed to update Vite client WebSocket disable.', + ); + + updatedContents = updatedContents.replace('console.debug("[vite] connecting...")', ''); + } + + return updatedContents; +} diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts new file mode 100644 index 000000000000..74cd03179a7e --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Plugin } from 'vite' with { + 'resolution-mode': 'import', +}; + +// NOTE: the implementation for this Vite plugin is roughly based on: +// https://github.com/MilanKovacic/vite-plugin-externalize-dependencies + +const VITE_ID_PREFIX = '@id/'; + +const escapeRegexSpecialChars = (inputString: string): string => { + return inputString.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +}; + +export function createRemoveIdPrefixPlugin(externals: string[]): Plugin { + return { + name: 'angular-plugin-remove-id-prefix', + apply: 'serve', + configResolved: (resolvedConfig) => { + // don't do anything when the list of externals is empty + if (externals.length === 0) { + return; + } + + const transformFn = createTransformer(resolvedConfig.base, externals); + + // @ts-expect-error: Property 'push' does not exist on type 'readonly Plugin[]' + // Reasoning: + // since the /@id/ prefix is added by Vite's import-analysis plugin, + // we must add our actual plugin dynamically, to ensure that it will run + // AFTER the import-analysis. + resolvedConfig.plugins.push({ + name: 'angular-plugin-remove-id-prefix-transform', + transform: transformFn, + }); + }, + }; +} + +/** + * Creates a transform function that removes the Vite ID prefix from externals. + * @param base The base path of the application. + * @param externals The external package names. + * @returns A function that transforms code by removing the Vite ID prefix. + */ +export function createTransformer(base: string, externals: string[]): (code: string) => string { + // The path suffix is bounded so that a match can never extend past the end of an + // import specifier string literal. With a greedy `.+`, minified (single-line) code + // would let the first match consume the remainder of the line, leaving all later + // `/@id/` occurrences on that line unstripped. + const escapedExternals = externals.map((e) => escapeRegexSpecialChars(e) + '(?:/[^\'"`\\s]+)?'); + + const prefixedExternalRegex = new RegExp( + `${base}${VITE_ID_PREFIX}(${escapedExternals.join('|')})`, + 'g', + ); + + return (code: string) => { + return code.includes(VITE_ID_PREFIX) + ? code.replace(prefixedExternalRegex, (_, externalName) => externalName) + : // don't do anything when code does not contain the Vite prefix + code; + }; +} diff --git a/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts new file mode 100644 index 000000000000..37d1bdf61fe6 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/id-prefix-plugin_spec.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createTransformer } from './id-prefix-plugin'; + +describe('createTransformer', () => { + it('should strip the prefix from every occurrence on a single (minified) line', () => { + const transform = createTransformer('/', [ + '@angular/common', + '@angular/common/http', + '@angular/core', + '@angular/router', + ]); + + const minified = + 'import{a}from"/@id/@angular/common/http";' + + 'import{b}from"/@id/@angular/router";' + + 'import{c}from"/@id/@angular/core";'; + + expect(transform(minified)).toBe( + 'import{a}from"@angular/common/http";' + + 'import{b}from"@angular/router";' + + 'import{c}from"@angular/core";', + ); + }); + + it('should strip the prefix from an external with a deep import path', () => { + const transform = createTransformer('/', ['@angular/common']); + + expect(transform('import{h}from"/@id/@angular/common/http";')).toBe( + 'import{h}from"@angular/common/http";', + ); + }); + + it('should strip the prefix when a non-root base is configured', () => { + const transform = createTransformer('/app/', ['@angular/router']); + + expect(transform('import{r}from"/app/@id/@angular/router";')).toBe( + 'import{r}from"@angular/router";', + ); + }); + + it('should strip the prefix from multi-line (unminified) code', () => { + const transform = createTransformer('/', ['@angular/common', '@angular/router']); + + const code = + 'import { CommonModule } from "/@id/@angular/common";\n' + + 'import { Router } from "/@id/@angular/router";\n'; + + expect(transform(code)).toBe( + 'import { CommonModule } from "@angular/common";\n' + + 'import { Router } from "@angular/router";\n', + ); + }); + + it('should not modify imports that are not configured externals', () => { + const transform = createTransformer('/', ['@angular/router']); + + const code = 'import{x}from"/@id/some-other-package";'; + expect(transform(code)).toBe(code); + }); +}); diff --git a/packages/angular/build/src/tools/vite/plugins/index.ts b/packages/angular/build/src/tools/vite/plugins/index.ts new file mode 100644 index 000000000000..6c4cdd4496e4 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export { createAngularMemoryPlugin } from './angular-memory-plugin'; +export { createRemoveIdPrefixPlugin } from './id-prefix-plugin'; +export { createAngularSetupMiddlewaresPlugin, ServerSsrMode } from './setup-middlewares-plugin'; +export { createAngularSsrTransformPlugin } from './ssr-transform-plugin'; +export { createAngularServerSideSSLPlugin } from './ssr-ssl-plugin'; diff --git a/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts new file mode 100644 index 000000000000..34b0704f4091 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/setup-middlewares-plugin.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Connect, Plugin } from 'vite' with { + 'resolution-mode': 'import', +}; +import { + ComponentStyleRecord, + angularHtmlFallbackMiddleware, + createAngularAssetsMiddleware, + createAngularComponentMiddleware, + createAngularHeadersMiddleware, + createAngularIndexHtmlMiddleware, + createAngularSsrExternalMiddleware, + createAngularSsrInternalMiddleware, + createChromeDevtoolsMiddleware, + patchBaseMiddleware, + patchHostValidationMiddleware, +} from '../middlewares'; +import { AngularMemoryOutputFiles, AngularOutputAssets } from '../utils'; + +export enum ServerSsrMode { + /** + * No SSR + */ + NoSsr, + + /** + * Internal server-side rendering (SSR) is handled through the built-in middleware. + * + * In this mode, the SSR process is managed internally by the dev-server's middleware. + * The server automatically renders pages on the server without requiring external + * middleware or additional configuration from the developer. + */ + InternalSsrMiddleware, + + /** + * External server-side rendering (SSR) is handled by a custom middleware defined in server.ts. + * + * This mode allows developers to define custom SSR behavior by providing a middleware in the + * `server.ts` file. It gives more flexibility for handling SSR, such as integrating with other + * frameworks or customizing the rendering pipeline. + */ + ExternalSsrMiddleware, +} + +interface AngularSetupMiddlewaresPluginOptions { + outputFiles: AngularMemoryOutputFiles; + assets: AngularOutputAssets; + extensionMiddleware?: Connect.NextHandleFunction[]; + indexHtmlTransformer?: (content: string) => Promise; + componentStyles: Map; + templateUpdates: Map; + ssrMode: ServerSsrMode; + resetComponentUpdates: () => void; + projectRoot: string; +} + +async function createEncapsulateStyle(): Promise< + (style: Uint8Array, componentId: string) => string +> { + const { encapsulateStyle } = await import('@angular/compiler'); + const decoder = new TextDecoder('utf-8'); + + return (style, componentId) => { + return encapsulateStyle(decoder.decode(style), componentId); + }; +} + +export function createAngularSetupMiddlewaresPlugin( + options: AngularSetupMiddlewaresPluginOptions, +): Plugin { + return { + name: 'vite:angular-setup-middlewares', + enforce: 'pre', + async configureServer(server) { + const { + indexHtmlTransformer, + outputFiles, + extensionMiddleware, + assets, + componentStyles, + templateUpdates, + ssrMode, + resetComponentUpdates, + } = options; + + const middlewares = server.middlewares; + + // Headers, assets and resources get handled first + middlewares.use(createAngularHeadersMiddleware(server)); + middlewares.use(createAngularComponentMiddleware(server, templateUpdates)); + middlewares.use( + createAngularAssetsMiddleware( + server, + assets, + outputFiles, + componentStyles, + await createEncapsulateStyle(), + ), + ); + + middlewares.use(createChromeDevtoolsMiddleware(server.config.cacheDir, options.projectRoot)); + + extensionMiddleware?.forEach((middleware) => middlewares.use(middleware)); + + // Returning a function, installs middleware after the main transform middleware but + // before the built-in HTML middleware + // eslint-disable-next-line @typescript-eslint/no-misused-promises + return async () => { + patchHostValidationMiddleware(server.middlewares); + + if (ssrMode === ServerSsrMode.ExternalSsrMiddleware) { + patchBaseMiddleware(server.middlewares, server.config.base); + middlewares.use( + await createAngularSsrExternalMiddleware( + server, + resetComponentUpdates, + indexHtmlTransformer, + ), + ); + + return; + } + + if (ssrMode === ServerSsrMode.InternalSsrMiddleware) { + middlewares.use( + createAngularSsrInternalMiddleware(server, resetComponentUpdates, indexHtmlTransformer), + ); + } + + middlewares.use(angularHtmlFallbackMiddleware); + middlewares.use( + createAngularIndexHtmlMiddleware( + server, + outputFiles, + resetComponentUpdates, + indexHtmlTransformer, + ), + ); + }; + }, + }; +} diff --git a/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts b/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts new file mode 100644 index 000000000000..ceb1f9630b7f --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/ssr-ssl-plugin.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { getCACertificates, setDefaultCACertificates } from 'node:tls'; +import type { Plugin } from 'vite' with { + 'resolution-mode': 'import', +}; + +export function createAngularServerSideSSLPlugin(): Plugin { + return { + name: 'angular-ssr-ssl-plugin', + apply: 'serve', + async configureServer({ config, httpServer }) { + const { + ssr, + server: { https }, + } = config; + + if (!ssr || !https?.cert) { + return; + } + + if (httpServer && 'ALPNProtocols' in httpServer) { + // Force Vite to use HTTP/1.1 when SSR and SSL are enabled. + // This is required because the Express server used for SSR does not support HTTP/2. + // See: https://github.com/vitejs/vite/blob/46d3077f2b63771cc50230bc907c48f5773c00fb/packages/vite/src/node/http.ts#L126 + + // We directly set the `ALPNProtocols` on the HTTP server to override the default behavior. + // Passing `ALPNProtocols` in the TLS options would cause Node.js to automatically include `h2`. + // Additionally, using `ALPNCallback` is not an option as it is mutually exclusive with `ALPNProtocols`. + // See: https://github.com/nodejs/node/blob/b8b4350ed3b73d225eb9e628d69151df56eaf298/lib/internal/http2/core.js#L3351 + httpServer.ALPNProtocols = ['http/1.1']; + } + + const { cert } = https; + const additionalCerts = Array.isArray(cert) ? cert : [cert]; + + const currentCerts = getCACertificates('default'); + setDefaultCACertificates([...currentCerts, ...additionalCerts]); + }, + }; +} diff --git a/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts b/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts new file mode 100644 index 000000000000..b857cc3cde74 --- /dev/null +++ b/packages/angular/build/src/tools/vite/plugins/ssr-transform-plugin.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import remapping, { SourceMapInput } from '@ampproject/remapping'; +import type * as Vite from 'vite' with { + 'resolution-mode': 'import', +}; + +export async function createAngularSsrTransformPlugin(workspaceRoot: string): Promise { + const { normalizePath } = (await import('vite' as string)) as typeof Vite; + + return { + name: 'vite:angular-ssr-transform', + enforce: 'post', + transform(code, _id, { ssr, inMap }: { ssr?: boolean; inMap?: SourceMapInput } = {}) { + if (!ssr || !inMap) { + return null; + } + + const remappedMap = remapping([inMap], () => null); + // Set the sourcemap root to the workspace root. This is needed since we set a virtual path as root. + remappedMap.sourceRoot = normalizePath(workspaceRoot) + '/'; + + return { + code, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + map: remappedMap as any, + }; + }, + }; +} diff --git a/packages/angular/build/src/tools/vite/utils.ts b/packages/angular/build/src/tools/vite/utils.ts new file mode 100644 index 000000000000..fa833066f8ed --- /dev/null +++ b/packages/angular/build/src/tools/vite/utils.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { builtinModules, isBuiltin } from 'node:module'; +import { extname } from 'node:path'; +import type { DepOptimizationConfig } from 'vite' with { + 'resolution-mode': 'import', +}; +import type { ExternalResultMetadata } from '../esbuild/bundler-execution-result'; +import { JavaScriptTransformer } from '../javascript-transformer'; + +export type AngularMemoryOutputFiles = Map< + string, + { contents: Uint8Array; hash: string; servable: boolean } +>; + +export type AngularOutputAssets = Map; + +export function pathnameWithoutBasePath(url: string, basePath: string): string { + const parsedUrl = new URL(url, 'http://localhost'); + const pathname = decodeURIComponent(parsedUrl.pathname); + + // slice(basePath.length - 1) to retain the trailing slash + return basePath !== '/' && pathname.startsWith(basePath) + ? pathname.slice(basePath.length - 1) + : pathname; +} + +export function lookupMimeTypeFromRequest(url: string): string | undefined { + const extension = extname(url.split('?')[0]); + + if (extension === '.ico') { + return 'image/x-icon'; + } + + return extension && lookupMimeType(extension); +} + +export type RolldownLoaderOption = Exclude< + DepOptimizationConfig['rolldownOptions'], + undefined +>['moduleTypes']; + +export function getDepOptimizationConfig({ + target, + disabled, + exclude, + include, + prebundleTransformer, + loader, + thirdPartySourcemaps, + define = {}, +}: { + target: string[]; + disabled: boolean; + exclude: string[]; + include: string[]; + prebundleTransformer: JavaScriptTransformer; + loader?: RolldownLoaderOption; + thirdPartySourcemaps: boolean; + define: Record | undefined; +}): DepOptimizationConfig { + const config: DepOptimizationConfig = { + // Exclude any explicitly defined dependencies (currently build defined externals) + exclude, + // NB: to disable the deps optimizer, set optimizeDeps.noDiscovery to true and optimizeDeps.include as undefined. + // Include all implict dependencies from the external packages internal option + include: disabled ? undefined : include, + noDiscovery: disabled, + rolldownOptions: { + transform: { + target, + define, + }, + moduleTypes: loader, + resolve: { + extensions: ['.mjs', '.js', '.cjs'], + }, + plugins: [ + { + name: `angular-vite-optimize-deps${thirdPartySourcemaps ? '-vendor-sourcemap' : ''}`, + load: { + filter: { id: /\.[cm]?js$/ }, + async handler(id: string) { + const code = await prebundleTransformer.transformFile(id); + + return { code: Buffer.from(code).toString('utf-8') }; + }, + }, + }, + ], + }, + }; + + return config; +} + +export interface DevServerExternalResultMetadata { + implicitBrowser: string[]; + implicitServer: string[]; + explicitBrowser: string[]; + explicitServer: string[]; +} + +export function isAbsoluteUrl(url: string): boolean { + try { + new URL(url); + + return true; + } catch { + return false; + } +} + +export function updateExternalMetadata( + result: { detail?: { externalMetadata?: ExternalResultMetadata } }, + externalMetadata: DevServerExternalResultMetadata, + externalDependencies: string[] | undefined, + explicitPackagesOnly: boolean = false, +): void { + if (!result.detail?.['externalMetadata']) { + return; + } + + const { implicitBrowser, implicitServer, explicit } = result.detail['externalMetadata']; + const implicitServerFiltered = implicitServer.filter( + (m) => !isBuiltin(m) && !isAbsoluteUrl(m) && !m.endsWith('.json'), + ); + const implicitBrowserFiltered = implicitBrowser.filter( + (m) => !isAbsoluteUrl(m) && !m.endsWith('.json'), + ); + const explicitBrowserFiltered = explicitPackagesOnly + ? explicit.filter((m) => !isAbsoluteUrl(m)) + : explicit; + + // Empty Arrays to avoid growing unlimited with every re-build. + externalMetadata.explicitBrowser.length = 0; + externalMetadata.explicitServer.length = 0; + externalMetadata.implicitServer.length = 0; + externalMetadata.implicitBrowser.length = 0; + + const externalDeps = externalDependencies ?? []; + externalMetadata.explicitBrowser.push(...explicitBrowserFiltered, ...externalDeps); + externalMetadata.explicitServer.push( + ...explicitBrowserFiltered, + ...externalDeps, + ...builtinModules, + ); + externalMetadata.implicitServer.push(...implicitServerFiltered); + externalMetadata.implicitBrowser.push(...implicitBrowserFiltered); + + // The below needs to be sorted as Vite uses these options as part of the hashing invalidation algorithm. + // See: https://github.com/vitejs/vite/blob/0873bae0cfe0f0718ad2f5743dd34a17e4ab563d/packages/vite/src/node/optimizer/index.ts#L1203-L1239 + externalMetadata.explicitBrowser.sort(); + externalMetadata.explicitServer.sort(); + externalMetadata.implicitServer.sort(); + externalMetadata.implicitBrowser.sort(); +} diff --git a/packages/angular/build/src/utils/bundle-calculator.ts b/packages/angular/build/src/utils/bundle-calculator.ts new file mode 100644 index 000000000000..71a75dafde02 --- /dev/null +++ b/packages/angular/build/src/utils/bundle-calculator.ts @@ -0,0 +1,399 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Budget as BudgetEntry, Type as BudgetType } from '../builders/application/schema'; +import { formatSize } from './format-bytes'; + +// Re-export to avoid direct schema importing throughout code +export { type BudgetEntry, BudgetType }; + +export const BYTES_IN_KILOBYTE = 1000; + +interface Size { + size: number; + label?: string; +} + +export interface Threshold { + limit: number; + type: ThresholdType; + severity: ThresholdSeverity; +} + +enum ThresholdType { + Max = 'maximum', + Min = 'minimum', +} + +export enum ThresholdSeverity { + Warning = 'warning', + Error = 'error', +} + +export interface BudgetCalculatorResult { + severity: ThresholdSeverity; + message: string; + label?: string; +} + +export interface BudgetChunk { + files?: string[]; + names?: string[]; + initial?: boolean; +} + +export interface BudgetAsset { + name: string; + size: number; + componentStyle?: boolean; +} + +export interface BudgetStats { + chunks?: BudgetChunk[]; + assets?: BudgetAsset[]; +} + +export function* calculateThresholds(budget: BudgetEntry): IterableIterator { + if (budget.maximumWarning) { + yield { + limit: calculateBytes(budget.maximumWarning, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.maximumError) { + yield { + limit: calculateBytes(budget.maximumError, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Error, + }; + } + + if (budget.minimumWarning) { + yield { + limit: calculateBytes(budget.minimumWarning, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.minimumError) { + yield { + limit: calculateBytes(budget.minimumError, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Error, + }; + } + + if (budget.warning) { + yield { + limit: calculateBytes(budget.warning, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Warning, + }; + + yield { + limit: calculateBytes(budget.warning, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Warning, + }; + } + + if (budget.error) { + yield { + limit: calculateBytes(budget.error, budget.baseline, -1), + type: ThresholdType.Min, + severity: ThresholdSeverity.Error, + }; + + yield { + limit: calculateBytes(budget.error, budget.baseline, 1), + type: ThresholdType.Max, + severity: ThresholdSeverity.Error, + }; + } +} + +/** + * Calculates the sizes for bundles in the budget type provided. + */ +function calculateSizes(budget: BudgetEntry, stats: BudgetStats): Size[] { + type CalculatorTypes = { + new (budget: BudgetEntry, chunks: BudgetChunk[], assets: BudgetAsset[]): Calculator; + }; + const calculatorMap: Record = { + all: AllCalculator, + allScript: AllScriptCalculator, + any: AnyCalculator, + anyScript: AnyScriptCalculator, + anyComponentStyle: AnyComponentStyleCalculator, + bundle: BundleCalculator, + initial: InitialCalculator, + }; + + const ctor = calculatorMap[budget.type]; + const { chunks, assets } = stats; + if (!chunks) { + throw new Error('Webpack stats output did not include chunk information.'); + } + if (!assets) { + throw new Error('Webpack stats output did not include asset information.'); + } + + const calculator = new ctor(budget, chunks, assets); + + return calculator.calculate(); +} + +abstract class Calculator { + private assetMap?: ReadonlyMap; + + constructor( + protected budget: BudgetEntry, + protected chunks: BudgetChunk[], + protected assets: BudgetAsset[], + ) {} + + abstract calculate(): Size[]; + + /** Calculates the size of the given chunk for the provided build type. */ + protected calculateChunkSize(chunk: BudgetChunk): number { + // No differential builds, get the chunk size by summing its assets. + if (!chunk.files) { + return 0; + } + + if (!this.assetMap) { + const map = new Map(); + for (const asset of this.assets) { + map.set(asset.name, asset.size); + } + this.assetMap = map; + } + const assetMap = this.assetMap; + + return chunk.files + .filter((file) => !file.endsWith('.map')) + .map((file) => { + const assetSize = assetMap.get(file); + if (assetSize === undefined) { + throw new Error(`Could not find asset for file: ${file}`); + } + + return assetSize; + }) + .reduce((l, r) => l + r, 0); + } + + protected getAssetSize(asset: BudgetAsset): number { + return asset.size; + } +} + +/** + * A named bundle. + */ +class BundleCalculator extends Calculator { + calculate() { + const budgetName = this.budget.name; + if (!budgetName) { + return []; + } + + const size = this.chunks + .filter((chunk) => chunk?.names?.includes(budgetName)) + .map((chunk) => this.calculateChunkSize(chunk)) + .reduce((l, r) => l + r, 0); + + return [{ size, label: this.budget.name }]; + } +} + +/** + * The sum of all initial chunks (marked as initial). + */ +class InitialCalculator extends Calculator { + calculate() { + return [ + { + label: `bundle initial`, + size: this.chunks + .filter((chunk) => chunk.initial) + .map((chunk) => this.calculateChunkSize(chunk)) + .reduce((l, r) => l + r, 0), + }, + ]; + } +} + +/** + * The sum of all the scripts portions. + */ +class AllScriptCalculator extends Calculator { + calculate() { + const size = this.assets + .filter((asset) => asset.name.endsWith('.js')) + .map((asset) => this.getAssetSize(asset)) + .reduce((total: number, size: number) => total + size, 0); + + return [{ size, label: 'total scripts' }]; + } +} + +/** + * All scripts and assets added together. + */ +class AllCalculator extends Calculator { + calculate() { + const size = this.assets + .filter((asset) => !asset.name.endsWith('.map') && !asset.componentStyle) + .map((asset) => this.getAssetSize(asset)) + .reduce((total: number, size: number) => total + size, 0); + + return [{ size, label: 'total' }]; + } +} + +/** + * Any script, individually. + */ +class AnyScriptCalculator extends Calculator { + calculate() { + return this.assets + .filter((asset) => asset.name.endsWith('.js')) + .map((asset) => ({ + size: this.getAssetSize(asset), + label: asset.name, + })); + } +} + +/** + * Any script or asset (images, css, etc). + */ +class AnyCalculator extends Calculator { + calculate() { + return this.assets + .filter((asset) => !asset.name.endsWith('.map') && !asset.componentStyle) + .map((asset) => ({ + size: this.getAssetSize(asset), + label: asset.name, + })); + } +} + +/** + * Any compoonent stylesheet + */ +class AnyComponentStyleCalculator extends Calculator { + calculate() { + return this.assets + .filter((asset) => asset.componentStyle) + .map((asset) => ({ + size: this.getAssetSize(asset), + label: asset.name, + })); + } +} + +/** + * Calculate the bytes given a string value. + */ +function calculateBytes(input: string, baseline?: string, factor: 1 | -1 = 1): number { + const matches = input.trim().match(/^(\d+(?:\.\d+)?)[ \t]*(%|[kmg]?b)?$/i); + if (!matches) { + return NaN; + } + + const baselineBytes = (baseline && calculateBytes(baseline)) || 0; + + let value = Number(matches[1]); + switch (matches[2] && matches[2].toLowerCase()) { + case '%': + value = (baselineBytes * value) / 100; + break; + case 'kb': + value *= BYTES_IN_KILOBYTE; + break; + case 'mb': + value *= BYTES_IN_KILOBYTE * BYTES_IN_KILOBYTE; + break; + case 'gb': + value *= BYTES_IN_KILOBYTE * BYTES_IN_KILOBYTE * BYTES_IN_KILOBYTE; + break; + } + + if (baselineBytes === 0) { + return value; + } + + return baselineBytes + value * factor; +} + +export function* checkBudgets( + budgets: BudgetEntry[], + stats: BudgetStats, + checkComponentStyles?: boolean, +): IterableIterator { + // Ignore AnyComponentStyle budgets as these are handled in `AnyComponentStyleBudgetChecker` unless requested + const computableBudgets = checkComponentStyles + ? budgets + : budgets.filter((budget) => budget.type !== BudgetType.AnyComponentStyle); + + for (const budget of computableBudgets) { + const sizes = calculateSizes(budget, stats); + for (const { size, label } of sizes) { + yield* checkThresholds(calculateThresholds(budget), size, label); + } + } +} + +export function* checkThresholds( + thresholds: Iterable, + size: number, + label?: string, +): IterableIterator { + for (const threshold of thresholds) { + switch (threshold.type) { + case ThresholdType.Max: { + if (size <= threshold.limit) { + continue; + } + + const sizeDifference = formatSize(size - threshold.limit); + yield { + severity: threshold.severity, + label, + message: `${label} exceeded maximum budget. Budget ${formatSize( + threshold.limit, + )} was not met by ${sizeDifference} with a total of ${formatSize(size)}.`, + }; + break; + } + case ThresholdType.Min: { + if (size >= threshold.limit) { + continue; + } + + const sizeDifference = formatSize(threshold.limit - size); + yield { + severity: threshold.severity, + label, + message: `${label} failed to meet minimum budget. Budget ${formatSize( + threshold.limit, + )} was not met by ${sizeDifference} with a total of ${formatSize(size)}.`, + }; + break; + } + default: { + throw new Error(`Unexpected threshold type: ${ThresholdType[threshold.type]}`); + } + } + } +} diff --git a/packages/angular/build/src/utils/bundle-calculator_spec.ts b/packages/angular/build/src/utils/bundle-calculator_spec.ts new file mode 100644 index 000000000000..9bb44394496b --- /dev/null +++ b/packages/angular/build/src/utils/bundle-calculator_spec.ts @@ -0,0 +1,379 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + BYTES_IN_KILOBYTE, + BudgetEntry, + BudgetType, + ThresholdSeverity, + checkBudgets, +} from './bundle-calculator'; + +describe('bundle-calculator', () => { + describe('checkBudgets()', () => { + it('yields maximum budgets exceeded', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Any, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [], + assets: [ + { + name: 'foo.js', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'foo.js', + message: jasmine.stringMatching('foo.js exceeded maximum budget.'), + }); + }); + + it('yields minimum budgets exceeded', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Any, + minimumError: '1kb', + }, + ]; + const stats = { + chunks: [], + assets: [ + { + name: 'foo.js', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'bar.js', + message: jasmine.stringMatching('bar.js failed to meet minimum budget.'), + }); + }); + + it('yields exceeded bundle budgets', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Bundle, + name: 'foo', + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + names: ['foo'], + files: ['foo.js', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'foo', + message: jasmine.stringMatching('foo exceeded maximum budget.'), + }); + }); + + it('yields exceeded initial budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Initial, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.js', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'bundle initial', + message: jasmine.stringMatching('initial exceeded maximum budget.'), + }); + }); + + it('yields exceeded total scripts budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.AllScript, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.js', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + { + name: 'baz.css', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'total scripts', + message: jasmine.stringMatching('total scripts exceeded maximum budget.'), + }); + }); + + it('yields exceeded total budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.All, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.js', 'bar.css'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.css', + size: 0.75 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'total', + message: jasmine.stringMatching('total exceeded maximum budget.'), + }); + }); + + it('skips component style budgets', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.AnyComponentStyle, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.css', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.css', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(0); + }); + + it('yields exceeded individual script budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.AnyScript, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.js', 'bar.js'], + }, + ], + assets: [ + { + name: 'foo.js', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.js', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'foo.js', + message: jasmine.stringMatching('foo.js exceeded maximum budget.'), + }); + }); + + it('yields exceeded individual file budget', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Any, + maximumError: '1kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['foo'], + files: ['foo.ext', 'bar.ext'], + }, + ], + assets: [ + { + name: 'foo.ext', + size: 1.5 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.ext', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures.length).toBe(1); + expect(failures).toContain({ + severity: ThresholdSeverity.Error, + label: 'foo.ext', + message: jasmine.stringMatching('foo.ext exceeded maximum budget.'), + }); + }); + + it('does not exceed the individual file budget limit', () => { + const budgets: BudgetEntry[] = [ + { + type: BudgetType.Bundle, + maximumError: '1000kb', + }, + ]; + const stats = { + chunks: [ + { + id: 0, + initial: true, + names: ['main'], + files: ['main.ext', 'bar.ext'], + }, + ], + assets: [ + { + name: 'main.ext', + size: 1 * BYTES_IN_KILOBYTE, + }, + { + name: 'bar.ext', + size: 0.5 * BYTES_IN_KILOBYTE, + }, + ], + }; + + const failures = Array.from(checkBudgets(budgets, stats)); + + expect(failures).toHaveSize(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/cache/cache.ts b/packages/angular/build/src/utils/cache/cache.ts new file mode 100644 index 000000000000..1773caca71ea --- /dev/null +++ b/packages/angular/build/src/utils/cache/cache.ts @@ -0,0 +1,337 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * Provides infrastructure for common caching functionality within the build system. + */ + +import { persistentCacheStoreSetting } from '../environment-options'; +import { assertIsError } from '../error'; + +/** + * A backing data store for one or more Cache instances. + * The interface is intentionally designed to support using a JavaScript + * Map instance as a potential cache store. + */ +export interface CacheStore { + /** + * Returns the specified value from the cache store or `undefined` if not found. + * @param key The key to retrieve from the store. + */ + get(key: string): V | undefined | Promise; + + /** + * Returns whether the provided key is present in the cache store. + * @param key The key to check from the store. + */ + has(key: string): boolean | Promise; + + /** + * Adds a new value to the cache store if the key is not present. + * Updates the value for the key if already present. + * @param key The key to associate with the value in the cache store. + * @param value The value to add to the cache store. + */ + set(key: string, value: V): this | Promise; +} + +/** + * A persistent backing data store that supports namespace partitioning + * and manual lifecycle close operations. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export interface PersistentCacheStore extends CacheStore { + createCache(namespace: string): Cache; + close(): void | Promise; +} + +/** + * A backing data store wrapper that namespaces all keys using length-prefix framing. + * Prevents key collisions between namespaces regardless of characters (such as colons) + * in the namespace or key. + */ +export class NamespacedCacheStore implements CacheStore { + readonly #prefix: string; + + constructor( + private readonly store: CacheStore, + readonly namespace: string, + ) { + this.#prefix = `${namespace.length}:${namespace}:`; + } + + get(key: string): V | undefined | Promise { + return this.store.get(this.#prefix + key); + } + + has(key: string): boolean | Promise { + return this.store.has(this.#prefix + key); + } + + set(key: string, value: V): this | Promise { + const result = this.store.set(this.#prefix + key, value); + if (result instanceof Promise) { + return result.then(() => this); + } + + return this; + } +} + +/** + * A cache object that allows accessing and storing key/value pairs in + * an underlying CacheStore. This class is the primary method for consumers + * to use a cache. + */ +export class Cache = CacheStore> { + // In-flight creator promises to deduplicate concurrent requests for the same key. + readonly #requests = new Map>(); + // Track how many writes occurred for a key to detect mutations during await gaps. + readonly #writeCounts = new Map(); + // Count the number of active, pending getOrCreate operations per key to avoid memory leaks. + readonly #pendingGets = new Map(); + + constructor(protected readonly store: S) {} + + #incrementWrite(key: string) { + // Only track write counts if there is a pending getOrCreate operation active for the key. + // This ensures that write counts are not leaked when no concurrent gets are running. + if (this.#pendingGets.has(key)) { + this.#writeCounts.set(key, (this.#writeCounts.get(key) || 0) + 1); + } + } + + /** + * Gets the value associated with a provided key if available. + * Otherwise, creates a value using the factory creator function, puts the value + * in the cache, and returns the new value. + * @param key A key associated with the value. + * @param creator A factory function for the value if no value is present. + * @returns A value associated with the provided key. + */ + async getOrCreate(key: string, creator: () => V | Promise): Promise { + // 1. If another call is already running the creator for this key, share its promise. + let activeRequest = this.#requests.get(key); + if (activeRequest !== undefined) { + return activeRequest; + } + + // Increment pending gets count to enable write-tracking for this key. + const currentPending = this.#pendingGets.get(key) || 0; + this.#pendingGets.set(key, currentPending + 1); + + try { + const startWriteCount = this.#writeCounts.get(key) || 0; + + // 2. Query the backing store. Since store.get can be async, we yield to the event loop. + const value = await this.store.get(key); + + // If a write (e.g. put) occurred during the store.get await gap, we must abort + // the current execution and restart to ensure we return the newly written value. + if ((this.#writeCounts.get(key) || 0) !== startWriteCount) { + return this.getOrCreate(key, creator); + } + + if (value !== undefined) { + return value; + } + + // 3. Recheck active request after the await gap in case another concurrent call + // initiated a creator during the store.get wait. + activeRequest = this.#requests.get(key); + if (activeRequest !== undefined) { + return activeRequest; + } + + // 4. Run the creator to produce the new value, and store its promise in #requests. + activeRequest = Promise.resolve(creator()).then( + async (newValue) => { + // Ensure this request is still the active one before writing back to the store + // (prevents overwriting newer data if put() was called before resolution). + if (this.#requests.get(key) === activeRequest) { + this.#incrementWrite(key); + await this.store.set(key, newValue); + this.#requests.delete(key); + } + + return newValue; + }, + (error) => { + // Clean up the active request if the creator fails. + if (this.#requests.get(key) === activeRequest) { + this.#requests.delete(key); + } + throw error; + }, + ); + + this.#requests.set(key, activeRequest); + + return activeRequest; + } finally { + // Clean up write counts and pending gets once all concurrent gets for this key finish. + const current = this.#pendingGets.get(key) || 0; + if (current <= 1) { + this.#pendingGets.delete(key); + this.#writeCounts.delete(key); + } else { + this.#pendingGets.set(key, current - 1); + } + } + } + + /** + * Gets the value associated with a provided key if available. + * @param key A key associated with the value. + * @returns A value associated with the provided key if present. Otherwise, `undefined`. + */ + async get(key: string): Promise { + const value = await this.store.get(key); + + return value; + } + + /** + * Puts a value in the cache and associates it with the provided key. + * If the key is already present, the value is updated instead. + * @param key A key associated with the value. + * @param value A value to put in the cache. + */ + async put(key: string, value: V): Promise { + this.#requests.delete(key); + this.#incrementWrite(key); + await this.store.set(key, value); + } + + /** + * Clears internal state for a specific key (requests, write counts, and pending gets). + */ + protected deleteInternal(key: string): void { + this.#requests.delete(key); + this.#writeCounts.delete(key); + this.#pendingGets.delete(key); + } + + /** + * Clears the base class internal state (requests, write counts, and pending gets). + */ + protected clearInternal(): void { + this.#requests.clear(); + this.#writeCounts.clear(); + this.#pendingGets.clear(); + } +} + +/** + * A lightweight in-memory cache implementation based on a JavaScript Map object. + */ +export class MemoryCache extends Cache> { + constructor() { + super(new Map()); + } + + /** + * Removes the specified key from the cache instance. + * @param key The key to remove. + * @returns True if an element in the Map existed and has been removed, or false if the element does not exist. + */ + delete(key: string): boolean { + this.deleteInternal(key); + + return this.store.delete(key); + } + + /** + * Removes all entries from the cache instance. + */ + clear() { + this.clearInternal(); + this.store.clear(); + } + + /** + * Provides all the values currently present in the cache instance. + * @returns An iterable of all values in the cache. + */ + values() { + return this.store.values(); + } + + /** + * Provides all the keys/values currently present in the cache instance. + * @returns An iterable of all key/value pairs in the cache. + */ + entries() { + return this.store.entries(); + } +} + +/** + * Creates and returns a persistent cache store. + * Attempts to use the native LMDB store first, and falls back to the built-in SQLite store + * if LMDB fails to initialize. + * + * @param baseCachePath The base path of the cache file/directory without suffix/extension. + * @returns A promise resolving to a PersistentCacheStore instance. + */ +export async function createPersistentCacheStore( + baseCachePath: string, +): Promise { + if (persistentCacheStoreSetting === 'sqlite') { + try { + const { SqliteCacheStore } = await import('./sqlite-cache-store'); + + return new SqliteCacheStore(baseCachePath + '-sqlite.db'); + } catch (err) { + assertIsError(err); + + throw new Error( + 'Unable to initialize JavaScript cache storage.\n' + `SQLite error: ${err.message}`, + { cause: err }, + ); + } + } + + if (persistentCacheStoreSetting === 'lmdb') { + try { + const { LmdbCacheStore } = await import('./lmdb-cache-store'); + + return new LmdbCacheStore(baseCachePath + '.db'); + } catch (err) { + assertIsError(err); + + throw new Error( + 'Unable to initialize JavaScript cache storage.\n' + `LMDB error: ${err.message}`, + { cause: err }, + ); + } + } + + try { + const { LmdbCacheStore } = await import('./lmdb-cache-store'); + + return new LmdbCacheStore(baseCachePath + '.db'); + } catch (lmdbError) { + try { + const { SqliteCacheStore } = await import('./sqlite-cache-store'); + + return new SqliteCacheStore(baseCachePath + '-sqlite.db'); + } catch (sqliteError) { + assertIsError(lmdbError); + assertIsError(sqliteError); + + throw new Error( + 'Unable to initialize JavaScript cache storage.\n' + + `LMDB error: ${lmdbError.message.split('\n')[0]}\n` + + `SQLite error: ${sqliteError.message.split('\n')[0]}`, + { cause: sqliteError }, + ); + } + } +} diff --git a/packages/angular/build/src/utils/cache/cache_spec.ts b/packages/angular/build/src/utils/cache/cache_spec.ts new file mode 100644 index 000000000000..c5c0b18f6a49 --- /dev/null +++ b/packages/angular/build/src/utils/cache/cache_spec.ts @@ -0,0 +1,267 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Cache, CacheStore, MemoryCache, NamespacedCacheStore } from './cache'; + +describe('MemoryCache', () => { + let cache: MemoryCache; + + beforeEach(() => { + cache = new MemoryCache(); + }); + + it('should return cached value on subsequent getOrCreate calls', async () => { + let callCount = 0; + const creator = () => { + callCount++; + + return 'value'; + }; + + const val1 = await cache.getOrCreate('key', creator); + const val2 = await cache.getOrCreate('key', creator); + + expect(val1).toBe('value'); + expect(val2).toBe('value'); + expect(callCount).toBe(1); + }); + + it('should call creator only once for concurrent getOrCreate calls with the same key', async () => { + let callCount = 0; + let resolveCreator!: (value: string) => void; + const promise = new Promise((resolve) => { + resolveCreator = resolve; + }); + + const creator = () => { + callCount++; + + return promise; + }; + + const p1 = cache.getOrCreate('key', creator); + const p2 = cache.getOrCreate('key', creator); + + resolveCreator('concurrent-value'); + + const [val1, val2] = await Promise.all([p1, p2]); + + expect(val1).toBe('concurrent-value'); + expect(val2).toBe('concurrent-value'); + expect(callCount).toBe(1); + }); + + it('should call creator multiple times for concurrent getOrCreate calls with different keys', async () => { + let callCount = 0; + const creator = (val: string) => { + callCount++; + + return Promise.resolve(val); + }; + + const p1 = cache.getOrCreate('key1', () => creator('value1')); + const p2 = cache.getOrCreate('key2', () => creator('value2')); + + const [val1, val2] = await Promise.all([p1, p2]); + + expect(val1).toBe('value1'); + expect(val2).toBe('value2'); + expect(callCount).toBe(2); + }); + + it('should clean up active request if creator throws/rejects', async () => { + let callCount = 0; + let rejectCreator!: (err: Error) => void; + const promise = new Promise((_, reject) => { + rejectCreator = reject; + }); + + const creator = () => { + callCount++; + + return promise; + }; + + const p1 = cache.getOrCreate('key', creator); + const p2 = cache.getOrCreate('key', creator); + + rejectCreator(new Error('creator error')); + + await expectAsync(p1).toBeRejectedWithError('creator error'); + await expectAsync(p2).toBeRejectedWithError('creator error'); + + // Subsequent call should trigger the creator again + const p3 = cache.getOrCreate('key', () => { + callCount++; + + return Promise.resolve('new-value'); + }); + const val3 = await p3; + expect(val3).toBe('new-value'); + expect(callCount).toBe(2); + }); + + it('should override/clear active requests when put is called', async () => { + let resolveCreator!: (value: string) => void; + const promise = new Promise((resolve) => { + resolveCreator = resolve; + }); + + let creatorStarted!: (value: void) => void; + const creatorStartedPromise = new Promise((resolve) => { + creatorStarted = resolve; + }); + + const creator = () => { + creatorStarted(); + + return promise; + }; + + const p1 = cache.getOrCreate('key', creator); + + // Wait for the creator to be called so that the active request is created + await creatorStartedPromise; + + // Call put before the creator promise resolves + await cache.put('key', 'override-value'); + + resolveCreator('original-value'); + + const val1 = await p1; + // p1 was already returned, so it resolves to original-value + expect(val1).toBe('original-value'); + + // Subsequent getOrCreate should return the put/overridden value, not the resolved original-value + const val2 = await cache.getOrCreate('key', () => 'should-not-run'); + expect(val2).toBe('override-value'); + }); + + it('should delete a cached key and allow creating a new value', async () => { + await cache.put('key', 'value-1'); + expect(await cache.get('key')).toBe('value-1'); + + const deleted = cache.delete('key'); + expect(deleted).toBeTrue(); + expect(await cache.get('key')).toBeUndefined(); + + // Subsequent getOrCreate should call creator + const newValue = await cache.getOrCreate('key', () => 'value-2'); + expect(newValue).toBe('value-2'); + }); + + it('should return false when deleting a non-existent key', () => { + expect(cache.delete('non-existent')).toBeFalse(); + }); + + it('should return unencoded keys in entries() and allow deletion', async () => { + await cache.put('component/style.scss', 'content'); + + const entries = Array.from(cache.entries()); + expect(entries).toEqual([['component/style.scss', 'content']]); + + expect(cache.delete(entries[0][0])).toBeTrue(); + expect(await cache.get('component/style.scss')).toBeUndefined(); + }); +}); + +describe('NamespacedCacheStore', () => { + class TestStore implements CacheStore { + readonly map = new Map(); + + get(key: string): string | undefined { + return this.map.get(key); + } + + has(key: string): boolean { + return this.map.has(key); + } + + set(key: string, value: string): this { + this.map.set(key, value); + + return this; + } + } + + let store: TestStore; + + beforeEach(() => { + store = new TestStore(); + }); + + it('should encode namespaced keys with ::', async () => { + const namespacedStore = new NamespacedCacheStore(store, 'test-ns'); + const cache = new Cache(namespacedStore); + await cache.put('my-key', 'my-val'); + + expect(store.map.has('7:test-ns:my-key')).toBeTrue(); + expect(await cache.get('my-key')).toBe('my-val'); + }); + + it('should prevent collisions between namespaces containing colons', async () => { + const cacheA = new Cache(new NamespacedCacheStore(store, 'a')); + const cacheB = new Cache(new NamespacedCacheStore(store, 'a:b')); + + await cacheA.put('b:c', 'val-a'); + await cacheB.put('c', 'val-b'); + + expect(await cacheA.get('b:c')).toBe('val-a'); + expect(await cacheB.get('c')).toBe('val-b'); + expect(store.map.get('1:a:b:c')).toBe('val-a'); + expect(store.map.get('3:a:b:c')).toBe('val-b'); + }); + + it('should encode empty string namespace as 0::', async () => { + const namespacedStore = new NamespacedCacheStore(store, ''); + const cache = new Cache(namespacedStore); + await cache.put('key', 'val'); + + expect(store.map.has('0::key')).toBeTrue(); + expect(await cache.get('key')).toBe('val'); + }); + + it('should forward get, has, and set calls with the namespaced prefix', async () => { + const namespacedStore = new NamespacedCacheStore(store, 'custom'); + await namespacedStore.set('hello', 'world'); + + expect(store.map.has('6:custom:hello')).toBeTrue(); + expect(await namespacedStore.has('hello')).toBeTrue(); + expect(await namespacedStore.get('hello')).toBe('world'); + }); + + it('should return this when the underlying store set is asynchronous', async () => { + class AsyncStore implements CacheStore { + readonly map = new Map(); + + get(key: string): Promise { + return Promise.resolve(this.map.get(key)); + } + + has(key: string): Promise { + return Promise.resolve(this.map.has(key)); + } + + async set(key: string, value: string): Promise { + this.map.set(key, value); + + return this; + } + } + + const asyncStore = new AsyncStore(); + const namespacedStore = new NamespacedCacheStore(asyncStore, 'async-ns'); + const setPromise = namespacedStore.set('foo', 'bar'); + + expect(setPromise instanceof Promise).toBeTrue(); + expect(await setPromise).toBe(namespacedStore); + expect(asyncStore.map.get('8:async-ns:foo')).toBe('bar'); + expect(await namespacedStore.get('foo')).toBe('bar'); + expect(await namespacedStore.has('foo')).toBeTrue(); + }); +}); diff --git a/packages/angular/build/src/utils/cache/index.ts b/packages/angular/build/src/utils/cache/index.ts new file mode 100644 index 000000000000..417d13326f50 --- /dev/null +++ b/packages/angular/build/src/utils/cache/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './cache'; diff --git a/packages/angular/build/src/utils/cache/lmdb-cache-store.ts b/packages/angular/build/src/utils/cache/lmdb-cache-store.ts new file mode 100644 index 000000000000..d7ac3fed49db --- /dev/null +++ b/packages/angular/build/src/utils/cache/lmdb-cache-store.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { RootDatabase, open } from 'lmdb'; +import { Cache, NamespacedCacheStore, PersistentCacheStore } from './cache'; + +export class LmdbCacheStore implements PersistentCacheStore { + readonly #cacheFileUrl; + #db: RootDatabase | undefined; + + constructor(readonly cachePath: string) { + this.#cacheFileUrl = cachePath; + } + + #ensureCacheFile(): RootDatabase { + this.#db ??= open({ + path: this.#cacheFileUrl, + compression: true, + }); + + return this.#db; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async get(key: string): Promise { + const db = this.#ensureCacheFile(); + const value = db.get(key); + + return value; + } + + has(key: string): boolean { + return this.#ensureCacheFile().doesExist(key); + } + + async set(key: string, value: unknown): Promise { + const db = this.#ensureCacheFile(); + await db.put(key, value); + + return this; + } + + createCache(namespace: string): Cache { + return new Cache(new NamespacedCacheStore(this, namespace)); + } + + async close() { + try { + await this.#db?.close(); + } catch { + // Failure to close should not be fatal + } + } +} diff --git a/packages/angular/build/src/utils/cache/sqlite-cache-store.ts b/packages/angular/build/src/utils/cache/sqlite-cache-store.ts new file mode 100644 index 000000000000..d9273f5816e7 --- /dev/null +++ b/packages/angular/build/src/utils/cache/sqlite-cache-store.ts @@ -0,0 +1,384 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { mkdirSync, rmSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { DatabaseSync, StatementSync } from 'node:sqlite'; +import { promisify } from 'node:util'; +import { deserialize, serialize } from 'node:v8'; +import { deflateRaw, inflateRawSync } from 'node:zlib'; +import { Cache, NamespacedCacheStore, PersistentCacheStore } from './cache'; + +const deflateRawAsync = promisify(deflateRaw); + +/** + * Minimum payload size (in bytes) required to attempt compression. + * Smaller payloads generally do not achieve meaningful compression ratios, while larger payloads + * (such as transformed JavaScript modules from node_modules) achieve 70-80% size reduction + * and drastically reduce SQLite overflow pages. + */ +const COMPRESSION_THRESHOLD = 32 * 1024; // 32 KB + +/** + * Storage format discriminator values for cache table entries. + */ +const enum CacheFormat { + V8 = 0, + V8Compressed = 1, + RawBinary = 2, + RawBinaryCompressed = 3, +} + +/** + * Common SQLite primary result codes. + * @see https://www.sqlite.org/rescode.html + */ +const enum SqliteResultCode { + Busy = 5, + Locked = 6, +} + +interface SqliteError extends Error { + code?: string; + errcode?: number; + errstr?: string; +} + +function isSqliteError(error: unknown): error is SqliteError { + return ( + error instanceof Error && + ('errcode' in error || + ('code' in error && (error as { code: unknown }).code === 'ERR_SQLITE_ERROR')) + ); +} + +/** + * A persistent cache store backed by SQLite. + * + * Values are persisted with the V8 structured clone serialization API instead of JSON. Cached + * values include binary data such as the `Uint8Array` output of the JavaScript transformer and + * the `contents` of an esbuild load result. A JSON round-trip converts those into plain objects + * (`{"0":105,"1":109,...}`), which breaks consumers on any build that reads them back from disk. + */ +export class SqliteCacheStore implements PersistentCacheStore { + #db: DatabaseSync | undefined; + #disabled = false; + #getStmt: StatementSync | undefined; + #hasStmt: StatementSync | undefined; + #setStmt: StatementSync | undefined; + #updateAccessedStmt: StatementSync | undefined; + readonly #pendingAccessedKeys = new Set(); + #flushTimeout: NodeJS.Timeout | undefined; + readonly #busyTimeoutMs: number; + + constructor( + readonly cachePath: string, + private readonly maxPayloadSize = 1024 * 1024 * 1024, + private readonly ttlDays = 14, + busyTimeoutMs = 5000, + ) { + this.#busyTimeoutMs = + Number.isSafeInteger(busyTimeoutMs) && busyTimeoutMs >= 0 ? busyTimeoutMs : 5000; + } + + #openDatabase(): DatabaseSync { + let db: DatabaseSync | undefined; + try { + if (this.cachePath === ':memory:') { + db = new DatabaseSync(this.cachePath); + } else { + // Optimistically attempt to open the database file first to avoid directory creation + // syscalls on warm builds where the parent directory already exists. + try { + db = new DatabaseSync(this.cachePath); + } catch { + mkdirSync(dirname(this.cachePath), { recursive: true }); + db = new DatabaseSync(this.cachePath); + } + } + + // Optimize SQLite for cache usage + db.exec(`PRAGMA busy_timeout = ${this.#busyTimeoutMs};`); + db.exec('PRAGMA auto_vacuum = FULL;'); + db.exec('PRAGMA journal_mode = WAL;'); + db.exec('PRAGMA synchronous = NORMAL;'); + db.exec('PRAGMA temp_store = MEMORY;'); + db.exec('PRAGMA mmap_size = 268435456;'); + db.exec( + 'CREATE TABLE IF NOT EXISTS cache (' + + 'key TEXT PRIMARY KEY, ' + + 'value BLOB, ' + + 'format INTEGER NOT NULL DEFAULT 0, ' + + 'last_accessed INTEGER NOT NULL' + + ') WITHOUT ROWID;', + ); + try { + db.exec('ALTER TABLE cache ADD COLUMN format INTEGER NOT NULL DEFAULT 0;'); + } catch { + // Ignore error if format column already exists + } + db.exec( + 'CREATE INDEX IF NOT EXISTS idx_cache_accessed ON cache (last_accessed DESC, key DESC);', + ); + + this.#getStmt = db.prepare('SELECT value, format FROM cache WHERE key = ?'); + this.#hasStmt = db.prepare('SELECT 1 FROM cache WHERE key = ?'); + this.#setStmt = db.prepare( + 'INSERT OR REPLACE INTO cache (key, value, format, last_accessed) VALUES (?, ?, ?, unixepoch())', + ); + this.#updateAccessedStmt = db.prepare( + 'UPDATE cache SET last_accessed = unixepoch() WHERE key = ?', + ); + + this.#db = db; + + return db; + } catch (error) { + try { + db?.close(); + } catch { + // Ignore close error on corrupted handle + } + this.#getStmt = undefined; + this.#hasStmt = undefined; + this.#setStmt = undefined; + this.#updateAccessedStmt = undefined; + throw error; + } + } + + #ensureDb(): DatabaseSync | undefined { + if (this.#disabled) { + return undefined; + } + + if (!this.#db) { + try { + return this.#openDatabase(); + } catch (error) { + // If the database is locked by another active process, + // do not attempt to delete the database files as that could corrupt the active process's database. + const isBusy = + isSqliteError(error) && + (error.errcode === SqliteResultCode.Busy || error.errcode === SqliteResultCode.Locked); + + // Attempt to recover from database corruption by deleting the corrupted files and recreating + if (!isBusy && this.cachePath !== ':memory:') { + try { + rmSync(this.cachePath, { force: true }); + rmSync(this.cachePath + '-wal', { force: true }); + rmSync(this.cachePath + '-shm', { force: true }); + rmSync(this.cachePath + '-journal', { force: true }); + + return this.#openDatabase(); + } catch { + // If recovery fails (e.g. read-only filesystem or permission denied), disable caching + } + } + + this.#disabled = true; + + return undefined; + } + } + + return this.#db; + } + + #queueAccessUpdate(key: string): void { + this.#pendingAccessedKeys.add(key); + + if (!this.#flushTimeout) { + this.#flushTimeout = setTimeout(() => this.#flushAccessUpdates(), 1000); + this.#flushTimeout.unref?.(); + } + } + + #flushAccessUpdates(): void { + if (this.#flushTimeout) { + clearTimeout(this.#flushTimeout); + this.#flushTimeout = undefined; + } + + if (this.#pendingAccessedKeys.size === 0) { + return; + } + + try { + if (this.#db && this.#updateAccessedStmt) { + this.#db.exec('BEGIN IMMEDIATE TRANSACTION;'); + for (const key of this.#pendingAccessedKeys) { + this.#updateAccessedStmt.run(key); + } + this.#db.exec('COMMIT;'); + } + } catch { + try { + this.#db?.exec('ROLLBACK;'); + } catch { + // Ignore rollback errors if transaction was not active + } + } finally { + this.#pendingAccessedKeys.clear(); + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async get(key: string): Promise { + if (!this.#ensureDb()) { + return undefined; + } + + try { + // SQLite column types are dynamic, so the stored value is only known at runtime. + const row = this.#getStmt?.get(key) as { value: unknown; format: CacheFormat } | undefined; + + if (row) { + this.#queueAccessUpdate(key); + + if (row.value instanceof Uint8Array) { + try { + switch (row.format) { + case CacheFormat.RawBinary: + return row.value; + case CacheFormat.RawBinaryCompressed: { + const decompressed = inflateRawSync(row.value); + + return new Uint8Array( + decompressed.buffer, + decompressed.byteOffset, + decompressed.byteLength, + ); + } + case CacheFormat.V8Compressed: + return deserialize(inflateRawSync(row.value)); + case CacheFormat.V8: + default: + return deserialize(row.value); + } + } catch { + // Treat corrupt or unparseable cached payloads as a cache miss. + } + } + } + } catch { + // Treat query errors (e.g. disk read failures) as a cache miss. + } + + return undefined; + } + + has(key: string): boolean { + if (!this.#ensureDb()) { + return false; + } + + try { + return !!this.#hasStmt?.get(key); + } catch { + return false; + } + } + + async set(key: string, value: unknown): Promise { + if (!this.#ensureDb()) { + return this; + } + + try { + this.#pendingAccessedKeys.delete(key); + + const isBinary = value instanceof Uint8Array; + const data = isBinary ? value : serialize(value); + let format = isBinary ? CacheFormat.RawBinary : CacheFormat.V8; + let payload: Uint8Array = data; + + if (data.byteLength >= COMPRESSION_THRESHOLD) { + const compressed = await deflateRawAsync(data, { level: 1 }); + if (compressed.byteLength < data.byteLength) { + payload = compressed; + format = isBinary ? CacheFormat.RawBinaryCompressed : CacheFormat.V8Compressed; + } + } + + this.#setStmt?.run(key, payload, format); + } catch { + // Writing to cache is non-fatal and should not fail the build. + } + + return this; + } + + createCache(namespace: string): Cache { + return new Cache(new NamespacedCacheStore(this, namespace)); + } + + close(): void { + this.#flushAccessUpdates(); + + if (this.#db) { + try { + this.#db.exec('BEGIN IMMEDIATE TRANSACTION;'); + try { + // 1. Delete items older than N days + this.#db + .prepare("DELETE FROM cache WHERE last_accessed < unixepoch('now', ?);") + .run(`-${this.ttlDays} days`); + + // 2. Prune oldest items if payload exceeds maxPayloadSize + // Skip the expensive window aggregate query if total database size is below maxPayloadSize + const sizeResult = this.#db + .prepare( + 'SELECT (page_count - freelist_count) * page_size AS total_size ' + + 'FROM pragma_page_count(), pragma_freelist_count(), pragma_page_size();', + ) + .get() as { total_size?: number } | undefined; + + if ((sizeResult?.total_size ?? 0) > this.maxPayloadSize) { + this.#db + .prepare( + `DELETE FROM cache WHERE key IN ( + SELECT key FROM ( + SELECT key, + sum(length(key) + length(value)) OVER (ORDER BY last_accessed DESC, key DESC) as running_size + FROM cache + ) WHERE running_size > ? + );`, + ) + .run(this.maxPayloadSize); + } + + this.#db.exec('COMMIT;'); + } catch (error) { + try { + this.#db.exec('ROLLBACK;'); + } catch { + // Ignore rollback errors if transaction was not active + } + throw error; + } + } catch { + // Pruning errors should not block build success + } finally { + this.#getStmt = undefined; + this.#hasStmt = undefined; + this.#setStmt = undefined; + this.#updateAccessedStmt = undefined; + + try { + this.#db.close(); + } catch { + // Failure to close should not block build success + } + this.#db = undefined; + } + } + + this.#disabled = false; + } +} diff --git a/packages/angular/build/src/utils/cache/sqlite-cache-store_spec.ts b/packages/angular/build/src/utils/cache/sqlite-cache-store_spec.ts new file mode 100644 index 000000000000..4eac21f3b34b --- /dev/null +++ b/packages/angular/build/src/utils/cache/sqlite-cache-store_spec.ts @@ -0,0 +1,630 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { SqliteCacheStore } from './sqlite-cache-store'; + +describe('SqliteCacheStore', () => { + let tempDir: string; + let cachePath: string; + let store: SqliteCacheStore; + + beforeEach(async () => { + // Create a temporary directory in the workspace for testing + tempDir = join(__dirname, `sqlite-test-temp-${Date.now()}`); + await fs.mkdir(tempDir, { recursive: true }); + cachePath = join(tempDir, 'test-cache.db'); + store = new SqliteCacheStore(cachePath); + }); + + afterEach(async () => { + store.close(); + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('should store and retrieve a value', async () => { + const data = { foo: 'bar', list: [1, 2, 3] }; + await store.set('test-key', data); + + const result = await store.get('test-key'); + expect(result).toEqual(data); + }); + + it('should preserve binary values', async () => { + const data = new TextEncoder().encode('export const value = 1;\n'); + await store.set('binary-key', data); + + const result = await store.get('binary-key'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + }); + + it('should preserve binary values nested within an object', async () => { + const data = { + contents: new TextEncoder().encode('export const value = 1;\n'), + loader: 'js', + watchFiles: ['/some/file.js'], + }; + await store.set('nested-binary-key', data); + + const result = await store.get('nested-binary-key'); + expect(result.contents).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + }); + + it('should preserve binary values across store instances', async () => { + const data = new TextEncoder().encode('export const value = 1;\n'); + await store.set('persisted-binary-key', data); + store.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('persisted-binary-key'); + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(data); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a corrupt payload as a cache miss', async () => { + await store.set('corrupt-key', 'value'); + store.close(); + + // Simulate an entry with an invalid/corrupt payload that fails deserialization. + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare('UPDATE cache SET value = ? WHERE key = ?') + .run(new Uint8Array([0x00, 0x01, 0x02]), 'corrupt-key'); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('corrupt-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + + it('should automatically recover from a corrupted database file', async () => { + // Write corrupted header data to the database file + await fs.writeFile(cachePath, 'CORRUPTED_DATABASE_FILE_CONTENTS'); + + // The store should detect corruption, reset the database files, and operate normally + await store.set('recover-key', 'recovered-value'); + const result = await store.get('recover-key'); + expect(result).toBe('recovered-value'); + }); + + it('should automatically recover from a corrupted B-tree page', async () => { + // Initialize valid database + await store.set('initial-key', 'initial-val'); + store.close(); + + // Overwrite page data with garbage + const handle = await fs.open(cachePath, 'r+'); + await handle.write(Buffer.alloc(200, 0xff), 0, 200, 100); + await handle.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + await reopenedStore.set('new-key', 'new-val'); + expect(await reopenedStore.get('new-key')).toBe('new-val'); + } finally { + reopenedStore.close(); + } + }); + + it('should gracefully degrade when database path is permanently unwritable', async () => { + // A regular file in place of a directory path ensures unwritability across all platforms (ENOTDIR) + const blockingFile = join(tempDir, 'blocking-file'); + await fs.writeFile(blockingFile, 'cannot-be-a-directory'); + + const unwritableStore = new SqliteCacheStore(join(blockingFile, 'cannot-create.db')); + try { + expect(unwritableStore.has('any-key')).toBeFalse(); + expect(await unwritableStore.get('any-key')).toBeUndefined(); + await unwritableStore.set('any-key', 'any-val'); + expect(await unwritableStore.get('any-key')).toBeUndefined(); + expect(unwritableStore.has('any-key')).toBeFalse(); + } finally { + unwritableStore.close(); + } + }); + + it('should automatically recover from a corrupted journal file', async () => { + await store.set('key-before', 'val-before'); + store.close(); + + // Create a corrupt rollback journal file + await fs.writeFile(cachePath + '-journal', 'CORRUPTED_JOURNAL_FILE'); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + await reopenedStore.set('new-key', 'new-val'); + expect(await reopenedStore.get('new-key')).toBe('new-val'); + } finally { + reopenedStore.close(); + } + }); + + it('should not delete database files when database is locked by another process', async () => { + await store.set('persist-key', 'persist-val'); + store.close(); + + // Open direct connection with an exclusive transaction holding a write lock + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb.exec('BEGIN EXCLUSIVE TRANSACTION;'); + + // Use a tiny busyTimeoutMs so the test fails fast instead of waiting 5s + const lockedStore = new SqliteCacheStore(cachePath, undefined, undefined, 10); + try { + expect(lockedStore.has('persist-key')).toBeFalse(); + expect(await lockedStore.get('persist-key')).toBeUndefined(); + } finally { + lockedStore.close(); + directDb.exec('COMMIT;'); + directDb.close(); + } + + // Verify the original database file and its data were not deleted + const verifyStore = new SqliteCacheStore(cachePath); + try { + expect(await verifyStore.get('persist-key')).toBe('persist-val'); + } finally { + verifyStore.close(); + } + }); + + it('should safely fall back to default timeout if invalid busyTimeoutMs is provided', async () => { + // Pass NaN as busyTimeoutMs + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const invalidStore = new SqliteCacheStore(cachePath, undefined, undefined, NaN as any); + try { + await invalidStore.set('valid-key', 'valid-value'); + expect(await invalidStore.get('valid-key')).toBe('valid-value'); + } finally { + invalidStore.close(); + } + }); + + it('should flush pending access updates on close', async () => { + await store.set('flush-key', 'flush-val'); + + // Manually backdate the entry's last_accessed timestamp to simulate elapsed time + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const pastTimestamp = Math.floor(Date.now() / 1000) - 3600; + directDb + .prepare('UPDATE cache SET last_accessed = ? WHERE key = ?') + .run(pastTimestamp, 'flush-key'); + directDb.close(); + + // Access the key via store.get() to queue an access update + await store.get('flush-key'); + + // Immediately close the store before the debounced 500ms timeout fires + store.close(); + + // Verify the timestamp in SQLite was updated upon close + const checkDb = new DatabaseSync(cachePath); + const row = checkDb + .prepare('SELECT last_accessed FROM cache WHERE key = ?') + .get('flush-key') as { last_accessed: number }; + checkDb.close(); + + expect(row.last_accessed).toBeGreaterThan(pastTimestamp); + }); + + it('should selectively compress entries that exceed the 32KB threshold', async () => { + const largeContent = + 'export const testFunction = () => { console.log("hello world"); };\n'.repeat(1000); + await store.set('large-key', largeContent); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('large-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format flag is 1 (CacheFormat.V8Compressed) and compressed size is drastically smaller + expect(row.format).toBe(1); + expect(row.value.byteLength).toBeLessThan(largeContent.length / 5); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('large-key'); + expect(result).toBe(largeContent); + } finally { + reopenedStore.close(); + } + }); + + it('should not compress entries below the 32KB threshold', async () => { + const smallContent = 'export const small = true;'; + await store.set('small-key', smallContent); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('small-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format flag is 0 (CacheFormat.V8) + expect(row.format).toBe(0); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('small-key'); + expect(result).toBe(smallContent); + } finally { + reopenedStore.close(); + } + }); + + it('should not compress entries exceeding the 32KB threshold if compression does not reduce size', async () => { + const { randomBytes } = await import('node:crypto'); + // High-entropy random bytes cannot be compressed by DEFLATE + const incompressibleData = new Uint8Array(randomBytes(48 * 1024)); + await store.set('incompressible-key', incompressibleData); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('incompressible-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify entry is stored uncompressed as raw binary (format 2 = CacheFormat.RawBinary) + expect(row.format).toBe(2); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('incompressible-key'); + expect(result).toEqual(incompressibleData); + } finally { + reopenedStore.close(); + } + }); + + it('should store raw Uint8Array directly with zero serialization', async () => { + const binaryData = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + await store.set('binary-key', binaryData); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('binary-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format is 2 (CacheFormat.RawBinary) and value is the exact raw bytes + expect(row.format).toBe(2); + expect(row.value).toEqual(binaryData); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('binary-key'); + expect(result).toEqual(binaryData); + } finally { + reopenedStore.close(); + } + }); + + it('should selectively compress large Uint8Array entries', async () => { + const largeBinary = new Uint8Array(Buffer.from('export const x = 1;\n'.repeat(2000))); + await store.set('large-binary-key', largeBinary); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + const row = directDb + .prepare('SELECT value, format FROM cache WHERE key = ?') + .get('large-binary-key') as { + value: Uint8Array; + format: number; + }; + directDb.close(); + + // Verify format is 3 (CacheFormat.RawBinaryCompressed) + expect(row.format).toBe(3); + expect(row.value.byteLength).toBeLessThan(largeBinary.byteLength / 5); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('large-binary-key'); + expect(result).toEqual(largeBinary); + } finally { + reopenedStore.close(); + } + }); + + it('should seamlessly read legacy entries from databases created without a format column', async () => { + const { serialize } = await import('node:v8'); + const { DatabaseSync } = await import('node:sqlite'); + + // Create a legacy database without a format column + const directDb = new DatabaseSync(cachePath); + directDb.exec( + 'CREATE TABLE cache (key TEXT PRIMARY KEY, value BLOB, last_accessed INTEGER NOT NULL) WITHOUT ROWID;', + ); + const legacyPayload = serialize('legacy-content'); + directDb + .prepare('INSERT INTO cache (key, value, last_accessed) VALUES (?, ?, unixepoch())') + .run('legacy-key', legacyPayload); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + const result = await reopenedStore.get('legacy-key'); + expect(result).toBe('legacy-content'); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a corrupted compressed payload as a cache miss', async () => { + // Initialize database + await store.set('init-key', 'init-val'); + store.close(); + + // Insert an entry with format = 1 (V8Compressed) followed by garbage + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare( + 'INSERT INTO cache (key, value, format, last_accessed) VALUES (?, ?, 1, unixepoch())', + ) + .run('corrupt-compressed-key', new Uint8Array([0xde, 0xad, 0xbe, 0xef])); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('corrupt-compressed-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + + it('should treat a non-binary payload as a cache miss', async () => { + await store.set('text-key', 'value'); + store.close(); + + // SQLite column types are dynamic, so a stored value is not guaranteed to be binary. + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb.prepare('UPDATE cache SET value = ? WHERE key = ?').run('"value"', 'text-key'); + directDb.close(); + + const reopenedStore = new SqliteCacheStore(cachePath); + try { + expect(await reopenedStore.get('text-key')).toBeUndefined(); + } finally { + reopenedStore.close(); + } + }); + + it('should return undefined for non-existent key', async () => { + const result = await store.get('missing-key'); + expect(result).toBeUndefined(); + }); + + it('should correctly report existence of a key', async () => { + expect(store.has('exist-key')).toBeFalse(); + + await store.set('exist-key', 'value'); + expect(store.has('exist-key')).toBeTrue(); + }); + + it('should overwrite values for existing keys', async () => { + await store.set('overwrite-key', 'initial'); + await store.set('overwrite-key', 'updated'); + + const result = await store.get('overwrite-key'); + expect(result).toBe('updated'); + }); + + it('should prune items older than TTL on close', async () => { + // Write two items + await store.set('new-key', 'new-val'); + await store.set('old-key', 'old-val'); + + // Close the store so we can modify the DB safely + store.close(); + + // Directly open database to update timestamp of 'old-key' to 15 days ago + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + directDb + .prepare('UPDATE cache SET last_accessed = unixepoch() - 15 * 24 * 3600 WHERE key = ?') + .run('old-key'); + directDb.close(); + + // Reopen store with a 14-day TTL, access it to open connection, then close to trigger pruning + const pruneStore = new SqliteCacheStore(cachePath, undefined, 14); + expect(pruneStore.has('new-key')).toBeTrue(); + pruneStore.close(); + + // Verify 'old-key' is gone but 'new-key' remains + const checkStore = new SqliteCacheStore(cachePath); + expect(checkStore.has('old-key')).toBeFalse(); + expect(checkStore.has('new-key')).toBeTrue(); + checkStore.close(); + }); + + it('should prune oldest items when total payload size exceeds maximum on close', async () => { + // Close the default store so we can instantiate one with a small limit + store.close(); + + // Create a store with a tiny size limit (e.g. 25 bytes) + // Keys 'k1', 'k2', 'k3' are small (each is 12 bytes: 2 byte key + 10 byte serialized value). + // Total size of k1 + k2 + k3 is 36 bytes, which exceeds the 25 bytes limit. + const sizeStore = new SqliteCacheStore(cachePath, 25); + + // Set k1, then k2, then k3. + // Order of inserts: k1 (oldest), k2 (middle), k3 (newest) + await sizeStore.set('k1', 'value1'); + await sizeStore.set('k2', 'value2'); + await sizeStore.set('k3', 'value3'); + + // Close sizeStore to trigger pruning + sizeStore.close(); + + // Reopen to check which keys were kept + const checkStore = new SqliteCacheStore(cachePath); + // k3 (newest) and k2 (middle) should be kept (~20 bytes total) + // k1 (oldest) should be pruned to get under 25 bytes. + expect(checkStore.has('k3')).toBeTrue(); + expect(checkStore.has('k2')).toBeTrue(); + expect(checkStore.has('k1')).toBeFalse(); + checkStore.close(); + }); + + it('should not prune items when total database size is within maxPayloadSize on close', async () => { + store.close(); + + const sizeStore = new SqliteCacheStore(cachePath, 1024 * 1024); + await sizeStore.set('k1', 'value1'); + await sizeStore.set('k2', 'value2'); + sizeStore.close(); + + const checkStore = new SqliteCacheStore(cachePath); + expect(checkStore.has('k1')).toBeTrue(); + expect(checkStore.has('k2')).toBeTrue(); + checkStore.close(); + }); + + it('should create an index on last_accessed and key', async () => { + // Trigger db initialization + await store.set('test-key', 'test-value'); + store.close(); + + const { DatabaseSync } = await import('node:sqlite'); + const directDb = new DatabaseSync(cachePath); + try { + const indexRows = directDb + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'cache' AND name = 'idx_cache_accessed'", + ) + .all(); + expect(indexRows.length).toBe(1); + } finally { + directDb.close(); + } + }); + + it('should create parent directories if they do not exist', async () => { + const nestedDir = join(tempDir, 'nested', 'deeply', 'cache'); + const nestedCachePath = join(nestedDir, 'nested-cache.db'); + const nestedStore = new SqliteCacheStore(nestedCachePath); + + try { + await nestedStore.set('nested-key', 'nested-value'); + const result = await nestedStore.get('nested-key'); + expect(result).toBe('nested-value'); + } finally { + nestedStore.close(); + } + }); + + it('should support in-memory databases', async () => { + const memoryStore = new SqliteCacheStore(':memory:'); + try { + await memoryStore.set('mem-key', 'mem-value'); + const result = await memoryStore.get('mem-key'); + expect(result).toBe('mem-value'); + } finally { + memoryStore.close(); + } + }); + + describe('NG_BUILD_CACHE_STORE env variable option', () => { + it('should force SQLite when NG_BUILD_CACHE_STORE=sqlite', () => { + const code = ` + (async () => { + const { createPersistentCacheStore } = await import('./cache.js'); + const { SqliteCacheStore } = await import('./sqlite-cache-store.js'); + const store = await createPersistentCacheStore('dummy-sqlite-env'); + if (!(store instanceof SqliteCacheStore)) { + console.error('Expected SqliteCacheStore, got:', store.constructor.name); + process.exit(1); + } + })().catch(err => { + console.error(err); + process.exit(2); + }); + `; + const { execFileSync } = require('node:child_process'); + execFileSync(process.execPath, ['--input-type=module', '-e', code], { + cwd: __dirname, + env: { + ...process.env, + NG_BUILD_CACHE_STORE: 'sqlite', + }, + }); + }); + + it('should force LMDB when NG_BUILD_CACHE_STORE=lmdb', () => { + const code = ` + (async () => { + const { createPersistentCacheStore } = await import('./cache.js'); + const { LmdbCacheStore } = await import('./lmdb-cache-store.js'); + const store = await createPersistentCacheStore('dummy-lmdb-env'); + if (!(store instanceof LmdbCacheStore)) { + console.error('Expected LmdbCacheStore, got:', store.constructor.name); + process.exit(1); + } + })().catch(err => { + console.error(err); + process.exit(2); + }); + `; + const { execFileSync } = require('node:child_process'); + try { + execFileSync(process.execPath, ['--input-type=module', '-e', code], { + cwd: __dirname, + env: { + ...process.env, + NG_BUILD_CACHE_STORE: 'lmdb', + }, + }); + } catch (e) { + if (e && typeof e === 'object' && 'message' in e) { + const error = e as { message: string; stderr?: Buffer }; + const output = error.stderr?.toString() || error.message; + if (!output.includes('Unable to initialize JavaScript cache storage')) { + throw e; + } + } else { + throw e; + } + } + }); + }); +}); diff --git a/packages/angular/build/src/utils/check-port.ts b/packages/angular/build/src/utils/check-port.ts new file mode 100644 index 000000000000..d7c04f0b9f72 --- /dev/null +++ b/packages/angular/build/src/utils/check-port.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { createServer } from 'node:net'; +import { isTTY } from './tty'; + +function createInUseError(port: number): Error { + return new Error(`Port ${port} is already in use. Use '--port' to specify a different port.`); +} + +export async function checkPort(port: number, host: string): Promise { + // Disabled due to Vite not handling port 0 and instead always using the default value (5173) + // TODO: Enable this again once Vite is fixed + // if (port === 0) { + // return 0; + // } + + return new Promise((resolve, reject) => { + const server = createServer(); + + server + .once('error', (err: NodeJS.ErrnoException) => { + if (err.code !== 'EADDRINUSE') { + reject(err); + + return; + } + + if (!isTTY()) { + reject(createInUseError(port)); + + return; + } + + import('@inquirer/confirm') + .then(({ default: confirm }) => + confirm({ + message: `Port ${port} is already in use.\nWould you like to use a different port?`, + default: true, + theme: { prefix: '' }, + }), + ) + .then( + (answer) => (answer ? resolve(checkPort(0, host)) : reject(createInUseError(port))), + () => reject(createInUseError(port)), + ); + }) + .once('listening', () => { + // Get the actual address from the listening server instance + const address = server.address(); + assert( + address && typeof address !== 'string', + 'Port check server address should always be an object.', + ); + + server.close(); + resolve(address.port); + }) + .listen(port, host); + }); +} diff --git a/packages/angular/build/src/utils/color.ts b/packages/angular/build/src/utils/color.ts new file mode 100644 index 000000000000..3915d99ce248 --- /dev/null +++ b/packages/angular/build/src/utils/color.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { WriteStream } from 'node:tty'; + +export { color as colors, figures } from 'listr2'; + +export function supportColor(stream: NodeJS.WritableStream = process.stdout): boolean { + if (stream instanceof WriteStream) { + return stream.hasColors(); + } + + try { + // The hasColors function does not rely on any instance state and should ideally be static + return WriteStream.prototype.hasColors(); + } catch { + return process.env['FORCE_COLOR'] !== undefined && process.env['FORCE_COLOR'] !== '0'; + } +} diff --git a/packages/angular/build/src/utils/concurrency.ts b/packages/angular/build/src/utils/concurrency.ts new file mode 100644 index 000000000000..39f2b70c95df --- /dev/null +++ b/packages/angular/build/src/utils/concurrency.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Maximum number of concurrent file system read/stat operations to prevent OS file descriptor exhaustion. + */ +export const MAX_CONCURRENT_READS = 16; + +/** + * Executes an asynchronous function for each item in an array concurrently up to a specified limit. + * + * If any task fails, processing of subsequent items stops and the first encountered error is re-thrown + * after all currently in-flight tasks have settled. + * + * @param items Array of items to process. + * @param limit Maximum number of concurrent tasks in flight. + * @param fn Async task function. + */ +export async function runConcurrent( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return; + } + + let index = 0; + let firstError: unknown; + + const concurrency = Math.min(Math.max(1, Math.floor(limit) || 1), items.length); + const workers = Array.from({ length: concurrency }, async () => { + while (!firstError && index < items.length) { + const i = index++; + try { + await fn(items[i], i); + } catch (error) { + firstError ??= error; + } + } + }); + + await Promise.allSettled(workers); + + if (firstError) { + throw firstError; + } +} + +/** + * Maps an array asynchronously with a sliding worker pool up to a specified concurrency limit. + * + * If any task fails, processing of subsequent items stops and the first encountered error is re-thrown + * after all currently in-flight tasks have settled. + * + * @param items Array of items to map. + * @param limit Maximum number of concurrent tasks in flight. + * @param fn Async mapper function. + * @returns Array of mapped results in the original item order. + */ +export async function mapConcurrent( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const results: R[] = new Array(items.length); + let index = 0; + let firstError: unknown; + + const concurrency = Math.min(Math.max(1, Math.floor(limit) || 1), items.length); + const workers = Array.from({ length: concurrency }, async () => { + while (!firstError && index < items.length) { + const i = index++; + try { + results[i] = await fn(items[i], i); + } catch (error) { + firstError ??= error; + } + } + }); + + await Promise.allSettled(workers); + + if (firstError) { + throw firstError; + } + + return results; +} diff --git a/packages/angular/build/src/utils/concurrency_spec.ts b/packages/angular/build/src/utils/concurrency_spec.ts new file mode 100644 index 000000000000..ab63054046c5 --- /dev/null +++ b/packages/angular/build/src/utils/concurrency_spec.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { mapConcurrent, runConcurrent } from './concurrency'; + +describe('concurrency utilities', () => { + describe('runConcurrent', () => { + it('should process all items in an array', async () => { + const items = [1, 2, 3, 4, 5]; + const processed: number[] = []; + + await runConcurrent(items, 2, async (item) => { + processed.push(item); + }); + + expect(processed.sort((a, b) => a - b)).toEqual(items); + }); + + it('should respect the concurrency limit', async () => { + const items = [10, 20, 30, 40, 50, 60]; + const limit = 2; + let active = 0; + let maxActive = 0; + + await runConcurrent(items, limit, async () => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + }); + + expect(maxActive).toBeLessThanOrEqual(limit); + }); + + it('should handle non-integer and NaN limits', async () => { + const items = [1, 2, 3]; + const processed: number[] = []; + + await runConcurrent(items, 2.7, async (item) => { + processed.push(item); + }); + expect(processed).toEqual(items); + + const processedNaN: number[] = []; + await runConcurrent(items, NaN, async (item) => { + processedNaN.push(item); + }); + expect(processedNaN).toEqual(items); + }); + + it('should handle an empty array', async () => { + let called = false; + await runConcurrent([], 3, async () => { + called = true; + }); + + expect(called).toBe(false); + }); + + it('should pass item and index to callback', async () => { + const items = ['a', 'b', 'c']; + const passed: { item: string; index: number }[] = []; + + await runConcurrent(items, 2, async (item, index) => { + passed.push({ item, index }); + }); + + expect(passed.sort((a, b) => a.index - b.index)).toEqual([ + { item: 'a', index: 0 }, + { item: 'b', index: 1 }, + { item: 'c', index: 2 }, + ]); + }); + + it('should stop processing new items and rethrow the first error', async () => { + const items = [1, 2, 3, 4, 5, 6]; + const executed: number[] = []; + + await expectAsync( + runConcurrent(items, 1, async (item) => { + executed.push(item); + if (item === 2) { + throw new Error('Task failed'); + } + }), + ).toBeRejectedWithError('Task failed'); + + // Subsequent items should not have been executed + expect(executed).toEqual([1, 2]); + }); + + it('should wait for in-flight tasks to settle when an error occurs', async () => { + const items = [1, 2, 3, 4]; + let task2Finished = false; + + await expectAsync( + runConcurrent(items, 2, async (item) => { + if (item === 1) { + throw new Error('Task 1 failed'); + } + if (item === 2) { + await new Promise((resolve) => setTimeout(resolve, 20)); + task2Finished = true; + } + }), + ).toBeRejectedWithError('Task 1 failed'); + + expect(task2Finished).toBe(true); + }); + }); + + describe('mapConcurrent', () => { + it('should map items and return results in original order', async () => { + const items = [1, 2, 3, 4, 5]; + + const results = await mapConcurrent(items, 2, async (item) => { + // Add varying delay so tasks finish out of order + await new Promise((resolve) => setTimeout(resolve, (5 - item) * 5)); + + return item * 2; + }); + + expect(results).toEqual([2, 4, 6, 8, 10]); + }); + + it('should respect the concurrency limit', async () => { + const items = [1, 2, 3, 4, 5]; + const limit = 2; + let active = 0; + let maxActive = 0; + + await mapConcurrent(items, limit, async (item) => { + active++; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => setTimeout(resolve, 10)); + active--; + + return item; + }); + + expect(maxActive).toBeLessThanOrEqual(limit); + }); + + it('should handle non-integer and NaN limits', async () => { + const items = [1, 2, 3]; + + const results = await mapConcurrent(items, 2.7, async (item) => item * 2); + expect(results).toEqual([2, 4, 6]); + + const resultsNaN = await mapConcurrent(items, NaN, async (item) => item * 2); + expect(resultsNaN).toEqual([2, 4, 6]); + }); + + it('should handle an empty array', async () => { + const results = await mapConcurrent([], 3, async (item) => item); + + expect(results).toEqual([]); + }); + + it('should pass item and index to mapper function', async () => { + const items = ['x', 'y', 'z']; + + const results = await mapConcurrent(items, 2, async (item, index) => `${item}:${index}`); + + expect(results).toEqual(['x:0', 'y:1', 'z:2']); + }); + + it('should stop processing new items and rethrow the first error', async () => { + const items = [1, 2, 3, 4, 5, 6]; + const executed: number[] = []; + + await expectAsync( + mapConcurrent(items, 1, async (item) => { + executed.push(item); + if (item === 2) { + throw new Error('Map failed'); + } + + return item; + }), + ).toBeRejectedWithError('Map failed'); + + expect(executed).toEqual([1, 2]); + }); + + it('should wait for in-flight tasks to settle when an error occurs', async () => { + const items = [1, 2, 3, 4]; + let task2Finished = false; + + await expectAsync( + mapConcurrent(items, 2, async (item) => { + if (item === 1) { + throw new Error('Map 1 failed'); + } + if (item === 2) { + await new Promise((resolve) => setTimeout(resolve, 20)); + task2Finished = true; + } + + return item; + }), + ).toBeRejectedWithError('Map 1 failed'); + + expect(task2Finished).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/utils/debug-id.ts b/packages/angular/build/src/utils/debug-id.ts new file mode 100644 index 000000000000..a03fd571ec72 --- /dev/null +++ b/packages/angular/build/src/utils/debug-id.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createHash } from 'node:crypto'; + +/** + * Fixed RFC 4122 namespace UUID used to derive deterministic UUIDv5 build/Debug IDs. + * Treated as 16 raw bytes when fed into the SHA-1 of `namespace || name`. + * + * The exact value is arbitrary but must remain stable across releases so that + * the same source map content always yields the same Debug ID. + */ +const ANGULAR_BUILD_NAMESPACE = Buffer.from('6f9619ff8b86d011b42d00cf4fc964ff', 'hex'); + +/** + * Generates a deterministic UUIDv5 (RFC 4122 §4.3) Debug ID from the given name bytes. + * + * Determinism is recommended by the ECMA-426 "Source Map Debug ID" proposal + * (https://github.com/tc39/ecma426/blob/main/proposals/debug-id.md) so that the + * produced artifacts are stable across builds with the same source content. + * + * @param name Bytes that uniquely identify the artifact (typically the source map content). + * @returns A canonical UUIDv5 string (lowercase, hyphenated). + */ +export function generateDebugId(name: string | Uint8Array): string { + const sha = createHash('sha1').update(ANGULAR_BUILD_NAMESPACE).update(name).digest(); + + // Set version (5) in the high nibble of byte 6. + sha[6] = (sha[6] & 0x0f) | 0x50; + // Set RFC 4122 variant bits (10xx) in byte 8. + sha[8] = (sha[8] & 0x3f) | 0x80; + + const h = sha.toString('hex'); + + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`; +} + +/** Pattern matching an existing `//# debugId=` comment at the end of the file. */ +const DEBUG_ID_COMMENT = /(\n\/\/# debugId=[^\r\n]*)(\n\/\/# sourceMappingURL=[^\r\n]*)?(\s*)$/; + +/** Pattern matching the `//# sourceMappingURL=` comment, used to position the debug-id line. */ +const SOURCE_MAPPING_URL_COMMENT = /\n\/\/# sourceMappingURL=[^\r\n]*\s*$/; + +/** + * Inserts (or replaces) a `//# debugId=` comment in the given JavaScript text. + * + * Per ECMA-426, the comment must appear within the last 5 lines and SHOULD be + * placed immediately above any `//# sourceMappingURL=` comment so that existing + * tools that only consult the final line still find the source-map URL. + */ +export function injectDebugIdIntoJs(text: string, id: string): string { + const comment = `//# debugId=${id}`; + + // Replace any existing debugId comment to keep the operation idempotent. + if (DEBUG_ID_COMMENT.test(text)) { + return text.replace(DEBUG_ID_COMMENT, (_, p1, p2, p3) => `\n${comment}${p2 || ''}${p3 || ''}`); + } + + if (SOURCE_MAPPING_URL_COMMENT.test(text)) { + return text.replace(SOURCE_MAPPING_URL_COMMENT, (match) => `\n${comment}${match}`); + } + + // No source map reference; append at the very end on its own line. + return text.endsWith('\n') ? `${text}${comment}\n` : `${text}\n${comment}\n`; +} + +/** + * Sets the top-level `debugId` field on a JSON source map. + * + * Per ECMA-426, source maps embed the same Debug ID under a `debugId` key so + * that consumers can pair a generated file with its source map without relying + * on URL/path conventions. + */ +export function injectDebugIdIntoSourceMap(json: string, id: string): string { + let parsed: Record; + try { + parsed = JSON.parse(json) as Record; + } catch { + // Source map is malformed; do not corrupt it further. + return json; + } + + if (parsed['debugId'] === id) { + return json; + } + + parsed['debugId'] = id; + + // Preserve existing pretty-print indentation when the source map is formatted. + const indent = json.match(/^[^{]*{\r?\n([ \t]+)/)?.[1]; + + return JSON.stringify(parsed, null, indent); +} + +/** + * Strips any existing `debugId` field from the source map JSON string to restore + * the original JSON contents for deterministic/idempotent hashing. + */ +export function stripDebugIdFromSourceMap(json: string): string { + return json.replace(/,\s*"debugId"\s*:\s*"[^"]*"|\s*"debugId"\s*:\s*"[^"]*"\s*,?/g, ''); +} diff --git a/packages/angular/build/src/utils/debug-id_spec.ts b/packages/angular/build/src/utils/debug-id_spec.ts new file mode 100644 index 000000000000..f254e28f6eee --- /dev/null +++ b/packages/angular/build/src/utils/debug-id_spec.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + generateDebugId, + injectDebugIdIntoJs, + injectDebugIdIntoSourceMap, + stripDebugIdFromSourceMap, +} from './debug-id'; + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +describe('debug-id', () => { + describe('generateDebugId', () => { + it('produces a canonical UUIDv5 string', () => { + expect(generateDebugId('hello')).toMatch(UUID); + expect(generateDebugId(new TextEncoder().encode('hello'))).toMatch(UUID); + }); + + it('is deterministic for identical inputs', () => { + expect(generateDebugId('same content')).toBe(generateDebugId('same content')); + }); + + it('differs for different inputs', () => { + expect(generateDebugId('one')).not.toBe(generateDebugId('two')); + }); + }); + + describe('injectDebugIdIntoJs', () => { + const id = '11111111-2222-5333-9444-555555555555'; + + it('inserts the debugId comment immediately above sourceMappingURL', () => { + const text = 'console.log(1);\n//# sourceMappingURL=foo.js.map\n'; + const result = injectDebugIdIntoJs(text, id); + expect(result).toBe( + 'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n', + ); + }); + + it('appends the comment when no sourceMappingURL is present', () => { + const text = 'console.log(1);\n'; + const result = injectDebugIdIntoJs(text, id); + expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n'); + }); + + it('appends a leading newline when input does not end with one', () => { + const text = 'console.log(1);'; + const result = injectDebugIdIntoJs(text, id); + expect(result).toBe('console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n'); + }); + + it('replaces an existing debugId comment (idempotent)', () => { + const original = + 'console.log(1);\n//# debugId=00000000-0000-5000-8000-000000000000\n//# sourceMappingURL=foo.js.map\n'; + const result = injectDebugIdIntoJs(original, id); + expect(result).toBe( + 'console.log(1);\n//# debugId=11111111-2222-5333-9444-555555555555\n//# sourceMappingURL=foo.js.map\n', + ); + // Re-running with the same id is a no-op. + expect(injectDebugIdIntoJs(result, id)).toBe(result); + }); + + it('does not replace a debugId comment that is inside a string or template literal', () => { + const text = + 'const t = `\n//# debugId=00000000-0000-5000-8000-000000000000\n`;\n//# sourceMappingURL=foo.js.map\n'; + const result = injectDebugIdIntoJs(text, id); + expect(result).toBe( + 'const t = `\n//# debugId=00000000-0000-5000-8000-000000000000\n`;\n' + + `//# debugId=${id}\n` + + '//# sourceMappingURL=foo.js.map\n', + ); + }); + }); + + describe('injectDebugIdIntoSourceMap', () => { + const id = '11111111-2222-5333-9444-555555555555'; + + it('adds a top-level debugId field', () => { + const map = JSON.stringify({ version: 3, sources: ['a.ts'], mappings: '' }); + const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id)); + expect(updated.debugId).toBe(id); + expect(updated.version).toBe(3); + }); + + it('overwrites an existing debugId field', () => { + const map = JSON.stringify({ version: 3, debugId: 'old', mappings: '' }); + const updated = JSON.parse(injectDebugIdIntoSourceMap(map, id)); + expect(updated.debugId).toBe(id); + }); + + it('returns the original input when debugId already matches', () => { + const map = + '{\n "version": 3,\n "debugId": "11111111-2222-5333-9444-555555555555",\n "mappings": ""\n}'; + expect(injectDebugIdIntoSourceMap(map, id)).toBe(map); + }); + + it('preserves indentation when updating a pretty-printed source map', () => { + const map = '{\n\t"version": 3,\n\t"mappings": ""\n}'; + expect(injectDebugIdIntoSourceMap(map, id)).toBe( + '{\n\t"version": 3,\n\t"mappings": "",\n\t"debugId": "11111111-2222-5333-9444-555555555555"\n}', + ); + }); + + it('returns the original input when JSON is malformed', () => { + const malformed = '{ this is not json'; + expect(injectDebugIdIntoSourceMap(malformed, id)).toBe(malformed); + }); + }); + + describe('stripDebugIdFromSourceMap', () => { + it('removes the debugId field from pretty-printed source maps', () => { + const original = '{\n\t"version": 3,\n\t"mappings": ""\n}'; + const updated = + '{\n\t"version": 3,\n\t"mappings": "",\n\t"debugId": "11111111-2222-5333-9444-555555555555"\n}'; + expect(stripDebugIdFromSourceMap(updated)).toBe(original); + }); + + it('removes the debugId field from minified source maps', () => { + const original = '{"version":3,"mappings":""}'; + const updated = + '{"version":3,"mappings":"","debugId":"11111111-2222-5333-9444-555555555555"}'; + expect(stripDebugIdFromSourceMap(updated)).toBe(original); + }); + + it('removes the debugId field when it is the first property', () => { + const original = '{\n\t"version": 3,\n\t"mappings": ""\n}'; + const updated = + '{\n\t"debugId": "11111111-2222-5333-9444-555555555555",\n\t"version": 3,\n\t"mappings": ""\n}'; + expect(stripDebugIdFromSourceMap(updated)).toBe(original); + }); + }); +}); diff --git a/packages/angular/build/src/utils/delete-output-dir.ts b/packages/angular/build/src/utils/delete-output-dir.ts new file mode 100644 index 000000000000..1f676e77b759 --- /dev/null +++ b/packages/angular/build/src/utils/delete-output-dir.ts @@ -0,0 +1,53 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readdir, rm } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +/** + * Delete an output directory, but error out if it's the root of the project. + */ +export async function deleteOutputDir( + root: string, + outputPath: string, + emptyOnlyDirectories?: string[], +): Promise { + const resolvedRoot = resolve(root); + const resolvedOutputPath = resolve(resolvedRoot, outputPath); + if (resolvedOutputPath === resolvedRoot) { + throw new Error('Output path MUST not be project root directory!'); + } + + const directoriesToEmpty = emptyOnlyDirectories + ? new Set(emptyOnlyDirectories.map((directory) => join(resolvedOutputPath, directory))) + : undefined; + + // Avoid removing the actual directory to avoid errors in cases where the output + // directory is mounted or symlinked. Instead the contents are removed. + let entries; + try { + entries = await readdir(resolvedOutputPath); + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { + return; + } + throw error; + } + + for (const entry of entries) { + const fullEntry = join(resolvedOutputPath, entry); + + // Leave requested directories. This allows symlinks to continue to function. + if (directoriesToEmpty?.has(fullEntry)) { + await deleteOutputDir(resolvedOutputPath, fullEntry); + continue; + } + + await rm(fullEntry, { force: true, recursive: true, maxRetries: 3 }); + } +} diff --git a/packages/angular/build/src/utils/disk-files.ts b/packages/angular/build/src/utils/disk-files.ts new file mode 100644 index 000000000000..dbbe34ee919e --- /dev/null +++ b/packages/angular/build/src/utils/disk-files.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { constants, cp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { runConcurrent } from './concurrency'; + +/** + * Maximum number of concurrent file system write/copy operations to avoid open file descriptor exhaustion. + */ +const MAX_CONCURRENT_OPS = 64; + +/** + * Represents a file that can be emitted to disk from memory or copied from an existing path on disk. + */ +export type EmittableFile = + { origin: 'memory'; contents: string | Uint8Array } | { origin: 'disk'; source: string }; + +/** + * Writes or copies a collection of files to disk with bounded concurrency, + * ensuring parent directories are created once per invocation. + * + * @param files The collection of files to emit. + * @param resolveDestinationPath Callback returning the absolute destination path for a file, or `undefined` to skip it. + */ +export async function emitFilesToDisk( + files: readonly T[], + resolveDestinationPath: (file: T) => string | undefined, +): Promise { + if (files.length === 0) { + return; + } + + const directoryCreationPromises = new Map>(); + + await runConcurrent(files, MAX_CONCURRENT_OPS, async (file) => { + const fullFilePath = resolveDestinationPath(file); + if (!fullFilePath) { + return; + } + + // Ensure output subdirectories exist + const fileBasePath = dirname(fullFilePath); + if (fileBasePath) { + let mkdirPromise = directoryCreationPromises.get(fileBasePath); + if (!mkdirPromise) { + mkdirPromise = mkdir(fileBasePath, { recursive: true }); + directoryCreationPromises.set(fileBasePath, mkdirPromise); + } + await mkdirPromise; + } + + if (file.origin === 'memory') { + // Write file contents + await writeFile(fullFilePath, file.contents); + + return; + } + + // Copy file contents + await cp(file.source, fullFilePath, { + mode: constants.COPYFILE_FICLONE, + preserveTimestamps: true, + }); + }); +} + +/** + * Deletes a collection of files from disk with bounded concurrency. + * @param files The collection of files to delete. + */ +export async function deleteFilesFromDisk(files: readonly string[]): Promise { + if (files.length === 0) { + return; + } + + await runConcurrent(files, MAX_CONCURRENT_OPS, (file) => rm(file, { force: true })); +} diff --git a/packages/angular/build/src/utils/disk-files_spec.ts b/packages/angular/build/src/utils/disk-files_spec.ts new file mode 100644 index 000000000000..4a9422bd4373 --- /dev/null +++ b/packages/angular/build/src/utils/disk-files_spec.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { type EmittableFile, deleteFilesFromDisk, emitFilesToDisk } from './disk-files'; + +describe('disk-files', () => { + let tempRoot: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempRoot = await mkdtemp(join(baseTmpDir, 'angular-cli-disk-files-test-')); + }); + + afterEach(async () => { + if (tempRoot) { + await rm(tempRoot, { recursive: true, force: true }); + } + }); + + describe('emitFilesToDisk', () => { + it('should write memory files and copy disk files while creating nested directories', async () => { + const sourceFile = join(tempRoot, 'source-asset.txt'); + await writeFile(sourceFile, 'from-disk'); + + const files: (EmittableFile & { path: string })[] = [ + { + origin: 'memory', + path: 'nested/a/mem1.txt', + contents: new TextEncoder().encode('mem-1'), + }, + { origin: 'memory', path: 'nested/a/mem2.txt', contents: 'mem-2' }, + { origin: 'disk', path: 'nested/b/disk1.txt', source: sourceFile }, + { origin: 'disk', path: 'nested/c/disk2.txt', source: sourceFile }, + { origin: 'memory', path: 'skipped/file.txt', contents: 'should-not-exist' }, + ]; + + const outDir = join(tempRoot, 'out'); + await emitFilesToDisk(files, (file) => + file.path.startsWith('skipped/') ? undefined : join(outDir, file.path), + ); + + expect(await readFile(join(outDir, 'nested/a/mem1.txt'), 'utf-8')).toBe('mem-1'); + expect(await readFile(join(outDir, 'nested/a/mem2.txt'), 'utf-8')).toBe('mem-2'); + expect(await readFile(join(outDir, 'nested/b/disk1.txt'), 'utf-8')).toBe('from-disk'); + expect(await readFile(join(outDir, 'nested/c/disk2.txt'), 'utf-8')).toBe('from-disk'); + expect(existsSync(join(outDir, 'skipped/file.txt'))).toBeFalse(); + }); + }); + + describe('deleteFilesFromDisk', () => { + it('should delete existing files and ignore non-existent files', async () => { + const fileA = join(tempRoot, 'a.txt'); + const fileB = join(tempRoot, 'b.txt'); + const nonExistent = join(tempRoot, 'missing.txt'); + + await writeFile(fileA, 'a'); + await writeFile(fileB, 'b'); + + await deleteFilesFromDisk([fileA, nonExistent]); + + expect(existsSync(fileA)).toBeFalse(); + expect(existsSync(fileB)).toBeTrue(); + }); + }); +}); diff --git a/packages/angular/build/src/utils/environment-options.ts b/packages/angular/build/src/utils/environment-options.ts new file mode 100644 index 000000000000..d87c104aba65 --- /dev/null +++ b/packages/angular/build/src/utils/environment-options.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { availableParallelism } from 'node:os'; + +/** A set of strings that are considered "truthy" when parsing environment variables. */ +const TRUTHY_VALUES = new Set(['1', 'true']); + +/** A set of strings that are considered "falsy" when parsing environment variables. */ +const FALSY_VALUES = new Set(['0', 'false']); + +/** + * Checks if an environment variable is present and has a non-empty value. + * @param variable The environment variable to check. + * @returns `true` if the variable is a non-empty string. + */ +function isPresent(variable: string | undefined): variable is string { + return typeof variable === 'string' && variable !== ''; +} + +/** + * Parses an environment variable into a boolean or undefined. + * @returns `true` if the variable is truthy ('1', 'true'). + * @returns `false` if the variable is falsy ('0', 'false'). + * @returns `undefined` if the variable is not present or has an unknown value. + */ +function parseTristate(variable: string | undefined): boolean | undefined { + if (!isPresent(variable)) { + return undefined; + } + + const value = variable.toLowerCase(); + if (TRUTHY_VALUES.has(value)) { + return true; + } + if (FALSY_VALUES.has(value)) { + return false; + } + + // TODO: Consider whether a warning is useful in this case of a malformed value + return undefined; +} + +// Optimization and mangling +const debugOptimizeVariable = process.env['NG_BUILD_DEBUG_OPTIMIZE']; +const debugOptimize = (() => { + if (!isPresent(debugOptimizeVariable) || parseTristate(debugOptimizeVariable) === false) { + return { + mangle: true, + minify: true, + beautify: false, + }; + } + + const debugValue = { + mangle: false, + minify: false, + beautify: true, + }; + + if (parseTristate(debugOptimizeVariable) === true) { + return debugValue; + } + + for (const part of debugOptimizeVariable.split(',')) { + switch (part.trim().toLowerCase()) { + case 'mangle': + debugValue.mangle = true; + break; + case 'minify': + debugValue.minify = true; + break; + case 'beautify': + debugValue.beautify = true; + break; + } + } + + return debugValue; +})(); + +/** + * Allows disabling of code mangling when the `NG_BUILD_MANGLE` environment variable is set to `0` or `false`. + * This is useful for debugging build output. + */ +export const allowMangle = parseTristate(process.env['NG_BUILD_MANGLE']) ?? debugOptimize.mangle; + +/** + * Allows beautification of build output when the `NG_BUILD_DEBUG_OPTIMIZE` environment variable is enabled. + * This is useful for debugging build output. + */ +export const shouldBeautify = debugOptimize.beautify; + +/** + * Allows disabling of code minification when the `NG_BUILD_DEBUG_OPTIMIZE` environment variable is enabled. + * This is useful for debugging build output. + */ +export const allowMinify = debugOptimize.minify; + +/** + * Allows using Rolldown for chunk optimization instead of Rollup. + * This is useful for debugging and testing scenarios. + */ +export const useRolldownChunks = parseTristate(process.env['NG_BUILD_CHUNKS_ROLLDOWN']) ?? true; + +const maxWorkersVariable = process.env['NG_BUILD_MAX_WORKERS']; + +let customMaxWorkers: number | undefined; +if (isPresent(maxWorkersVariable)) { + const parsed = +maxWorkersVariable; + if (Number.isInteger(parsed) && parsed >= 1) { + customMaxWorkers = parsed; + } +} + +/** + * Whether the maximum number of workers was explicitly configured via the + * `NG_BUILD_MAX_WORKERS` environment variable. + */ +export const hasCustomMaxWorkers = customMaxWorkers !== undefined; + +/** + * The maximum number of workers to use for parallel processing. + * This can be controlled by the `NG_BUILD_MAX_WORKERS` environment variable. + * When not set, defaults to available parallelism minus one to ensure the main thread is not starved. + */ +export const maxWorkers = customMaxWorkers ?? Math.max(availableParallelism() - 1, 1); + +/** + * The maximum number of workers to use for JavaScript transformations during bundling. + * Transformation tasks are short-lived, and esbuild concurrently utilizes all CPU cores + * for bundling. To prevent CPU starvation and thread startup overhead, concurrency is + * budgeted to a fraction of available cores, capped at 6, unless overridden by + * `NG_BUILD_MAX_WORKERS`. + */ +export const maxTransformWorkers = + customMaxWorkers ?? Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + +/** + * The maximum number of workers to use for i18n translation inlining. + * Translation inlining and sourcemap remapping are CPU- and memory-intensive operations. + * To prevent thread oversubscription, memory allocator lock contention, and high-core + * performance degradation, concurrency is capped at 8 unless overridden by + * `NG_BUILD_MAX_WORKERS`. + */ +export const maxInlinerWorkers = customMaxWorkers ?? Math.min(8, availableParallelism()); + +/** + * When `NG_BUILD_PARALLEL_TS` is set to `0` or `false`, parallel TypeScript compilation is disabled. + */ +export const useParallelTs = parseTristate(process.env['NG_BUILD_PARALLEL_TS']) !== false; + +/** + * When `NG_BUILD_DEBUG_PERF` is enabled, performance debugging information is printed. + */ +export const debugPerformance = parseTristate(process.env['NG_BUILD_DEBUG_PERF']) === true; + +/** + * When `NG_BUILD_WATCH_ROOT` is enabled, the build will watch the root directory for changes. + */ +export const shouldWatchRoot = parseTristate(process.env['NG_BUILD_WATCH_ROOT']) === true; + +/** + * When `NG_BUILD_TYPE_CHECK` is set to `0` or `false`, type checking is disabled. + */ +export const useTypeChecking = parseTristate(process.env['NG_BUILD_TYPE_CHECK']) !== false; + +/** + * When `NG_BUILD_LOGS_JSON` is enabled, build logs will be output in JSON format. + */ +export const useJSONBuildLogs = parseTristate(process.env['NG_BUILD_LOGS_JSON']) === true; + +/** + * When `NG_BUILD_OPTIMIZE_CHUNKS` is enabled, the build will optimize chunks. + */ +/** + * The threshold of lazy chunks required to enable the chunk optimization pass. + * Can be configured via the `NG_BUILD_OPTIMIZE_CHUNKS` environment variable. + * - `false` or `0` disables the feature. + * - `true` or `1` forces the feature on (threshold 0). + * - A number sets the specific threshold. + * - Default is 3. + */ +const optimizeChunksEnv = process.env['NG_BUILD_OPTIMIZE_CHUNKS']; +export const optimizeChunksThreshold = (() => { + if (optimizeChunksEnv === undefined) { + return 3; + } + if (optimizeChunksEnv === 'false' || optimizeChunksEnv === '0') { + return Infinity; + } + if (optimizeChunksEnv === 'true' || optimizeChunksEnv === '1') { + return 0; + } + const num = Number.parseInt(optimizeChunksEnv, 10); + + return Number.isNaN(num) || num < 0 ? 3 : num; +})(); + +/** + * When `NG_HMR_CSTYLES` is enabled, component styles will be hot-reloaded. + */ +export const useComponentStyleHmr = parseTristate(process.env['NG_HMR_CSTYLES']) === true; + +/** + * When `NG_HMR_TEMPLATES` is set to `0` or `false`, component templates will not be hot-reloaded. + */ +export const useComponentTemplateHmr = parseTristate(process.env['NG_HMR_TEMPLATES']) !== false; + +/** + * When `NG_BUILD_PARTIAL_SSR` is enabled, a partial server-side rendering build will be performed. + */ +export const usePartialSsrBuild = parseTristate(process.env['NG_BUILD_PARTIAL_SSR']) === true; + +/** + * When `NG_BUILD_BABEL_LINKER` is enabled (`1` or `true`), the Babel-based + * Angular Linker (`@angular/compiler-cli/linker/babel`) will be used instead of the + * default OXC in-place linker. + */ +export const useBabelLinker = parseTristate(process.env['NG_BUILD_BABEL_LINKER']) === true; + +/** + * When `NG_BUILD_SASS_EMBEDDED` is set to `0` or `false`, or when running within a + * WebContainer environment, the native embedded Sass compiler is disabled + * and the pure-JavaScript Sass compiler is used instead. + */ +export const useSassEmbedded = + !process.versions.webcontainer && parseTristate(process.env['NG_BUILD_SASS_EMBEDDED']) !== false; + +const bazelBinDirectory = process.env['BAZEL_BINDIR']; +const bazelExecRoot = process.env['JS_BINARY__EXECROOT']; + +export const bazelEsbuildPluginPath = + bazelBinDirectory && bazelExecRoot + ? process.env['NG_INTERNAL_ESBUILD_PLUGINS_DO_NOT_USE'] + : undefined; + +/** + * The persistent cache store configuration to use. + * Managed by the `NG_BUILD_CACHE_STORE` environment variable. + * - 'lmdb': Forces the use of LMDB. + * - 'sqlite': Forces the use of SQLite. + * - undefined / 'auto' / other: Automatically uses LMDB and falls back to SQLite. + */ +export const persistentCacheStoreSetting = (() => { + const env = process.env['NG_BUILD_CACHE_STORE']?.trim().toLowerCase(); + if (env === 'lmdb' || env === 'sqlite') { + return env; + } + + return undefined; +})(); diff --git a/packages/angular/build/src/utils/environment-options_spec.ts b/packages/angular/build/src/utils/environment-options_spec.ts new file mode 100644 index 000000000000..4ad098a24763 --- /dev/null +++ b/packages/angular/build/src/utils/environment-options_spec.ts @@ -0,0 +1,247 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { availableParallelism } from 'node:os'; + +describe('environment options - maxWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxWorkers to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '8'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(8); + }); + + it('allows maxWorkers greater than 4 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '32'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(32); + }); + + it('supports maxWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(1); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is 0', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is not a number', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is a float', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '2.5'; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is an empty string', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ''; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('falls back to availableParallelism - 1 when NG_BUILD_MAX_WORKERS is whitespace only', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ' '; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeFalse(); + expect(maxWorkers).toBe(Math.max(availableParallelism() - 1, 1)); + }); + + it('parses positive integers with surrounding whitespace', () => { + process.env['NG_BUILD_MAX_WORKERS'] = ' 8 '; + const { maxWorkers, hasCustomMaxWorkers } = loadEnvironmentOptions(); + + expect(hasCustomMaxWorkers).toBeTrue(); + expect(maxWorkers).toBe(8); + }); +}); + +describe('environment options - maxTransformWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxTransformWorkers to availableParallelism / 4 (bounded between 1 and 6) when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + expect(maxTransformWorkers).toBe(expected); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '8'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(8); + }); + + it('allows maxTransformWorkers greater than 6 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '32'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(32); + }); + + it('supports maxTransformWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + + expect(maxTransformWorkers).toBe(1); + }); + + it('falls back to default calculation when NG_BUILD_MAX_WORKERS is 0 or negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxTransformWorkers: zeroWorkers } = loadEnvironmentOptions(); + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + expect(zeroWorkers).toBe(expected); + + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxTransformWorkers: negativeWorkers } = loadEnvironmentOptions(); + expect(negativeWorkers).toBe(expected); + }); + + it('falls back to default calculation when NG_BUILD_MAX_WORKERS is invalid', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxTransformWorkers } = loadEnvironmentOptions(); + const expected = Math.max(1, Math.min(6, Math.floor(availableParallelism() / 4))); + + expect(maxTransformWorkers).toBe(expected); + }); +}); + +describe('environment options - maxInlinerWorkers', () => { + const originalEnvValue = process.env['NG_BUILD_MAX_WORKERS']; + + function loadEnvironmentOptions(): typeof import('./environment-options') { + delete require.cache[require.resolve('./environment-options')]; + + return require('./environment-options'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env['NG_BUILD_MAX_WORKERS'] = originalEnvValue; + } else { + delete process.env['NG_BUILD_MAX_WORKERS']; + } + delete require.cache[require.resolve('./environment-options')]; + }); + + it('defaults maxInlinerWorkers to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is unset', () => { + delete process.env['NG_BUILD_MAX_WORKERS']; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(Math.min(8, availableParallelism())); + }); + + it('uses configured positive integer when NG_BUILD_MAX_WORKERS is set', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '4'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(4); + }); + + it('allows maxInlinerWorkers greater than 8 when explicitly configured', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '16'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(16); + }); + + it('supports maxInlinerWorkers set to 1', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '1'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(1); + }); + + it('falls back to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is 0 or negative', () => { + process.env['NG_BUILD_MAX_WORKERS'] = '0'; + const { maxInlinerWorkers: zeroWorkers } = loadEnvironmentOptions(); + expect(zeroWorkers).toBe(Math.min(8, availableParallelism())); + + process.env['NG_BUILD_MAX_WORKERS'] = '-4'; + const { maxInlinerWorkers: negativeWorkers } = loadEnvironmentOptions(); + expect(negativeWorkers).toBe(Math.min(8, availableParallelism())); + }); + + it('falls back to min(8, availableParallelism()) when NG_BUILD_MAX_WORKERS is invalid', () => { + process.env['NG_BUILD_MAX_WORKERS'] = 'invalid'; + const { maxInlinerWorkers } = loadEnvironmentOptions(); + + expect(maxInlinerWorkers).toBe(Math.min(8, availableParallelism())); + }); +}); diff --git a/packages/angular/build/src/utils/error.ts b/packages/angular/build/src/utils/error.ts new file mode 100644 index 000000000000..0ca77c331d2d --- /dev/null +++ b/packages/angular/build/src/utils/error.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; + +export function assertIsError(value: unknown): asserts value is Error & { code?: string } { + const isError = + value instanceof Error || + // The following is needing to identify errors coming from RxJs. + (typeof value === 'object' && value && 'name' in value && 'message' in value); + assert(isError, 'catch clause variable is not an Error instance'); +} diff --git a/packages/angular/build/src/utils/format-bytes.ts b/packages/angular/build/src/utils/format-bytes.ts new file mode 100644 index 000000000000..5c9ecee6875a --- /dev/null +++ b/packages/angular/build/src/utils/format-bytes.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export function formatSize(size: number): string { + if (size <= 0) { + return '0 bytes'; + } + + const abbreviations = ['bytes', 'kB', 'MB', 'GB']; + const index = Math.floor(Math.log(size) / Math.log(1000)); + const roundedSize = size / Math.pow(1000, index); + // bytes don't have a fraction + const fractionDigits = index === 0 ? 0 : 2; + + return `${roundedSize.toFixed(fractionDigits)} ${abbreviations[index]}`; +} diff --git a/packages/angular/build/src/utils/format-bytes_spec.ts b/packages/angular/build/src/utils/format-bytes_spec.ts new file mode 100644 index 000000000000..63cb3f761ecf --- /dev/null +++ b/packages/angular/build/src/utils/format-bytes_spec.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { formatSize } from './format-bytes'; + +describe('formatSize', () => { + it('1000 bytes to be 1kB', () => { + expect(formatSize(1000)).toBe('1.00 kB'); + }); + + it('1_000_000 bytes to be 1MB', () => { + expect(formatSize(1_000_000)).toBe('1.00 MB'); + }); + + it('1_500_000 bytes to be 1.5MB', () => { + expect(formatSize(1_500_000)).toBe('1.50 MB'); + }); + + it('1_000_000_000 bytes to be 1GB', () => { + expect(formatSize(1_000_000_000)).toBe('1.00 GB'); + }); +}); diff --git a/packages/angular/build/src/utils/hash.ts b/packages/angular/build/src/utils/hash.ts new file mode 100644 index 000000000000..fdc5565afbb1 --- /dev/null +++ b/packages/angular/build/src/utils/hash.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import type { XXHashAPI } from 'xxhash-wasm'; + +let xxhashInstance: XXHashAPI | undefined; +let xxhashPromise: Promise | undefined; + +/** + * Initializes the xxHash WASM instance early to ensure synchronous hashing uses xxHash. + */ +export async function initializeHash(): Promise { + if (xxhashInstance) { + return; + } + + xxhashPromise ??= import('xxhash-wasm').then((m) => m.default()); + xxhashInstance = await xxhashPromise; +} + +function getXxhash(): XXHashAPI { + assert( + xxhashInstance, + 'Hash utility must be initialized by awaiting `initializeHash()` before use.', + ); + + return xxhashInstance; +} + +/** + * Calculates a fast 64-bit non-cryptographic hash of the provided content. + * Suitable for cache keys, ETags, and change detection. + */ +export function calculateHash(data: string | Uint8Array): string { + const instance = getXxhash(); + + if (typeof data === 'string') { + return instance.h64ToString(data); + } + + return instance.h64Raw(data).toString(16).padStart(16, '0'); +} + +export interface ContentHasher { + update(data: string | Uint8Array): ContentHasher; + digest(): string; +} + +/** + * Creates a streaming 64-bit non-cryptographic content hasher. + */ +export function createContentHash(): ContentHasher { + const instance = getXxhash(); + const hasher = instance.create64(); + + const contentHasher: ContentHasher = { + update(data: string | Uint8Array): ContentHasher { + hasher.update(data); + + return contentHasher; + }, + digest(): string { + return hasher.digest().toString(16).padStart(16, '0'); + }, + }; + + return contentHasher; +} diff --git a/packages/angular/build/src/utils/hash_spec.ts b/packages/angular/build/src/utils/hash_spec.ts new file mode 100644 index 000000000000..77fcbc909403 --- /dev/null +++ b/packages/angular/build/src/utils/hash_spec.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { calculateHash, createContentHash, initializeHash } from './hash'; + +describe('hash utility', () => { + beforeAll(async () => { + await initializeHash(); + }); + + it('should calculate identical 64-bit hex hash for string and Buffer with same content', () => { + const text = 'export const message = "hello world";'; + const buffer = Buffer.from(text, 'utf-8'); + + const stringHash = calculateHash(text); + const bufferHash = calculateHash(buffer); + + expect(typeof stringHash).toBe('string'); + expect(stringHash.length).toBe(16); + expect(stringHash).toBe(bufferHash); + }); + + it('should calculate different hashes for different contents', () => { + const hash1 = calculateHash('const a = 1;'); + const hash2 = calculateHash('const a = 2;'); + + expect(hash1).not.toBe(hash2); + }); + + it('should support streaming multi-part hashing matching combined single-shot hash', () => { + const part1 = 'header: '; + const part2 = 'body content: '; + const part3 = 'footer'; + + const hasher = createContentHash(); + hasher.update(part1); + hasher.update(part2); + hasher.update(Buffer.from(part3, 'utf-8')); + const streamingHash = hasher.digest(); + + const singleShotHash = calculateHash(part1 + part2 + part3); + + expect(streamingHash.length).toBe(16); + expect(streamingHash).toBe(singleShotHash); + }); + + it('should handle Uint8Array chunks in streaming hasher', () => { + const hasher = createContentHash(); + hasher.update(new Uint8Array([1, 2, 3, 4])).update('some-string'); + const digest = hasher.digest(); + + expect(typeof digest).toBe('string'); + expect(digest.length).toBe(16); + }); +}); diff --git a/packages/angular/build/src/utils/i18n-options.ts b/packages/angular/build/src/utils/i18n-options.ts new file mode 100644 index 000000000000..9a55656e6798 --- /dev/null +++ b/packages/angular/build/src/utils/i18n-options.ts @@ -0,0 +1,295 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ɵParsedTranslation } from '@angular/localize'; +import path from 'node:path'; +import type { TranslationLoader } from './load-translations'; + +export interface LocaleDescription { + files: { + path: string; + integrity?: string; + format?: string; + }[]; + translation?: Record; + dataPath?: string; + baseHref?: string; + subPath: string; +} + +export interface I18nOptions { + inlineLocales: Set; + sourceLocale: string; + locales: Record; + flatOutput?: boolean; + readonly shouldInline: boolean; + hasDefinedSourceLocale?: boolean; + localizeVersion?: string; +} + +function normalizeTranslationFileOption( + option: unknown, + locale: string, + expectObjectInError: boolean, +): string[] { + if (typeof option === 'string') { + return [option]; + } + + if (Array.isArray(option) && option.every((element) => typeof element === 'string')) { + return option; + } + + let errorMessage = `Project i18n locales translation field value for '${locale}' is malformed. `; + if (expectObjectInError) { + errorMessage += 'Expected a string, array of strings, or object.'; + } else { + errorMessage += 'Expected a string or array of strings.'; + } + + throw new Error(errorMessage); +} + +function ensureObject(value: unknown, name: string): asserts value is Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Project field '${name}' is malformed. Expected an object.`); + } +} + +function ensureString(value: unknown, name: string): asserts value is string { + if (typeof value !== 'string') { + throw new Error(`Project field '${name}' is malformed. Expected a string.`); + } +} + +function ensureValidSubPath(value: unknown, name: string): asserts value is string { + ensureString(value, name); + + if (!/^[\w-]*$/.test(value)) { + throw new Error( + `Project field '${name}' is invalid. It can only contain letters, numbers, hyphens, and underscores.`, + ); + } +} +export function createI18nOptions( + projectMetadata: { i18n?: unknown }, + inline?: boolean | string[], + logger?: { + warn(message: string): void; + }, + ssrEnabled?: boolean, +): I18nOptions { + const { i18n: metadata = {} } = projectMetadata; + + ensureObject(metadata, 'i18n'); + + const i18n: I18nOptions = { + inlineLocales: new Set(), + // en-US is the default locale added to Angular applications (https://angular.dev/guide/i18n/format-data-locale) + sourceLocale: 'en-US', + locales: {}, + get shouldInline() { + return this.inlineLocales.size > 0; + }, + }; + + let rawSourceLocale: string | undefined; + let rawSourceLocaleBaseHref: string | undefined; + let rawsubPath: string | undefined; + if (typeof metadata.sourceLocale === 'string') { + rawSourceLocale = metadata.sourceLocale; + } else if (metadata.sourceLocale !== undefined) { + ensureObject(metadata.sourceLocale, 'i18n.sourceLocale'); + + if (metadata.sourceLocale.code !== undefined) { + ensureString(metadata.sourceLocale.code, 'i18n.sourceLocale.code'); + rawSourceLocale = metadata.sourceLocale.code; + } + + if (metadata.sourceLocale.baseHref !== undefined) { + ensureString(metadata.sourceLocale.baseHref, 'i18n.sourceLocale.baseHref'); + if (ssrEnabled) { + logger?.warn( + `'baseHref' in 'i18n.sourceLocale' may lead to undefined behavior when used with SSR. ` + + `Consider using 'subPath' instead.\n\n` + + `Note: 'subPath' specifies the URL segment for the locale, serving as both the HTML base HREF ` + + `and the output directory name.\nBy default, if not explicitly set, 'subPath' defaults to the locale code.`, + ); + } + + rawSourceLocaleBaseHref = metadata.sourceLocale.baseHref; + } + + if (metadata.sourceLocale.subPath !== undefined) { + ensureValidSubPath(metadata.sourceLocale.subPath, 'i18n.sourceLocale.subPath'); + rawsubPath = metadata.sourceLocale.subPath; + } + + if (rawsubPath !== undefined && rawSourceLocaleBaseHref !== undefined) { + throw new Error( + `'i18n.sourceLocale.subPath' and 'i18n.sourceLocale.baseHref' cannot be used together.`, + ); + } + } + + if (rawSourceLocale !== undefined) { + i18n.sourceLocale = rawSourceLocale; + i18n.hasDefinedSourceLocale = true; + } + + i18n.locales[i18n.sourceLocale] = { + files: [], + baseHref: rawSourceLocaleBaseHref, + subPath: rawsubPath ?? i18n.sourceLocale, + }; + + if (metadata.locales !== undefined) { + ensureObject(metadata.locales, 'i18n locales'); + + for (const [locale, options] of Object.entries(metadata.locales)) { + let translationFiles: string[] | undefined; + let baseHref: string | undefined; + let subPath: string | undefined; + + if (options && typeof options === 'object' && 'translation' in options) { + translationFiles = normalizeTranslationFileOption(options.translation, locale, false); + + if ('baseHref' in options) { + ensureString(options.baseHref, `i18n.locales.${locale}.baseHref`); + + if (ssrEnabled) { + logger?.warn( + `'baseHref' in 'i18n.locales.${locale}' may lead to undefined behavior when used with SSR. ` + + `Consider using 'subPath' instead.\n\n` + + `Note: 'subPath' specifies the URL segment for the locale, serving as both the HTML base HREF ` + + `and the output directory name.\nBy default, if not explicitly set, 'subPath' defaults to the locale code.`, + ); + } + baseHref = options.baseHref; + } + + if ('subPath' in options) { + ensureValidSubPath(options.subPath, `i18n.locales.${locale}.subPath`); + subPath = options.subPath; + } + + if (subPath !== undefined && baseHref !== undefined) { + throw new Error( + `'i18n.locales.${locale}.subPath' and 'i18n.locales.${locale}.baseHref' cannot be used together.`, + ); + } + } else { + translationFiles = normalizeTranslationFileOption(options, locale, true); + } + + if (locale === i18n.sourceLocale) { + throw new Error( + `An i18n locale ('${locale}') cannot both be a source locale and provide a translation.`, + ); + } + + i18n.locales[locale] = { + files: translationFiles.map((file) => ({ path: file })), + baseHref, + subPath: subPath ?? locale, + }; + } + } + + if (inline === true) { + i18n.inlineLocales.add(i18n.sourceLocale); + Object.keys(i18n.locales).forEach((locale) => i18n.inlineLocales.add(locale)); + } else if (inline) { + for (const locale of inline) { + if (!i18n.locales[locale] && i18n.sourceLocale !== locale) { + throw new Error(`Requested locale '${locale}' is not defined for the project.`); + } + + i18n.inlineLocales.add(locale); + } + } + + // Check that subPaths are unique only the locales that we are inlining. + const localesData = Object.entries(i18n.locales).filter(([locale]) => + i18n.inlineLocales.has(locale), + ); + + for (let i = 0; i < localesData.length; i++) { + const [localeA, { subPath: subPathA }] = localesData[i]; + + for (let j = i + 1; j < localesData.length; j++) { + const [localeB, { subPath: subPathB }] = localesData[j]; + + if (subPathA === subPathB) { + throw new Error( + `Invalid i18n configuration: Locales '${localeA}' and '${localeB}' cannot have the same subPath: '${subPathB}'.`, + ); + } + } + } + + return i18n; +} + +export function loadTranslations( + locale: string, + desc: LocaleDescription, + workspaceRoot: string, + loader: TranslationLoader, + logger: { warn: (message: string) => void; error: (message: string) => void }, + usedFormats?: Set, + duplicateTranslation?: 'ignore' | 'error' | 'warning', +) { + let translations: Record | undefined = undefined; + for (const file of desc.files) { + const loadResult = loader(path.join(workspaceRoot, file.path)); + + for (const diagnostics of loadResult.diagnostics.messages) { + if (diagnostics.type === 'error') { + logger.error(`Error parsing translation file '${file.path}': ${diagnostics.message}`); + } else { + logger.warn(`WARNING [${file.path}]: ${diagnostics.message}`); + } + } + + if (loadResult.locale !== undefined && loadResult.locale !== locale) { + logger.warn( + `WARNING [${file.path}]: File target locale ('${loadResult.locale}') does not match configured locale ('${locale}')`, + ); + } + + usedFormats?.add(loadResult.format); + file.format = loadResult.format; + file.integrity = loadResult.integrity; + + if (translations) { + // Merge translations + for (const [id, message] of Object.entries(loadResult.translations)) { + if (translations[id] !== undefined) { + const duplicateTranslationMessage = `[${file.path}]: Duplicate translations for message '${id}' when merging.`; + switch (duplicateTranslation) { + case 'ignore': + break; + case 'error': + logger.error(`ERROR ${duplicateTranslationMessage}`); + break; + case 'warning': + default: + logger.warn(`WARNING ${duplicateTranslationMessage}`); + break; + } + } + translations[id] = message; + } + } else { + // First or only translation file + translations = loadResult.translations; + } + } + desc.translation = translations; +} diff --git a/packages/angular/build/src/utils/index-file/add-event-dispatch-contract.ts b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract.ts new file mode 100644 index 000000000000..749a489f8a4a --- /dev/null +++ b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { htmlRewritingStream } from './html-rewriting-stream'; + +let jsActionContractScript: string; + +export async function addEventDispatchContract(html: string): Promise { + const { rewriter, transformedContent } = await htmlRewritingStream(html); + + jsActionContractScript ??= + ''; + + rewriter.on('startTag', (tag) => { + rewriter.emitStartTag(tag); + + if (tag.tagName === 'body') { + rewriter.emitRaw(jsActionContractScript); + } + }); + + return transformedContent(); +} diff --git a/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts new file mode 100644 index 000000000000..6c0747730c29 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { addEventDispatchContract } from './add-event-dispatch-contract'; + +describe('addEventDispatchContract', () => { + it('should inline event dispatcher script', async () => { + const result = await addEventDispatchContract(` + + + +

Hello World!

+ + + `); + + expect(result).toMatch( + /\s*`); + } + + let subResourceIntegrityTag: string | undefined; + let headerLinkTags: string[] = []; + let bodyLinkTags: string[] = []; + + // Emit an integrity-only import map so the browser can validate lazy chunks + // resolved via dynamic `import()` (which otherwise carry no SRI metadata). + // The block is placed first inside `` so it precedes any module + // script, as required by the import-map spec. + if (sri && chunksIntegrity?.size) { + const integrity: Record = {}; + // Stable iteration order for reproducible builds. + const sortedEntries = [...chunksIntegrity.entries()].sort(([keyA], [keyB]) => + keyA.localeCompare(keyB), + ); + for (const [url, integrityHash] of sortedEntries) { + const resolvedUrl = generateUrl(url, deployUrl); + const key = RESOLVABLE_URL_REGEXP.test(resolvedUrl) ? resolvedUrl : `./${resolvedUrl}`; + integrity[key] = integrityHash; + } + const importMapJson = JSON.stringify({ integrity }).replace(/${importMapJson}`; + } + + for (const src of stylesheets) { + const attrs = [`rel="stylesheet"`, `href="${generateUrl(src, deployUrl)}"`]; + + if (crossOrigin !== 'none') { + attrs.push(`crossorigin="${crossOrigin}"`); + } + + if (sri) { + const content = await loadOutputFile(src); + attrs.push(generateSriAttributes(content)); + } + + headerLinkTags.push(``); + } + + if (params.hints?.length) { + for (const hint of params.hints) { + const attrs = [`rel="${hint.mode}"`, `href="${generateUrl(hint.url, deployUrl)}"`]; + + if (hint.mode !== 'modulepreload' && crossOrigin !== 'none') { + // Value is considered anonymous by the browser when not present or empty + attrs.push(crossOrigin === 'anonymous' ? 'crossorigin' : `crossorigin="${crossOrigin}"`); + } + + if (hint.mode === 'preload' || hint.mode === 'prefetch') { + switch (extname(hint.url)) { + case '.js': + attrs.push('as="script"'); + break; + case '.css': + attrs.push('as="style"'); + break; + default: + if (hint.as) { + attrs.push(`as="${hint.as}"`); + } + break; + } + } + + if ( + sri && + (hint.mode === 'preload' || hint.mode === 'prefetch' || hint.mode === 'modulepreload') + ) { + const content = await loadOutputFile(hint.url); + attrs.push(generateSriAttributes(content)); + } + + const tag = ``; + if (hint.mode === 'modulepreload') { + // Module preloads should be placed by the inserted script elements in the body since + // they are only useful in combination with the scripts. + bodyLinkTags.push(tag); + } else { + headerLinkTags.push(tag); + } + } + } + + const dir = lang ? await getLanguageDirection(lang, warnings) : undefined; + const { rewriter, transformedContent } = await htmlRewritingStream(html); + const baseTagExists = html.includes('(); + + rewriter + .on('startTag', (tag, rawTagHtml) => { + switch (tag.tagName) { + case 'html': + // Adjust document locale if specified + if (isString(lang)) { + updateAttribute(tag, 'lang', lang); + } + + if (dir) { + updateAttribute(tag, 'dir', dir); + } + break; + case 'head': + // Base href should be added before any link, meta tags + if (!baseTagExists && isString(baseHref)) { + rewriter.emitStartTag(tag); + rewriter.emitRaw(``); + if (subResourceIntegrityTag) { + rewriter.emitRaw(subResourceIntegrityTag); + } + + return; + } + break; + case 'base': + // Adjust base href if specified + if (isString(baseHref)) { + updateAttribute(tag, 'href', baseHref); + } + + if (subResourceIntegrityTag) { + rewriter.emitRaw(subResourceIntegrityTag); + } + + break; + case 'link': + if (readAttribute(tag, 'rel') === 'preconnect') { + const href = readAttribute(tag, 'href'); + if (href) { + foundPreconnects.add(href); + } + } + break; + default: + if (tag.selfClosing && !VALID_SELF_CLOSING_TAGS.has(tag.tagName)) { + errors.push(`Invalid self-closing element in index HTML file: '${rawTagHtml}'.`); + + return; + } + } + + rewriter.emitStartTag(tag); + }) + .on('endTag', (tag) => { + switch (tag.tagName) { + case 'head': + for (const linkTag of headerLinkTags) { + rewriter.emitRaw(linkTag); + } + if (imageDomains) { + for (const imageDomain of imageDomains) { + if (!foundPreconnects.has(imageDomain)) { + rewriter.emitRaw(``); + } + } + } + headerLinkTags = []; + break; + case 'body': + for (const linkTag of bodyLinkTags) { + rewriter.emitRaw(linkTag); + } + bodyLinkTags = []; + + // Add script tags + for (const scriptTag of scriptTags) { + rewriter.emitRaw(scriptTag); + } + + scriptTags = []; + break; + } + + rewriter.emitEndTag(tag); + }); + + const content = await transformedContent(); + + return { + content: + headerLinkTags.length || scriptTags.length + ? // In case no body/head tags are not present (dotnet partial templates) + headerLinkTags.join('') + scriptTags.join('') + content + : content, + warnings, + errors, + }; +} + +function generateSriAttributes(content: string): string { + const algo = 'sha384'; + const hash = createHash(algo).update(content, 'utf8').digest('base64'); + + return `integrity="${algo}-${hash}"`; +} + +function generateUrl(value: string, deployUrl: string | undefined): string { + if (!deployUrl) { + return value; + } + + // Skip if root-relative, absolute or protocol relative url + if (/^((?:\w+:)?\/\/|data:|chrome:|\/)/.test(value)) { + return value; + } + + return `${deployUrl}${value}`; +} + +function updateAttribute( + tag: { attrs: { name: string; value: string }[] }, + name: string, + value: string, +): void { + const index = tag.attrs.findIndex((a) => a.name === name); + const newValue = { name, value }; + + if (index === -1) { + tag.attrs.push(newValue); + } else { + tag.attrs[index] = newValue; + } +} + +function readAttribute( + tag: { attrs: { name: string; value: string }[] }, + name: string, +): string | undefined { + const targetAttr = tag.attrs.find((attr) => attr.name === name); + + return targetAttr ? targetAttr.value : undefined; +} + +function isString(value: unknown): value is string { + return typeof value === 'string'; +} + +async function getLanguageDirection( + locale: string, + warnings: string[], +): Promise { + const dir = await getLanguageDirectionFromLocales(locale); + + if (!dir) { + warnings.push( + `Locale data for '${locale}' cannot be found. 'dir' attribute will not be set for this locale.`, + ); + } + + return dir; +} + +async function getLanguageDirectionFromLocales(locale: string): Promise { + try { + const localeData = (await import(`@angular/common/locales/${locale}`)).default; + const dir = localeData[localeData.length - 2]; + + return isString(dir) ? dir : undefined; + } catch { + // In some cases certain locales might map to files which are named only with language id. + // Example: `en-US` -> `en`. + const [languageId] = locale.split('-', 1); + if (languageId !== locale) { + return getLanguageDirectionFromLocales(languageId); + } + } + + return undefined; +} diff --git a/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts new file mode 100644 index 000000000000..df292b8771a3 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts @@ -0,0 +1,592 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { AugmentIndexHtmlOptions, augmentIndexHtml } from './augment-index-html'; + +describe('augment-index-html', () => { + const indexGeneratorOptions: AugmentIndexHtmlOptions = { + html: '', + baseHref: '/', + sri: false, + files: [], + loadOutputFile: async (_fileName: string) => '', + entrypoints: [ + ['scripts', false], + ['polyfills', true], + ['main', true], + ['styles', false], + ], + }; + + const oneLineHtml = (html: TemplateStringsArray) => + `${html}`.replace(/(>\s+)/g, '>').replace(/\s+ { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + files: [ + { file: 'styles.css', extension: '.css', name: 'styles' }, + { file: 'runtime.js', extension: '.js', name: 'main' }, + { file: 'main.js', extension: '.js', name: 'main' }, + { file: 'runtime.js', extension: '.js', name: 'polyfills' }, + { file: 'polyfills.js', extension: '.js', name: 'polyfills' }, + ], + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + + `); + }); + + it('should replace base href value', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + baseHref: '/Apps/', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + `); + }); + + it('should add lang and dir LTR attribute for French (fr)', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'fr', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it('should add lang and dir RTL attribute for Pashto (ps)', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'ps', + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should fallback to use language ID to set the dir attribute (en-US)`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'en-US', + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should work when lang (locale) is not provided by '@angular/common'`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + lang: 'xx-XX', + }); + + expect(warnings).toEqual([ + `Locale data for 'xx-XX' cannot be found. 'dir' attribute will not be set for this locale.`, + ]); + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it(`should add script and link tags even when body and head element doesn't exist`, async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ``, + files: [ + { file: 'styles.css', extension: '.css', name: 'styles' }, + { file: 'runtime.js', extension: '.js', name: 'main' }, + { file: 'main.js', extension: '.js', name: 'main' }, + { file: 'runtime.js', extension: '.js', name: 'polyfills' }, + { file: 'polyfills.js', extension: '.js', name: 'polyfills' }, + ], + }); + + expect(content).toEqual(oneLineHtml` + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "use-credentials" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'use-credentials', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "anonymous" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'anonymous', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with "none" cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'none', + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add preconnect and dns-prefetch hints when provided with no cross origin`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'preconnect', url: 'http://example.com' }, + { mode: 'dns-prefetch', url: 'http://example.com' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add modulepreload hint when provided`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'modulepreload', url: 'x.js' }, + { mode: 'modulepreload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add modulepreload hint with no crossorigin attribute when provided with cross origin set`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + crossOrigin: 'anonymous', + hints: [ + { mode: 'modulepreload', url: 'x.js' }, + { mode: 'modulepreload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=script when specified with a JS url`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'x.js' }, + { mode: 'preload', url: 'y/z.js' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=style when specified with a CSS url`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'x.css' }, + { mode: 'preload', url: 'y/z.css' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should add prefetch/preload hints with as=style when specified with a URL and an 'as' option`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + hints: [ + { mode: 'prefetch', url: 'https://example.com/x?a=1', as: 'style' }, + { mode: 'preload', url: 'http://example.com/y?b=2', as: 'style' }, + ], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it(`should not add deploy URL to hints with an absolute URL`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + deployUrl: 'https://localhost/', + hints: [{ mode: 'preload', url: 'http://example.com/y?b=2' }], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + `); + }); + + it(`should not add deploy URL to hints with a root-relative URL`, async () => { + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + deployUrl: 'https://example.com/', + hints: [{ mode: 'preload', url: '/y?b=2' }], + }); + + expect(warnings).toHaveSize(0); + expect(content).toEqual(oneLineHtml` + + + + + + + + + `); + }); + + it('should add `.mjs` script tags', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + files: [{ file: 'main.mjs', extension: '.mjs', name: 'main' }], + entrypoints: [['main', true /* isModule */]], + }); + + expect(content).toContain(''); + }); + + it('should reject non-module `.mjs` scripts', async () => { + const options: AugmentIndexHtmlOptions = { + ...indexGeneratorOptions, + files: [{ file: 'main.mjs', extension: '.mjs', name: 'main' }], + entrypoints: [['main', false /* isModule */]], + }; + + await expectAsync(augmentIndexHtml(options)).toBeRejectedWithError( + '`.mjs` files *must* set `isModule` to `true`.', + ); + }); + + it('should escape `<` characters in inline importmap JSON', async () => { + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + sri: true, + chunksIntegrity: new Map([['lazy([^<]+)<\/script>/); + expect(match).withContext('importmap script tag missing').not.toBeNull(); + expect(match?.[1]).toContain('./lazy\\u003cchunk.js'); + expect(match?.[1]).not.toContain('./lazy { + const imageDomains = ['https://www.example.com', 'https://www.example2.com']; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it('should add no image preconnects if provided empty domain list', async () => { + const imageDomains: Array = []; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + `); + }); + + it('should not add duplicate preconnects', async () => { + const imageDomains = ['https://www.example1.com', 'https://www.example2.com']; + const { content, warnings } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + `); + }); + + it('should add image preconnects if it encounters preconnect elements for other resources', async () => { + const imageDomains = ['https://www.example2.com', 'https://www.example3.com']; + const { content } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: '', + imageDomains, + }); + + expect(content).toEqual(oneLineHtml` + + + + + + + + + + + `); + }); + + describe('self-closing tags', () => { + it('should return an error when used on a not supported element', async () => { + const { errors } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ` + + + + + ' + `, + }); + + expect(errors.length).toEqual(1); + expect(errors).toEqual([`Invalid self-closing element in index HTML file: ''.`]); + }); + + it('should not return an error when used on a supported element', async () => { + const { errors } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ` + + +
+ + + ' + `, + }); + + expect(errors.length).toEqual(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/auto-csp.ts b/packages/angular/build/src/utils/index-file/auto-csp.ts new file mode 100644 index 000000000000..cd07fb821640 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/auto-csp.ts @@ -0,0 +1,354 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as crypto from 'node:crypto'; +import { StartTag, htmlRewritingStream } from './html-rewriting-stream'; + +/** + * The hash function to use for hash directives to use in the CSP. + */ +const HASH_FUNCTION = 'sha256'; + +/** + * Store the appropriate attributes of a sourced script tag to generate the loader script. + */ +interface SrcScriptTag { + src: string; + type?: string; + async: boolean; + defer: boolean; + integrity?: string; + crossOrigin?: string; +} + +/** + * Get the specified attribute or return undefined if the tag doesn't have that attribute. + * + * @param tag StartTag of the `); + scriptContent = []; + } + + rewriter.on('startTag', (tag) => { + if (tag.tagName === 'script') { + openedScriptTag = tag; + const src = getScriptAttributeValue(tag, 'src'); + + if (src) { + // If there are any interesting attributes, note them down. + const scriptType = getScriptAttributeValue(tag, 'type'); + if (shouldDynamicallyLoadScriptTagBasedOnType(scriptType)) { + const crossOrigin = getScriptAttributeValue(tag, 'crossorigin'); + scriptContent.push({ + src: src, + type: scriptType, + async: getScriptAttributeValue(tag, 'async') !== undefined, + defer: getScriptAttributeValue(tag, 'defer') !== undefined, + integrity: getScriptAttributeValue(tag, 'integrity'), + crossOrigin: crossOrigin === '' ? 'anonymous' : crossOrigin, + }); + + return; // Skip writing my script tag until we've read it all. + } + } + } + // We are encountering the first start tag that's not tag if it's a part of the + // dynamic loader script. + if (src && shouldDynamicallyLoadScriptTagBasedOnType(scriptType)) { + return; + } + } + + if (tag.tagName === 'head' || tag.tagName === 'body' || tag.tagName === 'html') { + // Write the loader script if a string of +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(csps[0]).toContain(hashTextContent("console.log('foo');")); + }); + + it('should rewrite a single source script', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain(`const scripts = [['./main.js', "", false, false, null, null]];`); + }); + + it('should rewrite a single source script in place', async () => { + const result = await autoCsp(` + + + + +
Some text
+ + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // Our loader script appears after the HTML text content. + expect(result).toMatch( + /Some text<\/div>\s* + + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_TWO_HASHES_REGEX); + expect(result).toContain( + // eslint-disable-next-line max-len + `const scripts = [['./main1.js', "", false, false, null, null],['./main2.js', "", true, false, null, null],['./main3.js', "module", true, true, null, null]];`, + ); + // Head loader script is in the head. + expect(result).toContain(``); + // Only two loader scripts are created. + expect(Array.from(result.matchAll(/ + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // & encodes correctly + expect(result).toContain(`'/foo&bar'`); + // Impossible to escape a string and create invalid loader JS with a ' + // (Quotes and backslashes work) + expect(result).toContain(`'/one\\'two%5C\\'three%5C%5C\\'four%5C%5C%5C\\'five'`); + // HTML entities work + expect(result).toContain(`'/one&two&three&four'`); + // Cannot escape JS context to HTML + expect(result).toContain(`'./%3C/script%3E'`); + }); + + it('should rewrite all script tags', async () => { + const result = await autoCsp(` + + + + + + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + // Exactly four hashes for the four scripts that remain (inline, loader, inline, loader). + expect(csps[0]).toMatch(CSP_FOUR_HASHES_REGEX); + expect(csps[0]).toContain(hashTextContent("console.log('foo');")); + expect(csps[0]).toContain(hashTextContent("console.log('bar');")); + // Loader script for main.js and main2.js appear after 'foo' and before 'bar'. + expect(result).toMatch( + // eslint-disable-next-line max-len + /console.log\('foo'\);<\/script>\s* + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + + expect(result).toContain( + // eslint-disable-next-line max-len + `document.lastElementChild.appendChild`, + ); + // Head loader script is in the head. + expect(result).toContain(``); + // Only one loader script is created. + expect(Array.from(result.matchAll(/ +
Some text
+ + \r\n + `); + + const csps = getCsps(result); + expect(result).not.toContain(`\r\n`); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(csps[0]).toContain(hashTextContent(`\r\nconsole.log('foo');\r\n`)); + }); + + it('should preserve integrity and crossorigin attributes in loader script', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "module", false, false, "sha384-xyz123", "anonymous"]];`, + ); + }); + + it('should preserve only integrity attribute when crossorigin is omitted', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "", false, false, "sha384-xyz123", null]];`, + ); + }); + + it('should map empty crossorigin attribute to anonymous', async () => { + const result = await autoCsp(` + + + + + +
Some text
+ + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "", false, false, null, "anonymous"]];`, + ); + }); + + it('should rewrite scripts with application/javascript type', async () => { + const result = await autoCsp(` + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "application/javascript", false, false, null, null]];`, + ); + }); + + it('should rewrite scripts with case-insensitive type and parameters with whitespace', async () => { + const result = await autoCsp(` + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "Text/JavaScript ; charset=utf-8", false, false, null, null]];`, + ); + }); + + it('should rewrite scripts with case-insensitive module type', async () => { + const result = await autoCsp(` + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + expect(result).toContain( + `const scripts = [['./main.js', "Module", false, false, null, null]];`, + ); + }); + + it('should not rewrite non-JavaScript script tags', async () => { + const result = await autoCsp(` + + + + + + + `); + + // No dynamic loader script is emitted because application/json is not JavaScript. + expect(result).toContain(''); + expect(result).not.toContain('const scripts ='); + }); + + it('should encode a script type that carries MIME parameters', async () => { + const result = await autoCsp(` + + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // The type stays inside its string literal. + expect(result).toContain( + `const scripts = [['./main.js', "text/javascript;']];var x=1;var junk=[['a','b", false, false, null, null]];`, + ); + }); + + it('should encode a script type that contains a closing script tag', async () => { + const result = await autoCsp(` + + + + + + + + `); + + const csps = getCsps(result); + expect(csps).toHaveSize(1); + expect(csps[0]).toMatch(CSP_SINGLE_HASH_REGEX); + // Only the loader element is emitted. + expect(Array.from(result.matchAll(/ + + + + + `; + + const { content } = await inlineCriticalCss( + initialContent, + '/dist/', + undefined, + false, + readAsset, + ); + + expect(content).toContain(''); + }); + + it('should capture warnings from beasties', async () => { + const initialContent = ` + + + + + + `; + + const { warnings } = await inlineCriticalCss( + initialContent, + '/dist/', + undefined, + false, + readAsset, + ); + + expect(warnings.length).toBeGreaterThan(0); + expect(warnings[0]).toContain('missing.css'); + }); + + it('should retain nonce on stylesheet links and noscript fallback', async () => { + const initialContent = ` + + + + + + `; + + const { content, errors, warnings } = await inlineCriticalCss( + initialContent, + '/dist/', + undefined, + false, + readAsset, + ); + + expect(errors).toEqual([]); + expect(warnings).toEqual([]); + expect(content).toContain( + '', + ); + expect(content).toContain( + '', + ); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/inline-fonts.ts b/packages/angular/build/src/utils/index-file/inline-fonts.ts new file mode 100644 index 000000000000..52181200c885 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/inline-fonts.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createHash } from 'node:crypto'; +import { readFile, rm, writeFile } from 'node:fs/promises'; +import { type Agent, get as httpsGet } from 'node:https'; +import { join } from 'node:path'; +import { NormalizedCachedOptions } from '../normalize-cache'; +import { htmlRewritingStream } from './html-rewriting-stream'; + +interface FontProviderDetails { + preconnectUrl: string; +} + +export interface InlineFontsOptions { + minify?: boolean; + cache?: NormalizedCachedOptions; +} + +const SUPPORTED_PROVIDERS: Record = { + 'fonts.googleapis.com': { + preconnectUrl: 'https://fonts.gstatic.com', + }, + 'use.typekit.net': { + preconnectUrl: 'https://use.typekit.net', + }, +}; + +/** + * Hash algorithm used for cached files. + */ +const CONTENT_HASH_ALGORITHM = 'sha256'; + +/** + * String length of the SHA-256 content hash stored in cached files. + */ +const CONTENT_HASH_LENGTH = 64; + +export class InlineFontsProcessor { + private readonly cachePath: string | undefined; + constructor(private options: InlineFontsOptions) { + const { path: cacheDirectory, enabled } = this.options.cache || {}; + if (cacheDirectory && enabled) { + this.cachePath = join(cacheDirectory, 'angular-build-fonts'); + } + } + + async process(content: string): Promise { + const hrefList: string[] = []; + const existingPreconnect = new Set(); + + // Collector link tags with href + const { rewriter: collectorStream, transformedContent: initCollectorStream } = + await htmlRewritingStream(content); + + collectorStream.on('startTag', (tag) => { + const { tagName, attrs } = tag; + + if (tagName !== 'link') { + return; + } + + let hrefValue: string | undefined; + let relValue: string | undefined; + for (const { name, value } of attrs) { + switch (name) { + case 'rel': + relValue = value; + break; + + case 'href': + hrefValue = value; + break; + } + + if (hrefValue && relValue) { + switch (relValue) { + case 'stylesheet': + // + hrefList.push(hrefValue); + break; + + case 'preconnect': + // + existingPreconnect.add(hrefValue.replace(/\/$/, '')); + break; + } + + return; + } + } + }); + + void initCollectorStream().catch(() => { + // We don't really care about any errors here because it just initializes + // the rewriting stream, as we are waiting for `finish` below. + }); + + await new Promise((resolve) => collectorStream.on('finish', resolve)); + + // Download stylesheets + const hrefsContent = new Map(); + const newPreconnectUrls = new Set(); + + for (const hrefItem of hrefList) { + const url = this.createNormalizedUrl(hrefItem); + if (!url) { + continue; + } + + const content = await this.processURL(url); + if (content === undefined) { + continue; + } + + hrefsContent.set(hrefItem, content); + + // Add preconnect + const preconnectUrl = this.getFontProviderDetails(url)?.preconnectUrl; + if (preconnectUrl && !existingPreconnect.has(preconnectUrl)) { + newPreconnectUrls.add(preconnectUrl); + } + } + + if (hrefsContent.size === 0) { + return content; + } + + // Replace link with style tag. + const { rewriter, transformedContent } = await htmlRewritingStream(content); + rewriter.on('startTag', (tag) => { + const { tagName, attrs } = tag; + + switch (tagName) { + case 'head': + rewriter.emitStartTag(tag); + for (const url of newPreconnectUrls) { + rewriter.emitRaw(``); + } + break; + + case 'link': { + const hrefAttr = + attrs.some(({ name, value }) => name === 'rel' && value === 'stylesheet') && + attrs.find(({ name, value }) => name === 'href' && hrefsContent.has(value)); + if (hrefAttr) { + const href = hrefAttr.value; + const cssContent = hrefsContent.get(href); + rewriter.emitRaw(``); + } else { + rewriter.emitStartTag(tag); + } + break; + } + default: + rewriter.emitStartTag(tag); + + break; + } + }); + + return transformedContent(); + } + + private async getResponse(url: URL): Promise { + let cacheFile; + if (this.cachePath) { + const key = createHash(CONTENT_HASH_ALGORITHM).update(`${url}`).digest('hex'); + cacheFile = join(this.cachePath, key); + } + + if (cacheFile) { + try { + const data = await readFile(cacheFile, 'utf8'); + // Check for valid content via stored hash + if (data.length > CONTENT_HASH_LENGTH) { + const storedHash = data.slice(0, CONTENT_HASH_LENGTH); + const content = data.slice(CONTENT_HASH_LENGTH); + const contentHash = createHash(CONTENT_HASH_ALGORITHM).update(content).digest('base64'); + if (storedHash === contentHash) { + // Return valid content + return content; + } else { + // Delete corrupted cache content + await rm(cacheFile); + } + } + } catch {} + } + + const httpsProxy = process.env.HTTPS_PROXY ?? process.env.https_proxy; + let agent: Agent | undefined; + if (httpsProxy) { + // TODO: Remove `https-proxy-agent` usage once the min supported version of Node.js is 24.5.0 + // https.globalAgent = new https.Agent({ + // proxyEnv: { HTTPS_PROXY: 'http://proxy.company.com:8080' }, + // }); + // See: https://nodejs.org/en/learn/http/enterprise-network-configuration + // See: https://nodejs.org/docs/latest/api/https.html + + const { HttpsProxyAgent } = (await import('https-proxy-agent' as string)) as typeof import( + 'https-proxy-agent', + { with: { 'resolution-mode': 'import' } } + ); + agent = new HttpsProxyAgent(httpsProxy) as unknown as Agent; + } + + const data = await new Promise((resolve, reject) => { + let rawResponse = ''; + httpsGet( + url, + { + agent, + headers: { + /** + * Always use a Windows UA. This is because Google fonts will including hinting in fonts for Windows. + * Hinting is a technique used with Windows files to improve appearance however + * results in 20-50% larger file sizes. + * + * @see http://google3/java/com/google/fonts/css/OpenSansWebFontsCssBuilder.java?l=22 + * @see https://fonts.google.com/knowledge/glossary/hinting (short) + * @see https://glyphsapp.com/learn/hinting-manual-truetype-hinting (deep dive) + */ + 'user-agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }, + (res) => { + if (res.statusCode !== 200) { + reject( + new Error( + `Inlining of fonts failed. ${url} returned status code: ${res.statusCode}.`, + ), + ); + + return; + } + + res.on('data', (chunk) => (rawResponse += chunk)).on('end', () => resolve(rawResponse)); + }, + ).on('error', (e) => + reject( + new Error( + `Inlining of fonts failed. An error has occurred while retrieving ${url} over the internet.\n` + + e.message, + ), + ), + ); + }); + + if (cacheFile) { + try { + const dataHash = createHash(CONTENT_HASH_ALGORITHM).update(data).digest('hex'); + await writeFile(cacheFile, dataHash + data); + } catch {} + } + + return data; + } + + async processURL(url: string | URL): Promise { + const normalizedURL = url instanceof URL ? url : this.createNormalizedUrl(url); + if (!normalizedURL) { + return; + } + + const provider = this.getFontProviderDetails(normalizedURL); + if (!provider) { + return undefined; + } + + let cssContent = await this.getResponse(normalizedURL); + + if (this.options.minify) { + cssContent = cssContent + // Comments. + .replace(/\/\*([\s\S]*?)\*\//g, '') + // New lines. + .replace(/\n/g, '') + // Safe spaces. + .replace(/\s?[{:;]\s+/g, (s) => s.trim()); + } + + return cssContent; + } + + canInlineRequest(url: string): boolean { + const normalizedUrl = this.createNormalizedUrl(url); + + return normalizedUrl ? !!this.getFontProviderDetails(normalizedUrl) : false; + } + + private getFontProviderDetails(url: URL): FontProviderDetails | undefined { + return SUPPORTED_PROVIDERS[url.hostname]; + } + + private createNormalizedUrl(value: string): URL | undefined { + // Need to convert '//' to 'https://' because the URL parser will fail with '//'. + const url = new URL(value.startsWith('//') ? `https:${value}` : value, 'resolve://'); + + switch (url.protocol) { + case 'http:': + case 'https:': + url.protocol = 'https:'; + + return url; + default: + return undefined; + } + } +} diff --git a/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts b/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts new file mode 100644 index 000000000000..46511f853ecf --- /dev/null +++ b/packages/angular/build/src/utils/index-file/inline-fonts_spec.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { InlineFontsProcessor } from './inline-fonts'; + +describe('InlineFontsProcessor', () => { + describe('Google fonts', () => { + const content = ` + + + + + + `; + + it('works with // protocol', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(content.replace('https://', '//')); + expect(html).toContain(`format('woff2');`); + }); + + it('should inline supported fonts and icons in HTML', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(` + + + + + + + + `); + + expect(html).not.toContain( + 'href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"', + ); + expect(html).not.toContain('href="https://fonts.googleapis.com/icon?family=Material+Icons"'); + expect(html).toContain('href="theme.css"'); + expect(html).toContain(`font-family: 'Roboto'`); + expect(html).toContain(`font-family: 'Material Icons'`); + }); + + it('should inline multiple fonts from a single request with minification enabled', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: true, + }); + + const html = await inlineFontsProcessor.process(` + + + + + + + `); + + expect(html).toContain(`'Google Sans'`); + expect(html).toContain(`'Roboto'`); + expect(html).toContain(`'Roboto Mono'`); + expect(html).toContain(`'Material Icons'`); + }); + + it('works with http protocol', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(content.replace('https://', 'http://')); + expect(html).toContain(`format('woff2');`); + }); + + it('should remove comments and line breaks when `minifyInlinedCSS` is true', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: true, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).not.toContain('/*'); + expect(html).toContain(';font-style:normal;'); + }); + + it('should add preconnect hint', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).toContain( + ``, + ); + }); + }); + + describe('Adobe Typekit fonts', () => { + const content = ` + + + + + + `; + + it('should add preconnect hint', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).toContain(``); + }); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/ngcm-attribute.ts b/packages/angular/build/src/utils/index-file/ngcm-attribute.ts new file mode 100644 index 000000000000..af74778915e6 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/ngcm-attribute.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { htmlRewritingStream } from './html-rewriting-stream'; + +/** + * Defines a name of an attribute that is added to the `` tag + * in the `index.html` file in case a given route was configured + * with `RenderMode.Client`. 'cm' is an abbreviation for "Client Mode". + * + * @see https://github.com/angular/angular/pull/58004 + */ +const CLIENT_RENDER_MODE_FLAG = 'ngcm'; + +/** + * Transforms the provided HTML by adding the `ngcm` attribute to the `` tag. + * This is used in the client-side rendered (CSR) version of `index.html` to prevent hydration warnings. + * + * @param html The HTML markup to be transformed. + * @returns A promise that resolves to the transformed HTML string with the necessary modifications. + */ +export async function addNgcmAttribute(html: string): Promise { + const { rewriter, transformedContent } = await htmlRewritingStream(html); + + rewriter.on('startTag', (tag) => { + if ( + tag.tagName === 'body' && + !tag.attrs.some((attr) => attr.name === CLIENT_RENDER_MODE_FLAG) + ) { + tag.attrs.push({ name: CLIENT_RENDER_MODE_FLAG, value: '' }); + } + + rewriter.emitStartTag(tag); + }); + + return transformedContent(); +} diff --git a/packages/angular/build/src/utils/index-file/ngcm-attribute_spec.ts b/packages/angular/build/src/utils/index-file/ngcm-attribute_spec.ts new file mode 100644 index 000000000000..2eec900c862d --- /dev/null +++ b/packages/angular/build/src/utils/index-file/ngcm-attribute_spec.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { addNgcmAttribute } from './ngcm-attribute'; + +describe('addNgcmAttribute', () => { + it('should add the ngcm attribute to the tag', async () => { + const result = await addNgcmAttribute(` + + +

hello world!

+ + `); + + expect(result).toContain('

hello world!

'); + }); + + it('should not override an existing ngcm attribute', async () => { + const result = await addNgcmAttribute(` + + +

hello world!

+ + `); + + expect(result).toContain('

hello world!

'); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/nonce.ts b/packages/angular/build/src/utils/index-file/nonce.ts new file mode 100644 index 000000000000..fabb118cd9da --- /dev/null +++ b/packages/angular/build/src/utils/index-file/nonce.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { htmlRewritingStream } from './html-rewriting-stream'; + +/** + * Pattern matching the name of the Angular nonce attribute. Note that this is + * case-insensitive, because HTML attribute names are case-insensitive as well. + */ +const NONCE_ATTR_PATTERN = /ngCspNonce/i; + +const TARGET_LINK_RELS: ReadonlySet = new Set(['stylesheet', 'modulepreload']); + +/** + * Finds the `ngCspNonce` value and copies it to all ` + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should add a lowercase nonce expression to style tags', async () => { + const result = await addNonce(` + + + + + + + + + `); + + expect(result).toContain(''); + }); + + it('should preserve any pre-existing nonces', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should use the first nonce that is defined on the page', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + }); + + it('should to all script tags', async () => { + const result = await addNonce(` + + + + + + + + + + + `); + + expect(result).toContain(``); + expect(result).toContain(''); + expect(result).toContain(``); + }); + + it('should add the nonce expression to stylesheet and modulepreload link tags', async () => { + const result = await addNonce(` + + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + expect(result).toContain(''); + }); + + it('should add the nonce expression when link rel has mixed case or multiple tokens', async () => { + const result = await addNonce(` + + + + + + + + + + `); + + expect(result).toContain(''); + expect(result).toContain(''); + }); +}); diff --git a/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts new file mode 100644 index 000000000000..f86d556b36f0 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** A list of valid self closing HTML elements */ +export const VALID_SELF_CLOSING_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + + /** SVG tags */ + 'animate', + 'animateMotion', + 'animateTransform', + 'circle', + 'ellipse', + 'feBlend', + 'feColorMatrix', + 'feComponentTransfer', + 'feComposite', + 'feConvolveMatrix', + 'feDiffuseLighting', + 'feDisplacementMap', + 'feDistantLight', + 'feDropShadow', + 'feFlood', + 'feFuncA', + 'feFuncB', + 'feFuncG', + 'feFuncR', + 'feGaussianBlur', + 'feImage', + 'feMerge', + 'feMergeNode', + 'feMorphology', + 'feOffset', + 'fePointLight', + 'feSpecularLighting', + 'feSpotLight', + 'feTile', + 'feTurbulence', + 'line', + 'path', + 'polygon', + 'polyline', + 'rect', + 'text', + 'tspan', + 'linearGradient', + 'radialGradient', + 'stop', + 'image', + 'pattern', + 'defs', + 'g', + 'marker', + 'mask', + 'style', + 'symbol', + 'use', + 'view', + + /** MathML tags */ + 'mspace', + 'mphantom', + 'mrow', + 'mfrac', + 'msqrt', + 'mroot', + 'mstyle', + 'merror', + 'mpadded', + 'mtable', +]); diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts new file mode 100644 index 000000000000..9df32fc10568 --- /dev/null +++ b/packages/angular/build/src/utils/index.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './normalize-asset-patterns'; +export * from './normalize-optimization'; +export * from './normalize-source-maps'; +export * from './load-proxy-config'; +export * from './source-map'; diff --git a/packages/angular/build/src/utils/load-esm.ts b/packages/angular/build/src/utils/load-esm.ts new file mode 100644 index 000000000000..6a6220f66288 --- /dev/null +++ b/packages/angular/build/src/utils/load-esm.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Lazily compiled dynamic import loader function. + */ +let load: ((modulePath: string | URL) => Promise) | undefined; + +/** + * This uses a dynamic import to load a module which may be ESM. + * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript + * will currently, unconditionally downlevel dynamic import into a require call. + * require calls cannot load ESM code and will result in a runtime error. To workaround + * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. + * Once TypeScript provides support for keeping the dynamic import this workaround can + * be dropped. + * + * @param modulePath The path of the module to load. + * @returns A Promise that resolves to the dynamically imported module. + */ +export function loadEsmModule(modulePath: string | URL): Promise { + load ??= new Function('modulePath', `return import(modulePath);`) as Exclude< + typeof load, + undefined + >; + + return load(modulePath); +} diff --git a/packages/angular/build/src/utils/load-proxy-config.ts b/packages/angular/build/src/utils/load-proxy-config.ts new file mode 100644 index 000000000000..e40b1981d764 --- /dev/null +++ b/packages/angular/build/src/utils/load-proxy-config.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { makeRe as makeRegExpFromGlob } from 'picomatch'; +import { isDynamicPattern } from 'tinyglobby'; +import { assertIsError } from './error'; +import { loadEsmModule } from './load-esm'; + +export async function loadProxyConfiguration( + root: string, + proxyConfig: string | undefined, +): Promise | undefined> { + if (!proxyConfig) { + return undefined; + } + + const proxyPath = resolve(root, proxyConfig); + + if (!existsSync(proxyPath)) { + throw new Error(`Proxy configuration file ${proxyPath} does not exist.`); + } + + let proxyConfiguration; + switch (extname(proxyPath)) { + case '.json': { + const content = await readFile(proxyPath, 'utf-8'); + + const { parse, printParseErrorCode } = await import('jsonc-parser'); + const parseErrors: import('jsonc-parser').ParseError[] = []; + proxyConfiguration = parse(content, parseErrors, { allowTrailingComma: true }); + + if (parseErrors.length > 0) { + let errorMessage = `Proxy configuration file ${proxyPath} contains parse errors:`; + for (const parseError of parseErrors) { + const { line, column } = getJsonErrorLineColumn(parseError.offset, content); + errorMessage += `\n[${line}, ${column}] ${printParseErrorCode(parseError.error)}`; + } + throw new Error(errorMessage); + } + + break; + } + default: { + try { + proxyConfiguration = await import(proxyPath); + } catch (e) { + assertIsError(e); + if (e.code !== 'ERR_REQUIRE_ASYNC_MODULE') { + throw e; + } + + proxyConfiguration = await loadEsmModule<{ default: unknown }>(pathToFileURL(proxyPath)); + } + + break; + } + } + + if ('default' in proxyConfiguration) { + proxyConfiguration = proxyConfiguration.default; + } + + return normalizeProxyConfiguration(proxyConfiguration); +} + +/** + * Converts glob patterns to regular expressions to support Vite's proxy option. + * Also converts the Webpack supported array form to an object form supported by both. + * + * @param proxy A proxy configuration object. + */ +function normalizeProxyConfiguration( + proxy: Record | object[], +): Record { + let normalizedProxy: Record | undefined; + + if (Array.isArray(proxy)) { + // Construct an object-form proxy configuration from the array + normalizedProxy = {}; + for (const proxyEntry of proxy) { + if (!('context' in proxyEntry)) { + continue; + } + + // Array-form entries contain a context string array with the path(s) + // to use for the configuration entry. A single path string is also + // accepted, as with the Webpack-based development server. + const context = + typeof proxyEntry.context === 'string' ? [proxyEntry.context] : proxyEntry.context; + if (!Array.isArray(context)) { + continue; + } + + delete proxyEntry.context; + for (const contextEntry of context) { + if (typeof contextEntry !== 'string') { + continue; + } + + normalizedProxy[contextEntry] = proxyEntry; + } + } + } else { + normalizedProxy = proxy; + } + + // TODO: Consider upstreaming glob support + // The object is rebuilt so that converted glob entries keep their original position, + // since Vite proxies a request with the first entry that matches it. + const orderedProxy: Record = {}; + for (const [key, value] of Object.entries(normalizedProxy)) { + const context = key[0] !== '^' && isDynamicPattern(key) ? makeRegExpFromGlob(key).source : key; + orderedProxy[context] = value; + } + normalizedProxy = orderedProxy; + + // Replace `pathRewrite` field with a `rewrite` function + for (const proxyEntry of Object.values(normalizedProxy)) { + if ( + typeof proxyEntry === 'object' && + 'pathRewrite' in proxyEntry && + proxyEntry.pathRewrite && + typeof proxyEntry.pathRewrite === 'object' + ) { + // Preprocess path rewrite entries + const pathRewriteEntries: [RegExp, string][] = []; + for (const [pattern, value] of Object.entries( + proxyEntry.pathRewrite as Record, + )) { + pathRewriteEntries.push([new RegExp(pattern), value]); + } + + (proxyEntry as Record).rewrite = pathRewriter.bind( + undefined, + pathRewriteEntries, + ); + + delete proxyEntry.pathRewrite; + } + } + + return normalizedProxy; +} + +function pathRewriter(pathRewriteEntries: [RegExp, string][], path: string): string { + for (const [pattern, value] of pathRewriteEntries) { + const updated = path.replace(pattern, value); + if (path !== updated) { + return updated; + } + } + + return path; +} + +/** + * Calculates the line and column for an error offset in the content of a JSON file. + * @param location The offset error location from the beginning of the content. + * @param content The full content of the file containing the error. + * @returns An object containing the line and column + */ +function getJsonErrorLineColumn(offset: number, content: string) { + if (offset === 0) { + return { line: 1, column: 1 }; + } + + let line = 0; + let position = 0; + // eslint-disable-next-line no-constant-condition + while (true) { + ++line; + + const nextNewline = content.indexOf('\n', position); + if (nextNewline === -1 || nextNewline > offset) { + break; + } + + position = nextNewline + 1; + } + + return { line, column: offset - position + 1 }; +} diff --git a/packages/angular/build/src/utils/load-rolldown.ts b/packages/angular/build/src/utils/load-rolldown.ts new file mode 100644 index 000000000000..78af6aeb3c9d --- /dev/null +++ b/packages/angular/build/src/utils/load-rolldown.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY'; + +let rolldownPromise: Promise | undefined; + +/** + * Dynamically loads the `rolldown` module while configuring its embedded + * mimalloc v3 allocator to immediately purge freed arena memory. + * + * Rolldown's native binding statically links mimalloc v3, which defaults to a + * 4-second arena purge delay (`MIMALLOC_PURGE_DELAY=1000` * `MIMALLOC_ARENA_PURGE_MULT=4`) + * and only triggers purges cooperatively during subsequent allocations. In single-run + * CLI builds where Rolldown is not invoked again after bundling completes, non-zero + * purge delays prevent freed arena pages from being decommitted back to the OS, + * leading to high peak RSS during post-bundle stages. + * + * Because mimalloc reads and caches environment options synchronously when the native + * `.node` addon is first loaded (`mi_process_init` -> `_mi_options_init`), temporarily + * setting `MIMALLOC_PURGE_DELAY=0` during the initial `import('rolldown')` call + * configures Rolldown's allocator without leaving the environment variable set for + * subsequent code or child processes. + * + * @returns A promise that resolves to the `rolldown` module namespace. + */ +export function loadRolldown(): Promise { + if (!rolldownPromise) { + const hasCustomPurgeDelay = Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY); + if (!hasCustomPurgeDelay) { + process.env[MIMALLOC_PURGE_DELAY] = '0'; + } + + rolldownPromise = import('rolldown').finally(() => { + if (!hasCustomPurgeDelay) { + delete process.env[MIMALLOC_PURGE_DELAY]; + } + }); + } + + return rolldownPromise; +} diff --git a/packages/angular/build/src/utils/load-rolldown_spec.ts b/packages/angular/build/src/utils/load-rolldown_spec.ts new file mode 100644 index 000000000000..0f7ea7cb4a80 --- /dev/null +++ b/packages/angular/build/src/utils/load-rolldown_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +const MIMALLOC_PURGE_DELAY = 'MIMALLOC_PURGE_DELAY'; + +describe('loadRolldown', () => { + const originalEnvValue = process.env[MIMALLOC_PURGE_DELAY]; + + function getFreshLoadRolldown(): typeof import('./load-rolldown') { + delete require.cache[require.resolve('./load-rolldown')]; + + return require('./load-rolldown'); + } + + afterEach(() => { + if (originalEnvValue !== undefined) { + process.env[MIMALLOC_PURGE_DELAY] = originalEnvValue; + } else { + delete process.env[MIMALLOC_PURGE_DELAY]; + } + delete require.cache[require.resolve('./load-rolldown')]; + }); + + it('loads rolldown and cleans up temporary MIMALLOC_PURGE_DELAY when unset', async () => { + delete process.env[MIMALLOC_PURGE_DELAY]; + const { loadRolldown } = getFreshLoadRolldown(); + + const promise1 = loadRolldown(); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('0'); + + const promise2 = loadRolldown(); + expect(promise1).toBe(promise2); + + const rolldownModule = await promise1; + expect(typeof rolldownModule.rolldown).toBe('function'); + expect(Object.hasOwn(process.env, MIMALLOC_PURGE_DELAY)).toBeFalse(); + }); + + it('preserves user-configured MIMALLOC_PURGE_DELAY when already set', async () => { + process.env[MIMALLOC_PURGE_DELAY] = '1000'; + const { loadRolldown } = getFreshLoadRolldown(); + + const promise = loadRolldown(); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000'); + + const rolldownModule = await promise; + expect(typeof rolldownModule.rolldown).toBe('function'); + expect(process.env[MIMALLOC_PURGE_DELAY]).toBe('1000'); + }); +}); diff --git a/packages/angular/build/src/utils/load-translations.ts b/packages/angular/build/src/utils/load-translations.ts new file mode 100644 index 000000000000..e202273dc73d --- /dev/null +++ b/packages/angular/build/src/utils/load-translations.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Diagnostics } from '@angular/localize/tools'; +import { createHash } from 'node:crypto'; +import * as fs from 'node:fs'; + +export type TranslationLoader = (path: string) => { + translations: Record; + format: string; + locale?: string; + diagnostics: Diagnostics; + integrity: string; +}; + +export async function createTranslationLoader(): Promise { + const { parsers, diagnostics } = await importParsers(); + + return (path: string) => { + const content = fs.readFileSync(path, 'utf8'); + const unusedParsers = new Map(); + for (const [format, parser] of Object.entries(parsers)) { + const analysis = parser.analyze(path, content); + if (analysis.canParse) { + // Types don't overlap here so we need to use any. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { locale, translations } = parser.parse(path, content, analysis.hint as any); + const integrity = 'sha256-' + createHash('sha256').update(content).digest('base64'); + + return { format, locale, translations, diagnostics, integrity }; + } else { + unusedParsers.set(parser, analysis); + } + } + + const messages: string[] = []; + for (const [parser, analysis] of unusedParsers.entries()) { + messages.push(analysis.diagnostics.formatDiagnostics(`*** ${parser.constructor.name} ***`)); + } + throw new Error( + `Unsupported translation file format in ${path}. The following parsers were tried:\n` + + messages.join('\n'), + ); + }; +} + +async function importParsers() { + try { + // Load ESM `@angular/localize/tools` using the TypeScript dynamic import workaround. + // Once TypeScript provides support for keeping the dynamic import this workaround can be + // changed to a direct dynamic import. + const { + Diagnostics, + ArbTranslationParser, + SimpleJsonTranslationParser, + Xliff1TranslationParser, + Xliff2TranslationParser, + XtbTranslationParser, + } = await import('@angular/localize/tools'); + + const diagnostics = new Diagnostics(); + const parsers = { + arb: new ArbTranslationParser(), + json: new SimpleJsonTranslationParser(), + xlf: new Xliff1TranslationParser(), + xlf2: new Xliff2TranslationParser(), + // The name ('xmb') needs to match the AOT compiler option + xmb: new XtbTranslationParser(), + }; + + return { parsers, diagnostics }; + } catch { + throw new Error( + `Unable to load translation file parsers. Please ensure '@angular/localize' is installed.`, + ); + } +} diff --git a/packages/angular/build/src/utils/normalize-asset-patterns.ts b/packages/angular/build/src/utils/normalize-asset-patterns.ts new file mode 100644 index 000000000000..defa6511172f --- /dev/null +++ b/packages/angular/build/src/utils/normalize-asset-patterns.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { statSync } from 'node:fs'; +import path from 'node:path'; +import type { AssetPattern, AssetPatternClass } from '../builders/application/schema'; +import { isSubDirectory } from './path'; + +/** + * Asset pattern configuration normalized into its object form with a resolved `output` directory. + */ +export interface NormalizedAssetsPattern extends AssetPatternClass { + /** Relative output directory within the build output path. */ + output: string; +} + +/** + * Normalizes raw string and object asset patterns into {@link NormalizedAssetsPattern} objects + * and validates that all asset inputs and outputs stay within the workspace and output directories. + * + * @param assetPatterns Raw string or object asset patterns from builder options. + * @param workspaceRoot Workspace root directory path. + * @param projectRoot Project root directory path. + * @param projectSourceRoot Optional project source root directory path. + * @returns An array of normalized asset pattern configurations. + */ +export function normalizeAssetPatterns( + assetPatterns: AssetPattern[], + workspaceRoot: string, + projectRoot: string, + projectSourceRoot: string | undefined, +): NormalizedAssetsPattern[] { + if (assetPatterns.length === 0) { + return []; + } + + // When sourceRoot is not available, we default to ${projectRoot}/src. + const sourceRoot = projectSourceRoot || path.join(projectRoot, 'src'); + const resolvedWorkspaceRoot = path.resolve(workspaceRoot); + const resolvedSourceRoot = path.resolve(resolvedWorkspaceRoot, sourceRoot); + const resolvedProjectRoot = path.resolve(resolvedWorkspaceRoot, projectRoot); + + return assetPatterns.map((assetPattern) => { + let normalized: NormalizedAssetsPattern; + + // Normalize string asset patterns to objects. + if (typeof assetPattern === 'string') { + const assetPath = path.normalize(assetPattern); + const resolvedAssetPath = path.resolve(resolvedWorkspaceRoot, assetPath); + let root: string; + + // Check if the string asset is within sourceRoot. + if (resolvedAssetPath.startsWith(resolvedSourceRoot)) { + root = resolvedSourceRoot; + } else if (resolvedAssetPath.startsWith(resolvedProjectRoot)) { + root = resolvedProjectRoot; + } else if (resolvedAssetPath.startsWith(resolvedWorkspaceRoot)) { + root = resolvedWorkspaceRoot; + } else { + throw new Error(`The ${assetPattern} asset path must be within the workspace root.`); + } + + let isDirectory = true; + try { + isDirectory = statSync(resolvedAssetPath, { throwIfNoEntry: false })?.isDirectory() ?? true; + } catch {} + const glob = isDirectory ? '**/*' : path.basename(assetPath); + const input = isDirectory ? assetPath : path.dirname(assetPath); + + // Output directory for both is the relative path from the root to input. + const output = path.relative(root, path.resolve(resolvedWorkspaceRoot, input)); + + normalized = { glob, input, output }; + } else { + if (!isSubDirectory(resolvedWorkspaceRoot, assetPattern.input)) { + throw new Error(`The ${assetPattern.input} asset path must be within the workspace root.`); + } + + normalized = { + ...assetPattern, + output: path.join('.', assetPattern.output ?? ''), + }; + } + + if (normalized.output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return normalized; + }); +} diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts new file mode 100644 index 000000000000..bb24f774f79b --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; + +/** Version placeholder is replaced during the build process with actual package version */ +const VERSION = '0.0.0-PLACEHOLDER'; + +export interface NormalizedCachedOptions { + /** Whether disk cache is enabled. */ + enabled: boolean; + + /** Disk cache path. Example: `/.angular/cache/v12.0.0`. */ + path: string; + + /** Disk cache base path. Example: `/.angular/cache`. */ + basePath: string; + + /** + * Workspace-local disk cache path. Example: `/.angular/cache/v12.0.0`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localPath?: string; + + /** + * Workspace-local disk cache base path. Example: `/.angular/cache`. + * Always resolves relative to the current workspace root, even within a Git worktree. + */ + localBasePath?: string; +} + +interface CacheMetadata { + enabled?: boolean; + environment?: 'local' | 'ci' | 'all'; + path?: string; +} + +function hasCacheMetadata(value: unknown): value is { cli: { cache: CacheMetadata } } { + return ( + !!value && + typeof value === 'object' && + 'cli' in value && + !!value['cli'] && + typeof value['cli'] === 'object' && + 'cache' in value['cli'] + ); +} + +function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): string { + if (isAbsolute(cachePathSetting)) { + return cachePathSetting; + } + + try { + // Find the git directory, walking up from workspaceRoot if necessary + let currentDir = workspaceRoot; + while (true) { + const gitPath = join(currentDir, '.git'); + if (existsSync(gitPath)) { + const stat = statSync(gitPath); + if (stat.isFile()) { + // Could be a git worktree (or submodule) + const content = readFileSync(gitPath, 'utf8'); + const match = /^gitdir:\s*(.+)$/m.exec(content); + if (match) { + const gitdir = resolve(currentDir, match[1].trim()); + const commondirPath = join(gitdir, 'commondir'); + if (existsSync(commondirPath)) { + // It's a git worktree + const commondir = readFileSync(commondirPath, 'utf8').trim(); + const commonGitDir = resolve(gitdir, commondir); + const relativeWorkspacePath = relative(currentDir, workspaceRoot); + const mainWorkspaceRoot = resolve(dirname(commonGitDir), relativeWorkspacePath); + if (existsSync(mainWorkspaceRoot)) { + return resolve(mainWorkspaceRoot, cachePathSetting); + } + } + } + } + + break; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + } catch {} + + return resolve(workspaceRoot, cachePathSetting); +} + +/** + * Normalizes the persistent disk cache configuration for a project or workspace. + * + * Resolves whether disk caching is enabled based on the CLI cache metadata, current runtime + * environment (local vs. CI, or WebContainers), and computes the versioned cache directory paths + * (resolving shared Git worktree paths when applicable). + * + * @param projectMetadata The project or workspace metadata object containing optional `cli.cache` settings. + * @param workspaceRoot The absolute path to the workspace root directory. + * @param projectName Optional name of the project used to scope the resolved cache path. + * @param builderName Optional name of the builder or tool used to further scope the resolved cache path. + * @returns The normalized disk cache options including enabled state and resolved directory paths. + */ +export function normalizeCacheOptions( + projectMetadata: unknown, + workspaceRoot: string, + projectName = '', + builderName = '', +): NormalizedCachedOptions { + const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {}; + + const { + // Webcontainers do not currently benefit from persistent disk caching and can lead to increased browser memory usage + enabled = !process.versions.webcontainer, + environment = 'local', + path = '.angular/cache', + } = cacheMetadata; + const isCI = process.env['CI'] === '1' || process.env['CI']?.toLowerCase() === 'true'; + + let cacheEnabled = enabled; + if (cacheEnabled) { + switch (environment) { + case 'ci': + cacheEnabled = isCI; + break; + case 'local': + cacheEnabled = !isCI; + break; + } + } + + const cacheBasePath = getCacheBasePath(workspaceRoot, path); + const localCacheBasePath = isAbsolute(path) ? path : resolve(workspaceRoot, path); + const normalizedBuilderName = builderName.replace('@', '').replaceAll(/[:/]/g, '-'); + + return { + enabled: cacheEnabled, + basePath: cacheBasePath, + path: join(cacheBasePath, VERSION, projectName, normalizedBuilderName), + localBasePath: localCacheBasePath, + localPath: join(localCacheBasePath, VERSION, projectName, normalizedBuilderName), + }; +} diff --git a/packages/angular/build/src/utils/normalize-cache_spec.ts b/packages/angular/build/src/utils/normalize-cache_spec.ts new file mode 100644 index 000000000000..1a0520c96a96 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache_spec.ts @@ -0,0 +1,292 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { normalizeCacheOptions } from './normalize-cache'; + +describe('normalizeCacheOptions', () => { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'angular-cache-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it('should resolve cache path relative to workspace root in a standard repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to main repository root in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + // Create main repo structure + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree folder and .git file pointing to the main repo's worktree metadata folder + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Create the commondir file in the worktree metadata folder pointing back to the main .git dir + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to corresponding nested workspace in main repository for a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const mainWorkspaceRoot = join(mainRepoRoot, 'Site1/ClientApp'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeWorkspaceRoot = join(worktreeRoot, 'Site1/ClientApp'); + + // Create main repo with a nested Angular workspace directory + await mkdir(mainGitDir, { recursive: true }); + await mkdir(mainWorkspaceRoot, { recursive: true }); + + // Create worktree with the same nested Angular workspace structure and a .git file at the worktree root + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeWorkspaceRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Point the worktree metadata back to the main .git directory + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeWorkspaceRoot); + + expect(options.basePath).toBe(resolve(mainWorkspaceRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + }); + + it('should fall back to worktree workspace root when nested workspace does not exist in main repository', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeWorkspaceRoot = join(worktreeRoot, 'NewSite/ClientApp'); + + // Create main repo without the 'NewSite/ClientApp' subdirectory (e.g., added only on the worktree branch) + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree with the new nested Angular workspace and link it to the main repo's .git directory + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeWorkspaceRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeWorkspaceRoot); + + expect(options.basePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + expect(options.localBasePath).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeWorkspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER'), + ); + }); + + it('should resolve local cache path relative to worktree root with custom relative path', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: 'custom-cache' } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(resolve(mainRepoRoot, 'custom-cache')); + expect(options.path).toBe(resolve(mainRepoRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, 'custom-cache')); + expect(options.localPath).toBe(resolve(worktreeRoot, 'custom-cache', '0.0.0-PLACEHOLDER')); + }); + + it('should preserve absolute cache path for both shared and local paths', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const absoluteCachePath = join(tempDir, 'absolute-cache'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions( + { cli: { cache: { path: absoluteCachePath } } }, + worktreeRoot, + ); + + expect(options.basePath).toBe(absoluteCachePath); + expect(options.path).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); + expect(options.localBasePath).toBe(absoluteCachePath); + expect(options.localPath).toBe(resolve(absoluteCachePath, '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to workspace root in a git submodule', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const submoduleRoot = join(mainRepoRoot, 'submodule'); + + // Create main repo structure and submodule metadata folder + const submoduleGitDir = join(mainRepoRoot, '.git/modules/sub'); + await mkdir(submoduleGitDir, { recursive: true }); + await mkdir(submoduleRoot, { recursive: true }); + + // Create .git file in submodule pointing to the metadata folder + await writeFile(join(submoduleRoot, '.git'), `gitdir: ../.git/modules/sub`); + + // Submodules do NOT have a 'commondir' file. + const options = normalizeCacheOptions({}, submoduleRoot); + + expect(options.basePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(submoduleRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(submoduleRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should resolve cache path relative to workspace root when there is no git repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(workspaceRoot, { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe(resolve(workspaceRoot, '.angular/cache', '0.0.0-PLACEHOLDER')); + }); + + it('should scope path and localPath to projectName when provided', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app'); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.path).toBe(resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app')); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app'), + ); + }); + + it('should scope path and localPath to projectName in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot, 'my-lib'); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe(resolve(mainRepoRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-lib')); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-lib'), + ); + }); + + it('should scope path and localPath to projectName and builderName when provided', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app', 'vite'); + + expect(options.basePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + expect(options.localBasePath).toBe(resolve(workspaceRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + }); + + it('should scope path and localPath to projectName and builderName in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + await mkdir(mainGitDir, { recursive: true }); + + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const options = normalizeCacheOptions({}, worktreeRoot, 'my-app', 'vite'); + + expect(options.basePath).toBe(resolve(mainRepoRoot, '.angular/cache')); + expect(options.path).toBe( + resolve(mainRepoRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + expect(options.localBasePath).toBe(resolve(worktreeRoot, '.angular/cache')); + expect(options.localPath).toBe( + resolve(worktreeRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/vite'), + ); + }); + + it('should normalize builderName to avoid invalid path characters', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const options = normalizeCacheOptions({}, workspaceRoot, 'my-app', '@angular/build:unit-test'); + + expect(options.path).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/angular-build-unit-test'), + ); + expect(options.localPath).toBe( + resolve(workspaceRoot, '.angular/cache/0.0.0-PLACEHOLDER/my-app/angular-build-unit-test'), + ); + }); +}); diff --git a/packages/angular/build/src/utils/normalize-optimization.ts b/packages/angular/build/src/utils/normalize-optimization.ts new file mode 100644 index 000000000000..fcd5b556f27f --- /dev/null +++ b/packages/angular/build/src/utils/normalize-optimization.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + FontsClass, + OptimizationClass, + OptimizationUnion, + StylesClass, +} from '../builders/application/schema'; + +export type NormalizedOptimizationOptions = Required< + Omit +> & { + fonts: FontsClass; + styles: StylesClass; +}; + +export function normalizeOptimization( + optimization: OptimizationUnion = true, +): NormalizedOptimizationOptions { + if (typeof optimization === 'object') { + const styleOptimization = !!optimization.styles; + + return { + scripts: !!optimization.scripts, + styles: + typeof optimization.styles === 'object' + ? optimization.styles + : { + minify: styleOptimization, + removeSpecialComments: styleOptimization, + inlineCritical: styleOptimization, + }, + fonts: + typeof optimization.fonts === 'object' + ? optimization.fonts + : { + inline: !!optimization.fonts, + }, + }; + } + + return { + scripts: optimization, + styles: { + minify: optimization, + inlineCritical: optimization, + removeSpecialComments: optimization, + }, + fonts: { + inline: optimization, + }, + }; +} diff --git a/packages/angular/build/src/utils/normalize-source-maps.ts b/packages/angular/build/src/utils/normalize-source-maps.ts new file mode 100644 index 000000000000..cf26ca236bae --- /dev/null +++ b/packages/angular/build/src/utils/normalize-source-maps.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SourceMapClass, SourceMapUnion } from '../builders/application/schema'; + +export function normalizeSourceMaps(sourceMap: SourceMapUnion): SourceMapClass { + const scripts = typeof sourceMap === 'object' ? sourceMap.scripts : sourceMap; + const styles = typeof sourceMap === 'object' ? sourceMap.styles : sourceMap; + const hidden = (typeof sourceMap === 'object' && sourceMap.hidden) || false; + const vendor = (typeof sourceMap === 'object' && sourceMap.vendor) || false; + const sourcesContent = typeof sourceMap === 'object' ? sourceMap.sourcesContent : sourceMap; + + return { + vendor, + hidden, + scripts, + styles, + sourcesContent, + }; +} diff --git a/packages/angular/build/src/utils/path.ts b/packages/angular/build/src/utils/path.ts new file mode 100644 index 000000000000..a236878d134e --- /dev/null +++ b/packages/angular/build/src/utils/path.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isAbsolute, posix, relative, resolve } from 'node:path'; +import { platform } from 'node:process'; + +const WINDOWS_PATH_SEPERATOR_REGEXP = /\\/g; + +/** + * Converts a Windows-style file path to a POSIX-compliant path. + * + * This function replaces all backslashes (`\`) with forward slashes (`/`). + * It is a no-op on POSIX systems (e.g., Linux, macOS), as the conversion + * only runs on Windows (`win32`). + * + * @param path - The file path to convert. + * @returns The POSIX-compliant file path. + * + * @example + * ```ts + * // On a Windows system: + * toPosixPath('C:\\Users\\Test\\file.txt'); + * // => 'C:/Users/Test/file.txt' + * + * // On a POSIX system (Linux/macOS): + * toPosixPath('/home/user/file.txt'); + * // => '/home/user/file.txt' + * ``` + */ +export function toPosixPath(path: string): string { + return platform === 'win32' ? path.replace(WINDOWS_PATH_SEPERATOR_REGEXP, posix.sep) : path; +} + +/** + * Determines if a path is a subdirectory or file within a parent directory. + * + * @param parent - The parent directory path. + * @param child - The child path to check. + * @returns `true` if the child path is within the parent directory, `false` otherwise. + */ +export function isSubDirectory(parent: string, child: string): boolean { + const resolvedParent = resolve(parent); + const resolvedChild = resolve(parent, child); + const relativePath = toPosixPath(relative(resolvedParent, resolvedChild)); + + return relativePath !== '..' && !relativePath.startsWith('../') && !isAbsolute(relativePath); +} + +/** + * Canonicalizes a file path by normalising Windows drive-letter casing to uppercase. + * + * @param pathString - The file path to canonicalize. + * @returns The canonicalized file path. + */ +export function canonicalizePath(pathString: string): string { + if (platform === 'win32' && /^[a-z]:/.test(pathString)) { + return pathString[0].toUpperCase() + pathString.slice(1); + } + + return pathString; +} diff --git a/packages/angular/build/src/utils/path_spec.ts b/packages/angular/build/src/utils/path_spec.ts new file mode 100644 index 000000000000..cd8ca3372433 --- /dev/null +++ b/packages/angular/build/src/utils/path_spec.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { canonicalizePath, isSubDirectory } from './path'; + +describe('isSubDirectory', () => { + it('should return true for a direct child', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/baz')).toBeTrue(); + }); + + it('should return true for a nested child', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/baz/qux/file.txt')).toBeTrue(); + }); + + it('should return true if paths are identical', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar')).toBeTrue(); + }); + + it('should return false for a sibling directory starting with same prefix', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar-outside')).toBeFalse(); + expect(isSubDirectory('/foo/bar', '/foo/bar-outside/file.txt')).toBeFalse(); + }); + + it('should return false for directory outside parent via traversal', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/../outside')).toBeFalse(); + }); + + it('should return false for parent directory', () => { + expect(isSubDirectory('/foo/bar', '/foo')).toBeFalse(); + }); + + it('should return true for children containing directory names starting with double dots', () => { + expect(isSubDirectory('/foo/bar', '/foo/bar/..baz')).toBeTrue(); + expect(isSubDirectory('/foo/bar', '/foo/bar/..baz/qux')).toBeTrue(); + }); +}); + +describe('canonicalizePath', () => { + it('should return the path unmodified on POSIX systems', () => { + expect(canonicalizePath('/foo/bar/baz')).toBe('/foo/bar/baz'); + }); + + if (process.platform === 'win32') { + it('should uppercase Windows drive-letter casing', () => { + expect(canonicalizePath('c:/foo/bar')).toBe('C:/foo/bar'); + expect(canonicalizePath('d:\\foo\\bar')).toBe('D:\\foo\\bar'); + expect(canonicalizePath('C:/foo/bar')).toBe('C:/foo/bar'); + }); + } +}); diff --git a/packages/angular/build/src/utils/postcss-configuration.ts b/packages/angular/build/src/utils/postcss-configuration.ts new file mode 100644 index 000000000000..e5a4076232a0 --- /dev/null +++ b/packages/angular/build/src/utils/postcss-configuration.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join, relative } from 'node:path'; + +export interface PostcssConfiguration { + plugins: [name: string, options?: object | string][]; +} + +interface RawPostcssConfiguration { + plugins?: Record | (string | [string, object])[]; +} + +const postcssConfigurationFiles: string[] = ['postcss.config.json', '.postcssrc.json']; +const tailwindConfigFiles: string[] = [ + 'tailwind.config.js', + 'tailwind.config.cjs', + 'tailwind.config.mjs', + 'tailwind.config.ts', +]; + +export interface SearchDirectory { + root: string; + files: Set; +} + +export function generateSearchDirectories(roots: string[]): Promise { + return Promise.all( + roots.map((root) => + readdir(root, { withFileTypes: true }).then((entries) => ({ + root, + files: new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name)), + })), + ), + ); +} + +function findFile( + searchDirectories: SearchDirectory[], + potentialFiles: string[], +): string | undefined { + for (const { root, files } of searchDirectories) { + for (const potential of potentialFiles) { + if (files.has(potential)) { + return join(root, potential); + } + } + } + + return undefined; +} + +export function findTailwindConfiguration( + searchDirectories: SearchDirectory[], +): string | undefined { + return findFile(searchDirectories, tailwindConfigFiles); +} + +export function getTailwindConfig( + searchDirectories: SearchDirectory[], + workspaceRoot: string, + logger?: { warn(message: string): void }, +): { file: string; package: string } | undefined { + const tailwindConfigurationPath = findTailwindConfiguration(searchDirectories); + if (!tailwindConfigurationPath) { + return undefined; + } + + // Create a node resolver from the configuration file + const resolver = createRequire(tailwindConfigurationPath); + try { + return { + file: tailwindConfigurationPath, + package: resolver.resolve('tailwindcss'), + }; + } catch { + const relativeTailwindConfigPath = relative(workspaceRoot, tailwindConfigurationPath); + logger?.warn( + `Tailwind CSS configuration file found (${relativeTailwindConfigPath})` + + ` but the 'tailwindcss' package is not installed.` + + ` To enable Tailwind CSS, please install the 'tailwindcss' package.`, + ); + } + + return undefined; +} + +async function readPostcssConfiguration( + configurationFile: string, +): Promise { + const data = await readFile(configurationFile, 'utf-8'); + const config = JSON.parse(data) as RawPostcssConfiguration; + + return config; +} + +export async function loadPostcssConfiguration(searchDirectories: SearchDirectory[]): Promise< + | { + configPath: string; + config: PostcssConfiguration; + } + | undefined +> { + const configPath = findFile(searchDirectories, postcssConfigurationFiles); + if (!configPath) { + return undefined; + } + + const raw = await readPostcssConfiguration(configPath); + + // If no plugins are defined, consider it equivalent to no configuration + if (!raw.plugins || typeof raw.plugins !== 'object') { + return undefined; + } + + // Normalize plugin array form + if (Array.isArray(raw.plugins)) { + if (raw.plugins.length < 1) { + return undefined; + } + + const config: PostcssConfiguration = { plugins: [] }; + for (const element of raw.plugins) { + if (typeof element === 'string') { + config.plugins.push([element]); + } else { + config.plugins.push(element); + } + } + + return { config, configPath }; + } + + // Normalize plugin object map form + const entries = Object.entries(raw.plugins); + if (entries.length < 1) { + return undefined; + } + + const config: PostcssConfiguration = { plugins: [] }; + for (const [name, options] of entries) { + if (!options || (typeof options !== 'object' && typeof options !== 'string')) { + continue; + } + + config.plugins.push([name, options]); + } + + return { config, configPath }; +} diff --git a/packages/angular/build/src/utils/profiling.ts b/packages/angular/build/src/utils/profiling.ts new file mode 100644 index 000000000000..762a739bd318 --- /dev/null +++ b/packages/angular/build/src/utils/profiling.ts @@ -0,0 +1,112 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { debugPerformance } from './environment-options'; + +let cumulativeDurations: Map | undefined; + +export function resetCumulativeDurations(): void { + cumulativeDurations?.clear(); +} + +export function getAndClearCumulativeDurations(): Record | undefined { + if (!cumulativeDurations || cumulativeDurations.size === 0) { + return undefined; + } + + const data = Object.fromEntries(cumulativeDurations); + + cumulativeDurations.clear(); + + return data; +} + +export function mergeCumulativeDurations(data: Record): void { + cumulativeDurations ??= new Map(); + + for (const [name, durations] of Object.entries(data)) { + let existing = cumulativeDurations.get(name); + if (!existing) { + existing = []; + cumulativeDurations.set(name, existing); + } + + existing.push(...durations); + } +} + +export function logCumulativeDurations(): void { + if (!debugPerformance || !cumulativeDurations) { + return; + } + + for (const [name, durations] of cumulativeDurations) { + let total = 0; + let min; + let max; + for (const duration of durations) { + total += duration; + if (min === undefined || duration < min) { + min = duration; + } + if (max === undefined || duration > max) { + max = duration; + } + } + const average = total / durations.length; + // eslint-disable-next-line no-console + console.log( + `DURATION[${name}]: ${total.toFixed(9)}s [count: ${durations.length}; avg: ${average.toFixed( + 9, + )}s; min: ${min?.toFixed(9)}s; max: ${max?.toFixed(9)}s]`, + ); + } +} + +function recordDuration(name: string, startTime: bigint, cumulative?: boolean): void { + const duration = Number(process.hrtime.bigint() - startTime) / 10 ** 9; + if (cumulative) { + cumulativeDurations ??= new Map(); + const durations = cumulativeDurations.get(name) ?? []; + durations.push(duration); + cumulativeDurations.set(name, durations); + } else { + // eslint-disable-next-line no-console + console.log(`DURATION[${name}]: ${duration.toFixed(9)}s`); + } +} + +export async function profileAsync( + name: string, + action: () => Promise, + cumulative?: boolean, +): Promise { + if (!debugPerformance) { + return action(); + } + + const startTime = process.hrtime.bigint(); + try { + return await action(); + } finally { + recordDuration(name, startTime, cumulative); + } +} + +export function profileSync(name: string, action: () => T, cumulative?: boolean): T { + if (!debugPerformance) { + return action(); + } + + const startTime = process.hrtime.bigint(); + try { + return action(); + } finally { + recordDuration(name, startTime, cumulative); + } +} diff --git a/packages/angular/build/src/utils/project-metadata.ts b/packages/angular/build/src/utils/project-metadata.ts new file mode 100644 index 000000000000..31912d5e9905 --- /dev/null +++ b/packages/angular/build/src/utils/project-metadata.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'node:path'; + +/** + * Normalize a directory path string. + * Currently only removes a trailing slash if present. + * @param path A path string. + * @returns A normalized path string. + */ +export function normalizeDirectoryPath(path: string): string { + const last = path.at(-1); + if (last === '/' || last === '\\') { + return path.slice(0, -1); + } + + return path; +} + +export function getProjectRootPaths( + workspaceRoot: string, + projectMetadata: { root?: string; sourceRoot?: string }, +) { + const projectRoot = normalizeDirectoryPath(join(workspaceRoot, projectMetadata.root ?? '')); + const rawSourceRoot = projectMetadata.sourceRoot; + const projectSourceRoot = normalizeDirectoryPath( + rawSourceRoot === undefined ? join(projectRoot, 'src') : join(workspaceRoot, rawSourceRoot), + ); + + return { projectRoot, projectSourceRoot }; +} diff --git a/packages/angular/build/src/utils/purge-cache.ts b/packages/angular/build/src/utils/purge-cache.ts new file mode 100644 index 000000000000..545d2dd97bba --- /dev/null +++ b/packages/angular/build/src/utils/purge-cache.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext } from '@angular-devkit/architect'; +import { readdir, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { normalizeCacheOptions } from './normalize-cache'; + +/** Delete stale cache directories used by previous versions of build-angular. */ +export async function purgeStaleBuildCache(context: BuilderContext): Promise { + const projectName = context.target?.project; + if (!projectName) { + return; + } + + const metadata = await context.getProjectMetadata(projectName); + const { basePath, path, localBasePath, localPath, enabled } = normalizeCacheOptions( + metadata, + context.workspaceRoot, + ); + + if (!enabled) { + return; + } + + const basePaths = new Set([basePath]); + if (localBasePath) { + basePaths.add(localBasePath); + } + + for (const base of basePaths) { + let baseEntries; + try { + baseEntries = await readdir(base, { withFileTypes: true }); + } catch { + // No purging possible if base path does not exist or cannot otherwise be accessed + continue; + } + + const currentPath = base === localBasePath ? localPath : path; + if (!currentPath) { + // Avoid purging if current path is unavailable to prevent deleting the active cache + continue; + } + + const entriesToDelete = baseEntries + .filter((d) => d.isDirectory()) + .map((d) => join(base, d.name)) + .filter((cachePath) => cachePath !== currentPath) + .map((stalePath) => rm(stalePath, { force: true, recursive: true, maxRetries: 3 })); + + await Promise.allSettled(entriesToDelete); + } +} diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts new file mode 100644 index 000000000000..06a6c6c157e9 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { lstat, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { glob, isDynamicPattern } from 'tinyglobby'; +import { MAX_CONCURRENT_READS, mapConcurrent } from './concurrency'; +import { isSubDirectory } from './path'; + +/** + * Default glob ignore patterns for assets. + */ +export const DEFAULT_ASSET_IGNORE = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db'] as const; + +export async function resolveAssets( + entries: { + glob: string; + ignore?: string[]; + input: string; + output: string; + flatten?: boolean; + followSymlinks?: boolean; + }[], + root: string, +): Promise<{ source: string; destination: string }[]> { + const resolvedEntries = await mapConcurrent(entries, MAX_CONCURRENT_READS, async (entry) => { + if (!isSubDirectory(root, entry.input)) { + throw new Error(`The ${entry.input} asset path must be within the workspace root.`); + } + + const cwd = path.resolve(root, entry.input); + + if (!entry.ignore?.length && !isDynamicPattern(entry.glob)) { + const src = path.join(cwd, entry.glob); + if (isSubDirectory(cwd, src)) { + try { + const stats = await (entry.followSymlinks ? stat(src) : lstat(src)); + if (stats.isFile()) { + const filePath = entry.flatten ? path.basename(entry.glob) : entry.glob; + + return [{ source: src, destination: path.join(entry.output, filePath) }]; + } + + if (!stats.isSymbolicLink()) { + return []; + } + } catch { + // File does not exist or cannot be accessed. + return []; + } + } + } + + const files = await glob(entry.glob, { + cwd, + dot: true, + ignore: entry.ignore ? [...DEFAULT_ASSET_IGNORE, ...entry.ignore] : DEFAULT_ASSET_IGNORE, + followSymbolicLinks: entry.followSymlinks ?? false, + }); + + return files.map((file) => ({ + source: path.join(cwd, file), + destination: path.join(entry.output, entry.flatten ? path.basename(file) : file), + })); + }); + + return resolvedEntries.flat(); +} diff --git a/packages/angular/build/src/utils/resolve-project.ts b/packages/angular/build/src/utils/resolve-project.ts new file mode 100644 index 000000000000..ad31825683c4 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-project.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createRequire } from 'node:module'; +import { join } from 'node:path'; + +/** + * Creates a module resolver function that is strictly scoped to the project root. + * This prevents module resolution from leaking the parent module context when executing inside virtual stores (like pnpm). + * + * @param projectRoot The root directory of the project. + * @returns A resolver function that takes a package name/path and returns its resolved path. + */ +export function createProjectResolver(projectRoot: string): (packageName: string) => string { + const projectRequire = createRequire(join(projectRoot, 'package.json')); + + return (packageName: string) => projectRequire.resolve(packageName, { paths: [projectRoot] }); +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts new file mode 100644 index 000000000000..6ff4c8f8191c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { randomUUID } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +/** + * @note For some unknown reason, setting `globalThis.ngServerMode = true` does not work when using ESM loader hooks. + */ +const NG_SERVER_MODE_INIT_BYTES = new TextEncoder().encode('var ngServerMode=true;'); +const UTF8_DECODER = new TextDecoder(); + +/** + * Node.js ESM loader to redirect imports to in memory files. + * @see: https://nodejs.org/api/esm.html#loaders for more information about loaders. + */ + +const MEMORY_URL_SCHEME = 'memory://'; + +export interface ESMInMemoryFileLoaderWorkerData { + outputFiles: Record; + workspaceRoot: string; +} + +let memoryVirtualRootUrl: string; +let outputFiles: Record; + +export function initialize(data: ESMInMemoryFileLoaderWorkerData) { + // This path does not actually exist but is used to overlay the in memory files with the + // actual filesystem for resolution purposes. + // A custom URL schema (such as `memory://`) cannot be used for the resolve output because + // the in-memory files may use `import.meta.url` in ways that assume a file URL. + // `createRequire` is one example of this usage. + memoryVirtualRootUrl = pathToFileURL( + join(data.workspaceRoot, `.angular/prerender-root/${randomUUID()}/`), + ).href; + outputFiles = data.outputFiles; +} + +export function resolve( + specifier: string, + context: { parentURL: undefined | string }, + nextResolve: Function, +) { + // In-memory files loaded from external code will contain a memory scheme + if (specifier.startsWith(MEMORY_URL_SCHEME)) { + let memoryUrl; + try { + memoryUrl = new URL(specifier); + } catch { + assert.fail('External code attempted to use malformed memory scheme: ' + specifier); + } + + // Resolve with a URL based from the virtual filesystem root + return { + format: 'module', + shortCircuit: true, + url: new URL(memoryUrl.pathname.slice(1), memoryVirtualRootUrl).href, + }; + } + + // Use next/default resolve if the parent is not from the virtual root + if (!context.parentURL?.startsWith(memoryVirtualRootUrl)) { + return nextResolve(specifier, context); + } + + // Check for `./` and `../` relative specifiers + const isRelative = + specifier[0] === '.' && + (specifier[1] === '/' || (specifier[1] === '.' && specifier[2] === '/')); + + // Relative specifiers from memory file should be based from the parent memory location + if (isRelative) { + let specifierUrl; + try { + specifierUrl = new URL(specifier, context.parentURL); + } catch {} + + if ( + specifierUrl?.href.startsWith(memoryVirtualRootUrl) && + Object.hasOwn(outputFiles, specifierUrl.href.slice(memoryVirtualRootUrl.length)) + ) { + return { + format: 'module', + shortCircuit: true, + url: specifierUrl.href, + }; + } + + assert.fail( + `In-memory ESM relative file should always exist: '${context.parentURL}' --> '${specifier}'`, + ); + } + + // Update the parent URL to allow for module resolution for the workspace. + // This handles bare specifiers (npm packages) and absolute paths. + // Defer to the next hook in the chain, which would be the + // Node.js default resolve if this is the last user-specified loader. + return nextResolve(specifier, { + ...context, + parentURL: new URL('index.js', memoryVirtualRootUrl).href, + }); +} + +export function load(url: string, context: { format?: string | null }, nextLoad: Function) { + const { format } = context; + + // Load the file from memory if the URL is based in the virtual root + if (url.startsWith(memoryVirtualRootUrl)) { + const rawSource = outputFiles[url.slice(memoryVirtualRootUrl.length)]; + assert(rawSource !== undefined, 'Resolved in-memory ESM file should always exist: ' + url); + + const source = typeof rawSource === 'string' ? rawSource : UTF8_DECODER.decode(rawSource); + + // In-memory files have already been transformer during bundling and can be returned directly + return { + format: format ?? 'module', + shortCircuit: true, + source, + }; + } + + // Only module files potentially require transformation. Angular libraries that would + // need linking are ESM only. + if (format === 'module' && isFileProtocol(url)) { + // Check url instead of filePath so the check is robust across Windows and POSIX path separators. + if (!url.includes('/@angular/')) { + return nextLoad(url, context); + } + + const filePath = fileURLToPath(url); + const fileBytes = readFileSync(filePath); + const source = Buffer.concat([NG_SERVER_MODE_INIT_BYTES, fileBytes]); + + return { + format, + shortCircuit: true, + source, + }; + } + + // Let Node.js handle all other URLs. + return nextLoad(url); +} + +function isFileProtocol(url: string): boolean { + return url.startsWith('file://'); +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts new file mode 100644 index 000000000000..0b32c2248b10 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks_spec.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { initialize, load, resolve } from './loader-hooks'; +import { createSharedServerFiles } from './utils'; + +describe('esm-in-memory-loader loader-hooks', () => { + const workspaceRoot = '/mock/workspace/root'; + const sharedFiles = createSharedServerFiles({ + 'main.server.mjs': 'export const main = true;', + 'chunk-abc.mjs': 'export const chunk = "abc";', + 'nested/chunk-sub.mjs': 'export const sub = "sub";', + 'utf8.mjs': 'export const text = "🔥 UTF-8 🚀";', + 'empty.mjs': '', + }); + + beforeEach(() => { + initialize({ + workspaceRoot, + outputFiles: sharedFiles, + }); + }); + + describe('resolve', () => { + it('should resolve memory:// URLs into virtual filesystem URLs', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const result = resolve(memoryUrl, { parentURL: undefined }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/.angular/prerender-root/'); + expect(result.url).toContain('/main.server.mjs'); + }); + + it('should fail when memory:// URL is malformed', () => { + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('memory://::invalid', { parentURL: undefined }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('External code attempted to use malformed memory scheme'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should resolve relative specifiers within in-memory files', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('./chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should resolve relative specifiers navigating parent directories within in-memory files', () => { + const memoryUrl = new URL('./nested/chunk-sub.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + const result = resolve('../chunk-abc.mjs', { parentURL }, nextResolve); + + expect(nextResolve).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.url).toContain('/chunk-abc.mjs'); + }); + + it('should fail when relative specifier from in-memory file does not exist', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine.createSpy('nextResolve'); + expect(() => { + resolve('./non-existent.mjs', { parentURL }, nextResolve); + }).toThrowMatching((err: Error) => + err.message.includes('In-memory ESM relative file should always exist'), + ); + expect(nextResolve).not.toHaveBeenCalled(); + }); + + it('should rewrite parentURL to index.js in virtual root for bare package specifiers', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const parentURL = rootResolve.url; + + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/node_modules/@angular/core/index.js' }); + const result = resolve('@angular/core', { parentURL }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith( + '@angular/core', + jasmine.objectContaining({ + parentURL: jasmine.stringMatching(/\/\.angular\/prerender-root\/[^/]+\/index\.js$/), + }), + ); + expect(result.url).toBe('file:///some/node_modules/@angular/core/index.js'); + }); + + it('should delegate to nextResolve for external non-memory URLs', () => { + const nextResolve = jasmine + .createSpy('nextResolve') + .and.returnValue({ url: 'file:///some/ext/pkg' }); + const result = resolve('some-pkg', { parentURL: 'file:///some/ext/file.js' }, nextResolve); + + expect(nextResolve).toHaveBeenCalledWith('some-pkg', { + parentURL: 'file:///some/ext/file.js', + }); + expect(result.url).toBe('file:///some/ext/pkg'); + }); + }); + + describe('load', () => { + it('should load in-memory file source from SharedArrayBuffer backed Uint8Array', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const main = true;'); + }); + + it('should load in-memory file with multi-byte UTF-8 characters', () => { + const memoryUrl = new URL('./utf8.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const text = "🔥 UTF-8 🚀";'); + }); + + it('should load in-memory file with empty content', () => { + const memoryUrl = new URL('./empty.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(''); + }); + + it('should load in-memory file source with non-zero byteOffset in Uint8Array', () => { + const target = 'export const sliced = 42;'; + const fullBuffer = Buffer.from(`__PADDING__${target}__MORE__`); + const offset = Buffer.byteLength('__PADDING__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + initialize({ + workspaceRoot, + outputFiles: { + 'sliced.mjs': subView, + }, + }); + + const memoryUrl = new URL('./sliced.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe(target); + }); + + it('should load in-memory file source when outputFiles contain string values', () => { + initialize({ + workspaceRoot, + outputFiles: { + 'string-file.mjs': 'export const fromString = 1;', + }, + }); + + const memoryUrl = new URL('./string-file.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nextLoad = jasmine.createSpy('nextLoad'); + + const result = load(rootResolve.url, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(result.source).toBe('export const fromString = 1;'); + }); + + it('should throw when in-memory file does not exist in outputFiles', () => { + const memoryUrl = new URL('./main.server.mjs', 'memory://').href; + const rootResolve = resolve(memoryUrl, { parentURL: undefined }, () => {}); + const nonExistentVirtualUrl = rootResolve.url.replace('main.server.mjs', 'missing.mjs'); + const nextLoad = jasmine.createSpy('nextLoad'); + + expect(() => load(nonExistentVirtualUrl, { format: 'module' }, nextLoad)).toThrowError( + /Resolved in-memory ESM file should always exist/, + ); + expect(nextLoad).not.toHaveBeenCalled(); + }); + + it('should prepend ngServerMode initialization for @angular/ file URLs', async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + const tmpDir = await mkdtemp(join(baseTmpDir, 'loader-hooks-test-')); + + try { + const angularDir = join(tmpDir, '@angular/core'); + await mkdir(angularDir, { recursive: true }); + const filePath = join(angularDir, 'index.mjs'); + await writeFile(filePath, 'export const VERSION = "1.0.0";'); + + const nextLoad = jasmine.createSpy('nextLoad'); + const result = load(pathToFileURL(filePath).href, { format: 'module' }, nextLoad); + + expect(nextLoad).not.toHaveBeenCalled(); + expect(result.format).toBe('module'); + expect(result.shortCircuit).toBeTrue(); + expect(new TextDecoder().decode(result.source)).toBe( + 'var ngServerMode=true;export const VERSION = "1.0.0";', + ); + } finally { + await rm(tmpDir, { recursive: true, force: true }); + } + }); + + it('should delegate to nextLoad for non-angular file URLs', () => { + const nextLoad = jasmine.createSpy('nextLoad').and.returnValue({ format: 'module' }); + const result = load( + 'file:///workspace/node_modules/rxjs/index.js', + { format: 'module' }, + nextLoad, + ); + + expect(nextLoad).toHaveBeenCalledWith('file:///workspace/node_modules/rxjs/index.js', { + format: 'module', + }); + expect(result.format).toBe('module'); + }); + + it('should delegate to nextLoad for non-memory non-file URLs', () => { + const nextLoad = jasmine.createSpy('nextLoad').and.returnValue({ format: 'builtin' }); + const result = load('node:fs', { format: 'builtin' }, nextLoad); + + expect(nextLoad).toHaveBeenCalledWith('node:fs'); + expect(result.format).toBe('builtin'); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts new file mode 100644 index 000000000000..56747e17bd03 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { registerHooks } from 'node:module'; +import { workerData } from 'node:worker_threads'; +import { initialize, load, resolve } from './loader-hooks'; + +initialize(workerData); +registerHooks({ resolve, load }); diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts new file mode 100644 index 000000000000..7eb2bd1f5c49 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const IMPORT_EXEC_ARGV = + '--import=' + pathToFileURL(join(__dirname, 'register-hooks.js')).href; + +/** + * Creates a shared zero-copy `Uint8Array` backed by a `SharedArrayBuffer` for the given file content. + */ +export function createSharedFile(content: string | Uint8Array): Uint8Array { + if (typeof content === 'string') { + const byteLength = Buffer.byteLength(content, 'utf-8'); + const sab = new SharedArrayBuffer(byteLength); + Buffer.from(sab).write(content, 'utf-8'); + + return new Uint8Array(sab); + } + + if (content.buffer instanceof SharedArrayBuffer) { + return content; + } + + const sab = new SharedArrayBuffer(content.byteLength); + const view = new Uint8Array(sab); + view.set(content); + + return view; +} + +/** + * Creates shared zero-copy `Uint8Array` views backed by `SharedArrayBuffer` for all output files. + */ +export function createSharedServerFiles( + outputFiles: Record, +): Record { + const sharedFiles: Record = {}; + + for (const [key, value] of Object.entries(outputFiles)) { + sharedFiles[key] = createSharedFile(value); + } + + return sharedFiles; +} diff --git a/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts new file mode 100644 index 000000000000..b7bb2844b66e --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/utils_spec.ts @@ -0,0 +1,147 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createSharedFile, createSharedServerFiles } from './utils'; + +describe('server-rendering shared file utilities', () => { + describe('createSharedFile', () => { + it('should create a Uint8Array backed by SharedArrayBuffer from string', () => { + const text = 'console.log("hello world");'; + const shared = createSharedFile(text); + + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(Buffer.byteLength(text, 'utf-8')); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should correctly handle multi-byte UTF-8 characters', () => { + const text = 'export const greeting = "🚀 Привет, мир! 🌍";'; + const shared = createSharedFile(text); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(text); + }); + + it('should handle empty string', () => { + const shared = createSharedFile(''); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(''); + }); + + it('should handle empty Uint8Array', () => { + const shared = createSharedFile(new Uint8Array(0)); + + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(0); + }); + + it('should return the same Uint8Array if already backed by SharedArrayBuffer', () => { + const text = 'export default 42;'; + const shared1 = createSharedFile(text); + const shared2 = createSharedFile(shared1); + + expect(shared2).toBe(shared1); + expect(shared2.buffer).toBe(shared1.buffer); + }); + + it('should return existing Uint8Array view if already backed by SharedArrayBuffer with non-zero byteOffset', () => { + const text = '0123456789abcdef'; + const shared1 = createSharedFile(text); + const subView = new Uint8Array(shared1.buffer, 4, 8); + const shared2 = createSharedFile(subView); + + expect(shared2).toBe(subView); + expect(shared2.byteOffset).toBe(4); + expect(shared2.byteLength).toBe(8); + + const decoded = Buffer.from(shared2.buffer, shared2.byteOffset, shared2.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('456789ab'); + }); + + it('should convert standard Uint8Array to SharedArrayBuffer backed Uint8Array', () => { + const regularBuffer = Buffer.from('export const test = true;'); + expect(regularBuffer.buffer instanceof SharedArrayBuffer).toBeFalse(); + + const shared = createSharedFile(regularBuffer); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe('export const test = true;'); + }); + + it('should handle Uint8Array slice with non-zero byteOffset from standard ArrayBuffer', () => { + const target = 'export const data = 123;'; + const fullBuffer = Buffer.from(`__PREFIX__${target}__SUFFIX__`); + const offset = Buffer.byteLength('__PREFIX__', 'utf-8'); + const length = Buffer.byteLength(target, 'utf-8'); + const subView = new Uint8Array(fullBuffer.buffer, fullBuffer.byteOffset + offset, length); + + const shared = createSharedFile(subView); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + expect(shared.byteLength).toBe(length); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(target); + }); + }); + + describe('createSharedServerFiles', () => { + it('should convert all entries in a record to SharedArrayBuffer backed Uint8Arrays', () => { + const files: Record = { + 'main.server.mjs': 'export default function() {}', + 'chunk-1.mjs': 'export const x = 1;', + 'render-utils.mjs': Buffer.from('export const helper = () => true;'), + }; + + const sharedFiles = createSharedServerFiles(files); + + expect(Object.keys(sharedFiles)).toEqual([ + 'main.server.mjs', + 'chunk-1.mjs', + 'render-utils.mjs', + ]); + + for (const [key, shared] of Object.entries(sharedFiles)) { + expect(shared instanceof Uint8Array).toBeTrue(); + expect(shared.buffer instanceof SharedArrayBuffer).toBeTrue(); + + const expectedText = + typeof files[key] === 'string' ? files[key] : (files[key] as Buffer).toString('utf-8'); + + const decoded = Buffer.from(shared.buffer, shared.byteOffset, shared.byteLength).toString( + 'utf-8', + ); + expect(decoded).toBe(expectedText); + } + }); + + it('should handle empty file map', () => { + const sharedFiles = createSharedServerFiles({}); + expect(Object.keys(sharedFiles).length).toBe(0); + }); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/fetch-patch.ts b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts new file mode 100644 index 000000000000..c099d7dd902c --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { lookup as lookupMimeType } from 'mrmime'; +import { readFile } from 'node:fs/promises'; +import { extname } from 'node:path'; +import { workerData } from 'node:worker_threads'; + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { assetFiles } = workerData as { + assetFiles: Record; +}; + +const assetsCache: Map; content: Buffer }> = + new Map(); + +export function patchFetchToLoadInMemoryAssets(baseURL: URL): void { + const originalFetch = globalThis.fetch; + const patchedFetch: typeof fetch = async (input, init) => { + let url: URL; + if (input instanceof URL) { + url = input; + } else if (typeof input === 'string') { + url = new URL(input, baseURL); + } else if (typeof input === 'object' && 'url' in input) { + url = new URL(input.url, baseURL); + } else { + return originalFetch(input, init); + } + + const { hostname } = url; + const pathname = decodeURIComponent(url.pathname); + + if (hostname !== baseURL.hostname || !assetFiles[pathname]) { + // Only handle relative requests or files that are in assets. + return originalFetch(input, init); + } + + const cachedAsset = assetsCache.get(pathname); + if (cachedAsset) { + const { content, headers } = cachedAsset; + + return new Response(content, { + headers, + }); + } + + const extension = extname(pathname); + const mimeType = lookupMimeType(extension); + const content = await readFile(assetFiles[pathname]); + const headers = mimeType + ? { + 'Content-Type': mimeType, + } + : undefined; + + assetsCache.set(pathname, { headers, content }); + + return new Response(content, { + headers, + }); + }; + + globalThis.fetch = patchedFetch; +} diff --git a/packages/angular/build/src/utils/server-rendering/launch-server.ts b/packages/angular/build/src/utils/server-rendering/launch-server.ts new file mode 100644 index 000000000000..95b2784c6f63 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/launch-server.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import assert from 'node:assert'; +import { createServer } from 'node:http'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { isSsrNodeRequestHandler, isSsrRequestHandler } from './utils'; + +export const DEFAULT_URL = new URL('http://ng-localhost/'); + +/** + * Launches a server that handles local requests. + * + * @returns A promise that resolves to the URL of the running server. + */ +export async function launchServer(): Promise { + const { reqHandler } = await loadEsmModuleFromMemory('./server.mjs'); + const { createWebRequestFromNodeRequest, writeResponseToNodeResponse } = (await import( + '@angular/ssr/node' as string + )) as typeof import('@angular/ssr/node', { with: { 'resolution-mode': 'import' } }); + + if (!isSsrNodeRequestHandler(reqHandler) && !isSsrRequestHandler(reqHandler)) { + return DEFAULT_URL; + } + + const server = createServer((req, res) => { + (async () => { + // handle request + if (isSsrNodeRequestHandler(reqHandler)) { + await reqHandler(req, res, (e) => { + throw e ?? new Error(`Unable to handle request: '${req.url}'.`); + }); + } else { + const webRes = await reqHandler(createWebRequestFromNodeRequest(req)); + if (webRes) { + await writeResponseToNodeResponse(webRes, res); + } else { + res.statusCode = 501; + res.end('Not Implemented.'); + } + } + })().catch((e) => { + res.statusCode = 500; + res.end('Internal Server Error.'); + // eslint-disable-next-line no-console + console.error(e); + }); + }); + + server.unref(); + + await new Promise((resolve) => server.listen(0, 'localhost', resolve)); + + const serverAddress = server.address(); + assert(serverAddress, 'Server address should be defined.'); + assert(typeof serverAddress !== 'string', 'Server address should not be a string.'); + + return new URL(`http://localhost:${serverAddress.port}/`); +} diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts new file mode 100644 index 000000000000..87ca9928a86f --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ApplicationRef, Type } from '@angular/core'; +import type { BootstrapContext } from '@angular/platform-browser'; +import type { ɵextractRoutesAndCreateRouteTree, ɵgetOrCreateAngularServerApp } from '@angular/ssr'; +import { assertIsError } from '../error'; +import { loadEsmModule } from '../load-esm'; + +/** + * Represents the exports available from the main server bundle. + */ +interface MainServerBundleExports { + default: ((context: BootstrapContext) => Promise) | Type; + ɵextractRoutesAndCreateRouteTree: typeof ɵextractRoutesAndCreateRouteTree; + ɵgetOrCreateAngularServerApp: typeof ɵgetOrCreateAngularServerApp; +} + +/** + * Represents the exports available from the server bundle. + */ +interface ServerBundleExports { + reqHandler?: unknown; +} + +export function loadEsmModuleFromMemory( + path: './main.server.mjs', +): Promise; +export function loadEsmModuleFromMemory(path: './server.mjs'): Promise; +export function loadEsmModuleFromMemory( + path: './main.server.mjs' | './server.mjs', +): Promise { + return loadEsmModule(new URL(path, 'memory://')).catch((e) => { + assertIsError(e); + + // While the error is an 'instanceof Error', it is extended with non transferable properties + // and cannot be transferred from a worker when using `--import`. This results in the error object + // displaying as '[Object object]' when read outside of the worker. Therefore, we reconstruct the error message here. + const error: Error & { code?: string } = new Error(e.message); + error.stack = e.stack; + error.name = e.name; + error.code = e.code; + + throw error; + }) as Promise; +} diff --git a/packages/angular/build/src/utils/server-rendering/manifest.ts b/packages/angular/build/src/utils/server-rendering/manifest.ts new file mode 100644 index 000000000000..ab1d71a851cb --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest.ts @@ -0,0 +1,266 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { Buffer } from 'node:buffer'; +import { extname } from 'node:path'; +import { + INDEX_HTML_SERVER, + NormalizedApplicationBuildOptions, +} from '../../builders/application/options'; +import { + type BuildOutputFile, + BuildOutputFileType, + createOutputFile, +} from '../../tools/esbuild/bundler-files'; +import { findNonce } from '../index-file/nonce'; +import { joinUrlParts } from '../url'; + +export const SERVER_APP_MANIFEST_FILENAME = 'angular-app-manifest.mjs'; +export const SERVER_APP_ENGINE_MANIFEST_FILENAME = 'angular-app-engine-manifest.mjs'; + +/** + * A set of server-generated dependencies that are treated as external. + * + * These dependencies are marked as external because they are produced by a + * separate bundling process and are not included in the primary bundle. This + * ensures that these generated files are resolved from an external source rather + * than being part of the main bundle. + */ +export const SERVER_GENERATED_EXTERNALS = new Set([ + './polyfills.server.mjs', + './' + SERVER_APP_MANIFEST_FILENAME, + './' + SERVER_APP_ENGINE_MANIFEST_FILENAME, +]); + +const MAIN_SERVER_OUTPUT_FILENAME = 'main.server.mjs'; + +/** + * A mapping of unsafe characters to their escaped Unicode equivalents. + */ +const UNSAFE_CHAR_MAP: Record = { + '`': '\\`', + '$': '\\$', + '\\': '\\\\', +}; + +/** + * Escapes unsafe characters in a given string by replacing them with + * their Unicode escape sequences. + * + * @param str - The string to be escaped. + * @returns The escaped string where unsafe characters are replaced. + */ +function escapeUnsafeChars(str: string): string { + return str.replace(/[$`\\]/g, (c) => UNSAFE_CHAR_MAP[c]); +} + +/** + * Generates the server manifest for the App Engine environment. + * + * This manifest is used to configure the server-side rendering (SSR) setup for the + * Angular application when deployed to Google App Engine. It includes the entry points + * for different locales and the base HREF for the application. + * + * @param i18nOptions - The internationalization options for the application build. This + * includes settings for inlining locales and determining the output structure. + * @param allowedHosts - A list of hosts that are allowed to access the server-side application. + * @param baseHref - The base HREF for the application. This is used to set the base URL + * for all relative URLs in the application. + */ +export function generateAngularServerAppEngineManifest( + i18nOptions: NormalizedApplicationBuildOptions['i18nOptions'], + allowedHosts: string[], + baseHref: string | undefined, +): string { + const entryPoints: Record = {}; + const supportedLocales: Record = {}; + + if (i18nOptions.shouldInline && !i18nOptions.flatOutput) { + for (const locale of i18nOptions.inlineLocales) { + const { subPath } = i18nOptions.locales[locale]; + const importPath = `${subPath ? `${subPath}/` : ''}${MAIN_SERVER_OUTPUT_FILENAME}`; + entryPoints[subPath] = `() => import('./${importPath}')`; + supportedLocales[locale] = subPath; + } + } else { + entryPoints[''] = `() => import('./${MAIN_SERVER_OUTPUT_FILENAME}')`; + supportedLocales[i18nOptions.sourceLocale] = ''; + } + + // Remove trailing slash but retain leading slash. + let basePath = baseHref || '/'; + if (basePath.length > 1 && basePath.at(-1) === '/') { + basePath = basePath.slice(0, -1); + } + + const manifestContent = ` +export default { + basePath: '${basePath}', + allowedHosts: ${JSON.stringify(allowedHosts, undefined, 2)}, + supportedLocales: ${JSON.stringify(supportedLocales, undefined, 2)}, + entryPoints: { + ${Object.entries(entryPoints) + .map(([key, value]) => `'${key}': ${value}`) + .join(',\n ')} + }, +}; +`; + + return manifestContent; +} + +/** + * Generates the server manifest for the standard Node.js environment. + * + * This manifest is used to configure the server-side rendering (SSR) setup for the + * Angular application when running in a standard Node.js environment. It includes + * information about the bootstrap module, whether to inline critical CSS, and any + * additional HTML and CSS output files. + * + * @param additionalHtmlOutputFiles - A map of additional HTML output files generated + * during the build process, keyed by their file paths. + * @param outputFiles - An array of all output files from the build process, including + * JavaScript and CSS files. + * @param inlineCriticalCss - A boolean indicating whether critical CSS should be inlined + * in the server-side rendered pages. + * @param routes - An optional array of route definitions for the application, used for + * server-side rendering and routing. + * @param locale - An optional string representing the locale or language code to be used for + * the application, helping with localization and rendering content specific to the locale. + * @param baseHref - The base HREF for the application. This is used to set the base URL + * for all relative URLs in the application. + * @param initialFiles - A list of initial files that preload tags have already been added for. + * @param metafile - An esbuild metafile object. + * @param publicPath - The configured public path. + * + * @returns An object containing: + * - `manifestContent`: A string of the SSR manifest content. + * - `serverAssetsChunks`: An array of build output files containing the generated assets for the server. + */ +export async function generateAngularServerAppManifest( + additionalHtmlOutputFiles: Map, + outputFiles: BuildOutputFile[], + inlineCriticalCss: boolean, + routes: readonly unknown[] | undefined, + locale: string | undefined, + baseHref: string, + initialFiles: Set, + metafile: Metafile, + publicPath: string | undefined, +): Promise<{ + manifestContent: string; + serverAssetsChunks: BuildOutputFile[]; +}> { + const serverAssetsChunks: BuildOutputFile[] = []; + const serverAssets: Record = {}; + const criticalCssPlans: unknown[] = []; + let nonce: string | undefined; + + // TODO(alanagius): This is done here as we do not use module resolution bundler/node16 + const { compileSheet, encodePlan } = (await import( + 'beasties/compiler' as string + )) as typeof import('beasties/compiler', { with: { 'resolution-mode': 'import' } }); + + for (const file of [...additionalHtmlOutputFiles.values(), ...outputFiles]) { + const extension = extname(file.path); + if (extension === '.html') { + const jsChunkFilePath = `assets-chunks/${file.path.replace(/[./]/g, '_')}.mjs`; + const escapedContent = escapeUnsafeChars(file.text); + + serverAssetsChunks.push( + createOutputFile( + jsChunkFilePath, + `export default \`${escapedContent}\`;`, + BuildOutputFileType.ServerApplication, + ), + ); + + // JavaScript engine script parsers normalize `\r\n` (2 bytes in UTF-8) to `\n` (1 byte in UTF-8) in template literals. + // Subtracting the count of `\r\n` occurrences avoids allocating any temporary strings or match arrays for large assets. + let size = Buffer.byteLength(file.text); + let pos = file.text.indexOf('\r\n'); + while (pos !== -1) { + size--; + pos = file.text.indexOf('\r\n', pos + 2); + } + + serverAssets[file.path] = + `{size: ${size}, hash: '${file.hash}', text: () => import('./${jsChunkFilePath}').then(m => m.default)}`; + } else if (inlineCriticalCss && extension === '.css') { + const sheet = compileSheet(file.text, { + href: joinUrlParts(publicPath ?? '', file.path), + }); + criticalCssPlans.push(encodePlan(sheet)); + } + } + + const indexHtml = additionalHtmlOutputFiles.get(INDEX_HTML_SERVER)?.text; + if (indexHtml) { + nonce = (await findNonce(indexHtml)) ?? undefined; + } + + // When routes have been extracted, mappings are no longer needed, as preloads will be included in the metadata. + const entryPointToBrowserMapping = routes?.length + ? undefined + : generateLazyLoadedFilesMappings(metafile, initialFiles, publicPath); + + const manifestContent = ` +export default { + bootstrap: () => import('./main.server.mjs').then(m => m.default), + baseHref: '${baseHref}', +${criticalCssPlans.length ? ` criticalCssPlans: ${JSON.stringify(criticalCssPlans)},\n` : ''}${ + nonce ? ` nonce: ${JSON.stringify(nonce)},\n` : '' + } locale: ${JSON.stringify(locale)}, + routes: ${JSON.stringify(routes, undefined, 2)}, + entryPointToBrowserMapping: ${JSON.stringify(entryPointToBrowserMapping, undefined, 2)}, + assets: { + ${Object.entries(serverAssets) + .map(([key, value]) => `'${key}': ${value}`) + .join(',\n ')} + }, +}; +`; + + return { manifestContent, serverAssetsChunks }; +} + +/** + * Maps entry points to their corresponding browser bundles for lazy loading. + * + * This function processes a metafile's outputs to generate a mapping between browser-side entry points + * and the associated JavaScript files that should be loaded in the browser. It includes the entry-point's + * own path and any valid imports while excluding initial files or external resources. + */ +function generateLazyLoadedFilesMappings( + metafile: Metafile, + initialFiles: Set, + publicPath = '', +): Record { + const entryPointToBundles: Record = {}; + for (const [fileName, { entryPoint, exports, imports }] of Object.entries(metafile.outputs)) { + // Skip files that don't have an entryPoint, no exports, or are not .js + if (!entryPoint || exports?.length < 1 || !fileName.endsWith('.js')) { + continue; + } + + const importedPaths: string[] = [`${publicPath}${fileName}`]; + + for (const { kind, external, path } of imports) { + if (external || initialFiles.has(path) || kind !== 'import-statement') { + continue; + } + + importedPaths.push(`${publicPath}${path}`); + } + + entryPointToBundles[entryPoint] = importedPaths; + } + + return entryPointToBundles; +} diff --git a/packages/angular/build/src/utils/server-rendering/manifest_spec.ts b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts new file mode 100644 index 000000000000..76b7c00b13ff --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/manifest_spec.ts @@ -0,0 +1,143 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Metafile } from 'esbuild'; +import { BuildOutputFileType, createOutputFile } from '../../tools/esbuild/bundler-files'; +import { initializeHash } from '../hash'; +import { generateAngularServerAppManifest } from './manifest'; + +describe('generateAngularServerAppManifest', () => { + beforeAll(async () => { + await initializeHash(); + }); + + const dummyMetafile = { inputs: {}, outputs: {} } as unknown as Metafile; + + it('should include criticalCssPlans when inlineCriticalCss is true', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).toContain('criticalCssPlans: ['); + expect(manifestContent).toContain('styles.css'); + expect(manifestContent).not.toContain('nonce:'); + }); + + it('should not include criticalCssPlans when inlineCriticalCss is false', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + false, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).not.toContain('criticalCssPlans:'); + }); + + it('should extract template nonce from index HTML when present', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(manifestContent).toContain('nonce: "{% nonce %}"'); + }); + + it('should not include css files in serverAssetsChunks or assets', async () => { + const additionalHtml = new Map([ + [ + 'index.server.html', + createOutputFile( + 'index.server.html', + '', + BuildOutputFileType.ServerApplication, + ), + ], + ]); + const outputFiles = [ + createOutputFile('styles.css', 'h1 { color: blue; }', BuildOutputFileType.Browser), + ]; + + const { manifestContent, serverAssetsChunks } = await generateAngularServerAppManifest( + additionalHtml, + outputFiles, + true, + undefined, + undefined, + '/', + new Set(), + dummyMetafile, + undefined, + ); + + expect(serverAssetsChunks.some((chunk) => chunk.path.includes('styles'))).toBeFalse(); + expect(manifestContent).not.toContain("'styles.css':"); + }); +}); diff --git a/packages/angular/build/src/utils/server-rendering/models.ts b/packages/angular/build/src/utils/server-rendering/models.ts new file mode 100644 index 000000000000..9a9020d2db7f --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/models.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { RenderMode, ɵextractRoutesAndCreateRouteTree } from '@angular/ssr'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; + +type Writeable = T extends readonly (infer U)[] ? U[] : never; + +export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; +} + +export type SerializableRouteTreeNode = ReturnType< + Awaited>['routeTree']['toObject'] +>; + +export type WritableSerializableRouteTreeNode = Writeable; + +export interface RoutersExtractorWorkerResult { + serializedRouteTree: SerializableRouteTreeNode; + appShellRoute?: string; + errors: string[]; +} + +/** + * Local copy of `RenderMode` exported from `@angular/ssr`. + * This constant is needed to handle interop between CommonJS (CJS) and ES Modules (ESM) formats. + * + * It maps `RenderMode` enum values to their corresponding numeric identifiers. + */ +export const RouteRenderMode: Record = { + Server: 0, + Client: 1, + Prerender: 2, +}; diff --git a/packages/angular/build/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts new file mode 100644 index 000000000000..98502c63587d --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -0,0 +1,463 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { readFile } from 'node:fs/promises'; +import { extname, posix } from 'node:path'; +import { NormalizedApplicationBuildOptions } from '../../builders/application/options'; +import { OutputMode } from '../../builders/application/schema'; +import { + BuildOutputAsset, + PrerenderedRoutesRecord, +} from '../../tools/esbuild/bundler-execution-result'; +import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-files'; +import { assertIsError } from '../error'; +import { toPosixPath } from '../path'; +import { addLeadingSlash, addTrailingSlash, joinUrlParts, stripLeadingSlash } from '../url'; +import { WorkerPool } from '../worker-pool'; +import { + IMPORT_EXEC_ARGV, + createSharedFile, + createSharedServerFiles, +} from './esm-in-memory-loader/utils'; +import { SERVER_APP_MANIFEST_FILENAME } from './manifest'; +import { + RouteRenderMode, + RoutersExtractorWorkerResult, + RoutesExtractorWorkerData, + SerializableRouteTreeNode, + WritableSerializableRouteTreeNode, +} from './models'; +import type { RenderResult, RenderWorkerData } from './render-worker'; +import { generateRedirectStaticPage } from './utils'; + +type PrerenderOptions = NormalizedApplicationBuildOptions['prerenderOptions']; +type AppShellOptions = NormalizedApplicationBuildOptions['appShellOptions']; + +/** + * Represents the output of a prerendering process. + * + * The key is the file path, and the value is an object containing the following properties: + * + * - `content`: The HTML content or output generated for the corresponding file path. + * - `appShellRoute`: A boolean flag indicating whether the content is an app shell. + * + * @example + * { + * '/index.html': { content: '...', appShell: false }, + * '/shell/index.html': { content: '...', appShellRoute: true } + * } + */ +type PrerenderOutput = Record; + +export async function prerenderPages( + workspaceRoot: string, + baseHref: string, + appShellOptions: AppShellOptions | undefined, + prerenderOptions: PrerenderOptions | undefined, + outputFiles: Readonly, + assets: Readonly, + outputMode: OutputMode | undefined, + sourcemap = false, + maxThreads = 1, +): Promise<{ + output: PrerenderOutput; + warnings: string[]; + errors: string[]; + prerenderedRoutes: PrerenderedRoutesRecord; + serializableRouteTreeNode: SerializableRouteTreeNode; +}> { + const rawOutputFiles: Record = {}; + const serverBundlesSourceMaps = new Map(); + const warnings: string[] = []; + const errors: string[] = []; + + for (const { text, path, type } of outputFiles) { + if (type !== BuildOutputFileType.ServerApplication && type !== BuildOutputFileType.ServerRoot) { + continue; + } + + // Contains the server runnable application code + if (extname(path) === '.map') { + serverBundlesSourceMaps.set(path.slice(0, -4), text); + } else { + rawOutputFiles[path] = text; + } + } + + // Inline sourcemap into JS file. This is needed to make Node.js resolve sourcemaps + // when using `--enable-source-maps` when using in memory files. + for (const [filePath, map] of serverBundlesSourceMaps) { + const jsContent = rawOutputFiles[filePath]; + if (jsContent) { + rawOutputFiles[filePath] = + jsContent + + '\n//# sourceMappingURL=' + + `data:application/json;base64,${Buffer.from(map).toString('base64')}`; + } + } + serverBundlesSourceMaps.clear(); + + const outputFilesForWorker = createSharedServerFiles(rawOutputFiles); + + const assetsReversed: Record = {}; + for (const { source, destination } of assets) { + // Assets are not stored with baseHref when using i18n, + // we append the base href so that requests are resolved correctly. + assetsReversed[joinUrlParts(baseHref, toPosixPath(destination))] = source; + } + + // Get routes to prerender + const { + errors: extractionErrors, + serializedRouteTree: serializableRouteTreeNode, + appShellRoute, + } = await getAllRoutes( + workspaceRoot, + baseHref, + outputFilesForWorker, + assetsReversed, + appShellOptions, + prerenderOptions, + sourcemap, + outputMode, + ).catch((err) => { + assertIsError(err); + + return { + errors: [ + `An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ], + serializedRouteTree: [], + appShellRoute: undefined, + }; + }); + + errors.push(...extractionErrors); + + const serializableRouteTreeNodeForPrerender: WritableSerializableRouteTreeNode = []; + for (const metadata of serializableRouteTreeNode) { + if (outputMode !== OutputMode.Static && metadata.redirectTo) { + // Skip redirects if output mode is not static. + continue; + } + + if (metadata.route.includes('*')) { + // Skip catch all routes from prerender. + continue; + } + + switch (metadata.renderMode) { + case undefined: /* Legacy building mode */ + case RouteRenderMode.Prerender: + serializableRouteTreeNodeForPrerender.push(metadata); + break; + case RouteRenderMode.Server: + if (outputMode === OutputMode.Static) { + errors.push( + `Route '${metadata.route}' is configured with server render mode, but the build 'outputMode' is set to 'static'.`, + ); + } + break; + } + } + + if (!serializableRouteTreeNodeForPrerender.length || errors.length > 0) { + return { + errors, + warnings, + output: {}, + prerenderedRoutes: {}, + serializableRouteTreeNode, + }; + } + + // Add the extracted routes to the manifest file. + // We could re-generate it from the start, but that would require a number of options to be passed down. + const manifest = outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME]; + if (manifest) { + const manifestText = new TextDecoder().decode(manifest); + + outputFilesForWorker[SERVER_APP_MANIFEST_FILENAME] = createSharedFile( + manifestText.replace( + 'routes: undefined,', + `routes: ${JSON.stringify(serializableRouteTreeNodeForPrerender, undefined, 2)},`, + ), + ); + } + + // Render routes + const { errors: renderingErrors, output } = await renderPages( + baseHref, + sourcemap, + serializableRouteTreeNodeForPrerender, + maxThreads, + workspaceRoot, + outputFilesForWorker, + assetsReversed, + outputMode, + appShellRoute ?? appShellOptions?.route, + ); + + errors.push(...renderingErrors); + + const prerenderedRoutes: PrerenderedRoutesRecord = {}; + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + + for (const metadata of serializableRouteTreeNodeForPrerender) { + const outPath = getRouteOutPath(metadata.route, baseHrefPathnameWithLeadingSlash); + + if (output[outPath]) { + prerenderedRoutes[metadata.route] = { headers: metadata.headers }; + } + } + + return { + errors, + warnings, + output, + prerenderedRoutes, + serializableRouteTreeNode, + }; +} + +async function renderPages( + baseHref: string, + sourcemap: boolean, + serializableRouteTreeNode: SerializableRouteTreeNode, + maxThreads: number, + workspaceRoot: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + outputMode: OutputMode | undefined, + appShellRoute: string | undefined, +): Promise<{ + output: PrerenderOutput; + errors: string[]; +}> { + const output: PrerenderOutput = {}; + const errors: string[] = []; + + const baseHrefPathnameWithLeadingSlash = new URL(baseHref, 'http://localhost').pathname; + const appShellRouteWithoutBaseHref = appShellRoute + ? addLeadingSlash(getRouteWithoutBaseHref(appShellRoute, baseHrefPathnameWithLeadingSlash)) + : undefined; + + const routesToRender: { route: string; outPath: string; isAppShell: boolean }[] = []; + + for (const { route, redirectTo } of serializableRouteTreeNode) { + // Remove the base href from the file output path. + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathnameWithLeadingSlash); + const outPath = getRouteOutPath(route, baseHrefPathnameWithLeadingSlash); + + if (typeof redirectTo === 'string') { + output[outPath] = { content: generateRedirectStaticPage(redirectTo), appShellRoute: false }; + + continue; + } + + routesToRender.push({ + route, + outPath, + isAppShell: appShellRouteWithoutBaseHref === routeWithoutBaseHref, + }); + } + + if (routesToRender.length === 0) { + return { + errors, + output, + }; + } + + // Batch routes to reduce IPC overhead while ensuring enough batches exist for load balancing across worker threads. + const batchSize = Math.max(1, Math.min(50, Math.ceil(routesToRender.length / (maxThreads * 4)))); + const numBatches = Math.ceil(routesToRender.length / batchSize); + const effectiveMaxThreads = Math.min(numBatches, maxThreads); + + const workerExecArgv = [IMPORT_EXEC_ARGV]; + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new WorkerPool({ + filename: require.resolve('./render-worker'), + maxThreads: effectiveMaxThreads, + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['server.mjs'], + } as RenderWorkerData, + execArgv: workerExecArgv, + env: { + ...process.env, + 'NG_ALLOWED_HOSTS': 'localhost', + }, + }); + + try { + const routeOutPathMap = new Map(); + for (const item of routesToRender) { + routeOutPathMap.set(item.route, item); + } + + const renderingPromises: Promise[] = []; + + for (let i = 0; i < routesToRender.length; i += batchSize) { + const batch = routesToRender.slice(i, i + batchSize); + const urls = batch.map((item) => item.route); + const renderBatchPromise: Promise = renderWorker.run(urls); + const batchResultPromise = renderBatchPromise + .then((results) => { + for (const result of results) { + if ('error' in result) { + errors.push( + `An error occurred while prerendering route '${result.url}'.\n\n${result.error}`, + ); + continue; + } + + const routeInfo = routeOutPathMap.get(result.url); + if (routeInfo) { + output[routeInfo.outPath] = { + content: result.content, + appShellRoute: routeInfo.isAppShell, + }; + } + } + }) + .catch((err) => { + assertIsError(err); + for (const url of urls) { + errors.push( + `An error occurred while prerendering route '${url}'.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ); + } + }); + + renderingPromises.push(batchResultPromise); + } + + await Promise.all(renderingPromises); + } finally { + void renderWorker.destroy(); + } + + return { + errors, + output, + }; +} + +async function getAllRoutes( + workspaceRoot: string, + baseHref: string, + outputFilesForWorker: Record, + assetFilesForWorker: Record, + appShellOptions: AppShellOptions | undefined, + prerenderOptions: PrerenderOptions | undefined, + sourcemap: boolean, + outputMode: OutputMode | undefined, +): Promise<{ + serializedRouteTree: SerializableRouteTreeNode; + appShellRoute?: string; + errors: string[]; +}> { + const { routesFile, discoverRoutes } = prerenderOptions ?? {}; + const routes: WritableSerializableRouteTreeNode = []; + let appShellRoute: string | undefined; + + if (appShellOptions) { + appShellRoute = joinUrlParts(baseHref, appShellOptions.route); + + routes.push({ + renderMode: RouteRenderMode.Prerender, + route: appShellRoute, + }); + } + + if (routesFile) { + const routesFromFile = (await readFile(routesFile, 'utf8')).split(/\r?\n/); + for (const route of routesFromFile) { + routes.push({ + renderMode: RouteRenderMode.Prerender, + route: joinUrlParts(baseHref, route.trim()), + }); + } + } + + if (!discoverRoutes) { + return { errors: [], appShellRoute, serializedRouteTree: routes }; + } + + const workerExecArgv = [IMPORT_EXEC_ARGV]; + + if (sourcemap) { + workerExecArgv.push('--enable-source-maps'); + } + + const renderWorker = new WorkerPool({ + filename: require.resolve('./routes-extractor-worker'), + maxThreads: 1, + workerData: { + workspaceRoot, + outputFiles: outputFilesForWorker, + assetFiles: assetFilesForWorker, + outputMode, + hasSsrEntry: !!outputFilesForWorker['server.mjs'], + } as RoutesExtractorWorkerData, + execArgv: workerExecArgv, + env: { + ...process.env, + 'NG_ALLOWED_HOSTS': 'localhost', + }, + }); + + try { + const { serializedRouteTree, appShellRoute, errors }: RoutersExtractorWorkerResult = + await renderWorker.run({}); + + if (!routes.length) { + return { errors, appShellRoute, serializedRouteTree }; + } + + // Merge the routing trees + const uniqueRoutes = new Map(); + for (const item of [...routes, ...serializedRouteTree]) { + if (!uniqueRoutes.has(item.route)) { + uniqueRoutes.set(item.route, item); + } + } + + return { errors, serializedRouteTree: Array.from(uniqueRoutes.values()) }; + } catch (err) { + assertIsError(err); + + return { + errors: [ + `An error occurred while extracting routes.\n\n${err.stack ?? err.message ?? err.code ?? err}`, + ], + serializedRouteTree: [], + }; + } finally { + void renderWorker.destroy(); + } +} + +function getRouteWithoutBaseHref(route: string, baseHrefPathname: string): string { + return addTrailingSlash(route).startsWith(baseHrefPathname) + ? addLeadingSlash(route.slice(baseHrefPathname.length)) + : route; +} + +function getRouteOutPath(route: string, baseHrefPathname: string): string { + const routeWithoutBaseHref = getRouteWithoutBaseHref(route, baseHrefPathname); + + return stripLeadingSlash(posix.join(routeWithoutBaseHref, 'index.html')); +} diff --git a/packages/angular/build/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts new file mode 100644 index 000000000000..df1fdb4ea103 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workerData } from 'node:worker_threads'; +import type { OutputMode } from '../../builders/application/schema'; +import { assertIsError } from '../error'; +import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { DEFAULT_URL, launchServer } from './launch-server'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { generateRedirectStaticPage } from './utils'; + +export interface RenderWorkerData extends ESMInMemoryFileLoaderWorkerData { + assetFiles: Record; + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +} + +export type RenderResultItem = + | { + url: string; + content: string; + } + | { + url: string; + error: string; + }; + +export type RenderResult = RenderResultItem[]; + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputMode, hasSsrEntry } = workerData as { + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +}; + +let serverURL = DEFAULT_URL; + +/** + * Renders a single route URL. + */ +async function renderPage( + url: string, + angularServerApp: { handle: (request: Request) => Promise }, +): Promise { + const response = await angularServerApp.handle( + new Request(new URL(url, serverURL), { signal: AbortSignal.timeout(30_000) }), + ); + + if (!response) { + return null; + } + + const location = response.headers.get('Location'); + + return location ? generateRedirectStaticPage(location) : response.text(); +} + +/** + * Renders routes in batch or individual URL. + */ +async function renderPages(urls: string[]): Promise { + const { ɵgetOrCreateAngularServerApp: getOrCreateAngularServerApp } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const angularServerApp = getOrCreateAngularServerApp({ + allowStaticRouteRender: true, + }); + + const results: RenderResult = []; + for (const currentUrl of urls) { + try { + const content = await renderPage(currentUrl, angularServerApp); + + if (content === null) { + throw new Error('The content returned was empty.'); + } + + results.push({ url: currentUrl, content }); + } catch (err) { + assertIsError(err); + results.push({ + url: currentUrl, + error: err.stack ?? err.message ?? err.code ?? `${err}`, + }); + } + } + + return results; +} + +async function initialize() { + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + + if (outputMode !== undefined && hasSsrEntry) { + serverURL = await launchServer(); + } + + patchFetchToLoadInMemoryAssets(serverURL); + + return renderPages; +} + +export default initialize(); diff --git a/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts new file mode 100644 index 000000000000..423a71e83ba5 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workerData } from 'node:worker_threads'; +import { OutputMode } from '../../builders/application/schema'; +import { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; +import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; +import { DEFAULT_URL, launchServer } from './launch-server'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; +import { RoutersExtractorWorkerResult } from './models'; + +export interface ExtractRoutesWorkerData extends ESMInMemoryFileLoaderWorkerData { + outputMode: OutputMode | undefined; +} + +/** + * This is passed as workerData when setting up the worker via the `piscina` package. + */ +const { outputMode, hasSsrEntry } = workerData as { + outputMode: OutputMode | undefined; + hasSsrEntry: boolean; +}; + +/** Renders an application based on a provided options. */ +async function extractRoutes(): Promise { + // Load the compiler because `@angular/ssr/node` depends on `@angular/` packages, + // which must be processed by the runtime linker, even if they are not used. + await import('@angular/compiler'); + + const serverURL = outputMode !== undefined && hasSsrEntry ? await launchServer() : DEFAULT_URL; + + patchFetchToLoadInMemoryAssets(serverURL); + + const { ɵextractRoutesAndCreateRouteTree: extractRoutesAndCreateRouteTree } = + await loadEsmModuleFromMemory('./main.server.mjs'); + + const { routeTree, appShellRoute, errors } = await extractRoutesAndCreateRouteTree({ + url: serverURL, + invokeGetPrerenderParams: outputMode !== undefined, + includePrerenderFallbackRoutes: outputMode === OutputMode.Server, + signal: AbortSignal.timeout(30_000), + }); + + return { + errors, + appShellRoute, + serializedRouteTree: routeTree.toObject(), + }; +} + +export default extractRoutes; diff --git a/packages/angular/build/src/utils/server-rendering/utils.ts b/packages/angular/build/src/utils/server-rendering/utils.ts new file mode 100644 index 000000000000..2848fba52e75 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/utils.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { createRequestHandler } from '@angular/ssr'; +import type { createNodeRequestHandler } from '@angular/ssr/node' with { + 'resolution-mode': 'import', +}; + +export function isSsrNodeRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_node_request_handler__' in value; +} +export function isSsrRequestHandler( + value: unknown, +): value is ReturnType { + return typeof value === 'function' && '__ng_request_handler__' in value; +} + +/** + * Generates a static HTML page with a meta refresh tag to redirect the user to a specified URL. + * + * This function creates a simple HTML page that performs a redirect using a meta tag. + * It includes a fallback link in case the meta-refresh doesn't work. + * + * @param url - The URL to which the page should redirect. + * @returns The HTML content of the static redirect page. + */ +export function generateRedirectStaticPage(url: string): string { + return ` + + + + + Redirecting + + + +
Redirecting to ${url}
+ + +`.trim(); +} diff --git a/packages/angular/build/src/utils/service-worker.ts b/packages/angular/build/src/utils/service-worker.ts new file mode 100644 index 000000000000..ab8f058003db --- /dev/null +++ b/packages/angular/build/src/utils/service-worker.ts @@ -0,0 +1,251 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Config, Filesystem } from '@angular/service-worker/config' with { + 'resolution-mode': 'import', +}; +import * as crypto from 'node:crypto'; +import { existsSync, promises as fsPromises } from 'node:fs'; +import * as path from 'node:path'; +import { BuildOutputAsset } from '../tools/esbuild/bundler-execution-result'; +import { BuildOutputFile, BuildOutputFileType } from '../tools/esbuild/bundler-files'; +import { assertIsError } from './error'; +import { toPosixPath } from './path'; + +class CliFilesystem implements Filesystem { + constructor( + private fs: typeof fsPromises, + private base: string, + ) {} + + list(dir: string): Promise { + return this._recursiveList(this._resolve(dir), []); + } + + read(file: string): Promise { + return this.fs.readFile(this._resolve(file), 'utf-8'); + } + + async hash(file: string): Promise { + return crypto + .createHash('sha1') + .update(await this.fs.readFile(this._resolve(file))) + .digest('hex'); + } + + write(_file: string, _content: string): never { + throw new Error('This should never happen.'); + } + + private _resolve(file: string): string { + return path.join(this.base, file); + } + + private async _recursiveList(dir: string, items: string[]): Promise { + const subdirectories = []; + for (const entry of await this.fs.readdir(dir)) { + const entryPath = path.join(dir, entry); + const stats = await this.fs.stat(entryPath); + + if (stats.isFile()) { + // Uses posix paths since the service worker expects URLs + items.push('/' + toPosixPath(path.relative(this.base, entryPath))); + } else if (stats.isDirectory()) { + subdirectories.push(entryPath); + } + } + + for (const subdirectory of subdirectories) { + await this._recursiveList(subdirectory, items); + } + + return items; + } +} + +class ResultFilesystem implements Filesystem { + private readonly fileReaders = new Map Promise>(); + + constructor( + outputFiles: BuildOutputFile[], + assetFiles: { source: string; destination: string }[], + ) { + for (const file of outputFiles) { + if (file.type === BuildOutputFileType.Media || file.type === BuildOutputFileType.Browser) { + this.fileReaders.set('/' + toPosixPath(file.path), async () => file.contents); + } + } + for (const file of assetFiles) { + this.fileReaders.set('/' + toPosixPath(file.destination), () => + fsPromises.readFile(file.source), + ); + } + } + + async list(dir: string): Promise { + if (dir !== '/') { + throw new Error('Serviceworker manifest generator should only list files from root.'); + } + + return [...this.fileReaders.keys()]; + } + + async read(file: string): Promise { + const reader = this.fileReaders.get(file); + if (reader === undefined) { + throw new Error('File does not exist.'); + } + const contents = await reader(); + + return Buffer.from(contents.buffer, contents.byteOffset, contents.byteLength).toString('utf-8'); + } + + async hash(file: string): Promise { + const reader = this.fileReaders.get(file); + if (reader === undefined) { + throw new Error('File does not exist.'); + } + + return crypto + .createHash('sha1') + .update(await reader()) + .digest('hex'); + } + + write(): never { + throw new Error('Serviceworker manifest generator should not attempted to write.'); + } +} + +export async function augmentAppWithServiceWorker( + appRoot: string, + workspaceRoot: string, + outputPath: string, + baseHref: string, + ngswConfigPath?: string, + inputFileSystem = fsPromises, + outputFileSystem = fsPromises, +): Promise { + // Determine the configuration file path + const configPath = ngswConfigPath + ? path.join(workspaceRoot, ngswConfigPath) + : path.join(appRoot, 'ngsw-config.json'); + + // Read the configuration file + let config: Config | undefined; + try { + const configurationData = await inputFileSystem.readFile(configPath, 'utf-8'); + config = JSON.parse(configurationData) as Config; + } catch (error) { + assertIsError(error); + if (error.code === 'ENOENT') { + throw new Error( + 'Error: Expected to find an ngsw-config.json configuration file' + + ` in the ${appRoot} folder. Either provide one or` + + ' disable Service Worker in the angular.json configuration file.', + { cause: error }, + ); + } else { + throw error; + } + } + + const result = await augmentAppWithServiceWorkerCore( + config, + new CliFilesystem(outputFileSystem, outputPath), + baseHref, + ); + + const copy = async (src: string, dest: string): Promise => { + const resolvedDest = path.join(outputPath, dest); + + return outputFileSystem.writeFile(resolvedDest, await inputFileSystem.readFile(src)); + }; + + await outputFileSystem.writeFile(path.join(outputPath, 'ngsw.json'), result.manifest); + + for (const { source, destination } of result.assetFiles) { + await copy(source, destination); + } +} + +// This is currently used by the esbuild-based builder +export async function augmentAppWithServiceWorkerEsbuild( + workspaceRoot: string, + configPath: string, + baseHref: string, + indexHtml: string | undefined, + outputFiles: BuildOutputFile[], + assetFiles: BuildOutputAsset[], +): Promise<{ manifest: string; assetFiles: BuildOutputAsset[] }> { + // Read the configuration file + let config: Config | undefined; + try { + const configurationData = await fsPromises.readFile(configPath, 'utf-8'); + config = JSON.parse(configurationData) as Config; + + if (indexHtml) { + config.index = indexHtml; + } + } catch (error) { + assertIsError(error); + if (error.code === 'ENOENT') { + // TODO: Generate an error object that can be consumed by the esbuild-based builder + const message = `Service worker configuration file "${path.relative( + workspaceRoot, + configPath, + )}" could not be found.`; + throw new Error(message, { cause: error }); + } else { + throw error; + } + } + + return augmentAppWithServiceWorkerCore( + config, + new ResultFilesystem(outputFiles, assetFiles), + baseHref, + ); +} + +export async function augmentAppWithServiceWorkerCore( + config: Config, + serviceWorkerFilesystem: Filesystem, + baseHref: string, +): Promise<{ manifest: string; assetFiles: { source: string; destination: string }[] }> { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { Generator } = (await import('@angular/service-worker/config' as any)) as typeof import( + '@angular/service-worker/config', + { with: { 'resolution-mode': 'import' } } + ); + + // Generate the manifest + const generator = new Generator(serviceWorkerFilesystem, baseHref); + const output = await generator.process(config); + + // Write the manifest + const manifest = JSON.stringify(output, null, 2); + + // Find the service worker package + const workerPath = require.resolve('@angular/service-worker/ngsw-worker.js'); + + const result = { + manifest, + // Main worker code + assetFiles: [{ source: workerPath, destination: 'ngsw-worker.js' }], + }; + + // If present, write the safety worker code + const safetyPath = path.join(path.dirname(workerPath), 'safety-worker.js'); + if (existsSync(safetyPath)) { + result.assetFiles.push({ source: safetyPath, destination: 'worker-basic.min.js' }); + result.assetFiles.push({ source: safetyPath, destination: 'safety-worker.js' }); + } + + return result; +} diff --git a/packages/angular/build/src/utils/source-map.ts b/packages/angular/build/src/utils/source-map.ts new file mode 100644 index 000000000000..4fa97dd222c1 --- /dev/null +++ b/packages/angular/build/src/utils/source-map.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { EncodedSourceMap } from '@ampproject/remapping'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const SOURCEMAP_COMMENT_PREFIX = '//# sourceMappingURL='; +export const SOURCEMAP_COMMENT_BYTES = Buffer.from(SOURCEMAP_COMMENT_PREFIX); + +/** + * Checks for a single trailing `//# sourceMappingURL=` comment on a raw buffer. + * + * @param data The raw byte buffer to inspect. + * @returns An object containing the sliced code buffer and URL snippet if a single trailing comment exists, + * `null` if no sourcemap comment exists in the buffer, or `undefined` if multiple/non-trailing comments exist. + */ +export function findTrailingSourceMapComment( + data: Uint8Array, +): { code: Uint8Array; urlLine: string } | null | undefined { + const dataBuffer = Buffer.isBuffer(data) + ? data + : Buffer.from(data.buffer, data.byteOffset, data.byteLength); + + const firstIndex = dataBuffer.indexOf(SOURCEMAP_COMMENT_BYTES); + if (firstIndex === -1) { + return null; + } + + const lastIndex = dataBuffer.lastIndexOf(SOURCEMAP_COMMENT_BYTES); + // Skip any preceding horizontal whitespace (spaces/tabs) to find the start of the line. + let prevIdx = lastIndex - 1; + while (prevIdx >= 0 && (dataBuffer[prevIdx] === 32 || dataBuffer[prevIdx] === 9)) { + prevIdx--; + } + // Ensure the comment starts at the beginning of a line or the start of the file, + // preventing false positives for occurrences inside inline string literals or code. + const isLineStart = prevIdx < 0 || dataBuffer[prevIdx] === 10 || dataBuffer[prevIdx] === 13; + + if (firstIndex === lastIndex && isLineStart) { + const urlLine = dataBuffer + .subarray(lastIndex + SOURCEMAP_COMMENT_BYTES.length) + .toString('utf-8'); + + return { + code: dataBuffer.subarray(0, prevIdx < 0 ? 0 : prevIdx + 1), + urlLine, + }; + } + + return undefined; +} + +/** + * Removes `//# sourceMappingURL=` comments safely from the given JavaScript code, + * ignoring any occurrences that are inside string literals, template literals, or block comments. + * + * For raw Uint8Array / Buffer inputs, it optimizes performance by inspecting trailing byte sequences + * to slice the buffer directly without full string decoding. + * + * @param code The JavaScript source code as a string or Uint8Array. + * @returns The code with top-level sourcemap comments removed. + */ +export function removeSourceMappingURL(code: string): string; +export function removeSourceMappingURL(code: Uint8Array): Uint8Array; +export function removeSourceMappingURL(code: string | Uint8Array): string | Uint8Array { + if (typeof code === 'string') { + return removeSourceMappingURLFromString(code); + } + + const trailing = findTrailingSourceMapComment(code); + if (trailing === null) { + return code; + } + + if (trailing && isTrailingSourceMapComment(trailing.urlLine)) { + return trailing.code; + } + + // Fallback to full decode and state-machine stripping for multiple comments or non-trailing comments + const dataBuffer = Buffer.isBuffer(code) + ? code + : Buffer.from(code.buffer, code.byteOffset, code.byteLength); + const text = dataBuffer.toString('utf-8'); + const stripped = removeSourceMappingURLFromString(text); + + return stripped === text ? code : Buffer.from(stripped, 'utf-8'); +} + +function removeSourceMappingURLFromString(code: string): string { + if (!code.includes(SOURCEMAP_COMMENT_PREFIX)) { + return code; + } + + const result: string[] = []; + let lastCopiedIndex = 0; + let i = 0; + const len = code.length; + + // Stack to track template literal state and curly brace depth for nested interpolations. + const stack: { type: 'template'; braceDepth: number }[] = []; + let currentState: + 'normal' | 'string_double' | 'string_single' | 'template' | 'comment_block' | 'comment_line' = + 'normal'; + + while (i < len) { + const char = code[i]; + const nextChar = code[i + 1]; + + if (currentState === 'normal') { + if (char === '/' && nextChar === '*') { + currentState = 'comment_block'; + i += 2; + continue; + } + if (char === '/' && nextChar === '/') { + // Detect if the comment is escaped (e.g. inside a regex literal like `/\/\/#/`). + let isEscaped = false; + let prevIdx = i - 1; + while (prevIdx >= 0 && /\s/.test(code[prevIdx])) { + prevIdx--; + } + if (prevIdx >= 0 && code[prevIdx] === '\\') { + let bsCount = 0; + while (prevIdx >= 0 && code[prevIdx] === '\\') { + bsCount++; + prevIdx--; + } + if (bsCount % 2 === 1) { + isEscaped = true; + } + } + + if (!isEscaped && code.startsWith(SOURCEMAP_COMMENT_PREFIX, i)) { + if (i > lastCopiedIndex) { + result.push(code.slice(lastCopiedIndex, i)); + } + // Skip the rest of the comment line up to the newline + i += SOURCEMAP_COMMENT_PREFIX.length; + while (i < len && code[i] !== '\n' && code[i] !== '\r') { + i++; + } + lastCopiedIndex = i; + continue; + } else { + currentState = 'comment_line'; + i += 2; + continue; + } + } + if (char === '"') { + currentState = 'string_double'; + i++; + continue; + } + if (char === "'") { + currentState = 'string_single'; + i++; + continue; + } + if (char === '`') { + currentState = 'template'; + stack.push({ type: 'template', braceDepth: 0 }); + i++; + continue; + } + if (char === '{') { + const top = stack[stack.length - 1]; + if (top) { + top.braceDepth++; + } + i++; + continue; + } + if (char === '}') { + const top = stack[stack.length - 1]; + if (top) { + top.braceDepth--; + if (top.braceDepth < 0) { + // Exiting a template literal interpolation ${ ... } + stack.pop(); + currentState = 'template'; + i++; + continue; + } + } + i++; + continue; + } + + i++; + } else if (currentState === 'string_double') { + if (char === '\\') { + i += 2; + continue; + } + if (char === '"') { + currentState = 'normal'; + } + i++; + } else if (currentState === 'string_single') { + if (char === '\\') { + i += 2; + continue; + } + if (char === "'") { + currentState = 'normal'; + } + i++; + } else if (currentState === 'template') { + if (char === '\\') { + i += 2; + continue; + } + if (char === '$' && nextChar === '{') { + // Entering template literal interpolation context + currentState = 'normal'; + stack.push({ type: 'template', braceDepth: 0 }); + i += 2; + continue; + } + if (char === '`') { + stack.pop(); + currentState = 'normal'; + } + i++; + } else if (currentState === 'comment_block') { + if (char === '*' && nextChar === '/') { + currentState = 'normal'; + i += 2; + continue; + } + i++; + } else if (currentState === 'comment_line') { + if (char === '\n' || char === '\r') { + currentState = 'normal'; + } + i++; + } + } + + if (lastCopiedIndex === 0) { + return code; + } + + if (lastCopiedIndex < len) { + result.push(code.slice(lastCopiedIndex)); + } + + return result.join(''); +} + +/** + * Extracts the base64 payload from an inline sourcemap data URI line and verifies + * that only trailing whitespace follows the payload. + * + * @returns The base64 payload string if valid and trailing, or `undefined` otherwise. + */ +function extractTrailingBase64Payload(urlLine: string): string | undefined { + if (!urlLine.startsWith('data:application/json;')) { + return undefined; + } + + const base64StartIndex = urlLine.indexOf('base64,'); + if (base64StartIndex === -1) { + return undefined; + } + + const payloadStart = base64StartIndex + 7; + let payloadEnd = urlLine.length; + // Find the first trailing whitespace character that marks the end of the base64 payload. + for (let i = payloadStart; i < urlLine.length; i++) { + const char = urlLine[i]; + if (char === ' ' || char === '\r' || char === '\n' || char === '\t') { + payloadEnd = i; + break; + } + } + + // Verify that everything after the base64 payload is trailing whitespace + // to ensure this is a valid trailing sourceMappingURL comment at the end of the file. + for (let i = payloadEnd; i < urlLine.length; i++) { + const char = urlLine[i]; + if (char !== ' ' && char !== '\r' && char !== '\n' && char !== '\t') { + return undefined; + } + } + + return urlLine.slice(payloadStart, payloadEnd); +} + +/** + * Extracts the URL from an external sourcemap comment line and verifies + * that only trailing whitespace follows the URL. + * + * @returns The URL string if valid and trailing, or `undefined` otherwise. + */ +function extractTrailingUrl(urlLine: string): string | undefined { + if (urlLine.startsWith('data:')) { + return undefined; + } + + const urlMatch = /^([^\r\n\s'"`]+)/.exec(urlLine); + if (!urlMatch) { + return undefined; + } + + const remaining = urlLine.slice(urlMatch[1].length); + if (!/^\s*$/.test(remaining)) { + return undefined; + } + + return urlMatch[1]; +} + +/** + * Checks whether a `//# sourceMappingURL=` URL line snippet represents a valid trailing comment at the end of the file. + */ +export function isTrailingSourceMapComment(urlLine: string): boolean { + return ( + extractTrailingBase64Payload(urlLine) !== undefined || extractTrailingUrl(urlLine) !== undefined + ); +} + +/** + * Resolves and loads the input sourcemap referenced in a `//# sourceMappingURL=` URL line snippet. + * Supports inline base64 data URIs, local absolute file URLs, and relative/absolute filesystem paths. + */ +export function loadInputSourceMapFromUrl( + filename: string, + urlLine: string, +): EncodedSourceMap | undefined { + // Inline base64-encoded sourcemaps can be extremely large (up to megabytes). + // Parse them without regular expressions to avoid heavy backtracking and allocations. + const base64Payload = extractTrailingBase64Payload(urlLine); + if (base64Payload !== undefined) { + try { + return JSON.parse(Buffer.from(base64Payload, 'base64').toString('utf-8')) as EncodedSourceMap; + } catch { + return undefined; + } + } + + const url = extractTrailingUrl(urlLine); + if (!url) { + return undefined; + } + + if (url.startsWith('file://')) { + // Local absolute file URL scheme. + try { + const mapPath = fileURLToPath(url); + if (existsSync(mapPath)) { + return JSON.parse(readFileSync(mapPath, 'utf8')) as EncodedSourceMap; + } + } catch {} + } else if (!/^[a-z]+:\/\//i.test(url)) { + // Local relative or absolute filesystem path (percent-decoded as it originates from a URI). + try { + const mapPath = resolve(dirname(filename), decodeURIComponent(url)); + if (existsSync(mapPath)) { + return JSON.parse(readFileSync(mapPath, 'utf8')) as EncodedSourceMap; + } + } catch {} + } + + return undefined; +} + +/** + * Finds, resolves, and loads the input sourcemap referenced in the code's trailing + * sourceMappingURL comment, if present. Supports inline base64 data URIs, local absolute + * file URLs, and relative/absolute filesystem paths. + */ +export function loadInputSourceMap(filename: string, code: string): EncodedSourceMap | undefined { + // Locate the last sourceMappingURL comment using lastIndexOf to avoid scanning + // the entire file with a regular expression (significant for large files). + const lastSourceMapIndex = code.lastIndexOf(SOURCEMAP_COMMENT_PREFIX); + if (lastSourceMapIndex === -1) { + return undefined; + } + + if (lastSourceMapIndex > 0) { + // Skip any preceding horizontal whitespace (spaces/tabs) to find the start of the line. + let prevIdx = lastSourceMapIndex - 1; + while (prevIdx >= 0 && (code[prevIdx] === ' ' || code[prevIdx] === '\t')) { + prevIdx--; + } + // Ensure the comment starts at the beginning of a line, preventing false positives within code or strings. + if (prevIdx >= 0 && code[prevIdx] !== '\n' && code[prevIdx] !== '\r') { + return undefined; + } + } + + return loadInputSourceMapFromUrl( + filename, + code.slice(lastSourceMapIndex + SOURCEMAP_COMMENT_PREFIX.length), + ); +} diff --git a/packages/angular/build/src/utils/source-map_spec.ts b/packages/angular/build/src/utils/source-map_spec.ts new file mode 100644 index 000000000000..a5680d61fa4f --- /dev/null +++ b/packages/angular/build/src/utils/source-map_spec.ts @@ -0,0 +1,225 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + isTrailingSourceMapComment, + loadInputSourceMap, + loadInputSourceMapFromUrl, + removeSourceMappingURL, +} from './source-map'; + +describe('removeSourceMappingURL', () => { + it('should remove top-level sourcemap comments', () => { + const code = 'console.log("hello");\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe('console.log("hello");\n'); + }); + + it('should not remove sourcemap comments inside double-quoted strings', () => { + const code = 'const str = "//# sourceMappingURL=inline.js.map";'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside single-quoted strings', () => { + const code = "const str = '//# sourceMappingURL=inline.js.map';"; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside template literals', () => { + const code = 'const str = `\n//# sourceMappingURL=inline.js.map\n`;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside block comments', () => { + const code = '/*\n//# sourceMappingURL=inline.js.map\n*/'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside normal single-line comments', () => { + const code = '// Some description of //# sourceMappingURL=inline.js.map'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should remove multiple top-level sourcemap comments', () => { + const code = + '//# sourceMappingURL=first.js.map\nconsole.log("mid");\n//# sourceMappingURL=second.js.map'; + expect(removeSourceMappingURL(code)).toBe('\nconsole.log("mid");\n'); + }); + + it('should not remove sourcemap comments inside strings containing escaped quotes', () => { + const codeDouble = 'const str = "escaped \\" //# sourceMappingURL=inline.js.map";'; + expect(removeSourceMappingURL(codeDouble)).toBe(codeDouble); + + const codeSingle = "const str = 'escaped \\' //# sourceMappingURL=inline.js.map';"; + expect(removeSourceMappingURL(codeSingle)).toBe(codeSingle); + }); + + it('should handle strings containing escaped backslashes correctly', () => { + const code = 'const str = "backslash \\\\";\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe('const str = "backslash \\\\";\n'); + }); + + it('should not remove sourcemap comments inside template literal interpolations', () => { + const code = 'const str = `hello ${"//# sourceMappingURL=inline.js.map"} world`;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside nested template literals', () => { + const code = 'const str = `nested ${`inner ${"//# sourceMappingURL=inline.js.map"}`}`;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside nested template literals without inner quotes', () => { + const code = 'const str = `nested ${`inner //# sourceMappingURL=inline.js.map`}`;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not remove sourcemap comments inside regex literals', () => { + const code = 'const regex = /\\/\\/# sourceMappingURL=inline.js.map/;'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should not affect normal division operators', () => { + const code = 'const ratio = 10 / 2;\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe('const ratio = 10 / 2;\n'); + }); + + it('should only remove exact sourceMappingURL prefix comments', () => { + const code = '// # sourceMappingURL=main.js.map\n//# sourceMappingURL=main.js.map'; + expect(removeSourceMappingURL(code)).toBe('// # sourceMappingURL=main.js.map\n'); + }); + + it('should return the exact input string when no sourcemap comment is present', () => { + const code = 'const x = 1;\nconsole.log(x);'; + expect(removeSourceMappingURL(code)).toBe(code); + }); + + it('should handle sourcemap comments with CRLF newlines', () => { + const code = 'console.log("hello");\r\n//# sourceMappingURL=main.js.map\r\nconst next = 2;'; + expect(removeSourceMappingURL(code)).toBe('console.log("hello");\r\n\r\nconst next = 2;'); + }); + + describe('with Uint8Array / Buffer inputs', () => { + it('should strip trailing sourcemap comment from Uint8Array buffer', () => { + const buffer = Buffer.from( + 'console.log("hello");\n//# sourceMappingURL=main.js.map', + 'utf-8', + ); + const result = removeSourceMappingURL(buffer); + + expect(Buffer.from(result).toString('utf-8')).toBe('console.log("hello");\n'); + }); + + it('should return exact input buffer when no sourcemap comment is present', () => { + const buffer = Buffer.from('console.log("hello");\nconst x = 1;', 'utf-8'); + const result = removeSourceMappingURL(buffer); + + expect(result).toBe(buffer); + }); + + it('should handle multiple sourcemap comments in a buffer via fallback', () => { + const buffer = Buffer.from( + '//# sourceMappingURL=first.js.map\nconsole.log("mid");\n//# sourceMappingURL=second.js.map', + 'utf-8', + ); + const result = removeSourceMappingURL(buffer); + + expect(Buffer.from(result).toString('utf-8')).toBe('\nconsole.log("mid");\n'); + }); + + it('should not strip sourcemap comments inside template strings in a buffer', () => { + const buffer = Buffer.from('const str = `\n//# sourceMappingURL=inline.js.map\n`;', 'utf-8'); + const result = removeSourceMappingURL(buffer); + + expect(Buffer.from(result).toString('utf-8')).toBe(buffer.toString('utf-8')); + }); + }); +}); + +describe('loadInputSourceMapFromUrl', () => { + it('should decode inline base64 sourcemaps', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const urlLine = `data:application/json;charset=utf-8;base64,${base64}\n`; + + expect(loadInputSourceMapFromUrl('/src/foo.js', urlLine)).toEqual(map as never); + }); + + it('should return undefined for invalid base64 payloads', () => { + expect( + loadInputSourceMapFromUrl('/src/foo.js', 'data:application/json;base64,invalid!!!'), + ).toBeUndefined(); + }); + + it('should return undefined when no base64 marker is found in data URI', () => { + expect( + loadInputSourceMapFromUrl('/src/foo.js', 'data:application/json;utf8,{}'), + ).toBeUndefined(); + }); +}); + +describe('loadInputSourceMap', () => { + it('should extract and decode sourcemap from source string', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const code = `console.log("hello");\n//# sourceMappingURL=data:application/json;base64,${base64}\n`; + + expect(loadInputSourceMap('/src/foo.js', code)).toEqual(map as never); + }); + + it('should return undefined when no sourceMappingURL comment is present', () => { + expect(loadInputSourceMap('/src/foo.js', 'console.log("hello");')).toBeUndefined(); + }); + + it('should return undefined for comments inside template strings with code after them', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const code = `const str = \`\n//# sourceMappingURL=data:application/json;base64,${base64}\n\`;\nconsole.log(str);`; + + expect(loadInputSourceMap('/src/foo.js', code)).toBeUndefined(); + }); + + it('should return undefined for comments inside template strings ending with backticks', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const code = `const str = \`\n//# sourceMappingURL=data:application/json;base64,${base64}\n\`;`; + + expect(loadInputSourceMap('/src/foo.js', code)).toBeUndefined(); + }); + + it('should return undefined for single-line template literals', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const code = `const str = \`//# sourceMappingURL=data:application/json;base64,${base64}\`;`; + + expect(loadInputSourceMap('/src/foo.js', code)).toBeUndefined(); + }); +}); + +describe('isTrailingSourceMapComment', () => { + it('should return true for valid inline data URIs at end of file', () => { + const map = { version: 3, sources: ['foo.ts'], mappings: 'AAAA;' }; + const base64 = Buffer.from(JSON.stringify(map)).toString('base64'); + const urlLine = `data:application/json;charset=utf-8;base64,${base64}\n`; + + expect(isTrailingSourceMapComment(urlLine)).toBe(true); + }); + + it('should return true for external sourcemap URLs at end of file', () => { + expect(isTrailingSourceMapComment('main.js.map\n')).toBe(true); + expect(isTrailingSourceMapComment('main.js.map')).toBe(true); + }); + + it('should return false when followed by non-whitespace characters', () => { + expect(isTrailingSourceMapComment('main.js.map\n`;\nconsole.log("hi");')).toBe(false); + expect(isTrailingSourceMapComment('main.js.map`;')).toBe(false); + }); + + it('should return false for invalid data URI format', () => { + expect(isTrailingSourceMapComment('data:application/json;utf8,{}')).toBe(false); + }); +}); diff --git a/packages/angular/build/src/utils/stats-table.ts b/packages/angular/build/src/utils/stats-table.ts new file mode 100644 index 000000000000..b007fd7a4aa5 --- /dev/null +++ b/packages/angular/build/src/utils/stats-table.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { stripVTControlCharacters } from 'node:util'; +import { BudgetCalculatorResult } from './bundle-calculator'; +import { colors as ansiColors } from './color'; +import { formatSize } from './format-bytes'; + +export type BundleStatsData = [ + files: string, + names: string, + rawSize: number | string, + estimatedTransferSize: number | string, +]; +export interface BundleStats { + initial: boolean; + stats: BundleStatsData; +} + +export function generateEsbuildBuildStatsTable( + [browserStats, serverStats]: [browserStats: BundleStats[], serverStats: BundleStats[]], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], + verbose?: boolean, +): string { + const bundleInfo = generateBuildStatsData( + browserStats, + colors, + showTotalSize, + showEstimatedTransferSize, + budgetFailures, + verbose, + ); + + if (serverStats.length) { + const m = (x: string) => (colors ? ansiColors.magenta(x) : x); + if (browserStats.length) { + bundleInfo.unshift([m('Browser bundles')]); + // Add seperators between browser and server logs + bundleInfo.push([], []); + } + + bundleInfo.push( + [m('Server bundles')], + ...generateBuildStatsData(serverStats, colors, false, false, undefined, verbose), + ); + } + + return generateTableText(bundleInfo, colors); +} + +export function generateBuildStatsTable( + data: BundleStats[], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], +): string { + const bundleInfo = generateBuildStatsData( + data, + colors, + showTotalSize, + showEstimatedTransferSize, + budgetFailures, + true, + ); + + return generateTableText(bundleInfo, colors); +} + +function generateBuildStatsData( + data: BundleStats[], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], + verbose?: boolean, +): (string | number)[][] { + if (data.length === 0) { + return []; + } + + const g = (x: string) => (colors ? ansiColors.green(x) : x); + const c = (x: string) => (colors ? ansiColors.cyan(x) : x); + const r = (x: string) => (colors ? ansiColors.redBright(x) : x); + const y = (x: string) => (colors ? ansiColors.yellowBright(x) : x); + const bold = (x: string) => (colors ? ansiColors.bold(x) : x); + const dim = (x: string) => (colors ? ansiColors.dim(x) : x); + + const getSizeColor = (name: string, file?: string, defaultColor = c) => { + const severity = budgets.get(name) || (file && budgets.get(file)); + switch (severity) { + case 'warning': + return y; + case 'error': + return r; + default: + return defaultColor; + } + }; + + const changedEntryChunksStats: BundleStatsData[] = []; + const changedLazyChunksStats: BundleStatsData[] = []; + + let initialTotalRawSize = 0; + let changedLazyChunksCount = 0; + let initialTotalEstimatedTransferSize; + const maxLazyChunksWithoutBudgetFailures = 15; + + const budgets = new Map(); + if (budgetFailures) { + for (const { label, severity } of budgetFailures) { + // In some cases a file can have multiple budget failures. + // Favor error. + if (label && (!budgets.has(label) || budgets.get(label) === 'warning')) { + budgets.set(label, severity); + } + } + } + + // Sort descending by raw size + data.sort((a, b) => { + if (a.stats[2] > b.stats[2]) { + return -1; + } + + if (a.stats[2] < b.stats[2]) { + return 1; + } + + return 0; + }); + + for (const { initial, stats } of data) { + const [files, names, rawSize, estimatedTransferSize] = stats; + if ( + !initial && + !verbose && + changedLazyChunksStats.length >= maxLazyChunksWithoutBudgetFailures && + !budgets.has(names) && + !budgets.has(files) + ) { + // Limit the number of lazy chunks displayed in the stats table when there is no budget failure and not in verbose mode. + changedLazyChunksCount++; + continue; + } + + const getRawSizeColor = getSizeColor(names, files); + let data: BundleStatsData; + if (showEstimatedTransferSize) { + data = [ + g(files), + dim(names), + getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + c( + typeof estimatedTransferSize === 'number' + ? formatSize(estimatedTransferSize) + : estimatedTransferSize, + ), + ]; + } else { + data = [ + g(files), + dim(names), + getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + '', + ]; + } + + if (initial) { + changedEntryChunksStats.push(data); + if (typeof rawSize === 'number') { + initialTotalRawSize += rawSize; + } + if (showEstimatedTransferSize && typeof estimatedTransferSize === 'number') { + if (initialTotalEstimatedTransferSize === undefined) { + initialTotalEstimatedTransferSize = 0; + } + initialTotalEstimatedTransferSize += estimatedTransferSize; + } + } else { + changedLazyChunksStats.push(data); + changedLazyChunksCount++; + } + } + + const bundleInfo: (string | number)[][] = []; + const baseTitles = ['Names', 'Raw size']; + + if (showEstimatedTransferSize) { + baseTitles.push('Estimated transfer size'); + } + + // Entry chunks + if (changedEntryChunksStats.length) { + bundleInfo.push(['Initial chunk files', ...baseTitles].map(bold), ...changedEntryChunksStats); + + if (showTotalSize) { + const initialSizeTotalColor = getSizeColor('bundle initial', undefined, (x) => x); + const totalSizeElements = [ + ' ', + 'Initial total', + initialSizeTotalColor(formatSize(initialTotalRawSize)), + ]; + if (showEstimatedTransferSize) { + totalSizeElements.push( + typeof initialTotalEstimatedTransferSize === 'number' + ? formatSize(initialTotalEstimatedTransferSize) + : '-', + ); + } + bundleInfo.push([], totalSizeElements.map(bold)); + } + } + + // Seperator + if (changedEntryChunksStats.length && changedLazyChunksStats.length) { + bundleInfo.push([]); + } + + // Lazy chunks + if (changedLazyChunksStats.length) { + bundleInfo.push(['Lazy chunk files', ...baseTitles].map(bold), ...changedLazyChunksStats); + + if (changedLazyChunksCount > changedLazyChunksStats.length) { + bundleInfo.push([ + dim( + `...and ${changedLazyChunksCount - changedLazyChunksStats.length} more lazy chunks files. ` + + 'Use "--verbose" to show all the files.', + ), + ]); + } + } + + return bundleInfo; +} + +function generateTableText(bundleInfo: (string | number)[][], colors: boolean): string { + const skipText = (value: string) => value.includes('...and '); + const longest: number[] = []; + for (const item of bundleInfo) { + for (let i = 0; i < item.length; i++) { + if (item[i] === undefined) { + continue; + } + + const currentItem = item[i].toString(); + if (skipText(currentItem)) { + continue; + } + + const currentLongest = (longest[i] ??= 0); + const currentItemLength = stripVTControlCharacters(currentItem).length; + if (currentLongest < currentItemLength) { + longest[i] = currentItemLength; + } + } + } + + const seperator = colors ? ansiColors.dim(' | ') : ' | '; + const outputTable: string[] = []; + for (const item of bundleInfo) { + for (let i = 0; i < longest.length; i++) { + if (item[i] === undefined) { + continue; + } + + const currentItem = item[i].toString(); + if (skipText(currentItem)) { + continue; + } + + const currentItemLength = stripVTControlCharacters(currentItem).length; + const stringPad = ' '.repeat(longest[i] - currentItemLength); + // Values in columns at index 2 and 3 (Raw and Estimated sizes) are always right aligned. + item[i] = i >= 2 ? stringPad + currentItem : currentItem + stringPad; + } + + outputTable.push(item.join(seperator)); + } + + return outputTable.join('\n'); +} diff --git a/packages/angular/build/src/utils/supported-browsers.ts b/packages/angular/build/src/utils/supported-browsers.ts new file mode 100644 index 000000000000..d871d75789d3 --- /dev/null +++ b/packages/angular/build/src/utils/supported-browsers.ts @@ -0,0 +1,68 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import browserslist from 'browserslist'; + +// The below is replaced by bazel `npm_package`. +const BASELINE_DATE = 'BASELINE-DATE-PLACEHOLDER'; + +export function getSupportedBrowsers( + projectRoot: string, + logger: { warn(message: string): void }, +): string[] { + // Read the browserslist configuration containing Angular's browser support policy. + const angularBrowserslist = browserslist(`baseline widely available on ${getBaselineDate()}`); + + // Use Angular's configuration as the default. + browserslist.defaults = angularBrowserslist; + + // Get the minimum set of browser versions supported by Angular. + const minimumBrowsers = new Set(angularBrowserslist); + + // Get browsers from config or default. + const browsersFromConfigOrDefault = new Set(browserslist(undefined, { path: projectRoot })); + + // Get browsers that support ES6 modules. + const browsersThatSupportEs6 = new Set(browserslist('supports es6-module')); + + const nonEs6Browsers: string[] = []; + const unsupportedBrowsers: string[] = []; + for (const browser of browsersFromConfigOrDefault) { + if (!browsersThatSupportEs6.has(browser)) { + // Any browser which does not support ES6 is explicitly ignored, as Angular will not build successfully. + browsersFromConfigOrDefault.delete(browser); + nonEs6Browsers.push(browser); + } else if (!minimumBrowsers.has(browser)) { + // Any other unsupported browser we will attempt to use, but provide no support for. + unsupportedBrowsers.push(browser); + } + } + + if (nonEs6Browsers.length) { + logger.warn( + `One or more browsers which are configured in the project's Browserslist configuration ` + + 'will be ignored as ES5 output is not supported by the Angular CLI.\n' + + `Ignored browsers:\n${nonEs6Browsers.join(', ')}`, + ); + } + + if (unsupportedBrowsers.length) { + logger.warn( + `One or more browsers which are configured in the project's Browserslist configuration ` + + "fall outside Angular's browser support for this version.\n" + + `Unsupported browsers:\n${unsupportedBrowsers.join(', ')}`, + ); + } + + return Array.from(browsersFromConfigOrDefault); +} + +function getBaselineDate(): string { + // Unlike `npm_package`, `ts_project` which is used to run unit tests does not support substitutions. + return BASELINE_DATE[0] === 'B' ? '2025-01-01' : BASELINE_DATE; +} diff --git a/packages/angular/build/src/utils/test-files.ts b/packages/angular/build/src/utils/test-files.ts new file mode 100644 index 000000000000..763c762ac19f --- /dev/null +++ b/packages/angular/build/src/utils/test-files.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import path from 'node:path'; +import { ResultFile } from '../builders/application/results'; +import { BuildOutputFileType } from '../tools/esbuild/bundler-files'; +import { emitFilesToDisk } from './disk-files'; + +/** + * Writes a collection of build result files to a specified directory. + * This function handles both in-memory and on-disk files, creating subdirectories + * as needed. + * + * @param files A collection of `ResultFile` objects, representing the build output. + * @param testDir The absolute path to the directory where the files should be written. + */ +export function writeTestFiles(files: readonly ResultFile[], testDir: string): Promise { + // Writes the test related output files to disk and ensures the containing directories are present + return emitFilesToDisk(files, (file) => + file.type === BuildOutputFileType.Browser || file.type === BuildOutputFileType.Media + ? path.join(testDir, file.path) + : undefined, + ); +} diff --git a/packages/angular/build/src/utils/tty.ts b/packages/angular/build/src/utils/tty.ts new file mode 100644 index 000000000000..0d669c0301e3 --- /dev/null +++ b/packages/angular/build/src/utils/tty.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +function _isTruthy(value: undefined | string): boolean { + // Returns true if value is a string that is anything but 0 or false. + return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'; +} + +export function isTTY(): boolean { + // If we force TTY, we always return true. + const force = process.env['NG_FORCE_TTY']; + if (force !== undefined) { + return _isTruthy(force); + } + + return !!process.stdout.isTTY && !_isTruthy(process.env['CI']); +} diff --git a/packages/angular/build/src/utils/url.ts b/packages/angular/build/src/utils/url.ts new file mode 100644 index 000000000000..689eac37eab5 --- /dev/null +++ b/packages/angular/build/src/utils/url.ts @@ -0,0 +1,122 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Removes the trailing slash from a URL if it exists. + * + * @param url - The URL string from which to remove the trailing slash. + * @returns The URL string without a trailing slash. + * + * @example + * ```js + * stripTrailingSlash('path/'); // 'path' + * stripTrailingSlash('/path'); // '/path' + * stripTrailingSlash('/'); // '/' + * stripTrailingSlash(''); // '' + * ``` + */ +export function stripTrailingSlash(url: string): string { + // Check if the last character of the URL is a slash + return url.length > 1 && url.at(-1) === '/' ? url.slice(0, -1) : url; +} + +/** + * Removes the leading slash from a URL if it exists. + * + * @param url - The URL string from which to remove the leading slash. + * @returns The URL string without a leading slash. + * + * @example + * ```js + * stripLeadingSlash('/path'); // 'path' + * stripLeadingSlash('/path/'); // 'path/' + * stripLeadingSlash('/'); // '/' + * stripLeadingSlash(''); // '' + * ``` + */ +export function stripLeadingSlash(url: string): string { + // Check if the first character of the URL is a slash + return url.length > 1 && url[0] === '/' ? url.slice(1) : url; +} + +/** + * Adds a leading slash to a URL if it does not already have one. + * + * @param url - The URL string to which the leading slash will be added. + * @returns The URL string with a leading slash. + * + * @example + * ```js + * addLeadingSlash('path'); // '/path' + * addLeadingSlash('/path'); // '/path' + * ``` + */ +export function addLeadingSlash(url: string): string { + // Check if the URL already starts with a slash + return url[0] === '/' ? url : `/${url}`; +} + +/** + * Adds a trailing slash to a URL if it does not already have one. + * + * @param url - The URL string to which the trailing slash will be added. + * @returns The URL string with a trailing slash. + * + * @example + * ```js + * addTrailingSlash('path'); // 'path/' + * addTrailingSlash('path/'); // 'path/' + * ``` + */ +export function addTrailingSlash(url: string): string { + // Check if the URL already end with a slash + return url.at(-1) === '/' ? url : `${url}/`; +} + +/** + * Joins URL parts into a single URL string. + * + * This function takes multiple URL segments, normalizes them by removing leading + * and trailing slashes where appropriate, and then joins them into a single URL. + * + * @param parts - The parts of the URL to join. Each part can be a string with or without slashes. + * @returns The joined URL string, with normalized slashes. + * + * @example + * ```js + * joinUrlParts('path/', '/to/resource'); // '/path/to/resource' + * joinUrlParts('/path/', 'to/resource'); // '/path/to/resource' + * joinUrlParts('http://localhost/path/', 'to/resource'); // 'http://localhost/path/to/resource' + * joinUrlParts('', ''); // '/' + * ``` + */ +export function joinUrlParts(...parts: string[]): string { + const normalizeParts: string[] = []; + for (const part of parts) { + if (part === '') { + // Skip any empty parts + continue; + } + + let normalizedPart = part; + if (part[0] === '/') { + normalizedPart = normalizedPart.slice(1); + } + if (part.at(-1) === '/') { + normalizedPart = normalizedPart.slice(0, -1); + } + if (normalizedPart !== '') { + normalizeParts.push(normalizedPart); + } + } + + const protocolMatch = normalizeParts.length && /^https?:\/\//.test(normalizeParts[0]); + const joinedParts = normalizeParts.join('/'); + + return protocolMatch ? joinedParts : addLeadingSlash(joinedParts); +} diff --git a/packages/angular/build/src/utils/version.ts b/packages/angular/build/src/utils/version.ts new file mode 100644 index 000000000000..b9bbf5cd03b8 --- /dev/null +++ b/packages/angular/build/src/utils/version.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable no-console */ + +import { createRequire } from 'node:module'; +import { SemVer, satisfies } from 'semver'; + +export function assertCompatibleAngularVersion(projectRoot: string): void | never { + let angularPkgJson; + + // Create a custom require function for ESM compliance. + // NOTE: The trailing slash is significant. + const projectRequire = createRequire(projectRoot + '/'); + + try { + angularPkgJson = projectRequire('@angular/core/package.json'); + } catch { + console.error( + 'Error: It appears that "@angular/core" is missing as a dependency. Please ensure it is included in your project.', + ); + + process.exit(2); + } + + if (!angularPkgJson?.['version']) { + console.error( + 'Error: Unable to determine the versions of "@angular/core".\n' + + 'This likely indicates a corrupted local installation. Please try reinstalling your packages.', + ); + + process.exit(2); + } + + const angularCoreSemVer = new SemVer(angularPkgJson['version']); + const { version, build, raw } = angularCoreSemVer; + const supportedAngularSemver = '0.0.0-ANGULAR-FW-PEER-DEP'; + + if (version.startsWith('0.0.0') || supportedAngularSemver.startsWith('0.0.0')) { + // Internal CLI and FW testing version. + return; + } + + if (build.length && version.endsWith('.0.0-next.0')) { + // Special handle for local builds only when it's prerelease of major version and it's the 0th version. + // This happends when we are bumping to a new major version. and the cli has not releated a verion. + + // Example: + // raw: '22.0.0-next.0+sha-c7dc705-with-local-changes', + // major: 22, + // minor: 0, + // patch: 0, + // prerelease: [ 'next', 0 ], + // build: [ 'sha-c7dc705-with-local-changes' ], + // version: '22.0.0-next.0' + + return; + } + + if (!satisfies(angularCoreSemVer, supportedAngularSemver, { includePrerelease: true })) { + console.error( + `Error: The current version of "@angular/build" supports Angular versions ${supportedAngularSemver},\n` + + `but detected Angular version ${raw} instead.\n` + + 'Please visit the link below to find instructions on how to update Angular.\nhttps://update.angular.dev/', + ); + + process.exit(3); + } +} diff --git a/packages/angular/build/src/utils/watcher.ts b/packages/angular/build/src/utils/watcher.ts new file mode 100644 index 000000000000..fd865ee80b56 --- /dev/null +++ b/packages/angular/build/src/utils/watcher.ts @@ -0,0 +1,1370 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ParcelWatcher from '@parcel/watcher'; +import type * as Chokidar from 'chokidar'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import picomatch from 'picomatch'; +import { shouldWatchRoot } from './environment-options'; +import { toPosixPath } from './path'; + +export class ChangedFiles { + readonly added = new Set(); + readonly modified = new Set(); + readonly removed = new Set(); + + get all(): string[] { + return Array.from(new Set([...this.added, ...this.modified, ...this.removed])); + } + + toDebugString(): string { + const content = { + added: Array.from(this.added), + modified: Array.from(this.modified), + removed: Array.from(this.removed), + }; + + return JSON.stringify(content, null, 2); + } +} + +export interface BuildWatcher extends AsyncIterableIterator { + add(paths: string | readonly string[]): void; + remove(paths: string | readonly string[]): void; + close(): Promise; +} + +export interface WatcherOptions { + polling?: boolean; + interval?: number; + ignored?: string[]; + followSymlinks?: boolean; + cwd?: string; +} + +const NODE_MODULES = 'node_modules'; +const NODE_MODULES_SEGMENT = `/${NODE_MODULES}/`; + +// Watch workspace for package manager changes +const packageWatchFiles = [ + // manifest can affect module resolution + 'package.json', + // npm lock file + 'package-lock.json', + // pnpm lock file + 'pnpm-lock.yaml', + // yarn lock file including Yarn PnP manifest files (https://yarnpkg.com/advanced/pnp-spec/) + 'yarn.lock', + '.pnp.cjs', + '.pnp.data.json', +]; + +export interface SetupWatcherOptions { + workspaceRoot: string; + projectRoot: string; + outputPath: string; + cacheOptions: { basePath: string; localBasePath?: string }; + poll?: number; + preserveSymlinks?: boolean; + signal?: AbortSignal; + watchFiles?: Iterable; +} + +/** + * Sets up and initializes a file watcher with proper ignore patterns for build outputs and caches. + */ +export async function setupWatcher(options: SetupWatcherOptions): Promise { + const { + workspaceRoot, + projectRoot, + outputPath, + cacheOptions, + poll, + preserveSymlinks, + signal, + watchFiles, + } = options; + + const normalizedOutputBase = toPosixPath(outputPath); + const normalizedCacheBase = toPosixPath(cacheOptions.basePath); + const ignored: string[] = [ + // Ignore the output and cache paths to avoid infinite rebuild cycles + normalizedOutputBase, + `${normalizedOutputBase}/**`, + normalizedCacheBase, + `${normalizedCacheBase}/**`, + `${toPosixPath(workspaceRoot)}/**/.*/**`, + ]; + + if (cacheOptions.localBasePath && cacheOptions.localBasePath !== cacheOptions.basePath) { + const normalizedLocalCacheBase = toPosixPath(cacheOptions.localBasePath); + ignored.push(normalizedLocalCacheBase, `${normalizedLocalCacheBase}/**`); + } + + if (shouldWatchRoot && !preserveSymlinks) { + // Ignore all node modules directories to avoid excessive file watchers. + // Package changes are handled below by watching manifest and lock files. + // NOTE: this is not enabled when preserveSymlinks is true as this would break `npm link` usages. + ignored.push(`**/${NODE_MODULES}/**`); + } + + const watcher = await createWatcher({ + polling: typeof poll === 'number', + interval: poll, + followSymlinks: preserveSymlinks, + ignored, + cwd: workspaceRoot, + }); + + // Setup abort support + if (signal) { + const onAbort = () => void watcher.close(); + signal.addEventListener('abort', onAbort, { once: true }); + const originalClose = watcher.close.bind(watcher); + watcher.close = async () => { + signal.removeEventListener('abort', onAbort); + await originalClose(); + }; + } + + // Watch the entire project root if 'NG_BUILD_WATCH_ROOT' environment variable is set + if (shouldWatchRoot) { + if (!preserveSymlinks) { + watcher.add( + packageWatchFiles + .map((file) => path.join(workspaceRoot, file)) + .filter((file) => fs.existsSync(file)), + ); + } + + watcher.add(projectRoot); + } + + if (watchFiles) { + watcher.add(Array.isArray(watchFiles) ? watchFiles : Array.from(watchFiles)); + } + + return watcher; +} + +/** + * Probes the filesystem at the specified target directory to determine whether it is case-sensitive. + */ +function isFileSystemCaseSensitive(targetDir: string = process.cwd()): boolean { + try { + const resolved = path.resolve(targetDir); + if (!fs.existsSync(resolved)) { + return process.platform !== 'win32' && process.platform !== 'darwin'; + } + + // Invert the casing of the target directory path. + const altCase = + resolved === resolved.toLowerCase() ? resolved.toUpperCase() : resolved.toLowerCase(); + + // If the path contains no alphabetic characters (e.g. root '/'), invert-casing + // produces the exact same string. Fall back to platform-specific defaults in this case. + if (resolved === altCase) { + return process.platform !== 'win32' && process.platform !== 'darwin'; + } + + // If both the original path and the inverted-casing path exist on disk, + // the filesystem is case-insensitive (returns false). + return !fs.existsSync(altCase); + } catch { + // If an error occurs (e.g., permission denied), default to the platform-specific + // behavior (case-insensitive on Windows/macOS, sensitive on Linux/Unix). + return process.platform !== 'win32' && process.platform !== 'darwin'; + } +} + +/** + * Normalizes a file system path string to POSIX format (forward slashes '/') + * and strips trailing slashes (except root '/' or Windows drive root 'C:/'). + */ +export function toPosixPathNormalized(pathString: string): string { + let posixPath = toPosixPath(pathString); + if (posixPath.length > 1 && posixPath.endsWith('/') && !/^[a-zA-Z]:\/$/.test(posixPath)) { + posixPath = posixPath.slice(0, -1); + } + + return posixPath; +} + +/** + * Returns a lookup key for set lookups and matching, lowercasing on case-insensitive file systems. + */ +function toLookupKey(posixPath: string, isCaseSensitive: boolean): string { + return isCaseSensitive ? posixPath : posixPath.toLowerCase(); +} + +/** + * Returns the parent directory of a normalized POSIX path, correctly handling Windows drive roots. + */ +export function getDirectoryPath(posixPath: string): string { + const lastSlash = posixPath.lastIndexOf('/'); + if (lastSlash === -1) { + return '.'; + } + const dir = posixPath.slice(0, lastSlash); + if (dir === '' || dir.endsWith(':')) { + return dir + '/'; + } + + return dir; +} + +/** + * Determines whether a file path lookup key or any of its parent directories are present in watchedFiles. + */ +function isPathWatched(fileLookupKey: string, watchedFiles: Set): boolean { + if (watchedFiles.has(fileLookupKey)) { + return true; + } + + let current = fileLookupKey; + while (true) { + const parent = getDirectoryPath(current); + if (parent === current) { + break; + } + if (watchedFiles.has(parent)) { + return true; + } + current = parent; + } + + return false; +} + +class WatcherQueue { + private readonly nextQueue: ((value?: ChangedFiles) => void)[] = []; + private currentChangedFiles: ChangedFiles | undefined; + private isClosed = false; + private timeoutId: NodeJS.Timeout | undefined; + private firstChangeTime: number | undefined; + + constructor( + private readonly debounceMs = 100, + private readonly maxWaitMs = 500, + ) {} + + addChange(type: 'added' | 'modified' | 'removed', file: string): void { + if (this.isClosed) { + return; + } + + const changedFiles = (this.currentChangedFiles ??= new ChangedFiles()); + changedFiles[type].add(file); + this.scheduleFlush(); + } + + addChanges( + changes: ReadonlyArray<{ type: 'added' | 'modified' | 'removed'; file: string }>, + ): void { + if (this.isClosed || changes.length === 0) { + return; + } + + const changedFiles = (this.currentChangedFiles ??= new ChangedFiles()); + for (const { type, file } of changes) { + changedFiles[type].add(file); + } + this.scheduleFlush(); + } + + private scheduleFlush(): void { + const now = Date.now(); + const firstChangeTime = (this.firstChangeTime ??= now); + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + } + + const elapsed = now - firstChangeTime; + const remainingMaxWait = Math.max(0, this.maxWaitMs - elapsed); + const delay = Math.min(this.debounceMs, remainingMaxWait); + + this.timeoutId = setTimeout(() => { + this.timeoutId = undefined; + this.flush(); + }, delay); + } + + private flush(): void { + this.firstChangeTime = undefined; + if ( + this.currentChangedFiles && + this.currentChangedFiles.all.length > 0 && + this.nextQueue.length > 0 + ) { + const next = this.nextQueue.shift(); + if (next) { + const result = this.currentChangedFiles; + this.currentChangedFiles = undefined; + next(result); + } + } + } + + async next(): Promise> { + if ( + this.currentChangedFiles && + this.currentChangedFiles.all.length > 0 && + this.nextQueue.length === 0 && + !this.timeoutId + ) { + const result = { value: this.currentChangedFiles }; + this.currentChangedFiles = undefined; + + return result; + } + + if (this.isClosed) { + return { done: true, value: undefined as unknown as ChangedFiles }; + } + + return new Promise((resolve) => { + this.nextQueue.push((value) => + resolve(value ? { value } : { done: true, value: undefined as unknown as ChangedFiles }), + ); + }); + } + + close(): void { + if (this.isClosed) { + return; + } + + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + this.firstChangeTime = undefined; + + this.isClosed = true; + this.currentChangedFiles = undefined; + + let next; + while ((next = this.nextQueue.shift()) !== undefined) { + next(); + } + } +} + +export async function createWatcher(options?: WatcherOptions): Promise { + if (options?.polling || options?.followSymlinks) { + return createChokidarWatcher(options); + } + + try { + const parcelWatcher = await import('@parcel/watcher'); + + return await createParcelWatcher(options, parcelWatcher); + } catch { + return createChokidarWatcher(options); + } +} + +/** + * Checks whether a file path is located inside a parent directory. + * + * Input Expectations: + * - Both `file` and `dir` must be normalized POSIX-style paths (using forward slashes '/'). + * - Both paths must share the same casing normalization (e.g., lowercased on case-insensitive file systems). + */ +export function isPathInside(file: string, dir: string): boolean { + if (file === dir) { + return false; + } + + const dirWithSlash = dir.endsWith('/') ? dir : dir + '/'; + + return file.startsWith(dirWithSlash); +} + +/** + * Extracts the package directory inside node_modules for a given POSIX path. + * + * Input Expectations: + * - `posixPath` must be a normalized POSIX-style path (using forward slashes '/'). + */ +export function extractNodeModulesPackageDir(posixPath: string): string | undefined { + const index = posixPath.lastIndexOf(NODE_MODULES_SEGMENT); + if (index === -1) { + return undefined; + } + + const nodeModulesPath = posixPath.slice(0, index + NODE_MODULES_SEGMENT.length - 1); + const remainder = posixPath.slice(index + NODE_MODULES_SEGMENT.length); + const segments = remainder.split('/'); + if (segments.length === 0 || !segments[0]) { + return undefined; + } + + if (segments[0].startsWith('@')) { + if (segments.length < 2 || !segments[1]) { + return undefined; + } + + return `${nodeModulesPath}/${segments[0]}/${segments[1]}`; + } + + return `${nodeModulesPath}/${segments[0]}`; +} + +/** + * Maximum number of concurrent external directory subscriptions before coalescing + * sibling/cousin directories into common ancestor directories. + * Kept well below macOS's ~512 per-process FSEvents stream limit. + */ +const DEFAULT_MAX_EXTERNAL_SUBSCRIPTIONS = 256; + +/** + * Node in a directory prefix tree used to subsume nested directories + * and coalesce sibling/cousin directories into common ancestors. + */ +interface DirTrieNode { + /** Case-normalized POSIX path key for set/map lookups. */ + dirKey: string; + + /** Original-case POSIX directory path passed to `@parcel/watcher`. */ + dirPath: string; + + /** Depth of this directory node from the filesystem root (0 at root). */ + depth: number; + + /** Parent directory node, or `undefined` if this node is a filesystem root. */ + parent: DirTrieNode | undefined; + + /** Child directory nodes keyed by their `dirKey`. */ + children: Map; + + /** Whether this directory is selected as an active subscription target. */ + isTarget: boolean; + + /** Number of active target leaf directories within this node's subtree. */ + leafCount: number; +} + +/** + * Checks whether any strict ancestor of the given trie node is already marked as a target. + */ +function hasTargetAncestor(node: DirTrieNode): boolean { + let current = node.parent; + while (current) { + if (current.isTarget) { + return true; + } + current = current.parent; + } + + return false; +} + +/** + * Manages `@parcel/watcher` directory subscriptions for watched files located outside the + * primary `workspaceRoot`. + * + * Because `@parcel/watcher` watches directories recursively and macOS limits each process to + * ~512 concurrent `FSEvents` streams, this manager: + * - Normalizes external `node_modules` file paths to their package root directory. + * - Batches synchronous `watchFile` and `removeFile` calls within a microtask before subscribing. + * - Uses a directory prefix tree to subsume nested child directories and iteratively coalesce + * deepest branching subdirectories into common ancestors when the subscription budget is exceeded. + * - Dynamically lowers its subscription budget and coalesces further if `subscribe()` fails on + * an existing directory due to OS stream limits. + */ +export class ParcelExternalManager { + /** Active `@parcel/watcher` subscriptions keyed by subscribed directory lookup key. */ + readonly #extraSubscriptions = new Map(); + + /** Reference-counted watched files grouped by their target external directory lookup key. */ + readonly #externalDirFiles = new Map }>(); + + /** Reverse lookup mapping each watched file lookup key to its target directory lookup key. */ + readonly #fileToDirKey = new Map(); + + /** The `@parcel/watcher` module instance. */ + readonly #parcelWatcher: typeof ParcelWatcher; + + /** Watcher configuration options including ignore patterns. */ + readonly #options: WatcherOptions | undefined; + + /** Normalized POSIX path of the primary watched workspace root. */ + readonly #rootDirPosix: string; + + /** Case-normalized lookup key of the primary watched workspace root. */ + readonly #rootDirLookupKey: string; + + /** Whether the underlying filesystem is case-sensitive. */ + readonly #isCaseSensitive: boolean; + + /** Callback invoked when `@parcel/watcher` emits filesystem events. */ + readonly #handleEvents: (events: ParcelWatcher.Event[]) => void; + + /** Current maximum number of concurrent external directory subscriptions allowed before coalescing. */ + #maxSubscriptions: number; + + /** Whether external directory state has changed and requires a reconciliation pass. */ + #needsReconcile = false; + + /** Whether obsolete subscriptions should be unsubscribed before creating new ones (used after stream limit errors). */ + #shrinkBeforeSubscribe = false; + + /** In-flight promise for the currently scheduled or running reconciliation loop. */ + #reconcilePromise: Promise | undefined; + + /** Whether this manager has been closed. */ + #isClosed = false; + + /** + * Creates a new external directory subscription manager for `@parcel/watcher`. + * + * @param parcelWatcher The `@parcel/watcher` module. + * @param options Watcher configuration options. + * @param rootDirPosix Normalized POSIX path of the primary workspace root. + * @param rootDirLookupKey Case-normalized lookup key of the primary workspace root. + * @param isCaseSensitive Whether the filesystem at the workspace root is case-sensitive. + * @param handleEvents Callback to process incoming `@parcel/watcher` events. + * @param maxSubscriptions Maximum number of concurrent external directory subscriptions before coalescing. + */ + constructor( + parcelWatcher: typeof ParcelWatcher, + options: WatcherOptions | undefined, + rootDirPosix: string, + rootDirLookupKey: string, + isCaseSensitive: boolean, + handleEvents: (events: ParcelWatcher.Event[]) => void, + maxSubscriptions = DEFAULT_MAX_EXTERNAL_SUBSCRIPTIONS, + ) { + this.#parcelWatcher = parcelWatcher; + this.#options = options; + this.#rootDirPosix = rootDirPosix; + this.#rootDirLookupKey = rootDirLookupKey; + this.#isCaseSensitive = isCaseSensitive; + this.#handleEvents = handleEvents; + this.#maxSubscriptions = Math.max(1, maxSubscriptions); + } + + /** + * Registers a file path to be watched if it resides outside the primary workspace root, + * and schedules an asynchronous subscription reconciliation pass if needed. + * + * @param posixPath Normalized POSIX file path preserving original casing. + * @param lookupKey Case-normalized lookup key for the file path. + */ + watchFile(posixPath: string, lookupKey: string): void { + if ( + this.#isClosed || + isPathInside(lookupKey, this.#rootDirLookupKey) || + lookupKey === this.#rootDirLookupKey + ) { + return; + } + + const pkgDir = extractNodeModulesPackageDir(posixPath); + const dirPath = pkgDir ?? getDirectoryPath(posixPath); + const dirKey = pkgDir + ? toLookupKey(pkgDir, this.#isCaseSensitive) + : getDirectoryPath(lookupKey); + + this.#fileToDirKey.set(lookupKey, dirKey); + + let dirEntry = this.#externalDirFiles.get(dirKey); + const isNewDir = !dirEntry; + if (!dirEntry) { + dirEntry = { dirPath, files: new Set() }; + this.#externalDirFiles.set(dirKey, dirEntry); + } + dirEntry.files.add(lookupKey); + + if (isNewDir || !this.#isCoveredByActiveSubscription(dirKey)) { + this.#scheduleReconcile(); + } + } + + /** + * Unregisters a previously watched external file and schedules a reconciliation pass + * if it was the last watched file in its target directory. + * + * @param lookupKey Case-normalized lookup key for the file path to remove. + */ + removeFile(lookupKey: string): void { + const dirKey = this.#fileToDirKey.get(lookupKey); + if (!dirKey) { + return; + } + + this.#fileToDirKey.delete(lookupKey); + const dirEntry = this.#externalDirFiles.get(dirKey); + if (dirEntry) { + dirEntry.files.delete(lookupKey); + if (dirEntry.files.size === 0) { + this.#externalDirFiles.delete(dirKey); + this.#scheduleReconcile(); + } + } + } + + /** + * Waits for any scheduled or in-progress subscription reconciliation pass to complete. + */ + async flush(): Promise { + if (this.#reconcilePromise) { + await this.#reconcilePromise; + } + } + + /** + * Closes the manager, awaiting any pending reconciliation and unsubscribing all active + * external directory subscriptions. + */ + async close(): Promise { + this.#isClosed = true; + this.#needsReconcile = false; + try { + if (this.#reconcilePromise) { + await this.#reconcilePromise.catch(() => {}); + } + if (this.#extraSubscriptions.size > 0) { + await Promise.allSettled( + Array.from(this.#extraSubscriptions.values(), (sub) => sub.unsubscribe()), + ); + } + } finally { + this.#extraSubscriptions.clear(); + this.#externalDirFiles.clear(); + this.#fileToDirKey.clear(); + } + } + + /** + * Schedules a microtask to reconcile active `@parcel/watcher` subscriptions with the + * current set of external directories. + */ + #scheduleReconcile(): void { + if (this.#isClosed) { + return; + } + + this.#needsReconcile = true; + this.#reconcilePromise ??= Promise.resolve() + .then(() => this.#runReconcileLoop()) + .finally(() => { + this.#reconcilePromise = undefined; + }); + } + + /** + * Continuously executes reconciliation passes until no further changes are pending. + */ + async #runReconcileLoop(): Promise { + while (this.#needsReconcile && !this.#isClosed) { + this.#needsReconcile = false; + await this.#reconcileOnce(); + } + } + + /** + * Performs a single reconciliation pass: computes the desired coalesced directory set, + * subscribes to newly required directories, unsubscribes obsolete directories, and + * triggers fallback coalescing if OS stream limits are encountered. + */ + async #reconcileOnce(): Promise { + const desiredDirs = this.#computeDesiredDirectories(); + + if (this.#shrinkBeforeSubscribe) { + this.#shrinkBeforeSubscribe = false; + const toUnsubscribe: Promise[] = []; + for (const [existingKey, sub] of this.#extraSubscriptions.entries()) { + if (!desiredDirs.has(existingKey)) { + this.#extraSubscriptions.delete(existingKey); + toUnsubscribe.push(sub.unsubscribe().catch(() => {})); + } + } + if (toUnsubscribe.length > 0) { + await Promise.allSettled(toUnsubscribe); + } + } + + if (this.#isClosed) { + return; + } + + const toSubscribe: [string, string][] = []; + for (const [dirKey, dirPath] of desiredDirs.entries()) { + if (!this.#extraSubscriptions.has(dirKey)) { + toSubscribe.push([dirKey, dirPath]); + } + } + + let hadExistingDirFailure = false; + if (toSubscribe.length > 0) { + await Promise.allSettled( + toSubscribe.map(async ([dirKey, dirPath]) => { + const ignore = isPathInside(this.#rootDirLookupKey, dirKey) + ? [...(this.#options?.ignored ?? []), this.#rootDirPosix, `${this.#rootDirPosix}/**`] + : this.#options?.ignored; + + try { + const sub = await this.#parcelWatcher.subscribe( + dirPath, + (err, events) => { + if (!err) { + this.#handleEvents(events); + } + }, + { ignore }, + ); + + if (this.#isClosed) { + await sub.unsubscribe().catch(() => {}); + + return; + } + + this.#extraSubscriptions.set(dirKey, sub); + } catch { + // Ignore errors for missing directories; if the directory exists on disk, + // mark failure so the manager can coalesce further if over OS stream limits. + if (!this.#isClosed && fs.existsSync(dirPath)) { + hadExistingDirFailure = true; + } + } + }), + ); + } + + if (this.#isClosed) { + return; + } + + // Unsubscribe any previous subscriptions that are no longer desired and whose + // remaining watched files (if any) are now covered by another active subscription. + for (const [existingKey, sub] of this.#extraSubscriptions.entries()) { + if (!desiredDirs.has(existingKey) && this.#canRemoveSubscription(existingKey)) { + this.#extraSubscriptions.delete(existingKey); + sub.unsubscribe().catch(() => {}); + } + } + + // If subscribing to an existing directory failed (e.g. macOS FSEvents per-process limit), + // dynamically lower the subscription budget and re-reconcile to coalesce into ancestors. + if (hadExistingDirFailure && desiredDirs.size > 1 && this.#maxSubscriptions > 1) { + const nextMax = + this.#extraSubscriptions.size > 0 + ? Math.max(1, Math.floor(this.#extraSubscriptions.size / 2)) + : Math.max(1, Math.floor(Math.min(this.#maxSubscriptions, desiredDirs.size) / 2)); + + if (nextMax < desiredDirs.size && nextMax < this.#maxSubscriptions) { + this.#maxSubscriptions = nextMax; + this.#shrinkBeforeSubscribe = true; + this.#needsReconcile = true; + } + } + } + + /** + * Checks whether a directory lookup key is already covered by an active subscription. + */ + #isCoveredByActiveSubscription(dirLookupKey: string): boolean { + for (const existingDir of this.#extraSubscriptions.keys()) { + if (dirLookupKey === existingDir || isPathInside(dirLookupKey, existingDir)) { + return true; + } + } + + return false; + } + + /** + * Determines whether an existing subscription can be safely removed without leaving + * any remaining watched target directories uncovered. + */ + #canRemoveSubscription(subKey: string): boolean { + for (const targetDirKey of this.#externalDirFiles.keys()) { + if (targetDirKey === subKey || isPathInside(targetDirKey, subKey)) { + let coveredByOther = false; + for (const otherSubKey of this.#extraSubscriptions.keys()) { + if ( + otherSubKey !== subKey && + (targetDirKey === otherSubKey || isPathInside(targetDirKey, otherSubKey)) + ) { + coveredByOther = true; + break; + } + } + if (!coveredByOther) { + return false; + } + } + } + + return true; + } + + /** + * Computes the minimal set of external directories to subscribe to (mapped from `dirKey` to `dirPath`), + * subsuming nested directories and coalescing branching ancestors to stay within `#maxSubscriptions`. + */ + #computeDesiredDirectories(): Map { + if (this.#externalDirFiles.size === 0) { + return new Map(); + } + + const nodesByKey = new Map(); + const roots: DirTrieNode[] = []; + + for (const [dirKey, { dirPath }] of this.#externalDirFiles.entries()) { + const chain: { dirKey: string; dirPath: string }[] = []; + let currentKey = dirKey; + let currentPath = dirPath; + while (true) { + chain.push({ dirKey: currentKey, dirPath: currentPath }); + const parentKey = getDirectoryPath(currentKey); + if (parentKey === currentKey) { + break; + } + currentKey = parentKey; + currentPath = getDirectoryPath(currentPath); + } + + let parentNode: DirTrieNode | undefined; + for (let i = chain.length - 1; i >= 0; i--) { + const { dirKey: nodeKey, dirPath: nodePath } = chain[i]; + const depth = chain.length - 1 - i; + let node = nodesByKey.get(nodeKey); + if (!node) { + node = { + dirKey: nodeKey, + dirPath: nodePath, + depth, + parent: parentNode, + children: new Map(), + isTarget: false, + leafCount: 0, + }; + nodesByKey.set(nodeKey, node); + if (parentNode) { + parentNode.children.set(nodeKey, node); + } else { + roots.push(node); + } + } + parentNode = node; + } + + if (parentNode) { + parentNode.isTarget = true; + } + } + + const initSubtree = (node: DirTrieNode): number => { + if (node.isTarget) { + node.children.clear(); + node.leafCount = 1; + + return 1; + } + + let count = 0; + for (const child of node.children.values()) { + count += initSubtree(child); + } + node.leafCount = count; + + return count; + }; + + let totalLeaves = 0; + for (const root of roots) { + totalLeaves += initSubtree(root); + } + + if (totalLeaves > this.#maxSubscriptions) { + // Tier 1: non-ancestors of workspaceRoot (e.g., /libs, /node_modules) + // Tier 2: ancestors of workspaceRoot (e.g., when sibling apps/libs are directly under it) + const tier1ByDepth = new Map(); + const tier2ByDepth = new Map(); + + const collectInternalNodes = (node: DirTrieNode) => { + if (node.isTarget) { + return; + } + if (node.parent !== undefined && node.leafCount > 1) { + const isWorkspaceAncestor = isPathInside(this.#rootDirLookupKey, node.dirKey); + const bucketMap = isWorkspaceAncestor ? tier2ByDepth : tier1ByDepth; + let bucket = bucketMap.get(node.depth); + if (!bucket) { + bucket = []; + bucketMap.set(node.depth, bucket); + } + bucket.push(node); + } + for (const child of node.children.values()) { + collectInternalNodes(child); + } + }; + + for (const root of roots) { + collectInternalNodes(root); + } + + const collapseTier = (byDepth: Map) => { + const sortedLevels = Array.from(byDepth.entries()).sort(([a], [b]) => b - a); + for (const [, nodes] of sortedLevels) { + if (totalLeaves <= this.#maxSubscriptions) { + return; + } + + const candidates = nodes + .filter((n) => n.leafCount > 1 && !hasTargetAncestor(n)) + .sort((a, b) => b.leafCount - a.leafCount || a.dirKey.localeCompare(b.dirKey)); + + for (const candidate of candidates) { + if (totalLeaves <= this.#maxSubscriptions) { + return; + } + if (candidate.leafCount <= 1 || hasTargetAncestor(candidate)) { + continue; + } + + const reduction = candidate.leafCount - 1; + candidate.isTarget = true; + candidate.children.clear(); + candidate.leafCount = 1; + totalLeaves -= reduction; + + let ancestor = candidate.parent; + while (ancestor) { + ancestor.leafCount -= reduction; + ancestor = ancestor.parent; + } + } + } + }; + + collapseTier(tier1ByDepth); + if (totalLeaves > this.#maxSubscriptions) { + collapseTier(tier2ByDepth); + } + } + + const desired = new Map(); + const collectTargets = (node: DirTrieNode) => { + if (node.isTarget) { + desired.set(node.dirKey, node.dirPath); + + return; + } + for (const child of node.children.values()) { + collectTargets(child); + } + }; + + for (const root of roots) { + collectTargets(root); + } + + return desired; + } +} + +async function createParcelWatcher( + options: WatcherOptions | undefined, + parcelWatcher: typeof ParcelWatcher, +): Promise { + const watchedFiles = new Set(); + const queue = new WatcherQueue(); + + const isCaseSensitive = isFileSystemCaseSensitive(options?.cwd); + const rootDirPosix = toPosixPathNormalized(options?.cwd ?? process.cwd()); + const rootDirLookupKey = toLookupKey(rootDirPosix, isCaseSensitive); + const initTime = Date.now(); + + const handleEvents = (events: ParcelWatcher.Event[]) => { + const changes: { type: 'added' | 'modified' | 'removed'; file: string }[] = []; + for (const event of events) { + const posixPath = toPosixPathNormalized(event.path); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + continue; + } + + if (event.type !== 'delete') { + const stat = fs.statSync(event.path, { throwIfNoEntry: false }); + // Ignore historical events from before watcher initialization, but allow a 1000 ms window + // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second + // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes. + if (stat && stat.mtimeMs < initTime - 1000) { + continue; + } + } + + const type = + event.type === 'create' ? 'added' : event.type === 'delete' ? 'removed' : 'modified'; + changes.push({ type, file: event.path }); + } + + if (changes.length > 0) { + queue.addChanges(changes); + } + }; + + const subscription = await parcelWatcher.subscribe( + rootDirPosix, + (err, events) => { + if (!err) { + handleEvents(events); + } + }, + { + ignore: options?.ignored, + }, + ); + + const externalManager = new ParcelExternalManager( + parcelWatcher, + options, + rootDirPosix, + rootDirLookupKey, + isCaseSensitive, + handleEvents, + ); + + const buildWatcher: BuildWatcher = { + [Symbol.asyncIterator]() { + return this; + }, + + next() { + return queue.next(); + }, + + add(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + for (const file of targets) { + const posixPath = toPosixPathNormalized(file); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!watchedFiles.has(lookupKey)) { + watchedFiles.add(lookupKey); + externalManager.watchFile(posixPath, lookupKey); + } + } + }, + + remove(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + for (const file of targets) { + const posixPath = toPosixPathNormalized(file); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (watchedFiles.delete(lookupKey)) { + externalManager.removeFile(lookupKey); + } + } + }, + + async close() { + try { + if (subscription) { + await subscription.unsubscribe(); + } + await externalManager.close(); + } finally { + queue.close(); + } + }, + }; + + return buildWatcher; +} + +function createChokidarIgnored( + ignoredPatterns: string[] | undefined, + isCaseSensitive: boolean, + watchedFiles: ReadonlySet, +): Chokidar.Matcher[] { + // Strip static node_modules ignore patterns (e.g. `**/node_modules/**` from setupWatcher). + // If left in place, picomatch would unconditionally ignore all node_modules files and prevent + // watching explicitly added files inside node_modules. + const customIgnored = + ignoredPatterns + ?.filter((pattern) => { + const base = toPosixPathNormalized(pattern).replace(/\/\*\*$/, ''); + + return base !== NODE_MODULES && !base.endsWith(`/${NODE_MODULES}`); + }) + .map((pattern) => { + if (/[*?[\]{}()]/.test(pattern)) { + const isMatch = picomatch(pattern, { dot: true }); + + return (filePath: string) => isMatch(toPosixPathNormalized(filePath)); + } + + return { path: toPosixPathNormalized(pattern), recursive: true }; + }) ?? []; + + // When watching a directory (e.g. projectRoot via NG_BUILD_WATCH_ROOT), prune `node_modules` + // directories unless explicitly added to `watchedFiles`. Explicitly added files inside + // `node_modules` do not end with `/node_modules` and remain watchable. + return [ + ...customIgnored, + (filePath: string) => { + if (!filePath.includes(NODE_MODULES)) { + return false; + } + + const posixPath = toPosixPathNormalized(filePath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + + return ( + (lookupKey === NODE_MODULES || lookupKey.endsWith(`/${NODE_MODULES}`)) && + !watchedFiles.has(lookupKey) + ); + }, + ]; +} + +interface ChokidarBaselineEntry { + posixPath: string; + mtimeMs?: number; + size?: number; +} + +// Chokidar initializes file watchers asynchronously (`await stat(path)` followed by +// `fs.watch` or `fs.watchFile`). If a file is modified or recreated immediately after +// `watcher.add()` is called, Chokidar may capture the post-modification state as its initial +// baseline (or suppress the initial `'add'` event due to `ignoreInitial: true`) and miss the +// change. Re-statting after a short delay catches any modifications or recreations that occurred +// during asynchronous watcher registration, while `fileStats` deduplicates if Chokidar also fired. +async function checkChokidarBaselines( + baselineStats: ReadonlyMap, + watchedFiles: ReadonlySet, + fileStats: ReadonlyMap, + watcher: Chokidar.FSWatcher, + handleEvent: (type: 'added' | 'modified' | 'removed', rawPath: string) => void, +): Promise { + await Promise.all( + Array.from(baselineStats, async ([lookupKey, baseline]) => { + if (!watchedFiles.has(lookupKey)) { + return; + } + const currentStat = await fs.promises.stat(baseline.posixPath).catch(() => null); + if (!watchedFiles.has(lookupKey) || !currentStat?.isFile()) { + return; + } + + if (baseline.mtimeMs === undefined || baseline.size === undefined) { + const recorded = fileStats.get(lookupKey); + if (!recorded) { + watcher.add(baseline.posixPath); + handleEvent('added', path.normalize(baseline.posixPath)); + } else if (currentStat.mtimeMs !== recorded.mtimeMs || currentStat.size !== recorded.size) { + handleEvent('modified', path.normalize(baseline.posixPath)); + } + } else if (currentStat.mtimeMs !== baseline.mtimeMs || currentStat.size !== baseline.size) { + handleEvent('modified', path.normalize(baseline.posixPath)); + } + }), + ); +} + +async function createChokidarWatcher( + options?: WatcherOptions, + chokidarModule?: typeof Chokidar, +): Promise { + const chokidar = chokidarModule ?? (await import('chokidar')); + const watchedFiles = new Set(); + const fileStats = new Map(); + const pendingTimers = new Set>(); + + const pollingInterval = options?.interval ?? 100; + const debounceMs = Math.min(250, Math.max(100, Math.ceil(pollingInterval * 1.5))); + const queue = options?.polling + ? new WatcherQueue(debounceMs, Math.max(500, debounceMs * 3)) + : new WatcherQueue(); + + const rootDir = options?.cwd ?? process.cwd(); + const isCaseSensitive = isFileSystemCaseSensitive(rootDir); + const ignored = createChokidarIgnored(options?.ignored, isCaseSensitive, watchedFiles); + + const watcher = chokidar.watch([], { + ignoreInitial: true, + ignored, + followSymlinks: options?.followSymlinks, + usePolling: !!options?.polling, + interval: options?.interval, + }); + + // Prevent unhandled watcher errors (such as EMFILE or EACCES) from crashing the process. + watcher.on('error', () => {}); + + const initTime = Date.now(); + + const handleEvent = (type: 'added' | 'modified' | 'removed', rawPath: string) => { + const posixPath = toPosixPathNormalized(rawPath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + return; + } + + if (type === 'removed') { + fileStats.delete(lookupKey); + queue.addChange(type, rawPath); + + return; + } + + const stat = fs.statSync(rawPath, { throwIfNoEntry: false }); + // Ignore historical events from before watcher initialization, but allow a 1000 ms window + // to account for coarse filesystem timestamp resolution (e.g., ext4/overlayfs integer second + // mtime truncation on Linux) where files modified during startup may have truncated .000 ms mtimes. + if (!stat || stat.mtimeMs < initTime - 1000) { + return; + } + + const previousStat = fileStats.get(lookupKey); + if ( + type === 'modified' && + previousStat && + previousStat.mtimeMs === stat.mtimeMs && + previousStat.size === stat.size + ) { + return; + } + + fileStats.set(lookupKey, { mtimeMs: stat.mtimeMs, size: stat.size }); + queue.addChange(type, rawPath); + }; + + const scheduleBaselineCheck = (baselineStats: ReadonlyMap) => { + if (baselineStats.size === 0) { + return; + } + + const timer = setTimeout(() => { + pendingTimers.delete(timer); + void checkChokidarBaselines(baselineStats, watchedFiles, fileStats, watcher, handleEvent); + }, 50); + timer.unref(); + pendingTimers.add(timer); + }; + + watcher.on('add', (path) => handleEvent('added', path)); + watcher.on('change', (path) => handleEvent('modified', path)); + watcher.on('unlink', (rawPath) => { + const posixPath = toPosixPathNormalized(rawPath); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!isPathWatched(lookupKey, watchedFiles)) { + return; + } + + // When Chokidar processes a file removal, it closes the underlying file watcher unless only a + // single directory is tracked (`_watched.size === 1`). Re-adding a directly watched path + // ensures Chokidar continues watching either the recreated file (if replaced within the atomic + // window) or its parent directory for future recreation. + if (watchedFiles.has(lookupKey)) { + fileStats.delete(lookupKey); + watcher.add(posixPath); + const stat = fs.statSync(rawPath, { throwIfNoEntry: false }); + if (stat?.isFile()) { + handleEvent('modified', rawPath); + scheduleBaselineCheck( + new Map([[lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size }]]), + ); + + return; + } + + handleEvent('removed', rawPath); + scheduleBaselineCheck(new Map([[lookupKey, { posixPath }]])); + + return; + } + + handleEvent('removed', rawPath); + }); + + return { + [Symbol.asyncIterator]() { + return this; + }, + + async next() { + const result = await queue.next(); + if (result.value) { + const files = [...result.value.added, ...result.value.modified]; + await Promise.all( + files.map(async (file) => { + const stat = await fs.promises.stat(file).catch(() => undefined); + if (stat?.isFile()) { + const posixPath = toPosixPathNormalized(file); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + fileStats.set(lookupKey, { mtimeMs: stat.mtimeMs, size: stat.size }); + } + }), + ); + } + + return result; + }, + + add(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + const newPaths: string[] = []; + const baselineStats = new Map(); + + for (const p of targets) { + const posixPath = toPosixPathNormalized(p); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (!watchedFiles.has(lookupKey)) { + watchedFiles.add(lookupKey); + newPaths.push(posixPath); + const stat = fs.statSync(posixPath, { throwIfNoEntry: false }); + if (stat?.isFile()) { + baselineStats.set(lookupKey, { posixPath, mtimeMs: stat.mtimeMs, size: stat.size }); + } + } + } + if (newPaths.length > 0) { + watcher.add(newPaths); + scheduleBaselineCheck(baselineStats); + } + }, + + remove(paths) { + const targets = typeof paths === 'string' ? [paths] : paths; + const removePaths: string[] = []; + for (const p of targets) { + const posixPath = toPosixPathNormalized(p); + const lookupKey = toLookupKey(posixPath, isCaseSensitive); + if (watchedFiles.has(lookupKey)) { + watchedFiles.delete(lookupKey); + fileStats.delete(lookupKey); + removePaths.push(posixPath); + } + } + if (removePaths.length > 0) { + watcher.unwatch(removePaths); + } + }, + + async close() { + for (const timer of pendingTimers) { + clearTimeout(timer); + } + pendingTimers.clear(); + try { + await watcher.close(); + } finally { + queue.close(); + } + }, + } satisfies BuildWatcher; +} diff --git a/packages/angular/build/src/utils/watcher_spec.ts b/packages/angular/build/src/utils/watcher_spec.ts new file mode 100644 index 000000000000..ef7a14d831d2 --- /dev/null +++ b/packages/angular/build/src/utils/watcher_spec.ts @@ -0,0 +1,1286 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type * as ParcelWatcher from '@parcel/watcher'; +import assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { setTimeout } from 'node:timers/promises'; +import { + type BuildWatcher, + ChangedFiles, + ParcelExternalManager, + createWatcher, + extractNodeModulesPackageDir, + getDirectoryPath, + isPathInside, + setupWatcher, + toPosixPathNormalized, +} from './watcher'; + +describe('Watcher', () => { + const TMP_DIR = process.env['TEST_TMPDIR']; + assert(TMP_DIR, 'TEST_TMPDIR must be set'); + + describe('toPosixPathNormalized', () => { + it('should strip trailing slashes for standard directories', () => { + expect(toPosixPathNormalized('/src/app/')).toBe('/src/app'); + expect(toPosixPathNormalized('C:/src/app/')).toBe('C:/src/app'); + }); + + it('should preserve single root slash', () => { + expect(toPosixPathNormalized('/')).toBe('/'); + }); + + it('should preserve trailing slash for Windows drive root', () => { + expect(toPosixPathNormalized('C:/')).toBe('C:/'); + expect(toPosixPathNormalized('c:/')).toBe('c:/'); + }); + }); + + describe('getDirectoryPath', () => { + it('should return parent directory for POSIX paths', () => { + expect(getDirectoryPath('/src/app/main.ts')).toBe('/src/app'); + expect(getDirectoryPath('/src/app')).toBe('/src'); + expect(getDirectoryPath('/src')).toBe('/'); + expect(getDirectoryPath('/')).toBe('/'); + }); + + it('should correctly handle Windows drive roots', () => { + expect(getDirectoryPath('C:/src/app/main.ts')).toBe('C:/src/app'); + expect(getDirectoryPath('C:/src')).toBe('C:/'); + expect(getDirectoryPath('C:/')).toBe('C:/'); + expect(getDirectoryPath('c:/')).toBe('c:/'); + }); + + it('should return dot for relative paths without slash', () => { + expect(getDirectoryPath('main.ts')).toBe('.'); + }); + }); + + describe('extractNodeModulesPackageDir', () => { + it('should extract unscoped package directory', () => { + expect(extractNodeModulesPackageDir('/project/node_modules/my-lib/index.js')).toBe( + '/project/node_modules/my-lib', + ); + }); + + it('should extract scoped package directory', () => { + expect( + extractNodeModulesPackageDir('/project/node_modules/@my-scope/my-lib/src/index.js'), + ).toBe('/project/node_modules/@my-scope/my-lib'); + }); + + it('should handle package directory path directly', () => { + expect(extractNodeModulesPackageDir('/project/node_modules/my-lib')).toBe( + '/project/node_modules/my-lib', + ); + }); + + it('should return undefined for paths outside node_modules', () => { + expect(extractNodeModulesPackageDir('/project/src/main.ts')).toBeUndefined(); + }); + }); + + describe('isPathInside', () => { + it('should return true for a file inside a directory', () => { + expect(isPathInside('/src/app/main.ts', '/src/app')).toBeTrue(); + }); + + it('should return false when file and dir are identical', () => { + expect(isPathInside('/src/app', '/src/app')).toBeFalse(); + }); + + it('should return false for sibling directories with matching prefix', () => { + expect(isPathInside('/src/app-other/main.ts', '/src/app')).toBeFalse(); + }); + + it('should handle Windows drive letters on the same drive', () => { + expect(isPathInside('c:/src/app/main.ts', 'c:/src/app')).toBeTrue(); + }); + + it('should return false for Windows drive letters on different drives', () => { + expect(isPathInside('d:/src/app/main.ts', 'c:/src/app')).toBeFalse(); + }); + + it('should handle root directory correctly', () => { + expect(isPathInside('/src/main.ts', '/')).toBeTrue(); + }); + + it('should handle Windows drive root directory correctly', () => { + expect(isPathInside('c:/src/main.ts', 'c:/')).toBeTrue(); + }); + }); + + describe('ChangedFiles', () => { + it('should track added, modified, and removed files', () => { + const changes = new ChangedFiles(); + changes.added.add('/src/app.component.ts'); + changes.modified.add('/src/main.ts'); + changes.removed.add('/src/old.ts'); + + expect(changes.all).toEqual(['/src/app.component.ts', '/src/main.ts', '/src/old.ts']); + }); + + it('should deduplicate files present in multiple sets in .all', () => { + const changes = new ChangedFiles(); + changes.added.add('/src/main.ts'); + changes.modified.add('/src/main.ts'); + + expect(changes.all).toEqual(['/src/main.ts']); + }); + + it('should format debug string correctly', () => { + const changes = new ChangedFiles(); + changes.modified.add('/src/main.ts'); + + const debug = JSON.parse(changes.toDebugString()); + expect(debug).toEqual({ + added: [], + modified: ['/src/main.ts'], + removed: [], + }); + }); + }); + + describe('setupWatcher', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(TMP_DIR, 'setup-watcher-spec-')); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should setup watcher with watchFiles and close on abort signal', async () => { + const abortController = new AbortController(); + const testFile = path.join(tempDir, 'main.ts'); + const watcher = await setupWatcher({ + workspaceRoot: tempDir, + projectRoot: tempDir, + outputPath: path.join(tempDir, 'dist'), + cacheOptions: { basePath: path.join(tempDir, '.cache') }, + watchFiles: [testFile], + signal: abortController.signal, + }); + + expect(watcher).toBeDefined(); + + const closeSpy = spyOn(watcher, 'close').and.callThrough(); + abortController.abort(); + + expect(closeSpy).toHaveBeenCalled(); + await watcher.close(); + }); + + it('should remove abort listener when watcher is closed', async () => { + const abortController = new AbortController(); + const removeSpy = spyOn(abortController.signal, 'removeEventListener').and.callThrough(); + const watcher = await setupWatcher({ + workspaceRoot: tempDir, + projectRoot: tempDir, + outputPath: path.join(tempDir, 'dist'), + cacheOptions: { basePath: path.join(tempDir, '.cache') }, + signal: abortController.signal, + }); + + await watcher.close(); + expect(removeSpy).toHaveBeenCalledWith('abort', jasmine.any(Function)); + }); + + it('should setup watcher with preserveSymlinks: true and detect changes in linked package in node_modules', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'setup-watcher-npm-link-')); + + try { + const externalTargetFile = path.join(externalDir, 'index.ts'); + fs.writeFileSync(externalTargetFile, 'export const a = 1;'); + + const nodeModulesDir = path.join(tempDir, 'node_modules'); + fs.mkdirSync(nodeModulesDir); + + const linkedPkgDir = path.join(nodeModulesDir, 'linked-pkg'); + fs.symlinkSync(externalDir, linkedPkgDir, 'junction'); + const linkedFile = path.join(linkedPkgDir, 'index.ts'); + + const watcher = await setupWatcher({ + workspaceRoot: tempDir, + projectRoot: tempDir, + outputPath: path.join(tempDir, 'dist'), + cacheOptions: { basePath: path.join(tempDir, '.cache') }, + preserveSymlinks: true, + watchFiles: [linkedFile], + }); + + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(externalTargetFile, 'export const a = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('linked-pkg'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + }); + + describe('createWatcher', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(TMP_DIR, 'watcher-spec-')); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('should instantiate and close watcher without error', async () => { + const watcher = await createWatcher({ cwd: tempDir }); + expect(watcher).toBeDefined(); + + watcher.add(path.join(tempDir, 'main.ts')); + watcher.remove(path.join(tempDir, 'main.ts')); + + await watcher.close(); + }); + + it('should support array of paths in add and remove', async () => { + const watcher = await createWatcher({ cwd: tempDir }); + const file1 = path.join(tempDir, 'a.ts'); + const file2 = path.join(tempDir, 'b.ts'); + + watcher.add([file1, file2]); + watcher.remove([file1, file2]); + + await watcher.close(); + }); + + it('should support polling option', async () => { + const watcher = await createWatcher({ polling: true, interval: 100, cwd: tempDir }); + expect(watcher).toBeDefined(); + + watcher.add(path.join(tempDir, 'main.ts')); + await watcher.close(); + }); + + it('should emit changes when a watched file is modified (chokidar polling)', async () => { + const testFile = path.join(tempDir, 'test.txt'); + fs.writeFileSync(testFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(testFile); + + // Wait a short moment for watcher setup and mtime tick + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Trigger change + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.length).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + + it('should preserve original path character casing in emitted changes', async () => { + const casedFile = path.join(tempDir, 'App.Component.ts'); + fs.writeFileSync(casedFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(casedFile); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(casedFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const emittedFiles = result.value?.all ?? []; + expect(emittedFiles.some((f: string) => f.includes('App.Component.ts'))).toBeTrue(); + + await watcher.close(); + }, 10000); + + it('should emit changes when watching a directory containing modified files', async () => { + const subDir = path.join(tempDir, 'sub'); + fs.mkdirSync(subDir); + const testFile = path.join(subDir, 'nested.txt'); + fs.writeFileSync(testFile, 'initial'); + + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(subDir); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.length).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + + it('should support watching paths outside cwd', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'external-watcher-spec-')); + const externalFile = path.join(externalDir, 'external.txt'); + fs.writeFileSync(externalFile, 'initial'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(externalFile); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(externalFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('external.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should handle adding multiple external files in the same directory concurrently', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'external-watcher-spec-')); + const file1 = path.join(externalDir, 'file1.txt'); + const file2 = path.join(externalDir, 'file2.txt'); + fs.writeFileSync(file1, 'initial1'); + fs.writeFileSync(file2, 'initial2'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add([file1, file2]); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + let nextPromise = iterator.next(); + fs.writeFileSync(file1, 'updated1'); + let result = await nextPromise; + expect(result.value?.all.some((f: string) => f.includes('file1.txt'))).toBeTrue(); + + nextPromise = iterator.next(); + fs.writeFileSync(file2, 'updated2'); + result = await nextPromise; + expect(result.value?.all.some((f: string) => f.includes('file2.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should clean up external subscriptions when all external files in a directory are removed', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'external-watcher-spec-')); + const file1 = path.join(externalDir, 'file1.txt'); + const file2 = path.join(externalDir, 'file2.txt'); + fs.writeFileSync(file1, 'initial1'); + fs.writeFileSync(file2, 'initial2'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add([file1, file2]); + + await setTimeout(100); + + // Remove files from watcher + watcher.remove(file1); + watcher.remove(file2); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }); + + it('should handle nested external directories without creating duplicate subscriptions', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'external-watcher-spec-')); + const subDir = path.join(externalDir, 'sub'); + fs.mkdirSync(subDir); + const parentFile = path.join(externalDir, 'parent.txt'); + const childFile = path.join(subDir, 'child.txt'); + fs.writeFileSync(parentFile, 'initial-parent'); + fs.writeFileSync(childFile, 'initial-child'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(parentFile); + watcher.add(childFile); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(childFile, 'updated-child'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should subscribe to subsumed external child directory when parent external subscription is removed', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'external-watcher-spec-')); + const subDir = path.join(externalDir, 'sub'); + fs.mkdirSync(subDir); + const parentFile = path.join(externalDir, 'parent.txt'); + const childFile = path.join(subDir, 'child.txt'); + fs.writeFileSync(parentFile, 'initial-parent'); + fs.writeFileSync(childFile, 'initial-child'); + + try { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + watcher.add(parentFile); + watcher.add(childFile); + + await setTimeout(100); + + watcher.remove(parentFile); + + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(childFile, 'updated-child'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('child.txt'))).toBeTrue(); + + await watcher.close(); + } finally { + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should signal completion on close', async () => { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + const iterator = watcher[Symbol.asyncIterator](); + + const nextPromise = iterator.next(); + await watcher.close(); + + const result = await nextPromise; + expect(result.done).toBeTrue(); + }); + + it('should return done immediately if next() is called after close()', async () => { + const watcher = await createWatcher({ polling: true, interval: 50, cwd: tempDir }); + await watcher.close(); + + const result = await watcher.next(); + expect(result.done).toBeTrue(); + }); + + it('should ignore stale modifications before initTime and emit changes after initTime (@parcel/watcher)', async () => { + const testFile = path.join(tempDir, 'test.txt'); + fs.writeFileSync(testFile, 'initial'); + + // Small delay to ensure initial mtimeMs is strictly earlier than initTime - 1000 + await setTimeout(1100); + + // Create native @parcel/watcher (polling: false / default) + const watcher = await createWatcher({ cwd: tempDir }); + watcher.add(testFile); + + // Wait a short moment for native watcher setup and kernel event stream initialization + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Trigger a change after watcher initialization + fs.writeFileSync(testFile, 'updated'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('test.txt'))).toBeTrue(); + + await watcher.close(); + }, 10000); + + it('should emit changes when a file is deleted and recreated with stabilization delay (@parcel/watcher)', async () => { + const testFile = path.join(tempDir, 'recreate.txt'); + fs.writeFileSync(testFile, 'initial'); + + await setTimeout(50); + + const watcher = await createWatcher({ cwd: tempDir }); + watcher.add(testFile); + + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + + // Delete the file + fs.rmSync(testFile); + let result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.removed.size).toBeGreaterThan(0); + + // Brief stabilization delay before recreating to prevent macOS fsevents kernel driver + // from coalescing unlink and create into a single directory event + await setTimeout(150); + + // Recreate the file + fs.writeFileSync(testFile, 'recreated'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.added.size).toBeGreaterThan(0); + + await watcher.close(); + }, 10000); + + it('should ignore changes matching glob patterns in polling mode (chokidar)', async () => { + const ignoredDir = path.join(tempDir, 'dist'); + fs.mkdirSync(ignoredDir); + const ignoredFile = path.join(ignoredDir, 'bundle.js'); + const watchedFile = path.join(tempDir, 'src.ts'); + fs.writeFileSync(ignoredFile, 'initial-dist'); + fs.writeFileSync(watchedFile, 'initial-src'); + + const watcher = await createWatcher({ + polling: true, + interval: 50, + cwd: tempDir, + ignored: [`${toPosixPathNormalized(ignoredDir)}/**`], + }); + + watcher.add(tempDir); + await setTimeout(100); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Trigger changes in ignored file and watched file + fs.writeFileSync(ignoredFile, 'updated-dist'); + fs.writeFileSync(watchedFile, 'updated-src'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const emitted = result.value?.all ?? []; + expect(emitted.some((f: string) => f.includes('src.ts'))).toBeTrue(); + expect(emitted.some((f: string) => f.includes('bundle.js'))).toBeFalse(); + + await watcher.close(); + }, 10000); + + it('should detect changes behind a directory symlink when followSymlinks is true', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'watcher-external-')); + let watcher: BuildWatcher | undefined; + + try { + const externalTargetFile = path.join(externalDir, 'index.ts'); + fs.writeFileSync(externalTargetFile, 'export const a = 1;'); + + const symlinkDir = path.join(tempDir, 'symlinked-lib'); + fs.symlinkSync(externalDir, symlinkDir, 'junction'); + + const symlinkedFile = path.join(symlinkDir, 'index.ts'); + + watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + watcher.add(symlinkedFile); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(externalTargetFile, 'export const a = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('index.ts'))).toBeTrue(); + } finally { + await watcher?.close(); + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should detect changes in linked package in node_modules while ignoring unimported packages', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'watcher-npm-link-')); + let watcher: BuildWatcher | undefined; + + try { + const externalTargetFile = path.join(externalDir, 'index.ts'); + fs.writeFileSync(externalTargetFile, 'export const a = 1;'); + + const nodeModulesDir = path.join(tempDir, 'node_modules'); + fs.mkdirSync(nodeModulesDir); + + // Unimported package that should be ignored + const unimportedPkgDir = path.join(nodeModulesDir, 'unimported-pkg'); + fs.mkdirSync(unimportedPkgDir); + const unimportedFile = path.join(unimportedPkgDir, 'dep.ts'); + fs.writeFileSync(unimportedFile, 'export const dep = 1;'); + + // Linked package in node_modules + const linkedPkgDir = path.join(nodeModulesDir, 'linked-pkg'); + fs.symlinkSync(externalDir, linkedPkgDir, 'junction'); + const linkedFile = path.join(linkedPkgDir, 'index.ts'); + + watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + watcher.add(linkedFile); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Modify both the linked file and the unimported file + fs.writeFileSync(unimportedFile, 'export const dep = 2;'); + fs.writeFileSync(externalTargetFile, 'export const a = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const changed = result.value?.all ?? []; + expect(changed.some((f: string) => f.includes('linked-pkg'))).toBeTrue(); + expect(changed.some((f: string) => f.includes('unimported-pkg'))).toBeFalse(); + } finally { + await watcher?.close(); + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should detect changes in scoped linked package in node_modules', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'watcher-scoped-npm-link-')); + let watcher: BuildWatcher | undefined; + + try { + const externalTargetFile = path.join(externalDir, 'index.ts'); + fs.writeFileSync(externalTargetFile, 'export const a = 1;'); + + const nodeModulesDir = path.join(tempDir, 'node_modules'); + const scopeDir = path.join(nodeModulesDir, '@my-scope'); + fs.mkdirSync(scopeDir, { recursive: true }); + + // Unimported package in scope + const unimportedScopedDir = path.join(scopeDir, 'unimported-scoped'); + fs.mkdirSync(unimportedScopedDir); + const unimportedFile = path.join(unimportedScopedDir, 'dep.ts'); + fs.writeFileSync(unimportedFile, 'export const dep = 1;'); + + // Linked scoped package in node_modules + const linkedPkgDir = path.join(scopeDir, 'linked-pkg'); + fs.symlinkSync(externalDir, linkedPkgDir, 'junction'); + const linkedFile = path.join(linkedPkgDir, 'index.ts'); + + watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + watcher.add(linkedFile); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(unimportedFile, 'export const dep = 2;'); + fs.writeFileSync(externalTargetFile, 'export const a = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const changed = result.value?.all ?? []; + expect(changed.some((f: string) => f.includes('linked-pkg'))).toBeTrue(); + expect(changed.some((f: string) => f.includes('unimported-scoped'))).toBeFalse(); + } finally { + await watcher?.close(); + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should detect changes in linked package added dynamically via watcher.add', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'watcher-dynamic-npm-link-')); + let watcher: BuildWatcher | undefined; + + try { + const externalTargetFile = path.join(externalDir, 'index.ts'); + fs.writeFileSync(externalTargetFile, 'export const a = 1;'); + + const nodeModulesDir = path.join(tempDir, 'node_modules'); + fs.mkdirSync(nodeModulesDir); + + const linkedPkgDir = path.join(nodeModulesDir, 'linked-pkg'); + fs.symlinkSync(externalDir, linkedPkgDir, 'junction'); + const linkedFile = path.join(linkedPkgDir, 'index.ts'); + + // Initially create watcher with no watchFiles + watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + // Add linked file dynamically after watcher creation + watcher.add(linkedFile); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + fs.writeFileSync(externalTargetFile, 'export const a = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('linked-pkg'))).toBeTrue(); + } finally { + await watcher?.close(); + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should unwatch linked package file when removed via watcher.remove', async () => { + const externalDir = fs.mkdtempSync(path.join(TMP_DIR, 'watcher-remove-npm-link-')); + let watcher: BuildWatcher | undefined; + + try { + const externalTargetFile = path.join(externalDir, 'index.ts'); + fs.writeFileSync(externalTargetFile, 'export const a = 1;'); + + const nodeModulesDir = path.join(tempDir, 'node_modules'); + fs.mkdirSync(nodeModulesDir); + + const linkedPkgDir = path.join(nodeModulesDir, 'linked-pkg'); + fs.symlinkSync(externalDir, linkedPkgDir, 'junction'); + const linkedFile = path.join(linkedPkgDir, 'index.ts'); + + const regularFile = path.join(tempDir, 'main.ts'); + fs.writeFileSync(regularFile, 'export const main = 1;'); + + watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + watcher.add([linkedFile, regularFile]); + // Allow a brief moment for Chokidar to register the new watch paths + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + + // Verify changes in the linked package are detected initially + let nextPromise = iterator.next(); + fs.writeFileSync(externalTargetFile, 'export const a = 2;'); + let result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('linked-pkg'))).toBeTrue(); + + // Remove the linked file from the watcher + watcher.remove(linkedFile); + // Allow a brief moment for Chokidar to asynchronously complete unwatching the file + await setTimeout(250); + + // Modify both the removed linked file and the regular file + nextPromise = iterator.next(); + fs.writeFileSync(externalTargetFile, 'export const a = 3;'); + fs.writeFileSync(regularFile, 'export const main = 2;'); + + result = await nextPromise; + expect(result.done).toBeFalsy(); + const changed = result.value?.all ?? []; + expect(changed.some((f: string) => f.includes('main.ts'))).toBeTrue(); + expect(changed.some((f: string) => f.includes('linked-pkg'))).toBeFalse(); + } finally { + await watcher?.close(); + fs.rmSync(externalDir, { recursive: true, force: true }); + } + }, 10000); + + it('should detect changes across multiple external directories when using @parcel/watcher', async () => { + const externalRoot = fs.mkdtempSync(path.join(TMP_DIR, 'parcel-external-spec-')); + let watcher: BuildWatcher | undefined; + + try { + const lib1Dir = path.join(externalRoot, 'libs', 'lib-1', 'src'); + const lib2Dir = path.join(externalRoot, 'libs', 'lib-2', 'src'); + fs.mkdirSync(lib1Dir, { recursive: true }); + fs.mkdirSync(lib2Dir, { recursive: true }); + + const file1 = path.join(lib1Dir, 'index.ts'); + const file2 = path.join(lib2Dir, 'index.ts'); + fs.writeFileSync(file1, 'export const a = 1;'); + fs.writeFileSync(file2, 'export const b = 1;'); + + // Backdate initial file timestamps to earlier than `initTime - 1000` so that any + // delayed initial FSEvents replay from file creation is ignored by the watcher. + const pastTime = new Date(Date.now() - 5000); + fs.utimesSync(file1, pastTime, pastTime); + fs.utimesSync(file2, pastTime, pastTime); + + watcher = await createWatcher({ cwd: tempDir }); + watcher.add([file1, file2]); + + await setTimeout(150); + + const iterator = watcher[Symbol.asyncIterator](); + + let nextPromise = iterator.next(); + fs.writeFileSync(file1, 'export const a = 2;'); + let result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('lib-1'))).toBeTrue(); + + nextPromise = iterator.next(); + fs.writeFileSync(file2, 'export const b = 2;'); + result = await nextPromise; + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('lib-2'))).toBeTrue(); + } finally { + await watcher?.close(); + fs.rmSync(externalRoot, { recursive: true, force: true }); + } + }, 10000); + + it('should detect file deletion and recreation when watching multiple files with followSymlinks: true', async () => { + const subDirA = path.join(tempDir, 'dir-a'); + const subDirB = path.join(tempDir, 'dir-b'); + fs.mkdirSync(subDirA); + fs.mkdirSync(subDirB); + + const fileA = path.join(subDirA, 'a.ts'); + const fileB = path.join(subDirB, 'b.ts'); + fs.writeFileSync(fileA, 'export const a = 1;'); + fs.writeFileSync(fileB, 'export const b = 1;'); + + const watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + try { + watcher.add([fileA, fileB]); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + + // Delete fileA and wait past Chokidar's atomic write window + fs.rmSync(fileA); + let result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.removed.size).toBeGreaterThan(0); + + await setTimeout(150); + + // Recreate fileA and verify the watcher still detects it + fs.writeFileSync(fileA, 'export const a = 2;'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('a.ts'))).toBeTrue(); + + // Modify recreated fileA again to ensure ongoing changes are tracked + await setTimeout(150); + fs.writeFileSync(fileA, 'export const a = 3;'); + result = await iterator.next(); + expect(result.done).toBeFalsy(); + expect(result.value?.all.some((f: string) => f.includes('a.ts'))).toBeTrue(); + } finally { + await watcher.close(); + } + }, 10000); + + it('should prune node_modules when watching root directory while watching added package files', async () => { + const nodeModulesDir = path.join(tempDir, 'node_modules'); + const pkgDir = path.join(nodeModulesDir, 'some-pkg'); + fs.mkdirSync(pkgDir, { recursive: true }); + + const watchedPkgFile = path.join(pkgDir, 'index.js'); + const unwatchedPkgFile = path.join(pkgDir, 'other.js'); + const rootFile = path.join(tempDir, 'main.ts'); + fs.writeFileSync(watchedPkgFile, 'export const idx = 1;'); + fs.writeFileSync(unwatchedPkgFile, 'export const other = 1;'); + fs.writeFileSync(rootFile, 'export const main = 1;'); + + const pastTime = new Date(Date.now() - 5000); + fs.utimesSync(watchedPkgFile, pastTime, pastTime); + fs.utimesSync(unwatchedPkgFile, pastTime, pastTime); + fs.utimesSync(rootFile, pastTime, pastTime); + + const watcher = await createWatcher({ + followSymlinks: true, + cwd: tempDir, + }); + + try { + // Watch both the root directory and a specific file in node_modules + watcher.add([tempDir, watchedPkgFile]); + await setTimeout(250); + + const iterator = watcher[Symbol.asyncIterator](); + const nextPromise = iterator.next(); + + // Modify unwatched file in node_modules (should be ignored even though tempDir is watched) + // and watched file in node_modules + fs.writeFileSync(unwatchedPkgFile, 'export const other = 2;'); + fs.writeFileSync(watchedPkgFile, 'export const idx = 2;'); + + const result = await nextPromise; + expect(result.done).toBeFalsy(); + const changed = result.value?.all ?? []; + expect(changed.some((f: string) => f.includes('index.js'))).toBeTrue(); + expect(changed.some((f: string) => f.includes('other.js'))).toBeFalse(); + } finally { + await watcher.close(); + } + }, 10000); + }); + + describe('ParcelExternalManager', () => { + function createMockParcelWatcher(options?: { + failAfter?: number; + onSubscribe?: (dir: string, opts?: ParcelWatcher.Options) => void; + }) { + const activeDirs = new Set(); + const subscribeHistory: { dir: string; options?: ParcelWatcher.Options }[] = []; + let totalCalls = 0; + + const mockWatcher = { + subscribe: jasmine + .createSpy('subscribe') + .and.callFake( + async ( + dir: string, + _cb: ParcelWatcher.SubscribeCallback, + opts?: ParcelWatcher.Options, + ): Promise => { + totalCalls++; + options?.onSubscribe?.(dir, opts); + if (options?.failAfter !== undefined && activeDirs.size >= options.failAfter) { + throw new Error('Error starting FSEvents stream'); + } + activeDirs.add(dir); + subscribeHistory.push({ dir, options: opts }); + + return { + unsubscribe: async () => { + activeDirs.delete(dir); + }, + }; + }, + ), + } as unknown as typeof ParcelWatcher; + + return { + mockWatcher, + activeDirs, + subscribeHistory, + getTotalCalls: () => totalCalls, + }; + } + + it('should batch synchronous watchFile calls and subsume child directories added before parent', async () => { + const { mockWatcher, activeDirs, getTotalCalls } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + ); + + // Add deeper child directories first, then their parent directory in the same synchronous turn + manager.watchFile( + '/monorepo/libs/lib-1/src/components/button/button.ts', + '/monorepo/libs/lib-1/src/components/button/button.ts', + ); + manager.watchFile( + '/monorepo/libs/lib-1/src/components/card/card.ts', + '/monorepo/libs/lib-1/src/components/card/card.ts', + ); + manager.watchFile('/monorepo/libs/lib-1/src/index.ts', '/monorepo/libs/lib-1/src/index.ts'); + + await manager.flush(); + + // Should only ever have subscribed once to the parent '/monorepo/libs/lib-1/src' + expect(getTotalCalls()).toBe(1); + expect(Array.from(activeDirs)).toEqual(['/monorepo/libs/lib-1/src']); + + await manager.close(); + }); + + it('should normalize external node_modules paths to their package root directory', async () => { + const { mockWatcher, activeDirs, getTotalCalls } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + ); + + manager.watchFile( + '/monorepo/node_modules/my-pkg/fesm2022/my-pkg.mjs', + '/monorepo/node_modules/my-pkg/fesm2022/my-pkg.mjs', + ); + manager.watchFile( + '/monorepo/node_modules/my-pkg/esm2022/sub/helper.mjs', + '/monorepo/node_modules/my-pkg/esm2022/sub/helper.mjs', + ); + manager.watchFile( + '/monorepo/node_modules/@scope/scoped-pkg/fesm2022/index.mjs', + '/monorepo/node_modules/@scope/scoped-pkg/fesm2022/index.mjs', + ); + manager.watchFile( + '/monorepo/node_modules/@scope/scoped-pkg/package.json', + '/monorepo/node_modules/@scope/scoped-pkg/package.json', + ); + + await manager.flush(); + + expect(getTotalCalls()).toBe(2); + expect(Array.from(activeDirs).sort()).toEqual([ + '/monorepo/node_modules/@scope/scoped-pkg', + '/monorepo/node_modules/my-pkg', + ]); + + // Removing one file from my-pkg should keep the package subscription active + manager.removeFile('/monorepo/node_modules/my-pkg/fesm2022/my-pkg.mjs'); + await manager.flush(); + expect(activeDirs.has('/monorepo/node_modules/my-pkg')).toBeTrue(); + + // Removing the last file from my-pkg should unsubscribe the package directory + manager.removeFile('/monorepo/node_modules/my-pkg/esm2022/sub/helper.mjs'); + await manager.flush(); + expect(activeDirs.has('/monorepo/node_modules/my-pkg')).toBeFalse(); + expect(Array.from(activeDirs)).toEqual(['/monorepo/node_modules/@scope/scoped-pkg']); + + await manager.close(); + }); + + it('should coalesce large numbers of external library and node_modules directories into common ancestors within budget', async () => { + const { mockWatcher, activeDirs, getTotalCalls } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + 64, + ); + + // Simulate 600 sibling libraries and 200 hoisted node_modules packages (800 external dirs) + for (let i = 0; i < 600; i++) { + const file = `/monorepo/libs/lib-${i}/src/index.ts`; + manager.watchFile(file, file); + } + for (let i = 0; i < 200; i++) { + const file = `/monorepo/node_modules/pkg-${i}/dist/index.mjs`; + manager.watchFile(file, file); + } + + await manager.flush(); + + // All 600 libs coalesce into '/monorepo/libs' and all 200 packages coalesce into '/monorepo/node_modules' + expect(getTotalCalls()).toBe(2); + expect(Array.from(activeDirs).sort()).toEqual(['/monorepo/libs', '/monorepo/node_modules']); + + await manager.close(); + }); + + it('should prioritize collapsing deeper branching subdirectories before broader ancestor directories', async () => { + const { mockWatcher, activeDirs } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + 3, // Budget of 3 subscriptions + ); + + // lib-1 has 4 component subdirectories, lib-2 and lib-3 have 1 each (total = 6 leaf dirs) + for (let c = 1; c <= 4; c++) { + const file = `/monorepo/libs/lib-1/src/comp-${c}/comp.ts`; + manager.watchFile(file, file); + } + manager.watchFile('/monorepo/libs/lib-2/src/index.ts', '/monorepo/libs/lib-2/src/index.ts'); + manager.watchFile('/monorepo/libs/lib-3/src/index.ts', '/monorepo/libs/lib-3/src/index.ts'); + + await manager.flush(); + + // Collapsing lib-1/src reduces total leaves from 6 to 3, meeting the budget of 3 + // without needing to collapse all of '/monorepo/libs' + expect(Array.from(activeDirs).sort()).toEqual([ + '/monorepo/libs/lib-1/src', + '/monorepo/libs/lib-2/src', + '/monorepo/libs/lib-3/src', + ]); + + await manager.close(); + }); + + it('should ignore workspaceRoot when coalescing into an ancestor of workspaceRoot (Tier 2)', async () => { + const { mockWatcher, activeDirs, subscribeHistory } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + { ignored: ['/monorepo/apps/app/dist'] }, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + 2, // Budget of 2 subscriptions + ); + + // Sibling apps/packages directly under /monorepo/apps (which is an ancestor of /monorepo/apps/app) + manager.watchFile( + '/monorepo/apps/shared-1/src/index.ts', + '/monorepo/apps/shared-1/src/index.ts', + ); + manager.watchFile( + '/monorepo/apps/shared-2/src/index.ts', + '/monorepo/apps/shared-2/src/index.ts', + ); + manager.watchFile( + '/monorepo/apps/shared-3/src/index.ts', + '/monorepo/apps/shared-3/src/index.ts', + ); + + await manager.flush(); + + expect(Array.from(activeDirs)).toEqual(['/monorepo/apps']); + expect(subscribeHistory.length).toBe(1); + expect(subscribeHistory[0].options?.ignore).toEqual([ + '/monorepo/apps/app/dist', + '/monorepo/apps/app', + '/monorepo/apps/app/**', + ]); + + await manager.close(); + }); + + it('should dynamically lower budget and coalesce into ancestor when subscribe fails with FSEvents stream limit error', async () => { + const externalRoot = fs.mkdtempSync(path.join(TMP_DIR, 'parcel-fallback-spec-')); + + try { + const rootPosix = toPosixPathNormalized(path.join(externalRoot, 'apps', 'app')); + const libFiles: string[] = []; + for (let i = 0; i < 6; i++) { + const libDir = path.join(externalRoot, 'libs', `lib-${i}`, 'src'); + fs.mkdirSync(libDir, { recursive: true }); + const file = toPosixPathNormalized(path.join(libDir, 'index.ts')); + fs.writeFileSync(file, 'export const x = 1;'); + libFiles.push(file); + } + + // Allow at most 2 active FSEvents streams even though manager budget starts at 64 + const { mockWatcher, activeDirs } = createMockParcelWatcher({ failAfter: 2 }); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + rootPosix, + rootPosix, + true, + () => {}, + 64, + ); + + for (const file of libFiles) { + manager.watchFile(file, file); + } + + await manager.flush(); + + // Should have shrunk and coalesced the 6 libraries into the single '/libs' ancestor directory + const expectedLibsDir = toPosixPathNormalized(path.join(externalRoot, 'libs')); + expect(Array.from(activeDirs)).toEqual([expectedLibsDir]); + + await manager.close(); + } finally { + fs.rmSync(externalRoot, { recursive: true, force: true }); + } + }); + + it('should uncoalesce and subscribe to remaining leaf directories when removals bring count back within budget', async () => { + const { mockWatcher, activeDirs } = createMockParcelWatcher(); + const manager = new ParcelExternalManager( + mockWatcher, + undefined, + '/monorepo/apps/app', + '/monorepo/apps/app', + true, + () => {}, + 2, // Budget of 2 subscriptions + ); + + const file1 = '/monorepo/libs/lib-1/src/index.ts'; + const file2 = '/monorepo/libs/lib-2/src/index.ts'; + const file3 = '/monorepo/libs/lib-3/src/index.ts'; + + manager.watchFile(file1, file1); + manager.watchFile(file2, file2); + manager.watchFile(file3, file3); + + await manager.flush(); + + // 3 directories exceed budget of 2, so they coalesce into '/monorepo/libs' + expect(Array.from(activeDirs)).toEqual(['/monorepo/libs']); + + // Removing file3 brings remaining directories to 2, which fits within budget + manager.removeFile(file3); + await manager.flush(); + + expect(Array.from(activeDirs).sort()).toEqual([ + '/monorepo/libs/lib-1/src', + '/monorepo/libs/lib-2/src', + ]); + + // Removing the remaining files unsubscribes everything + manager.removeFile(file1); + manager.removeFile(file2); + await manager.flush(); + + expect(activeDirs.size).toBe(0); + + await manager.close(); + }); + }); +}); diff --git a/packages/angular/build/src/utils/worker-pool.ts b/packages/angular/build/src/utils/worker-pool.ts new file mode 100644 index 000000000000..907de66ba02f --- /dev/null +++ b/packages/angular/build/src/utils/worker-pool.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { getCompileCacheDir } from 'node:module'; +import { Piscina } from 'piscina'; + +export type WorkerPoolOptions = ConstructorParameters[0]; + +export class WorkerPool extends Piscina { + constructor(options: WorkerPoolOptions) { + const piscinaOptions: WorkerPoolOptions = { + minThreads: 1, + idleTimeout: 4_000, + // Web containers do not support transferable objects with receiveOnMessagePort which + // is used when the Atomics based wait loop is enable. + atomics: process.versions.webcontainer ? 'disabled' : 'sync', + recordTiming: false, + ...options, + }; + + // Enable compile code caching if enabled for the main process (only exists on Node.js v22.8+). + // Skip if running inside Bazel via a RUNFILES environment variable check. The cache does not work + // well with Bazel's hermeticity requirements. + const compileCacheDirectory = process.env['JS_BINARY__RUNFILES'] + ? undefined + : getCompileCacheDir?.(); + if (compileCacheDirectory) { + if (typeof piscinaOptions.env === 'object') { + piscinaOptions.env['NODE_COMPILE_CACHE'] = compileCacheDirectory; + } else { + // Default behavior of `env` option is to copy current process values + piscinaOptions.env = { + ...process.env, + 'NODE_COMPILE_CACHE': compileCacheDirectory, + }; + } + } + + super(piscinaOptions); + } +} diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index ac2868a1f7de..2fbad284fd31 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -1,248 +1,213 @@ -# Copyright Google Inc. All Rights Reserved. +# Copyright Google LLC. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license -licenses(["notice"]) # MIT - -load("@npm_bazel_typescript//:index.bzl", "ts_library") -load("@npm_bazel_jasmine//:index.bzl", "jasmine_node_test") +load("@aspect_rules_esbuild//esbuild:defs.bzl", "esbuild") +load("@aspect_rules_ts//ts:defs.bzl", "ts_config") +load("@npm//:defs.bzl", "npm_link_all_packages") +load("//tools:defaults.bzl", "jasmine_test", "npm_package", "ts_project") +load("//tools:ng_cli_schema_generator.bzl", "cli_json_schema") load("//tools:ts_json_schema.bzl", "ts_json_schema") -package(default_visibility = ["//visibility:public"]) +licenses(["notice"]) -ts_library( - name = "angular-cli", - srcs = glob( - ["**/*.ts"], - exclude = [ - "**/*_spec.ts", - "**/*_spec_large.ts", - ], - ) + [ - "//packages/angular/cli:lib/config/schema.ts", - "//packages/angular/cli:commands/analytics.ts", - "//packages/angular/cli:commands/add.ts", - "//packages/angular/cli:commands/build.ts", - "//packages/angular/cli:commands/deploy.ts", - "//packages/angular/cli:commands/config.ts", - "//packages/angular/cli:commands/deprecated.ts", - "//packages/angular/cli:commands/doc.ts", - "//packages/angular/cli:commands/e2e.ts", - "//packages/angular/cli:commands/easter-egg.ts", - "//packages/angular/cli:commands/generate.ts", - "//packages/angular/cli:commands/help.ts", - "//packages/angular/cli:commands/lint.ts", - "//packages/angular/cli:commands/new.ts", - "//packages/angular/cli:commands/serve.ts", - "//packages/angular/cli:commands/test.ts", - "//packages/angular/cli:commands/update.ts", - "//packages/angular/cli:commands/version.ts", - "//packages/angular/cli:commands/run.ts", - "//packages/angular/cli:commands/xi18n.ts", - ], - data = glob([ - "**/*.json", - "**/*.md", - ]), - module_name = "@angular/cli", - # strict_checks = False, - deps = [ - "//packages/angular_devkit/architect", - "//packages/angular_devkit/architect/node", - "//packages/angular_devkit/core", - "//packages/angular_devkit/core/node", - "//packages/angular_devkit/schematics", - "//packages/angular_devkit/schematics/tools", - # @node_module: typescript:es2017.object - "@npm//@types/debug", - "@npm//@types/node", - "@npm//@types/inquirer", - "@npm//@types/rimraf", - "@npm//@types/semver", - "@npm//@types/universal-analytics", - "@npm//@types/uuid", - "@npm//ansi-colors", - ], -) - -ts_json_schema( - name = "cli_schema", - src = "lib/config/schema.json", -) - -ts_json_schema( - name = "analytics_schema", - src = "commands/analytics.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "add_schema", - src = "commands/add.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "build_schema", - src = "commands/build.json", - data = [ - "commands/definitions.json", - ], -) +package(default_visibility = ["//visibility:public"]) -ts_json_schema( - name = "deploy_schema", - src = "commands/deploy.json", - data = [ - "commands/definitions.json", - ], -) +npm_link_all_packages() -ts_json_schema( - name = "config_schema", - src = "commands/config.json", - data = [ - "commands/definitions.json", +ts_config( + name = "tsconfig-build", + src = "tsconfig-build.json", + deps = [ + "//:build-tsconfig", ], ) -ts_json_schema( - name = "deprecated_schema", - src = "commands/deprecated.json", - data = [ - "commands/definitions.json", +ts_config( + name = "tsconfig-test", + src = "tsconfig-test.json", + deps = [ + ":tsconfig-build", + "//:node_modules/@types/jasmine", + "//:node_modules/@types/node", ], ) -ts_json_schema( - name = "doc_schema", - src = "commands/doc.json", - data = [ - "commands/definitions.json", +genrule( + name = "angular_best_practices", + srcs = [ + "//:node_modules/@angular/core/dir", ], + outs = ["src/commands/mcp/resources/best-practices.md"], + cmd = """ + cp "$(location //:node_modules/@angular/core/dir)/resources/best-practices.md" $@ + """, ) -ts_json_schema( - name = "e2e_schema", - src = "commands/e2e.json", - data = [ - "commands/definitions.json", +PACKAGE_ASSETS = glob( + include = [ + "bin/**/*", ], -) - -ts_json_schema( - name = "easter_egg_schema", - src = "commands/easter-egg.json", - data = [ - "commands/definitions.json", + exclude = [ + "lib/config/workspace-schema.json", ], -) +) + [ + "//packages/angular/cli:lib/config/schema.json", +] -ts_json_schema( - name = "generate_schema", - src = "commands/generate.json", - data = [ - "commands/definitions.json", - ], -) +RUNTIME_ASSETS = PACKAGE_ASSETS + glob(["src/**/*.md"]) + [":angular_best_practices"] -ts_json_schema( - name = "help_schema", - src = "commands/help.json", - data = [ - "commands/definitions.json", - ], -) - -ts_json_schema( - name = "lint_schema", - src = "commands/lint.json", - data = [ - "commands/definitions.json", +ts_project( + name = "angular-cli", + srcs = glob( + include = [ + "lib/**/*.ts", + "src/**/*.ts", + ], + exclude = [ + "**/*_spec.ts", + "**/testing/**", + ], + ) + [ + # These files are generated from the JSON schema + "//packages/angular/cli:lib/config/workspace-schema.ts", ], + data = RUNTIME_ASSETS, + tsconfig = ":tsconfig-build", + deps = [ + ":node_modules/@angular-devkit/architect", + ":node_modules/@angular-devkit/core", + ":node_modules/@angular-devkit/schematics", + ":node_modules/@inquirer/prompts", + ":node_modules/@listr2/prompt-adapter-inquirer", + ":node_modules/@modelcontextprotocol/server", + ":node_modules/jsonc-parser", + ":node_modules/listr2", + ":node_modules/npm-package-arg", + ":node_modules/parse5-html-rewriting-stream", + ":node_modules/yargs", + ":node_modules/zod", + "//:node_modules/@angular/core", + "//:node_modules/@types/node", + "//:node_modules/@types/npm-package-arg", + "//:node_modules/@types/semver", + "//:node_modules/@types/yargs", + "//:node_modules/semver", + "//:node_modules/typescript", + ], +) + +esbuild( + name = "bundled_cli", + srcs = [ + ":angular-cli", + ":angular_best_practices", + ] + glob(["src/**/*.md"]), + config = { + "packages": "external", + "loader": { + ".md": "text", + }, + }, + entry_points = [ + "lib/cli/index.js", + "lib/init.js", + ], + format = "esm", + output_dir = True, + platform = "node", + sourcemap = False, + splitting = True, + target = "node22", +) + +CLI_SCHEMA_DATA = [ + "//packages/angular/build:schemas", + "//packages/angular_devkit/build_angular:schemas", + "//packages/schematics/angular:schemas", +] + +cli_json_schema( + name = "cli_config_schema", + src = "lib/config/workspace-schema.json", + out = "lib/config/schema.json", + data = CLI_SCHEMA_DATA, ) ts_json_schema( - name = "new_schema", - src = "commands/new.json", - data = [ - "commands/definitions.json", - ], + name = "cli_schema", + src = "lib/config/workspace-schema.json", + data = CLI_SCHEMA_DATA, ) -ts_json_schema( - name = "run_schema", - src = "commands/run.json", - data = [ - "commands/definitions.json", +ts_project( + name = "angular-cli_test_lib", + testonly = True, + srcs = glob( + include = [ + "**/*_spec.ts", + "**/testing/**", + ], + exclude = [ + # NB: we need to exclude the nested node_modules that is laid out by yarn workspaces + "node_modules/**", + ], + ), + tsconfig = ":tsconfig-test", + deps = [ + ":angular-cli", + ":node_modules/@angular-devkit/core", + ":node_modules/@angular-devkit/schematics", + ":node_modules/@modelcontextprotocol/server", + ":node_modules/yargs", + "//:node_modules/@types/semver", + "//:node_modules/@types/yargs", + "//:node_modules/semver", + "//:node_modules/typescript", ], ) -ts_json_schema( - name = "serve_schema", - src = "commands/serve.json", +jasmine_test( + name = "test", data = [ - "commands/definitions.json", + "package.json", + "test-esm-loader.mjs", + ":angular-cli_test_lib", ], -) - -ts_json_schema( - name = "test_schema", - src = "commands/test.json", - data = [ - "commands/definitions.json", + node_options = [ + "--import=./test-esm-loader.mjs", ], ) -ts_json_schema( - name = "update_schema", - src = "commands/update.json", - data = [ - "commands/definitions.json", - ], +genrule( + name = "license", + srcs = ["//:LICENSE"], + outs = ["LICENSE"], + cmd = "cp $(execpath //:LICENSE) $@", ) -ts_json_schema( - name = "version_schema", - src = "commands/version.json", - data = [ - "commands/definitions.json", +npm_package( + name = "pkg", + pkg_deps = [ + "//packages/angular_devkit/architect:package.json", + "//packages/angular_devkit/build_angular:package.json", + "//packages/angular_devkit/build_webpack:package.json", + "//packages/angular_devkit/core:package.json", + "//packages/angular_devkit/schematics:package.json", + "//packages/schematics/angular:package.json", ], -) - -ts_json_schema( - name = "xi18n_schema", - src = "commands/xi18n.json", - data = [ - "commands/definitions.json", + replace_prefixes = { + "bundled_cli/": "lib/", + }, + stamp_files = [ + "bin/version.js", ], -) - -ts_library( - name = "angular-cli_test_lib", - testonly = True, - srcs = glob( - include = [ - "**/*_spec.ts", - "**/*_spec_large.ts", - ], - ), - # strict_checks = False, - tsconfig = "//:tsconfig-test.json", - deps = [ - ":angular-cli", - "//packages/angular_devkit/core", - "@npm//@types/jasmine", - "@npm//@types/node", + tags = ["release-package"], + deps = PACKAGE_ASSETS + [ + ":README.md", + ":bundled_cli", + ":index.d.ts", + ":license", ], ) - -jasmine_node_test( - name = "angular-cli_test", - srcs = [":angular-cli_test_lib"], -) diff --git a/packages/angular/cli/README.md b/packages/angular/cli/README.md index c4c5e25a8440..4fa87391f04c 100644 --- a/packages/angular/cli/README.md +++ b/packages/angular/cli/README.md @@ -1,269 +1,5 @@ -## Angular CLI +# Angular CLI - The CLI tool for Angular. - -[![Dependency Status][david-badge]][david-badge-url] -[![devDependency Status][david-dev-badge]][david-dev-badge-url] - -[![npm](https://img.shields.io/npm/v/%40angular/cli.svg)][npm-badge-url] -[![npm](https://img.shields.io/npm/v/%40angular/cli/next.svg)][npm-badge-url] -[![npm](https://img.shields.io/npm/l/@angular/cli.svg)][license-url] -[![npm](https://img.shields.io/npm/dm/@angular/cli.svg)][npm-badge-url] - -[![Join the chat at https://gitter.im/angular/angular-cli](https://img.shields.io/gitter/room/nwjs/nw.js.svg)](https://gitter.im/angular/angular-cli?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) - -[![GitHub forks](https://img.shields.io/github/forks/angular/angular-cli.svg?style=social&label=Fork)](https://github.com/angular/angular-cli/fork) -[![GitHub stars](https://img.shields.io/github/stars/angular/angular-cli.svg?style=social&label=Star)](https://github.com/angular/angular-cli) - - -## Note - -If you are updating from a beta or RC version, check out our [1.0 Update Guide](https://github.com/angular/angular-cli/wiki/stories-1.0-update). - -If you wish to collaborate, check out [our issue list](https://github.com/angular/angular-cli/issues). - -Before submitting new issues, have a look at [issues marked with the `type: faq` label](https://github.com/angular/angular-cli/issues?utf8=%E2%9C%93&q=is%3Aissue%20label%3A%22type%3A%20faq%22%20). - -## Prerequisites - -Both the CLI and generated project have dependencies that require Node 8.9 or higher, together -with NPM 5.5.1 or higher. - -## Table of Contents - -* [Installation](#installation) -* [Usage](#usage) -* [Generating a New Project](#generating-and-serving-an-angular-project-via-a-development-server) -* [Generating Components, Directives, Pipes and Services](#generating-components-directives-pipes-and-services) -* [Updating Angular CLI](#updating-angular-cli) -* [Development Hints for working on Angular CLI](#development-hints-for-working-on-angular-cli) -* [Documentation](#documentation) -* [License](#license) - -## Installation - -**BEFORE YOU INSTALL:** please read the [prerequisites](#prerequisites) - -### Install Globally -```bash -npm install -g @angular/cli -``` - -### Install Locally -```bash -npm install @angular/cli -``` - -To run a locally installed version of the angular-cli, you can call `ng` commands directly by adding the `.bin` folder within your local `node_modules` folder to your PATH. The `node_modules` and `.bin` folders are created in the directory where `npm install @angular/cli` was run upon completion of the install command. - -Alternatively, you can install [npx](https://www.npmjs.com/package/npx) and run `npx ng ` within the local directory where `npm install @angular/cli` was run, which will use the locally installed angular-cli. - -### Install Specific Version (Example: 6.1.1) -```bash -npm install -g @angular/cli@6.1.1 -``` - -## Usage - -```bash -ng help -``` - -### Generating and serving an Angular project via a development server - -```bash -ng new PROJECT-NAME -cd PROJECT-NAME -ng serve -``` -Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. - -You can configure the default HTTP host and port used by the development server with two command-line options : - -```bash -ng serve --host 0.0.0.0 --port 4201 -``` - -### Generating Components, Directives, Pipes and Services - -You can use the `ng generate` (or just `ng g`) command to generate Angular components: - -```bash -ng generate component my-new-component -ng g component my-new-component # using the alias - -# components support relative path generation -# if in the directory src/app/feature/ and you run -ng g component new-cmp -# your component will be generated in src/app/feature/new-cmp -# but if you were to run -ng g component ./newer-cmp -# your component will be generated in src/app/newer-cmp -# if in the directory src/app you can also run -ng g component feature/new-cmp -# and your component will be generated in src/app/feature/new-cmp -``` -You can find all possible blueprints in the table below: - -Scaffold | Usage ---- | --- -[Component](https://angular.io/cli/generate#component) | `ng g component my-new-component` -[Directive](https://angular.io/cli/generate#directive) | `ng g directive my-new-directive` -[Pipe](https://angular.io/cli/generate#pipe) | `ng g pipe my-new-pipe` -[Service](https://angular.io/cli/generate#service) | `ng g service my-new-service` -[Class](https://angular.io/cli/generate#class) | `ng g class my-new-class` -[Guard](https://angular.io/cli/generate#guard) | `ng g guard my-new-guard` -[Interface](https://angular.io/cli/generate#interface) | `ng g interface my-new-interface` -[Enum](https://angular.io/cli/generate#enum) | `ng g enum my-new-enum` -[Module](https://angular.io/cli/generate#module) | `ng g module my-module` - - - - -angular-cli will add reference to `components`, `directives` and `pipes` automatically in the `app.module.ts`. If you need to add this references to another custom module, follow these steps: - - 1. `ng g module new-module` to create a new module - 2. call `ng g component new-module/new-component` - -This should add the new `component`, `directive` or `pipe` reference to the `new-module` you've created. - -### Updating Angular CLI - -If you're using Angular CLI `1.0.0-beta.28` or less, you need to uninstall `angular-cli` package. It should be done due to changing of package's name and scope from `angular-cli` to `@angular/cli`: -```bash -npm uninstall -g angular-cli -npm uninstall --save-dev angular-cli -``` - -To update Angular CLI to a new version, you must update both the global package and your project's local package. - -Global package: -```bash -npm uninstall -g @angular/cli -npm cache verify -# if npm version is < 5 then use `npm cache clean` -npm install -g @angular/cli@latest -``` - -Local project package: -```bash -rm -rf node_modules dist # use rmdir /S/Q node_modules dist in Windows Command Prompt; use rm -r -fo node_modules,dist in Windows PowerShell -npm install --save-dev @angular/cli@latest -npm install -``` - -If you are updating to 1.0 from a beta or RC version, check out our [1.0 Update Guide](https://github.com/angular/angular-cli/wiki/stories-1.0-update). - -You can find more details about changes between versions in [the Releases tab on GitHub](https://github.com/angular/angular-cli/releases). - - -## Development Hints for working on Angular CLI - -### Working with master - -```bash -git clone https://github.com/angular/angular-cli.git -yarn -npm run build -cd dist/@angular/cli -npm link -``` - -`npm link` is very similar to `npm install -g` except that instead of downloading the package -from the repo, the just built `dist/@angular/cli/` folder becomes the global package. -Additionally, this repository publishes several packages and we use special logic to load all of them -on development setups. - -Any changes to the files in the `angular-cli/` folder will immediately affect the global `@angular/cli` package, -meaning that, in order to quickly test any changes you make to the cli project, you should simply just run `npm run build` -again. - -Now you can use `@angular/cli` via the command line: - -```bash -ng new foo -cd foo -npm link @angular/cli -ng serve -``` - -`npm link @angular/cli` is needed because by default the globally installed `@angular/cli` just loads -the local `@angular/cli` from the project which was fetched remotely from npm. -`npm link @angular/cli` symlinks the global `@angular/cli` package to the local `@angular/cli` package. -Now the `angular-cli` you cloned before is in three places: -The folder you cloned it into, npm's folder where it stores global packages and the Angular CLI project you just created. - -You can also use `ng new foo --link-cli` to automatically link the `@angular/cli` package. - -Please read the official [npm-link documentation](https://docs.npmjs.com/cli/link) -and the [npm-link cheatsheet](http://browsenpm.org/help#linkinganynpmpackagelocally) for more information. - -To run the Angular CLI E2E test suite, use the `node ./tests/legacy-cli/run_e2e` command. -It can also receive a filename to only run that test (e.g. `node ./tests/legacy-cli/run_e2e tests/legacy-cli/e2e/tests/build/dev-build.ts`). - -As part of the test procedure, all packages will be built and linked. -You will need to re-run `npm link` to re-link the development Angular CLI environment after tests finish. - -### Debugging with VS Code - -In order to debug some Angular CLI behaviour using Visual Studio Code, you can run `npm run build`, and then use a launch configuration like the following: - -```json -{ - "type": "node", - "request": "launch", - "name": "ng serve", - "cwd": "", - "program": "${workspaceFolder}/dist/@angular/cli/bin/ng", - "args": [ - "", - ...other arguments - ], - "console": "integratedTerminal" -} -``` - -Then you can add breakpoints in `dist/@angular` files. - -For more informations about Node.js debugging in VS Code, see the related [VS Code Documentation](https://code.visualstudio.com/docs/nodejs/nodejs-debugging). - -### CPU Profiling - -In order to investigate performance issues, CPU profiling is often useful. - -To capture a CPU profiling, you can: -1. install the v8-profiler-node8 dependency: `npm install v8-profiler-node8 --no-save` -1. set the NG_CLI_PROFILING Environment variable to the file name you want: - * on Unix systems (Linux & Mac OS X): ̀`export NG_CLI_PROFILING=my-profile` - * on Windows: ̀̀`setx NG_CLI_PROFILING my-profile` - -Then, just run the ng command on which you want to capture a CPU profile. -You will then obtain a `my-profile.cpuprofile` file in the folder from which you ran the ng command. - -You can use the Chrome Devtools to process it. To do so: -1. open `chrome://inspect/#devices` in Chrome -1. click on "Open dedicated DevTools for Node" -1. go to the "profiler" tab -1. click on the "Load" button and select the generated .cpuprofile file -1. on the left panel, select the associated file - -In addition to this one, another, more elaborated way to capture a CPU profile using the Chrome Devtools is detailed in https://github.com/angular/angular-cli/issues/8259#issue-269908550. - -## Documentation - -The documentation for the Angular CLI is located in this repo's [wiki](https://angular.io/cli). - -## License - -[MIT](https://github.com/angular/angular-cli/blob/master/LICENSE) - - -[travis-badge]: https://travis-ci.org/angular/angular-cli.svg?branch=master -[travis-badge-url]: https://travis-ci.org/angular/angular-cli -[david-badge]: https://david-dm.org/angular/angular-cli.svg -[david-badge-url]: https://david-dm.org/angular/angular-cli -[david-dev-badge]: https://david-dm.org/angular/angular-cli/dev-status.svg -[david-dev-badge-url]: https://david-dm.org/angular/angular-cli?type=dev -[npm-badge]: https://img.shields.io/npm/v/@angular/cli.svg -[npm-badge-url]: https://www.npmjs.com/package/@angular/cli -[license-url]: https://github.com/angular/angular-cli/blob/master/LICENSE +The sources for this package are in the [Angular CLI](https://github.com/angular/angular-cli) repository. Please file issues and pull requests against that repository. +Usage information and reference details can be found in repository [README](https://github.com/angular/angular-cli/blob/main/README.md) file. diff --git a/packages/angular/cli/bin/bootstrap.js b/packages/angular/cli/bin/bootstrap.js new file mode 100644 index 000000000000..6b1a18db4d42 --- /dev/null +++ b/packages/angular/cli/bin/bootstrap.js @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * + * This file is used to bootstrap the CLI process by dynamically importing the main initialization code. + * This is done to allow the main bin file (`ng`) to remain CommonJS so that older versions of Node.js + * can be checked and validated prior to the execution of the CLI. This separate bootstrap file is + * needed to allow the use of a dynamic import expression without crashing older versions of Node.js that + * do not support dynamic import expressions and would otherwise throw a syntax error. This bootstrap file + * is required from the main bin file only after the Node.js version is determined to be in the supported + * range. + */ + +// Enable on-disk code caching if available (Node.js 22.8+) +// Skip if running inside Bazel via a JS_BINARY__RUNFILES environment variable check and no explicit cache +// location defined. The default cache location does not work well with Bazel's hermeticity requirements. +if (!process.env['JS_BINARY__RUNFILES'] || process.env['NODE_COMPILE_CACHE']) { + try { + const { enableCompileCache } = require('node:module'); + + enableCompileCache?.(); + } catch {} +} + +// Initialize the Angular CLI +void import('../lib/init.js'); diff --git a/packages/angular/cli/bin/ng b/packages/angular/cli/bin/ng deleted file mode 100755 index cae16e5be8e2..000000000000 --- a/packages/angular/cli/bin/ng +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -// Provide a title to the process in `ps`. -// Due to an obscure Mac bug, do not start this title with any symbol. -try { - process.title = 'ng ' + Array.from(process.argv).slice(2).join(' '); -} catch (_) { - // If an error happened above, use the most basic title. - process.title = 'ng'; -} - -// This node version check ensures that extremely old versions of node are not used. -// These may not support ES2015 features such as const/let/async/await/etc. -// These would then crash with a hard to diagnose error message. -// tslint:disable-next-line: no-var-keyword -var version = process.versions.node.split('.').map(part => Number(part)); -if (version[0] < 10 || version[0] === 11 || (version[0] === 10 && version[1] < 13)) { - process.stderr.write( - 'Node.js version ' + process.version + ' detected.\n' + - 'The Angular CLI requires a minimum Node.js version of either v10.13 or v12.0.\n\n' + - 'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n', - ); - - process.exit(3); -} - -require('../lib/init'); diff --git a/packages/angular/cli/bin/ng.js b/packages/angular/cli/bin/ng.js new file mode 100755 index 000000000000..d503fb895f5f --- /dev/null +++ b/packages/angular/cli/bin/ng.js @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/* eslint-disable no-console */ +/* eslint-disable import/no-unassigned-import */ +'use strict'; + +const path = require('path'); +const nodeUtils = require('./version'); + +// Error if the external CLI appears to be used inside a google3 context. +if (process.cwd().split(path.sep).includes('google3')) { + console.error( + 'This is the external Angular CLI, but you appear to be running in google3. There is a separate, internal version of the CLI which should be used instead. See http://go/angular/cli.', + ); + process.exit(); +} + +// Provide a title to the process in `ps`. +// Due to an obscure Mac bug, do not start this title with any symbol. +try { + process.title = 'ng ' + Array.from(process.argv).slice(2).join(' '); +} catch (_) { + // If an error happened above, use the most basic title. + process.title = 'ng'; +} + +const rawCommandName = process.argv[2]; +if (rawCommandName === '--get-yargs-completions' || rawCommandName === 'completion') { + // Skip Node.js supported checks when running ng completion. + // A warning at this stage could cause a broken source action (`source <(ng completion script)`) when in the shell init script. + require('./bootstrap'); + + return; +} + +// This node version check ensures that extremely old versions of node are not used. +// These may not support ES2015 features such as const/let/async/await/etc. +// These would then crash with a hard to diagnose error message. +if (nodeUtils.isNodeVersionSupported()) { + require('./bootstrap'); +} else if (nodeUtils.isNodeVersionRunnable()) { + console.warn( + 'Node.js version ' + + process.version + + ' detected.\n' + + 'The Angular CLI has not been officially tested on this Node.js version and it is not supported for production use.\n' + + 'For more information, please see https://nodejs.org/en/about/previous-releases/.', + ); + + require('./bootstrap'); +} else { + // Error and exit if less than the supported versions. + console.error( + 'Node.js version ' + + process.version + + ' detected.\n' + + 'The Angular CLI requires a minimum Node.js version of ' + + nodeUtils.supportedNodeVersions.map((v) => 'v' + v).join(' or ') + + '.\n\n' + + 'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n', + ); + + process.exitCode = 3; +} diff --git a/packages/angular/cli/bin/package.json b/packages/angular/cli/bin/package.json new file mode 100644 index 000000000000..5bbefffbabee --- /dev/null +++ b/packages/angular/cli/bin/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/packages/angular/cli/bin/postinstall/analytics-prompt.js b/packages/angular/cli/bin/postinstall/analytics-prompt.js deleted file mode 100644 index 04121439939e..000000000000 --- a/packages/angular/cli/bin/postinstall/analytics-prompt.js +++ /dev/null @@ -1,19 +0,0 @@ -'use strict'; -// This file is ES6 because it needs to be executed as is. - -if ('NG_CLI_ANALYTICS' in process.env) { - return; -} - -try { - var analytics = require('../../models/analytics'); - - analytics - .hasGlobalAnalyticsConfiguration() - .then(hasGlobalConfig => { - if (!hasGlobalConfig) { - return analytics.promptGlobalAnalytics(); - } - }) - .catch(() => {}); -} catch (_) {} diff --git a/packages/angular/cli/bin/postinstall/ng-update-message.js b/packages/angular/cli/bin/postinstall/ng-update-message.js deleted file mode 100755 index 5e4742bdefd5..000000000000 --- a/packages/angular/cli/bin/postinstall/ng-update-message.js +++ /dev/null @@ -1,44 +0,0 @@ -'use strict'; -// This file is ES6 because it needs to be executed as is. - -// Check if the current directory contains '.angular-cli.json'. If it does, show a message to the user that they -// should use the migration script. - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - - -let found = false; -let current = path.dirname(path.dirname(__dirname)); -while (current !== path.dirname(current)) { - if (fs.existsSync(path.join(current, 'angular-cli.json')) - || fs.existsSync(path.join(current, '.angular-cli.json'))) { - found = os.homedir() !== current || fs.existsSync(path.join(current, 'package.json')); - break; - } - if (fs.existsSync(path.join(current, 'angular.json')) - || fs.existsSync(path.join(current, '.angular.json')) - || fs.existsSync(path.join(current, 'package.json'))) { - break; - } - - current = path.dirname(current); -} - - -if (found) { - // ------------------------------------------------------------------------------------------ - // If changing this message, please update the same message in - // `packages/@angular/cli/models/command-runner.ts` - - // eslint-disable-next-line no-console - console.error(`\u001b[31m - ${'='.repeat(80)} - The Angular CLI configuration format has been changed, and your existing configuration can - be updated automatically by running the following command: - - ng update @angular/cli - ${'='.repeat(80)} - \u001b[39m`.replace(/^ {4}/gm, '')); -} diff --git a/packages/angular/cli/bin/postinstall/script.js b/packages/angular/cli/bin/postinstall/script.js deleted file mode 100644 index 26aa53bb7e18..000000000000 --- a/packages/angular/cli/bin/postinstall/script.js +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -// These should not fail but if they do they should not block installation of the package -try { - require('./ng-update-message'); - require('./analytics-prompt'); -} catch (_) {} diff --git a/packages/angular/cli/bin/version.js b/packages/angular/cli/bin/version.js new file mode 100644 index 000000000000..1755c1ed55c8 --- /dev/null +++ b/packages/angular/cli/bin/version.js @@ -0,0 +1,98 @@ +'use strict'; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * The supported Node.js version for the Angular CLI. + */ +var SUPPORTED_NODE_VERSIONS = '0.0.0-ENGINES-NODE'; + +/** + * The version of the Angular CLI. + */ +var VERSION = '0.0.0-PLACEHOLDER'; + +/** + * The supported Node.js versions. + */ +var supportedNodeVersions = SUPPORTED_NODE_VERSIONS.replace(/[\^~<>=]/g, '') + .split('||') + .map(function (v) { + return v.trim(); + }); + +/** + * Checks if the current Node.js version is officially supported. + * @param {string} [currentVersion] Optional Node.js version string to check. Defaults to `process.versions.node`. + * @param {string[]} [supportedVersions] Optional supported versions array. Defaults to `supportedNodeVersions`. + * @returns {boolean} `true` if the current Node.js version is officially supported, `false` otherwise. + */ +function isNodeVersionSupported(currentVersion, supportedVersions) { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0' && !supportedVersions && !currentVersion) { + return true; + } + + var parts = (currentVersion || process.versions.node).split('.', 3).map(Number); + var processMajor = parts[0]; + var processMinor = parts[1]; + var processPatch = parts[2]; + + var versions = supportedVersions || supportedNodeVersions; + for (var i = 0; i < versions.length; i++) { + var vParts = versions[i].split('.', 3).map(Number); + var major = vParts[0]; + var minor = vParts[1]; + var patch = vParts[2]; + if ( + (major === processMajor && processMinor === minor && processPatch >= patch) || + (major === processMajor && processMinor > minor) + ) { + return true; + } + } + + return false; +} + +/** + * Checks if the current Node.js version is runnable (newer than supported LTS or an interim odd release). + * @param {string} [currentVersion] Optional Node.js version string to check. Defaults to `process.versions.node`. + * @param {string[]} [supportedVersions] Optional supported versions array. Defaults to `supportedNodeVersions`. + * @returns {boolean} `true` if the current Node.js version is runnable, `false` otherwise. + */ +function isNodeVersionRunnable(currentVersion, supportedVersions) { + if (SUPPORTED_NODE_VERSIONS.charAt(0) === '0' && !supportedVersions && !currentVersion) { + return false; + } + + var processMajor = Number((currentVersion || process.versions.node).split('.', 1)[0]); + var versions = supportedVersions || supportedNodeVersions; + + var minMajor = Number(versions[0].split('.', 1)[0]); + var maxMajor = Number(versions[versions.length - 1].split('.', 1)[0]); + + // Versions newer than the maximum supported major (e.g. Node 27, 28+) + if (processMajor > maxMajor) { + return true; + } + + // Odd-numbered interim releases between min and max (e.g. Node 23, 25) + if (processMajor > minMajor && processMajor % 2 === 1) { + return true; + } + + return false; +} + +module.exports = { + VERSION: VERSION, + SUPPORTED_NODE_VERSIONS: SUPPORTED_NODE_VERSIONS, + supportedNodeVersions: supportedNodeVersions, + isNodeVersionSupported: isNodeVersionSupported, + isNodeVersionRunnable: isNodeVersionRunnable, +}; diff --git a/packages/angular/cli/commands.json b/packages/angular/cli/commands.json deleted file mode 100644 index a5cd8ee63122..000000000000 --- a/packages/angular/cli/commands.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "add": "./commands/add.json", - "analytics": "./commands/analytics.json", - "build": "./commands/build.json", - "config": "./commands/config.json", - "deploy": "./commands/deploy.json", - "doc": "./commands/doc.json", - "e2e": "./commands/e2e.json", - "make-this-awesome": "./commands/easter-egg.json", - "generate": "./commands/generate.json", - "get": "./commands/deprecated.json", - "set": "./commands/deprecated.json", - "help": "./commands/help.json", - "lint": "./commands/lint.json", - "new": "./commands/new.json", - "run": "./commands/run.json", - "serve": "./commands/serve.json", - "test": "./commands/test.json", - "update": "./commands/update.json", - "version": "./commands/version.json", - "xi18n": "./commands/xi18n.json" -} diff --git a/packages/angular/cli/commands/add-impl.ts b/packages/angular/cli/commands/add-impl.ts deleted file mode 100644 index 0e6c45727d70..000000000000 --- a/packages/angular/cli/commands/add-impl.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { analytics, tags } from '@angular-devkit/core'; -import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools'; -import { dirname, join } from 'path'; -import { intersects, prerelease, rcompare, satisfies, valid, validRange } from 'semver'; -import { PackageManager } from '../lib/config/schema'; -import { isPackageNameSafeForAnalytics } from '../models/analytics'; -import { Arguments } from '../models/interface'; -import { RunSchematicOptions, SchematicCommand } from '../models/schematic-command'; -import { installPackage, installTempPackage } from '../tasks/install-package'; -import { colors } from '../utilities/color'; -import { getPackageManager } from '../utilities/package-manager'; -import { - NgAddSaveDepedency, - PackageManifest, - fetchPackageManifest, - fetchPackageMetadata, -} from '../utilities/package-metadata'; -import { Schema as AddCommandSchema } from './add'; - -const npa = require('npm-package-arg'); - -export class AddCommand extends SchematicCommand { - readonly allowPrivateSchematics = true; - - async initialize(options: AddCommandSchema & Arguments) { - if (options.registry) { - return super.initialize({ ...options, packageRegistry: options.registry }); - } else { - return super.initialize(options); - } - } - - async run(options: AddCommandSchema & Arguments) { - if (!options.collection) { - this.logger.fatal( - `The "ng add" command requires a name argument to be specified eg. ` + - `${colors.yellow('ng add [name] ')}. For more details, use "ng help".`, - ); - - return 1; - } - - let packageIdentifier; - try { - packageIdentifier = npa(options.collection); - } catch (e) { - this.logger.error(e.message); - - return 1; - } - - if (packageIdentifier.registry && this.isPackageInstalled(packageIdentifier.name)) { - let validVersion = false; - const installedVersion = await this.findProjectVersion(packageIdentifier.name); - if (installedVersion) { - if (packageIdentifier.type === 'range') { - validVersion = satisfies(installedVersion, packageIdentifier.fetchSpec); - } else if (packageIdentifier.type === 'version') { - const v1 = valid(packageIdentifier.fetchSpec); - const v2 = valid(installedVersion); - validVersion = v1 !== null && v1 === v2; - } else if (!packageIdentifier.rawSpec) { - validVersion = true; - } - } - - if (validVersion) { - // Already installed so just run schematic - this.logger.info('Skipping installation: Package already installed'); - - return this.executeSchematic(packageIdentifier.name, options['--']); - } - } - - const packageManager = await getPackageManager(this.workspace.root); - const usingYarn = packageManager === PackageManager.Yarn; - - if (packageIdentifier.type === 'tag' && !packageIdentifier.rawSpec) { - // only package name provided; search for viable version - // plus special cases for packages that did not have peer deps setup - let packageMetadata; - try { - packageMetadata = await fetchPackageMetadata(packageIdentifier.name, this.logger, { - registry: options.registry, - usingYarn, - verbose: options.verbose, - }); - } catch (e) { - this.logger.error('Unable to fetch package metadata: ' + e.message); - - return 1; - } - - const latestManifest = packageMetadata.tags['latest']; - if (latestManifest && Object.keys(latestManifest.peerDependencies).length === 0) { - if (latestManifest.name === '@angular/pwa') { - const version = await this.findProjectVersion('@angular/cli'); - // tslint:disable-next-line:no-any - const semverOptions = { includePrerelease: true } as any; - - if ( - version && - ((validRange(version) && intersects(version, '7', semverOptions)) || - (valid(version) && satisfies(version, '7', semverOptions))) - ) { - packageIdentifier = npa.resolve('@angular/pwa', '0.12'); - } - } - } else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) { - // 'latest' is invalid so search for most recent matching package - const versionManifests = Object.values(packageMetadata.versions).filter( - (value: PackageManifest) => !prerelease(value.version), - ) as PackageManifest[]; - - versionManifests.sort((a, b) => rcompare(a.version, b.version, true)); - - let newIdentifier; - for (const versionManifest of versionManifests) { - if (!(await this.hasMismatchedPeer(versionManifest))) { - newIdentifier = npa.resolve(packageIdentifier.name, versionManifest.version); - break; - } - } - - if (!newIdentifier) { - this.logger.warn("Unable to find compatible package. Using 'latest'."); - } else { - packageIdentifier = newIdentifier; - } - } - } - - let collectionName = packageIdentifier.name; - let savePackage: NgAddSaveDepedency | undefined; - - try { - const manifest = await fetchPackageManifest(packageIdentifier, this.logger, { - registry: options.registry, - verbose: options.verbose, - usingYarn, - }); - - savePackage = manifest['ng-add'] && manifest['ng-add'].save; - collectionName = manifest.name; - - if (await this.hasMismatchedPeer(manifest)) { - this.logger.warn( - 'Package has unmet peer dependencies. Adding the package may not succeed.', - ); - } - } catch (e) { - this.logger.error('Unable to fetch package manifest: ' + e.message); - - return 1; - } - - if (savePackage === false) { - // Temporary packages are located in a different directory - // Hence we need to resolve them using the temp path - const tempPath = installTempPackage( - packageIdentifier.raw, - this.logger, - packageManager, - options.registry ? [`--registry="${options.registry}"`] : undefined, - ); - const resolvedCollectionPath = require.resolve( - join(collectionName, 'package.json'), - { - paths: [tempPath], - }, - ); - - collectionName = dirname(resolvedCollectionPath); - } else { - installPackage( - packageIdentifier.raw, - this.logger, - packageManager, - savePackage, - options.registry ? [`--registry="${options.registry}"`] : undefined, - ); - } - - return this.executeSchematic(collectionName, options['--']); - } - - async reportAnalytics( - paths: string[], - options: AddCommandSchema & Arguments, - dimensions: (boolean | number | string)[] = [], - metrics: (boolean | number | string)[] = [], - ): Promise { - const collection = options.collection; - - // Add the collection if it's safe listed. - if (collection && isPackageNameSafeForAnalytics(collection)) { - dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection] = collection; - } else { - delete dimensions[analytics.NgCliAnalyticsDimensions.NgAddCollection]; - } - - return super.reportAnalytics(paths, options, dimensions, metrics); - } - - private isPackageInstalled(name: string): boolean { - try { - require.resolve(join(name, 'package.json'), { paths: [this.workspace.root] }); - - return true; - } catch (e) { - if (e.code !== 'MODULE_NOT_FOUND') { - throw e; - } - } - - return false; - } - - private async executeSchematic( - collectionName: string, - options: string[] = [], - ): Promise { - const runOptions: RunSchematicOptions = { - schematicOptions: options, - collectionName, - schematicName: 'ng-add', - dryRun: false, - force: false, - }; - - try { - return await this.runSchematic(runOptions); - } catch (e) { - if (e instanceof NodePackageDoesNotSupportSchematics) { - this.logger.error(tags.oneLine` - The package that you are trying to add does not support schematics. You can try using - a different version of the package or contact the package author to add ng-add support. - `); - - return 1; - } - - throw e; - } - } - - private async findProjectVersion(name: string): Promise { - let installedPackage; - try { - installedPackage = require.resolve(join(name, 'package.json'), { - paths: [this.workspace.root], - }); - } catch {} - - if (installedPackage) { - try { - const installed = await fetchPackageManifest(dirname(installedPackage), this.logger); - - return installed.version; - } catch {} - } - - let projectManifest; - try { - projectManifest = await fetchPackageManifest(this.workspace.root, this.logger); - } catch {} - - if (projectManifest) { - const version = projectManifest.dependencies[name] || projectManifest.devDependencies[name]; - if (version) { - return version; - } - } - - return null; - } - - private async hasMismatchedPeer(manifest: PackageManifest): Promise { - for (const peer in manifest.peerDependencies) { - let peerIdentifier; - try { - peerIdentifier = npa.resolve(peer, manifest.peerDependencies[peer]); - } catch { - this.logger.warn(`Invalid peer dependency ${peer} found in package.`); - continue; - } - - if (peerIdentifier.type === 'version' || peerIdentifier.type === 'range') { - try { - const version = await this.findProjectVersion(peer); - if (!version) { - continue; - } - - // tslint:disable-next-line:no-any - const options = { includePrerelease: true } as any; - - if ( - !intersects(version, peerIdentifier.rawSpec, options) && - !satisfies(version, peerIdentifier.rawSpec, options) - ) { - return true; - } - } catch { - // Not found or invalid so ignore - continue; - } - } else { - // type === 'tag' | 'file' | 'directory' | 'remote' | 'git' - // Cannot accurately compare these as the tag/location may have changed since install - } - } - - return false; - } -} diff --git a/packages/angular/cli/commands/add.json b/packages/angular/cli/commands/add.json deleted file mode 100644 index 45679b0faf4d..000000000000 --- a/packages/angular/cli/commands/add.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/add.json", - "description": "Adds support for an external library to your project.", - "$longDescription": "./add.md", - - "$scope": "in", - "$impl": "./add-impl#AddCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "collection": { - "type": "string", - "description": "The package to be added.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "registry": { - "description": "The NPM registry to use.", - "type": "string", - "oneOf": [ - { - "format": "uri" - }, - { - "format": "hostname" - } - ] - }, - "verbose": { - "description": "Display additional details about internal operations during execution.", - "type": "boolean", - "default": false - } - }, - "required": [ - ] - }, - { - "$ref": "./definitions.json#/definitions/interactive" - }, - { - "$ref": "./definitions.json#/definitions/base" - } - ] -} diff --git a/packages/angular/cli/commands/add.md b/packages/angular/cli/commands/add.md deleted file mode 100644 index 465438a4e5b2..000000000000 --- a/packages/angular/cli/commands/add.md +++ /dev/null @@ -1,9 +0,0 @@ -Adds the npm package for a published library to your workspace, and configures -the project in the current working directory (or the default project if you are -not in a project directory) to use that library, as specified by the library's schematic. -For example, adding `@angular/pwa` configures your project for PWA support: -```bash -ng add @angular/pwa -``` - -The default project is the value of `defaultProject` in `angular.json`. diff --git a/packages/angular/cli/commands/analytics-impl.ts b/packages/angular/cli/commands/analytics-impl.ts deleted file mode 100644 index 98209f5207ab..000000000000 --- a/packages/angular/cli/commands/analytics-impl.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { - promptGlobalAnalytics, - promptProjectAnalytics, - setAnalyticsConfig, -} from '../models/analytics'; -import { Command } from '../models/command'; -import { Arguments } from '../models/interface'; -import { ProjectSetting, Schema as AnalyticsCommandSchema, SettingOrProject } from './analytics'; - - -export class AnalyticsCommand extends Command { - public async run(options: AnalyticsCommandSchema & Arguments) { - // Our parser does not support positional enums (won't report invalid parameters). Do the - // validation manually. - // TODO(hansl): fix parser to better support positionals. This would be a breaking change. - if (options.settingOrProject === undefined) { - if (options['--']) { - // The user passed positional arguments but they didn't validate. - this.logger.error(`Argument ${JSON.stringify(options['--'][0])} is invalid.`); - this.logger.error(`Please provide one of the following value: on, off, ci or project.`); - - return 1; - } else { - // No argument were passed. - await this.printHelp(options); - - return 2; - } - } else if (options.settingOrProject == SettingOrProject.Project - && options.projectSetting === undefined) { - this.logger.error(`Argument ${JSON.stringify(options.settingOrProject)} requires a second ` - + `argument of one of the following value: on, off.`); - - return 2; - } - - try { - switch (options.settingOrProject) { - case SettingOrProject.Off: - setAnalyticsConfig('global', false); - break; - - case SettingOrProject.On: - setAnalyticsConfig('global', true); - break; - - case SettingOrProject.Ci: - setAnalyticsConfig('global', 'ci'); - break; - - case SettingOrProject.Project: - switch (options.projectSetting) { - case ProjectSetting.Off: - setAnalyticsConfig('local', false); - break; - - case ProjectSetting.On: - setAnalyticsConfig('local', true); - break; - - case ProjectSetting.Prompt: - await promptProjectAnalytics(true); - break; - - default: - await this.printHelp(options); - - return 3; - } - break; - - case SettingOrProject.Prompt: - await promptGlobalAnalytics(true); - break; - - default: - await this.printHelp(options); - - return 4; - } - } catch (err) { - this.logger.fatal(err.message); - - return 1; - } - - return 0; - } -} diff --git a/packages/angular/cli/commands/analytics-long.md b/packages/angular/cli/commands/analytics-long.md deleted file mode 100644 index 60e0d86686e8..000000000000 --- a/packages/angular/cli/commands/analytics-long.md +++ /dev/null @@ -1,7 +0,0 @@ -The value of *settingOrProject* is one of the following. -* "on" : Enables analytics gathering and reporting for the user. -* "off" : Disables analytics gathering and reporting for the user. -* "ci" : Enables analytics and configures reporting for use with Continuous Integration, -which uses a common CI user. -* "prompt" : Prompts the user to set the status interactively. -* "project" : Sets the default status for the project to the *projectSetting* value, which can be any of the other values. The *projectSetting* argument is ignored for all other values of *settingOrProject*. \ No newline at end of file diff --git a/packages/angular/cli/commands/analytics.json b/packages/angular/cli/commands/analytics.json deleted file mode 100644 index f806e5c8e7bf..000000000000 --- a/packages/angular/cli/commands/analytics.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/analytics.json", - "description": "Configures the gathering of Angular CLI usage metrics. See https://v8.angular.io/cli/usage-analytics-gathering.", - "$longDescription": "./analytics-long.md", - - "$aliases": [], - "$scope": "all", - "$type": "native", - "$impl": "./analytics-impl#AnalyticsCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "settingOrProject": { - "enum": [ - "on", - "off", - "ci", - "project", - "prompt" - ], - "description": "Directly enables or disables all usage analytics for the user, or prompts the user to set the status interactively, or sets the default status for the project.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "projectSetting": { - "enum": [ - "on", - "off", - "prompt" - ], - "description": "Sets the default analytics enablement status for the project.", - "$default": { - "$source": "argv", - "index": 1 - } - } - }, - "required": [ - "settingOrProject" - ] - }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/build-impl.ts b/packages/angular/cli/commands/build-impl.ts deleted file mode 100644 index 081588c2b9cb..000000000000 --- a/packages/angular/cli/commands/build-impl.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { analytics } from '@angular-devkit/core'; -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as BuildCommandSchema } from './build'; - -export class BuildCommand extends ArchitectCommand { - public readonly target = 'build'; - - public async run(options: ArchitectCommandOptions & Arguments) { - return this.runArchitectTarget(options); - } - - async reportAnalytics( - paths: string[], - options: BuildCommandSchema & Arguments, - dimensions: (boolean | number | string)[] = [], - metrics: (boolean | number | string)[] = [], - ): Promise { - if (options.buildEventLog !== undefined) { - dimensions[analytics.NgCliAnalyticsDimensions.NgBuildBuildEventLog] = true; - } - - return super.reportAnalytics(paths, options, dimensions, metrics); - } -} diff --git a/packages/angular/cli/commands/build-long.md b/packages/angular/cli/commands/build-long.md deleted file mode 100644 index 4e10498f4cd5..000000000000 --- a/packages/angular/cli/commands/build-long.md +++ /dev/null @@ -1,18 +0,0 @@ -The command can be used to build a project of type "application" or "library". -When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, and `watch` options are applied. -All other options apply only to building applications. - -The application builder uses the [webpack](https://webpack.js.org/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration. -A "production" configuration is created by default when you use the CLI to create the project, and you can use that configuration by specifying the `--configuration="production"` or the `--prod="true"` option. - -The configuration options generally correspond to the command options. -You can override individual configuration defaults by specifying the corresponding options on the command line. -The command can accept option names given in either dash-case or camelCase. -Note that in the configuration file, you must specify names in camelCase. - -Some additional options can only be set through the configuration file, -either by direct editing or with the `ng config` command. -These include `assets`, `styles`, and `scripts` objects that provide runtime-global resources to include in the project. -Resources in CSS, such as images and fonts, are automatically written and fingerprinted at the root of the output folder. - -For further details, see [Workspace Configuration](guide/workspace-config). diff --git a/packages/angular/cli/commands/build.json b/packages/angular/cli/commands/build.json deleted file mode 100644 index 6678ba5d744e..000000000000 --- a/packages/angular/cli/commands/build.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/build.json", - "description": "Compiles an Angular app into an output directory named dist/ at the given output path. Must be executed from within a workspace directory.", - "$longDescription": "./build-long.md", - - "$aliases": [ "b" ], - "$scope": "in", - "$type": "architect", - "$impl": "./build-impl#BuildCommand", - - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" }, - { - "type": "object", - "properties": { - "buildEventLog": { - "type": "string", - "description": "**EXPERIMENTAL** Output file path for Build Event Protocol events" - } - } - } - ] -} diff --git a/packages/angular/cli/commands/config-impl.ts b/packages/angular/cli/commands/config-impl.ts deleted file mode 100644 index c5d9159603b1..000000000000 --- a/packages/angular/cli/commands/config-impl.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { - InvalidJsonCharacterException, - JsonArray, - JsonObject, - JsonParseMode, - JsonValue, - experimental, - parseJson, - tags, -} from '@angular-devkit/core'; -import { writeFileSync } from 'fs'; -import { v4 as uuidV4 } from 'uuid'; -import { Command } from '../models/command'; -import { Arguments, CommandScope } from '../models/interface'; -import { - getWorkspace, - getWorkspaceRaw, - migrateLegacyGlobalConfig, - validateWorkspace, -} from '../utilities/config'; -import { Schema as ConfigCommandSchema, Value as ConfigCommandSchemaValue } from './config'; - -function _validateBoolean(value: string) { - if (('' + value).trim() === 'true') { - return true; - } else if (('' + value).trim() === 'false') { - return false; - } else { - throw new Error(`Invalid value type; expected Boolean, received ${JSON.stringify(value)}.`); - } -} -function _validateNumber(value: string) { - const numberValue = Number(value); - if (!Number.isFinite(numberValue)) { - return numberValue; - } - throw new Error(`Invalid value type; expected Number, received ${JSON.stringify(value)}.`); -} -function _validateString(value: string) { - return value; -} -function _validateAnalytics(value: string) { - if (value === '') { - // Disable analytics. - return null; - } else { - return value; - } -} -function _validateAnalyticsSharingUuid(value: string) { - if (value == '') { - return uuidV4(); - } else { - return value; - } -} -function _validateAnalyticsSharingTracking(value: string) { - if (!value.match(/^GA-\d+-\d+$/)) { - throw new Error(`Invalid GA property ID: ${JSON.stringify(value)}.`); - } - - return value; -} - -const validCliPaths = new Map JsonValue>([ - ['cli.warnings.versionMismatch', _validateBoolean], - ['cli.defaultCollection', _validateString], - ['cli.packageManager', _validateString], - ['cli.analytics', _validateAnalytics], - ['cli.analyticsSharing.tracking', _validateAnalyticsSharingTracking], - ['cli.analyticsSharing.uuid', _validateAnalyticsSharingUuid], -]); - -/** - * Splits a JSON path string into fragments. Fragments can be used to get the value referenced - * by the path. For example, a path of "a[3].foo.bar[2]" would give you a fragment array of - * ["a", 3, "foo", "bar", 2]. - * @param path The JSON string to parse. - * @returns {(string|number)[]} The fragments for the string. - * @private - */ -function parseJsonPath(path: string): (string | number)[] { - const fragments = (path || '').split(/\./g); - const result: (string | number)[] = []; - - while (fragments.length > 0) { - const fragment = fragments.shift(); - if (fragment == undefined) { - break; - } - - const match = fragment.match(/([^\[]+)((\[.*\])*)/); - if (!match) { - throw new Error('Invalid JSON path.'); - } - - result.push(match[1]); - if (match[2]) { - const indices = match[2] - .slice(1, -1) - .split('][') - .map(x => (/^\d$/.test(x) ? +x : x.replace(/\"|\'/g, ''))); - result.push(...indices); - } - } - - return result.filter(fragment => fragment != null); -} - -function getValueFromPath( - root: T, - path: string, -): JsonValue | undefined { - const fragments = parseJsonPath(path); - - try { - return fragments.reduce((value: JsonValue, current: string | number) => { - if (value == undefined || typeof value != 'object') { - return undefined; - } else if (typeof current == 'string' && !Array.isArray(value)) { - return value[current]; - } else if (typeof current == 'number' && Array.isArray(value)) { - return value[current]; - } else { - return undefined; - } - }, root); - } catch { - return undefined; - } -} - -function setValueFromPath( - root: T, - path: string, - newValue: JsonValue, -): JsonValue | undefined { - const fragments = parseJsonPath(path); - - try { - return fragments.reduce((value: JsonValue, current: string | number, index: number) => { - if (value == undefined || typeof value != 'object') { - return undefined; - } else if (typeof current == 'string' && !Array.isArray(value)) { - if (index === fragments.length - 1) { - value[current] = newValue; - } else if (value[current] == undefined) { - if (typeof fragments[index + 1] == 'number') { - value[current] = []; - } else if (typeof fragments[index + 1] == 'string') { - value[current] = {}; - } - } - - return value[current]; - } else if (typeof current == 'number' && Array.isArray(value)) { - if (index === fragments.length - 1) { - value[current] = newValue; - } else if (value[current] == undefined) { - if (typeof fragments[index + 1] == 'number') { - value[current] = []; - } else if (typeof fragments[index + 1] == 'string') { - value[current] = {}; - } - } - - return value[current]; - } else { - return undefined; - } - }, root); - } catch { - return undefined; - } -} - -function normalizeValue(value: ConfigCommandSchemaValue, path: string): JsonValue { - const cliOptionType = validCliPaths.get(path); - if (cliOptionType) { - return cliOptionType('' + value); - } - - if (typeof value === 'string') { - try { - return parseJson(value, JsonParseMode.Loose); - } catch (e) { - if (e instanceof InvalidJsonCharacterException && !value.startsWith('{')) { - return value; - } else { - throw e; - } - } - } - - return value; -} - -export class ConfigCommand extends Command { - public async run(options: ConfigCommandSchema & Arguments) { - const level = options.global ? 'global' : 'local'; - - if (!options.global) { - await this.validateScope(CommandScope.InProject); - } - - let config = await getWorkspace(level); - - if (options.global && !config) { - try { - if (migrateLegacyGlobalConfig()) { - config = await getWorkspace(level); - this.logger.info(tags.oneLine` - We found a global configuration that was used in Angular CLI 1. - It has been automatically migrated.`); - } - } catch {} - } - - if (options.value == undefined) { - if (!config) { - this.logger.error('No config found.'); - - return 1; - } - - const workspace = ((config as {}) as { _workspace: experimental.workspace.WorkspaceSchema }) - ._workspace; - - return this.get(workspace, options); - } else { - return this.set(options); - } - } - - private get(config: experimental.workspace.WorkspaceSchema, options: ConfigCommandSchema) { - let value; - if (options.jsonPath) { - if (options.jsonPath === 'cli.warnings.typescriptMismatch') { - // NOTE: Remove this in 9.0. - this.logger.warn('The "typescriptMismatch" warning has been removed in 8.0.'); - // Since there is no actual warning, this value is always false. - this.logger.info('false'); - - return 0; - } - - value = getValueFromPath((config as {}) as JsonObject, options.jsonPath); - } else { - value = config; - } - - if (value === undefined) { - this.logger.error('Value cannot be found.'); - - return 1; - } else if (typeof value == 'object') { - this.logger.info(JSON.stringify(value, null, 2)); - } else { - this.logger.info(value.toString()); - } - - return 0; - } - - private async set(options: ConfigCommandSchema) { - if (!options.jsonPath || !options.jsonPath.trim()) { - throw new Error('Invalid Path.'); - } - - if (options.jsonPath === 'cli.warnings.typescriptMismatch') { - // NOTE: Remove this in 9.0. - this.logger.warn('The "typescriptMismatch" warning has been removed in 8.0.'); - - return 0; - } - - if ( - options.global && - !options.jsonPath.startsWith('schematics.') && - !validCliPaths.has(options.jsonPath) - ) { - throw new Error('Invalid Path.'); - } - - const [config, configPath] = getWorkspaceRaw(options.global ? 'global' : 'local'); - if (!config || !configPath) { - this.logger.error('Confguration file cannot be found.'); - - return 1; - } - - // TODO: Modify & save without destroying comments - const configValue = config.value; - - const value = normalizeValue(options.value || '', options.jsonPath); - const result = setValueFromPath(configValue, options.jsonPath, value); - - if (result === undefined) { - this.logger.error('Value cannot be found.'); - - return 1; - } - - try { - await validateWorkspace(configValue); - } catch (error) { - this.logger.fatal(error.message); - - return 1; - } - - const output = JSON.stringify(configValue, null, 2); - writeFileSync(configPath, output); - - return 0; - } -} diff --git a/packages/angular/cli/commands/config-long.md b/packages/angular/cli/commands/config-long.md deleted file mode 100644 index 5f6052b3894a..000000000000 --- a/packages/angular/cli/commands/config-long.md +++ /dev/null @@ -1,13 +0,0 @@ -A workspace has a single CLI configuration file, `angular.json`, at the top level. -The `projects` object contains a configuration object for each project in the workspace. - -You can edit the configuration directly in a code editor, -or indirectly on the command line using this command. - -The configurable property names match command option names, -except that in the configuration file, all names must use camelCase, -while on the command line options can be given in either camelCase or dash-case. - -For further details, see [Workspace Configuration](guide/workspace-config). - -For configuration of CLI usage analytics, see [Gathering an Viewing CLI Usage Analytics](./usage-analytics-gathering). \ No newline at end of file diff --git a/packages/angular/cli/commands/config.json b/packages/angular/cli/commands/config.json deleted file mode 100644 index d0c3d59520b3..000000000000 --- a/packages/angular/cli/commands/config.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/config.json", - "description": "Retrieves or sets Angular configuration values in the angular.json file for the workspace.", - "$longDescription": "", - - "$aliases": [], - "$scope": "all", - "$type": "native", - "$impl": "./config-impl#ConfigCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "jsonPath": { - "type": "string", - "description": "The configuration key to set or query, in JSON path format. For example: \"a[3].foo.bar[2]\". If no new value is provided, returns the current value of this key.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "value": { - "type": ["string", "number", "boolean"], - "description": "If provided, a new value for the given configuration key.", - "$default": { - "$source": "argv", - "index": 1 - } - }, - "global": { - "type": "boolean", - "description": "When true, accesses the global configuration in the caller's home directory.", - "default": false, - "aliases": ["g"] - } - }, - "required": [ - ] - }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/definitions.json b/packages/angular/cli/commands/definitions.json deleted file mode 100644 index 1713520fc1d1..000000000000 --- a/packages/angular/cli/commands/definitions.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/definitions.json", - - "definitions": { - "architect": { - "properties": { - "project": { - "type": "string", - "description": "The name of the project to build. Can be an application or a library.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "configuration": { - "description": "A named build target, as specified in the \"configurations\" section of angular.json.\nEach named target is accompanied by a configuration of option defaults for that target.\nSetting this explicitly overrides the \"--prod\" flag", - "type": "string", - "aliases": [ - "c" - ] - }, - "prod": { - "description": "Shorthand for \"--configuration=production\".\nWhen true, sets the build configuration to the production target.\nBy default, the production target is set up in the workspace configuration such that all builds make use of bundling, limited tree-shaking, and also limited dead code elimination.", - "type": "boolean" - } - } - }, - "base": { - "type": "object", - "properties": { - "help": { - "enum": [true, false, "json", "JSON"], - "description": "Shows a help message for this command in the console.", - "default": false - } - } - }, - "schematic": { - "properties": { - "dryRun": { - "type": "boolean", - "default": false, - "aliases": [ "d" ], - "description": "When true, runs through and reports activity without writing out results." - }, - "force": { - "type": "boolean", - "default": false, - "aliases": [ "f" ], - "description": "When true, forces overwriting of existing files." - } - } - }, - "interactive": { - "properties": { - "interactive": { - "type": "boolean", - "default": "true", - "description": "When false, disables interactive input prompts." - }, - "defaults": { - "type": "boolean", - "default": "false", - "description": "When true, disables interactive input prompts for options with a default." - } - } - } - } -} diff --git a/packages/angular/cli/commands/deploy-impl.ts b/packages/angular/cli/commands/deploy-impl.ts deleted file mode 100644 index ebeab5e669e0..000000000000 --- a/packages/angular/cli/commands/deploy-impl.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as DeployCommandSchema } from './deploy'; - -const BuilderMissing = ` - -Cannot find "deploy" target for the specified project. - -You should add a package that implements deployment capabilities for your -favorite platform. - -For example: - ng add @angular/fire - ng add @azure/ng-deploy - ng add @zeit/ng-deploy - -Find more packages on npm https://www.npmjs.com/search?q=ng%20deploy -`; - -export class DeployCommand extends ArchitectCommand { - public readonly target = 'deploy'; - public readonly missingTargetError = BuilderMissing; - - public async run(options: ArchitectCommandOptions & Arguments) { - return this.runArchitectTarget(options); - } - - public async initialize(options: DeployCommandSchema & Arguments): Promise { - if (!options.help) { - return super.initialize(options); - } - } - - async reportAnalytics( - paths: string[], - options: DeployCommandSchema & Arguments, - dimensions: (boolean | number | string)[] = [], - metrics: (boolean | number | string)[] = [], - ): Promise { - return super.reportAnalytics(paths, options, dimensions, metrics); - } -} diff --git a/packages/angular/cli/commands/deploy-long.md b/packages/angular/cli/commands/deploy-long.md deleted file mode 100644 index 5d600ef2e8d8..000000000000 --- a/packages/angular/cli/commands/deploy-long.md +++ /dev/null @@ -1,22 +0,0 @@ -The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. -When a project name is not supplied, executes the `deploy` builder for the default project. - -To use the `ng deploy` command, use `ng add` to add a package that implements deployment capabilities to your favorite platform. -Adding the package automatically updates your workspace configuration, adding a deployment -[CLI builder](guide/cli-builder). -For example: - -```json -"projects": { - "my-project": { - ... - "architect": { - ... - "deploy": { - "builder": "@angular/fire:deploy", - "options": {} - } - } - } - } - ``` \ No newline at end of file diff --git a/packages/angular/cli/commands/deploy.json b/packages/angular/cli/commands/deploy.json deleted file mode 100644 index 861917bc882d..000000000000 --- a/packages/angular/cli/commands/deploy.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/deploy.json", - "description": "Invokes the deploy builder for a specified project or for the default project in the workspace.", - "$longDescription": "./deploy-long.md", - - "$scope": "in", - "$type": "architect", - "$impl": "./deploy-impl#DeployCommand", - - "allOf": [ - { - "properties": { - "project": { - "type": "string", - "description": "The name of the project to deploy.", - "$default": { - "$source": "argv", - "index": 0 - } - } - }, - "configuration": { - "description": "A named build target, as specified in the \"configurations\" section of angular.json.\nEach named target is accompanied by a configuration of option defaults for that target.", - "type": "string", - "aliases": [ - "c" - ] - }, - "required": [ - ] - }, - { - "$ref": "./definitions.json#/definitions/base" - } - ] -} diff --git a/packages/angular/cli/commands/deprecated-impl.ts b/packages/angular/cli/commands/deprecated-impl.ts deleted file mode 100644 index 7887eac8d04d..000000000000 --- a/packages/angular/cli/commands/deprecated-impl.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { Command } from '../models/command'; -import { Schema as DeprecatedCommandSchema } from './deprecated'; - -export class DeprecatedCommand extends Command { - public async run() { - let message = 'The "${this.description.name}" command has been deprecated.'; - if (this.description.name == 'get' || this.description.name == 'set') { - message = 'get/set have been deprecated in favor of the config command.'; - } - - this.logger.error(message); - - return 0; - } -} diff --git a/packages/angular/cli/commands/deprecated.json b/packages/angular/cli/commands/deprecated.json deleted file mode 100644 index 05dd86855d95..000000000000 --- a/packages/angular/cli/commands/deprecated.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/deprecated.json", - "description": "Deprecated in favor of config command.", - "$longDescription": "", - - "$impl": "./deprecated-impl#DeprecatedCommand", - "$hidden": true, - "$type": "deprecated", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/doc-impl.ts b/packages/angular/cli/commands/doc-impl.ts deleted file mode 100644 index 587d760c3346..000000000000 --- a/packages/angular/cli/commands/doc-impl.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { Command } from '../models/command'; -import { Arguments } from '../models/interface'; -import { Schema as DocCommandSchema } from './doc'; - -const open = require('open'); - -export class DocCommand extends Command { - public async run(options: DocCommandSchema & Arguments) { - if (!options.keyword) { - this.logger.error('You should specify a keyword, for instance, `ng doc ActivatedRoute`.'); - - return 0; - } - - let domain = 'angular.io'; - - if (options.version) { - // version can either be a string containing "next" - if (options.version == 'next') { - domain = 'next.angular.io'; - // or a number where version must be a valid Angular version (i.e. not 0, 1 or 3) - } else if (!isNaN(+options.version) && ![0, 1, 3].includes(+options.version)) { - domain = `v${options.version}.angular.io`; - } else { - this.logger.error('Version should either be a number (2, 4, 5, 6...) or "next"'); - - return 0; - } - } else { - // we try to get the current Angular version of the project - // and use it if we can find it - try { - /* tslint:disable-next-line:no-implicit-dependencies */ - const currentNgVersion = require('@angular/core').VERSION.major; - domain = `v${currentNgVersion}.angular.io`; - } catch (e) { } - } - - let searchUrl = `https://${domain}/api?query=${options.keyword}`; - - if (options.search) { - searchUrl = `https://${domain}/docs?search=${options.keyword}`; - } - - // We should wrap `open` in a new Promise because `open` is already resolved - await new Promise(() => { - open(searchUrl, { - wait: false, - }); - }); - } -} diff --git a/packages/angular/cli/commands/doc.json b/packages/angular/cli/commands/doc.json deleted file mode 100644 index b43f448d03cb..000000000000 --- a/packages/angular/cli/commands/doc.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/doc.json", - "description": "Opens the official Angular documentation (angular.io) in a browser, and searches for a given keyword.", - "$longDescription": "", - - "$aliases": [ "d" ], - "$type": "native", - "$impl": "./doc-impl#DocCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "keyword": { - "type": "string", - "description": "The keyword to search for, as provided in the search bar in angular.io.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "search": { - "aliases": ["s"], - "type": "boolean", - "default": false, - "description": "When true, searches all of angular.io. Otherwise, searches only API reference documentation." - }, - "version" : { - "oneOf": [ - { - "type": "number", - "minimum": 4 - }, - { - "enum": [2, "next"] - } - ], - "description": "Contains the version of Angular to use for the documentation. If not provided, the command uses your current Angular core version." - } - }, - "required": [ - ] - }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/e2e-impl.ts b/packages/angular/cli/commands/e2e-impl.ts deleted file mode 100644 index f24a44ca4122..000000000000 --- a/packages/angular/cli/commands/e2e-impl.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as E2eCommandSchema } from './e2e'; - - -export class E2eCommand extends ArchitectCommand { - public readonly target = 'e2e'; - public readonly multiTarget = true; - - public async run(options: E2eCommandSchema & Arguments) { - return this.runArchitectTarget(options); - } -} diff --git a/packages/angular/cli/commands/e2e-long.md b/packages/angular/cli/commands/e2e-long.md deleted file mode 100644 index 6b651df713d9..000000000000 --- a/packages/angular/cli/commands/e2e-long.md +++ /dev/null @@ -1,2 +0,0 @@ -Must be executed from within a workspace directory. -When a project name is not supplied, it will execute for all projects. \ No newline at end of file diff --git a/packages/angular/cli/commands/e2e.json b/packages/angular/cli/commands/e2e.json deleted file mode 100644 index 92c626d4c63e..000000000000 --- a/packages/angular/cli/commands/e2e.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/e2e.json", - "description": "Builds and serves an Angular app, then runs end-to-end tests using Protractor.", - "$longDescription": "./e2e-long.md", - - "$aliases": [ "e" ], - "$scope": "in", - "$type": "architect", - "$impl": "./e2e-impl#E2eCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/easter-egg-impl.ts b/packages/angular/cli/commands/easter-egg-impl.ts deleted file mode 100644 index 8e70d46dc827..000000000000 --- a/packages/angular/cli/commands/easter-egg-impl.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { Command } from '../models/command'; -import { colors } from '../utilities/color'; -import { Schema as AwesomeCommandSchema } from './easter-egg'; - -function pickOne(of: string[]): string { - return of[Math.floor(Math.random() * of.length)]; -} - -export class AwesomeCommand extends Command { - async run() { - const phrase = pickOne([ - `You're on it, there's nothing for me to do!`, - `Let's take a look... nope, it's all good!`, - `You're doing fine.`, - `You're already doing great.`, - `Nothing to do; already awesome. Exiting.`, - `Error 418: As Awesome As Can Get.`, - `I spy with my little eye a great developer!`, - `Noop... already awesome.`, - ]); - this.logger.info(colors.green(phrase)); - } -} diff --git a/packages/angular/cli/commands/easter-egg.json b/packages/angular/cli/commands/easter-egg.json deleted file mode 100644 index d0a7e94189e9..000000000000 --- a/packages/angular/cli/commands/easter-egg.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/easter-egg.json", - "description": "", - "$longDescription": "", - "$hidden": true, - - "$impl": "./easter-egg-impl#AwesomeCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/generate-impl.ts b/packages/angular/cli/commands/generate-impl.ts deleted file mode 100644 index 3fa015da84fd..000000000000 --- a/packages/angular/cli/commands/generate-impl.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { Arguments, SubCommandDescription } from '../models/interface'; -import { SchematicCommand } from '../models/schematic-command'; -import { colors } from '../utilities/color'; -import { parseJsonSchemaToSubCommandDescription } from '../utilities/json-schema'; -import { Schema as GenerateCommandSchema } from './generate'; - -export class GenerateCommand extends SchematicCommand { - // Allows us to resolve aliases before reporting analytics - longSchematicName: string | undefined; - - async initialize(options: GenerateCommandSchema & Arguments) { - // Fill up the schematics property of the command description. - const [collectionName, schematicName] = await this.parseSchematicInfo(options); - this.collectionName = collectionName; - this.schematicName = schematicName; - - await super.initialize(options); - - const collection = this.getCollection(collectionName); - const subcommands: { [name: string]: SubCommandDescription } = {}; - - const schematicNames = schematicName ? [schematicName] : collection.listSchematicNames(); - // Sort as a courtesy for the user. - schematicNames.sort(); - - for (const name of schematicNames) { - const schematic = this.getSchematic(collection, name, true); - this.longSchematicName = schematic.description.name; - let subcommand: SubCommandDescription; - if (schematic.description.schemaJson) { - subcommand = await parseJsonSchemaToSubCommandDescription( - name, - schematic.description.path, - this._workflow.registry, - schematic.description.schemaJson, - ); - } else { - continue; - } - - if ((await this.getDefaultSchematicCollection()) == collectionName) { - subcommands[name] = subcommand; - } else { - subcommands[`${collectionName}:${name}`] = subcommand; - } - } - - this.description.options.forEach(option => { - if (option.name == 'schematic') { - option.subcommands = subcommands; - } - }); - } - - public async run(options: GenerateCommandSchema & Arguments) { - if (!this.schematicName || !this.collectionName) { - return this.printHelp(options); - } - - return this.runSchematic({ - collectionName: this.collectionName, - schematicName: this.schematicName, - schematicOptions: options['--'] || [], - debug: !!options.debug || false, - dryRun: !!options.dryRun || false, - force: !!options.force || false, - }); - } - - async reportAnalytics( - paths: string[], - options: GenerateCommandSchema & Arguments, - ): Promise { - const [collectionName, schematicName] = await this.parseSchematicInfo(options); - - if (!schematicName || !collectionName) { - return; - } - const escapedSchematicName = (this.longSchematicName || schematicName).replace(/\//g, '_'); - - return super.reportAnalytics( - ['generate', collectionName.replace(/\//g, '_'), escapedSchematicName], - options, - ); - } - - private async parseSchematicInfo(options: { - schematic?: string; - }): Promise<[string, string | undefined]> { - let collectionName = await this.getDefaultSchematicCollection(); - - let schematicName = options.schematic; - - if (schematicName && schematicName.includes(':')) { - [collectionName, schematicName] = schematicName.split(':', 2); - } - - return [collectionName, schematicName]; - } - - public async printHelp(options: GenerateCommandSchema & Arguments) { - await super.printHelp(options); - - this.logger.info(''); - // Find the generate subcommand. - const subcommand = this.description.options.filter(x => x.subcommands)[0]; - if (Object.keys((subcommand && subcommand.subcommands) || {}).length == 1) { - this.logger.info(`\nTo see help for a schematic run:`); - this.logger.info(colors.cyan(` ng generate --help`)); - } - - return 0; - } -} diff --git a/packages/angular/cli/commands/generate.json b/packages/angular/cli/commands/generate.json deleted file mode 100644 index be287dd307c8..000000000000 --- a/packages/angular/cli/commands/generate.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/generate.json", - "description": "Generates and/or modifies files based on a schematic.", - "$longDescription": "", - - "$aliases": [ "g" ], - "$scope": "in", - "$type": "schematics", - "$impl": "./generate-impl#GenerateCommand", - - "allOf": [ - { - "type": "object", - "properties": { - "schematic": { - "type": "string", - "description": "The schematic or collection:schematic to generate.", - "$default": { - "$source": "argv", - "index": 0 - } - } - }, - "required": [ - ] - }, - { "$ref": "./definitions.json#/definitions/base" }, - { "$ref": "./definitions.json#/definitions/schematic" }, - { "$ref": "./definitions.json#/definitions/interactive" } - ] -} diff --git a/packages/angular/cli/commands/help-impl.ts b/packages/angular/cli/commands/help-impl.ts deleted file mode 100644 index 9c30c5e2f608..000000000000 --- a/packages/angular/cli/commands/help-impl.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { Command } from '../models/command'; -import { colors } from '../utilities/color'; -import { Schema as HelpCommandSchema } from './help'; - -export class HelpCommand extends Command { - async run() { - this.logger.info(`Available Commands:`); - - for (const cmd of Object.values(await Command.commandMap())) { - if (cmd.hidden) { - continue; - } - - const aliasInfo = cmd.aliases.length > 0 ? ` (${cmd.aliases.join(', ')})` : ''; - this.logger.info(` ${colors.cyan(cmd.name)}${aliasInfo} ${cmd.description}`); - } - this.logger.info(`\nFor more detailed help run "ng [command name] --help"`); - } -} diff --git a/packages/angular/cli/commands/help-long.md b/packages/angular/cli/commands/help-long.md deleted file mode 100644 index b104a1a6c03e..000000000000 --- a/packages/angular/cli/commands/help-long.md +++ /dev/null @@ -1,7 +0,0 @@ - For help with individual commands, use the `--help` or `-h` option with the command. - - For example, - - ```sh - ng help serve - ``` diff --git a/packages/angular/cli/commands/help.json b/packages/angular/cli/commands/help.json deleted file mode 100644 index b8df614b75b2..000000000000 --- a/packages/angular/cli/commands/help.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/help.json", - "description": "Lists available commands and their short descriptions.", - "$longDescription": "./help-long.md", - - "$scope": "all", - "$aliases": [], - "$impl": "./help-impl#HelpCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/lint-impl.ts b/packages/angular/cli/commands/lint-impl.ts deleted file mode 100644 index 6edd8556f994..000000000000 --- a/packages/angular/cli/commands/lint-impl.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as LintCommandSchema } from './lint'; - -export class LintCommand extends ArchitectCommand { - public readonly target = 'lint'; - public readonly multiTarget = true; - - public async run(options: ArchitectCommandOptions & Arguments) { - return this.runArchitectTarget(options); - } -} diff --git a/packages/angular/cli/commands/lint-long.md b/packages/angular/cli/commands/lint-long.md deleted file mode 100644 index 03917ffb252e..000000000000 --- a/packages/angular/cli/commands/lint-long.md +++ /dev/null @@ -1,4 +0,0 @@ -Takes the name of the project, as specified in the `projects` section of the `angular.json` workspace configuration file. -When a project name is not supplied, it will execute for all projects. - -The default linting tool is [TSLint](https://palantir.github.io/tslint/), and the default configuration is specified in the project's `tslint.json` file. \ No newline at end of file diff --git a/packages/angular/cli/commands/lint.json b/packages/angular/cli/commands/lint.json deleted file mode 100644 index eed1cda035c6..000000000000 --- a/packages/angular/cli/commands/lint.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/lint.json", - "description": "Runs linting tools on Angular app code in a given project folder.", - "$longDescription": "./lint-long.md", - - "$aliases": [ "l" ], - "$scope": "in", - "$type": "architect", - "$impl": "./lint-impl#LintCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "project": { - "type": "string", - "description": "The name of the project to lint.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "configuration": { - "description": "The linting configuration to use.", - "type": "string", - "aliases": [ - "c" - ] - } - }, - "required": [ - ] - }, - { - "$ref": "./definitions.json#/definitions/base" - } - ] -} diff --git a/packages/angular/cli/commands/new-impl.ts b/packages/angular/cli/commands/new-impl.ts deleted file mode 100644 index b7b418cb3926..000000000000 --- a/packages/angular/cli/commands/new-impl.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -// tslint:disable:no-global-tslint-disable no-any -import { Arguments } from '../models/interface'; -import { SchematicCommand } from '../models/schematic-command'; -import { Schema as NewCommandSchema } from './new'; - - -export class NewCommand extends SchematicCommand { - public readonly allowMissingWorkspace = true; - schematicName = 'ng-new'; - - async initialize(options: NewCommandSchema & Arguments) { - this.collectionName = options.collection || await this.getDefaultSchematicCollection(); - - return super.initialize(options); - } - - public async run(options: NewCommandSchema & Arguments) { - // Register the version of the CLI in the registry. - const packageJson = require('../package.json'); - const version = packageJson.version; - - this._workflow.registry.addSmartDefaultProvider('ng-cli-version', () => version); - - return this.runSchematic({ - collectionName: this.collectionName, - schematicName: this.schematicName, - schematicOptions: options['--'] || [], - debug: !!options.debug, - dryRun: !!options.dryRun, - force: !!options.force, - }); - } - -} diff --git a/packages/angular/cli/commands/new.json b/packages/angular/cli/commands/new.json deleted file mode 100644 index 89cfbda500dd..000000000000 --- a/packages/angular/cli/commands/new.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/new.json", - "description": "Creates a new workspace and an initial Angular app.", - "$longDescription": "./new.md", - - "$aliases": [ "n" ], - "$scope": "out", - "$type": "schematic", - "$impl": "./new-impl#NewCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "collection": { - "type": "string", - "aliases": [ "c" ], - "description": "A collection of schematics to use in generating the initial app." - }, - "verbose": { - "type": "boolean", - "default": false, - "aliases": [ "v" ], - "description": "When true, adds more details to output logging." - } - }, - "required": [] - }, - { "$ref": "./definitions.json#/definitions/base" }, - { "$ref": "./definitions.json#/definitions/schematic" }, - { "$ref": "./definitions.json#/definitions/interactive" } - ] -} diff --git a/packages/angular/cli/commands/new.md b/packages/angular/cli/commands/new.md deleted file mode 100644 index 822deb745ee1..000000000000 --- a/packages/angular/cli/commands/new.md +++ /dev/null @@ -1,16 +0,0 @@ -Creates and initializes a new Angular app that is the default project for a new workspace. - -Provides interactive prompts for optional configuration, such as adding routing support. -All prompts can safely be allowed to default. - -* The new workspace folder is given the specified project name, and contains configuration files at the top level. - -* By default, the files for a new initial app (with the same name as the workspace) are placed in the `src/` subfolder. Corresponding end-to-end tests are placed in the `e2e/` subfolder. - -* The new app's configuration appears in the `projects` section of the `angular.json` workspace configuration file, under its project name. - -* Subsequent apps that you generate in the workspace reside in the `projects/` subfolder. - -If you plan to have multiple apps in the workspace, you can create an empty workspace by setting the `--createApplication` option to false. -You can then use `ng generate application` to create an initial app. -This allows a workspace name different from the initial app name, and ensures that all apps reside in the `/projects` subfolder, matching the structure of the configuration file. \ No newline at end of file diff --git a/packages/angular/cli/commands/run-impl.ts b/packages/angular/cli/commands/run-impl.ts deleted file mode 100644 index feefe731fa5b..000000000000 --- a/packages/angular/cli/commands/run-impl.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as RunCommandSchema } from './run'; - -export class RunCommand extends ArchitectCommand { - public async run(options: ArchitectCommandOptions & Arguments) { - if (options.target) { - return this.runArchitectTarget(options); - } else { - throw new Error('Invalid architect target.'); - } - } -} diff --git a/packages/angular/cli/commands/run-long.md b/packages/angular/cli/commands/run-long.md deleted file mode 100644 index a95bbd78a27a..000000000000 --- a/packages/angular/cli/commands/run-long.md +++ /dev/null @@ -1,16 +0,0 @@ -Architect is the tool that the CLI uses to perform complex tasks such as compilation, according to provided configurations. -The CLI commands run Architect targets such as `build`, `serve`, `test`, and `lint`. -Each named target has a default configuration, specified by an "options" object, -and an optional set of named alternate configurations in the "configurations" object. - -For example, the "serve" target for a newly generated app has a predefined -alternate configuration named "production". - -You can define new targets and their configuration options in the "architect" section -of the `angular.json` file. -If you do so, you can run them from the command line using the `ng run` command. -Execute the command using the following format. - -``` -ng run project:target[:configuration] -``` \ No newline at end of file diff --git a/packages/angular/cli/commands/run.json b/packages/angular/cli/commands/run.json deleted file mode 100644 index 4111cc014a67..000000000000 --- a/packages/angular/cli/commands/run.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/run.json", - "description": "Runs an Architect target with an optional custom builder configuration defined in your project.", - "$longDescription": "./run-long.md", - - "$aliases": [], - "$scope": "in", - "$type": "architect", - "$impl": "./run-impl#RunCommand", - - "type": "object", - "allOf": [ - { - "properties": { - "target": { - "type": "string", - "description": "The Architect target to run.", - "$default": { - "$source": "argv", - "index": 0 - } - }, - "configuration": { - "description": "A named builder configuration, defined in the \"configurations\" section of angular.json.\nThe builder uses the named configuration to run the given target.", - "type": "string", - "aliases": [ "c" ] - } - }, - "required": [ - ] - }, - { - "$ref": "./definitions.json#/definitions/base" - } - ] -} diff --git a/packages/angular/cli/commands/serve-impl.ts b/packages/angular/cli/commands/serve-impl.ts deleted file mode 100644 index dff1855e6e7a..000000000000 --- a/packages/angular/cli/commands/serve-impl.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { analytics } from '@angular-devkit/core'; -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as BuildCommandSchema } from './build'; -import { Schema as ServeCommandSchema } from './serve'; - -export class ServeCommand extends ArchitectCommand { - public readonly target = 'serve'; - - public validate(_options: ArchitectCommandOptions & Arguments) { - return true; - } - - public async run(options: ArchitectCommandOptions & Arguments) { - return this.runArchitectTarget(options); - } - - async reportAnalytics( - paths: string[], - options: BuildCommandSchema & Arguments, - dimensions: (boolean | number | string)[] = [], - metrics: (boolean | number | string)[] = [], - ): Promise { - if (options.buildEventLog !== undefined) { - dimensions[analytics.NgCliAnalyticsDimensions.NgBuildBuildEventLog] = true; - } - - return super.reportAnalytics(paths, options, dimensions, metrics); - } -} diff --git a/packages/angular/cli/commands/serve.json b/packages/angular/cli/commands/serve.json deleted file mode 100644 index e19f0e5bab56..000000000000 --- a/packages/angular/cli/commands/serve.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/serve.json", - "description": "Builds and serves your app, rebuilding on file changes.", - "$longDescription": "", - - "$aliases": [ "s" ], - "$scope": "in", - "$type": "architect", - "$impl": "./serve-impl#ServeCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" }, - { - "type": "object", - "properties": { - "buildEventLog": { - "type": "string", - "description": "**EXPERIMENTAL** Output file path for Build Event Protocol events" - } - } - } - ] -} diff --git a/packages/angular/cli/commands/test-impl.ts b/packages/angular/cli/commands/test-impl.ts deleted file mode 100644 index 28f09df4d7b2..000000000000 --- a/packages/angular/cli/commands/test-impl.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand, ArchitectCommandOptions } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as TestCommandSchema } from './test'; - -export class TestCommand extends ArchitectCommand { - public readonly target = 'test'; - public readonly multiTarget = true; - - public async run(options: ArchitectCommandOptions & Arguments) { - return this.runArchitectTarget(options); - } -} diff --git a/packages/angular/cli/commands/test-long.md b/packages/angular/cli/commands/test-long.md deleted file mode 100644 index 64dae312ab47..000000000000 --- a/packages/angular/cli/commands/test-long.md +++ /dev/null @@ -1,2 +0,0 @@ -Takes the name of the project, as specified in the `projects` section of the `angular.json` workspace configuration file. -When a project name is not supplied, it will execute for all projects. \ No newline at end of file diff --git a/packages/angular/cli/commands/test.json b/packages/angular/cli/commands/test.json deleted file mode 100644 index 3b330a9cb71e..000000000000 --- a/packages/angular/cli/commands/test.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/test.json", - "description": "Runs unit tests in a project.", - "$longDescription": "./test-long.md", - - "$aliases": [ "t" ], - "$scope": "in", - "$type": "architect", - "$impl": "./test-impl#TestCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/update-impl.ts b/packages/angular/cli/commands/update-impl.ts deleted file mode 100644 index 932f9795957a..000000000000 --- a/packages/angular/cli/commands/update-impl.ts +++ /dev/null @@ -1,841 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { normalize, virtualFs } from '@angular-devkit/core'; -import { NodeJsSyncHost } from '@angular-devkit/core/node'; -import { UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics'; -import { NodeWorkflow, validateOptionsWithSchema } from '@angular-devkit/schematics/tools'; -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import * as semver from 'semver'; -import { PackageManager } from '../lib/config/schema'; -import { Command } from '../models/command'; -import { Arguments } from '../models/interface'; -import { runTempPackageBin } from '../tasks/install-package'; -import { colors } from '../utilities/color'; -import { writeErrorToLogFile } from '../utilities/log-file'; -import { getPackageManager } from '../utilities/package-manager'; -import { - PackageIdentifier, - PackageManifest, - PackageMetadata, - fetchPackageManifest, - fetchPackageMetadata, -} from '../utilities/package-metadata'; -import { PackageTreeNode, findNodeDependencies, readPackageTree } from '../utilities/package-tree'; -import { Schema as UpdateCommandSchema } from './update'; - -const npa = require('npm-package-arg') as (selector: string) => PackageIdentifier; -const pickManifest = require('npm-pick-manifest') as ( - metadata: PackageMetadata, - selector: string, -) => PackageManifest; - -const oldConfigFileNames = ['.angular-cli.json', 'angular-cli.json']; - -const NG_VERSION_9_POST_MSG = colors.cyan( - '\nYour project has been updated to Angular version 9!\n' + - 'For more info, please see: https://v9.angular.io/guide/updating-to-version-9', -); - -/** - * Disable CLI version mismatch checks and forces usage of the invoked CLI - * instead of invoking the local installed version. - */ -const disableVersionCheckEnv = process.env['NG_DISABLE_VERSION_CHECK']; -const disableVersionCheck = - disableVersionCheckEnv !== undefined && - disableVersionCheckEnv !== '0' && - disableVersionCheckEnv.toLowerCase() !== 'false'; - -export class UpdateCommand extends Command { - public readonly allowMissingWorkspace = true; - - private workflow: NodeWorkflow; - private packageManager: PackageManager; - - async initialize() { - this.packageManager = await getPackageManager(this.workspace.root); - this.workflow = new NodeWorkflow( - new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(this.workspace.root)), - { - packageManager: this.packageManager, - root: normalize(this.workspace.root), - // __dirname -> favor @schematics/update from this package - // Otherwise, use packages from the active workspace (migrations) - resolvePaths: [__dirname, this.workspace.root], - }, - ); - this.workflow.engineHost.registerOptionsTransform( - validateOptionsWithSchema(this.workflow.registry), - ); - } - - private async executeSchematic( - collection: string, - schematic: string, - options = {}, - ): Promise<{ success: boolean; files: Set }> { - let error = false; - let logs: string[] = []; - const files = new Set(); - - const reporterSubscription = this.workflow.reporter.subscribe(event => { - // Strip leading slash to prevent confusion. - const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path; - - switch (event.kind) { - case 'error': - error = true; - const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist.'; - this.logger.error(`ERROR! ${eventPath} ${desc}.`); - break; - case 'update': - logs.push(`${colors.whiteBright('UPDATE')} ${eventPath} (${event.content.length} bytes)`); - files.add(eventPath); - break; - case 'create': - logs.push(`${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)`); - files.add(eventPath); - break; - case 'delete': - logs.push(`${colors.yellow('DELETE')} ${eventPath}`); - files.add(eventPath); - break; - case 'rename': - const eventToPath = event.to.startsWith('/') ? event.to.substr(1) : event.to; - logs.push(`${colors.blue('RENAME')} ${eventPath} => ${eventToPath}`); - files.add(eventPath); - break; - } - }); - - const lifecycleSubscription = this.workflow.lifeCycle.subscribe(event => { - if (event.kind == 'end' || event.kind == 'post-tasks-start') { - if (!error) { - // Output the logging queue, no error happened. - logs.forEach(log => this.logger.info(log)); - logs = []; - } - } - }); - - // TODO: Allow passing a schematic instance directly - try { - await this.workflow - .execute({ - collection, - schematic, - options, - logger: this.logger, - }) - .toPromise(); - - reporterSubscription.unsubscribe(); - lifecycleSubscription.unsubscribe(); - - return { success: !error, files }; - } catch (e) { - if (e instanceof UnsuccessfulWorkflowExecution) { - this.logger.error(`${colors.symbols.cross} Migration failed. See above for further details.\n`); - } else { - const logPath = writeErrorToLogFile(e); - this.logger.fatal( - `${colors.symbols.cross} Migration failed: ${e.message}\n` + - ` See "${logPath}" for further details.\n`, - ); - } - - return { success: false, files }; - } - } - - /** - * @return Whether or not the migration was performed successfully. - */ - private async executeMigration( - packageName: string, - collectionPath: string, - migrationName: string, - commit?: boolean, - ): Promise { - const collection = this.workflow.engine.createCollection(collectionPath); - const name = collection.listSchematicNames().find(name => name === migrationName); - if (!name) { - this.logger.error(`Cannot find migration '${migrationName}' in '${packageName}'.`); - - return false; - } - - const schematic = this.workflow.engine.createSchematic(name, collection); - - this.logger.info( - colors.cyan(`** Executing '${migrationName}' of package '${packageName}' **\n`), - ); - - return this.executePackageMigrations([schematic.description], packageName, commit); - } - - /** - * @return Whether or not the migrations were performed successfully. - */ - private async executeMigrations( - packageName: string, - collectionPath: string, - range: semver.Range, - commit?: boolean, - ): Promise { - const collection = this.workflow.engine.createCollection(collectionPath); - const migrations = []; - - for (const name of collection.listSchematicNames()) { - const schematic = this.workflow.engine.createSchematic(name, collection); - const description = schematic.description as typeof schematic.description & { - version?: string; - }; - description.version = coerceVersionNumber(description.version) || undefined; - if (!description.version) { - continue; - } - - if (semver.satisfies(description.version, range, { includePrerelease: true })) { - migrations.push(description as typeof schematic.description & { version: string }); - } - } - - migrations.sort((a, b) => semver.compare(a.version, b.version) || a.name.localeCompare(b.name)); - - if (migrations.length === 0) { - return true; - } - - this.logger.info( - colors.cyan(`** Executing migrations of package '${packageName}' **\n`), - ); - - return this.executePackageMigrations(migrations, packageName, commit); - } - - // tslint:disable-next-line: no-any - private async executePackageMigrations(migrations: any[], packageName: string, commit = false): Promise { - for (const migration of migrations) { - this.logger.info(`${colors.symbols.pointer} ${migration.description.replace(/\. /g, '.\n ')}`); - - const result = await this.executeSchematic(migration.collection.name, migration.name); - if (!result.success) { - return false; - } - - this.logger.info(' Migration completed.'); - - // Commit migration - if (commit) { - const commitPrefix = `${packageName} migration - ${migration.name}`; - const commitMessage = migration.description - ? `${commitPrefix}\n${migration.description}` - : commitPrefix; - const committed = this.commit(commitMessage); - if (!committed) { - // Failed to commit, something went wrong. Abort the update. - return false; - } - } - - this.logger.info(''); // Extra trailing newline. - } - - return true; - } - - // tslint:disable-next-line:no-big-function - async run(options: UpdateCommandSchema & Arguments) { - // Check if the @angular-devkit/schematics package can be resolved from the workspace root - // This works around issues with packages containing migrations that cannot directly depend on the package - // This check can be removed once the schematic runtime handles this situation - try { - require.resolve('@angular-devkit/schematics', { paths: [this.workspace.root] }); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - this.logger.fatal( - 'The "@angular-devkit/schematics" package cannot be resolved from the workspace root directory. ' + - 'This may be due to an unsupported node modules structure.\n' + - 'Please remove both the "node_modules" directory and the package lock file; and then reinstall.\n' + - 'If this does not correct the problem, ' + - 'please temporarily install the "@angular-devkit/schematics" package within the workspace. ' + - 'It can be removed once the update is complete.', - ); - - return 1; - } - - throw e; - } - - // Check if the current installed CLI version is older than the latest version. - if (!disableVersionCheck && await this.checkCLILatestVersion(options.verbose, options.next)) { - this.logger.warn( - `The installed local Angular CLI version is older than the latest ${options.next ? 'pre-release' : 'stable'} version.\n` + - 'Installing a temporary version to perform the update.', - ); - - return runTempPackageBin( - `@angular/cli@${options.next ? 'next' : 'latest'}`, - this.logger, - this.packageManager, - process.argv.slice(2), - ); - } - - const packages: PackageIdentifier[] = []; - for (const request of options['--'] || []) { - try { - const packageIdentifier = npa(request); - - // only registry identifiers are supported - if (!packageIdentifier.registry) { - this.logger.error(`Package '${request}' is not a registry package identifer.`); - - return 1; - } - - if (packages.some(v => v.name === packageIdentifier.name)) { - this.logger.error(`Duplicate package '${packageIdentifier.name}' specified.`); - - return 1; - } - - if (options.migrateOnly && packageIdentifier.rawSpec) { - this.logger.warn('Package specifier has no effect when using "migrate-only" option.'); - } - - // If next option is used and no specifier supplied, use next tag - if (options.next && !packageIdentifier.rawSpec) { - packageIdentifier.fetchSpec = 'next'; - } - - packages.push(packageIdentifier); - } catch (e) { - this.logger.error(e.message); - - return 1; - } - } - - if (options.all && packages.length > 0) { - this.logger.error('Cannot specify packages when using the "all" option.'); - - return 1; - } else if (options.all && options.migrateOnly) { - this.logger.error('Cannot use "all" option with "migrate-only" option.'); - - return 1; - } else if (!options.migrateOnly && (options.from || options.to)) { - this.logger.error('Can only use "from" or "to" options with "migrate-only" option.'); - - return 1; - } - - // If not asking for status then check for a clean git repository. - // This allows the user to easily reset any changes from the update. - const statusCheck = packages.length === 0 && !options.all; - if (!statusCheck && !this.checkCleanGit()) { - if (options.allowDirty) { - this.logger.warn( - 'Repository is not clean. Update changes will be mixed with pre-existing changes.', - ); - } else { - this.logger.error( - 'Repository is not clean. Please commit or stash any changes before updating.', - ); - - return 2; - } - } - - this.logger.info(`Using package manager: '${this.packageManager}'`); - - // Special handling for Angular CLI 1.x migrations - if ( - options.migrateOnly === undefined && - options.from === undefined && - !options.all && - packages.length === 1 && - packages[0].name === '@angular/cli' && - this.workspace.configFile && - oldConfigFileNames.includes(this.workspace.configFile) - ) { - options.migrateOnly = true; - options.from = '1.0.0'; - } - - this.logger.info('Collecting installed dependencies...'); - - const packageTree = await readPackageTree(this.workspace.root); - const rootDependencies = findNodeDependencies(packageTree); - - this.logger.info(`Found ${Object.keys(rootDependencies).length} dependencies.`); - - if (options.all) { - // 'all' option and a zero length packages have already been checked. - // Add all direct dependencies to be updated - for (const dep of Object.keys(rootDependencies)) { - const packageIdentifier = npa(dep); - if (options.next) { - packageIdentifier.fetchSpec = 'next'; - } - - packages.push(packageIdentifier); - } - } else if (packages.length === 0) { - // Show status - const { success } = await this.executeSchematic('@schematics/update', 'update', { - force: options.force || false, - next: options.next || false, - verbose: options.verbose || false, - packageManager: this.packageManager, - packages: options.all ? Object.keys(rootDependencies) : [], - }); - - return success ? 0 : 1; - } - - if (options.migrateOnly) { - if (!options.from && typeof options.migrateOnly !== 'string') { - this.logger.error('"from" option is required when using the "migrate-only" option without a migration name.'); - - return 1; - } else if (packages.length !== 1) { - this.logger.error( - 'A single package must be specified when using the "migrate-only" option.', - ); - - return 1; - } - - if (options.next) { - this.logger.warn('"next" option has no effect when using "migrate-only" option.'); - } - - const packageName = packages[0].name; - const packageDependency = rootDependencies[packageName]; - let packageNode = packageDependency && packageDependency.node; - if (packageDependency && !packageNode) { - this.logger.error('Package found in package.json but is not installed.'); - - return 1; - } else if (!packageDependency) { - // Allow running migrations on transitively installed dependencies - // There can technically be nested multiple versions - // TODO: If multiple, this should find all versions and ask which one to use - const child = packageTree.children.find(c => c.name === packageName); - if (child) { - packageNode = child; - } - } - - if (!packageNode) { - this.logger.error('Package is not installed.'); - - return 1; - } - - const updateMetadata = packageNode.package['ng-update']; - let migrations = updateMetadata && updateMetadata.migrations; - if (migrations === undefined) { - this.logger.error('Package does not provide migrations.'); - - return 1; - } else if (typeof migrations !== 'string') { - this.logger.error('Package contains a malformed migrations field.'); - - return 1; - } else if (path.posix.isAbsolute(migrations) || path.win32.isAbsolute(migrations)) { - this.logger.error( - 'Package contains an invalid migrations field. Absolute paths are not permitted.', - ); - - return 1; - } - - // Normalize slashes - migrations = migrations.replace(/\\/g, '/'); - - if (migrations.startsWith('../')) { - this.logger.error( - 'Package contains an invalid migrations field. ' + - 'Paths outside the package root are not permitted.', - ); - - return 1; - } - - // Check if it is a package-local location - const localMigrations = path.join(packageNode.path, migrations); - if (fs.existsSync(localMigrations)) { - migrations = localMigrations; - } else { - // Try to resolve from package location. - // This avoids issues with package hoisting. - try { - migrations = require.resolve(migrations, { paths: [packageNode.path] }); - } catch (e) { - if (e.code === 'MODULE_NOT_FOUND') { - this.logger.error('Migrations for package were not found.'); - } else { - this.logger.error(`Unable to resolve migrations for package. [${e.message}]`); - } - - return 1; - } - } - - let success = false; - if (typeof options.migrateOnly == 'string') { - success = await this.executeMigration( - packageName, - migrations, - options.migrateOnly, - options.createCommits, - ); - } else { - const from = coerceVersionNumber(options.from); - if (!from) { - this.logger.error(`"from" value [${options.from}] is not a valid version.`); - - return 1; - } - - const migrationRange = new semver.Range( - '>' + from + ' <=' + (options.to || packageNode.package.version), - ); - - success = await this.executeMigrations( - packageName, - migrations, - migrationRange, - options.createCommits, - ); - } - - if (success) { - if ( - packageName === '@angular/core' - && options.from - && +options.from.split('.')[0] < 9 - && (options.to || packageNode.package.version).split('.')[0] === '9' - ) { - this.logger.info(NG_VERSION_9_POST_MSG); - } - - return 0; - } - - return 1; - } - - const requests: { - identifier: PackageIdentifier; - node: PackageTreeNode; - }[] = []; - - // Validate packages actually are part of the workspace - for (const pkg of packages) { - const node = rootDependencies[pkg.name] && rootDependencies[pkg.name].node; - if (!node) { - this.logger.error(`Package '${pkg.name}' is not a dependency.`); - - return 1; - } - - // If a specific version is requested and matches the installed version, skip. - if (pkg.type === 'version' && node.package.version === pkg.fetchSpec) { - this.logger.info(`Package '${pkg.name}' is already at '${pkg.fetchSpec}'.`); - continue; - } - - requests.push({ identifier: pkg, node }); - } - - if (requests.length === 0) { - return 0; - } - - const packagesToUpdate: string[] = []; - - this.logger.info('Fetching dependency metadata from registry...'); - for (const { identifier: requestIdentifier, node } of requests) { - const packageName = requestIdentifier.name; - - let metadata; - try { - // Metadata requests are internally cached; multiple requests for same name - // does not result in additional network traffic - metadata = await fetchPackageMetadata(packageName, this.logger, { - verbose: options.verbose, - }); - } catch (e) { - this.logger.error(`Error fetching metadata for '${packageName}': ` + e.message); - - return 1; - } - - // Try to find a package version based on the user requested package specifier - // registry specifier types are either version, range, or tag - let manifest: PackageManifest | undefined; - if ( - requestIdentifier.type === 'version' || - requestIdentifier.type === 'range' || - requestIdentifier.type === 'tag' - ) { - try { - manifest = pickManifest(metadata, requestIdentifier.fetchSpec); - } catch (e) { - if (e.code === 'ETARGET') { - // If not found and next was used and user did not provide a specifier, try latest. - // Package may not have a next tag. - if ( - requestIdentifier.type === 'tag' && - requestIdentifier.fetchSpec === 'next' && - !requestIdentifier.rawSpec - ) { - try { - manifest = pickManifest(metadata, 'latest'); - } catch (e) { - if (e.code !== 'ETARGET' && e.code !== 'ENOVERSIONS') { - throw e; - } - } - } - } else if (e.code !== 'ENOVERSIONS') { - throw e; - } - } - } - - if (!manifest) { - this.logger.error( - `Package specified by '${requestIdentifier.raw}' does not exist within the registry.`, - ); - - return 1; - } - - if (manifest.version === node.package.version) { - this.logger.info(`Package '${packageName}' is already up to date.`); - continue; - } - - packagesToUpdate.push(requestIdentifier.toString()); - } - - if (packagesToUpdate.length === 0) { - return 0; - } - - const { success } = await this.executeSchematic('@schematics/update', 'update', { - verbose: options.verbose || false, - force: options.force || false, - next: !!options.next, - packageManager: this.packageManager, - packages: packagesToUpdate, - migrateExternal: true, - }); - - if (success && options.createCommits) { - const committed = this.commit( - `Angular CLI update for packages - ${packagesToUpdate.join(', ')}`); - if (!committed) { - return 1; - } - } - - // This is a temporary workaround to allow data to be passed back from the update schematic - // tslint:disable-next-line: no-any - const migrations = (global as any).externalMigrations as { - package: string; - collection: string; - from: string; - to: string; - }[]; - - if (success && migrations) { - for (const migration of migrations) { - const result = await this.executeMigrations( - migration.package, - migration.collection, - new semver.Range('>' + migration.from + ' <=' + migration.to), - options.createCommits, - ); - - if (!result) { - return 0; - } - } - - if (migrations.some(m => m.package === '@angular/core' && m.to.split('.')[0] === '9' && +m.from.split('.')[0] < 9)) { - this.logger.info(NG_VERSION_9_POST_MSG); - } - } - - return success ? 0 : 1; - } - - /** - * @return Whether or not the commit was successful. - */ - private commit(message: string): boolean { - // Check if a commit is needed. - let commitNeeded: boolean; - try { - commitNeeded = hasChangesToCommit(); - } catch (err) { - this.logger.error(` Failed to read Git tree:\n${err.stderr}`); - - return false; - } - - if (!commitNeeded) { - this.logger.info(' No changes to commit after migration.'); - - return true; - } - - // Commit changes and abort on error. - try { - createCommit(message); - } catch (err) { - this.logger.error( - `Failed to commit update (${message}):\n${err.stderr}`); - - return false; - } - - // Notify user of the commit. - const hash = findCurrentGitSha(); - const shortMessage = message.split('\n')[0]; - if (hash) { - this.logger.info(` Committed migration step (${getShortHash(hash)}): ${ - shortMessage}.`); - } else { - // Commit was successful, but reading the hash was not. Something weird happened, - // but nothing that would stop the update. Just log the weirdness and continue. - this.logger.info(` Committed migration step: ${shortMessage}.`); - this.logger.warn(' Failed to look up hash of most recent commit, continuing anyways.'); - } - - return true; - } - - private checkCleanGit(): boolean { - try { - const topLevel = execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: 'pipe' }); - const result = execSync('git status --porcelain', { encoding: 'utf8', stdio: 'pipe' }); - if (result.trim().length === 0) { - return true; - } - - // Only files inside the workspace root are relevant - for (const entry of result.split('\n')) { - const relativeEntry = path.relative( - path.resolve(this.workspace.root), - path.resolve(topLevel.trim(), entry.slice(3).trim()), - ); - - if (!relativeEntry.startsWith('..') && !path.isAbsolute(relativeEntry)) { - return false; - } - } - } catch {} - - return true; - } - - /** - * Checks if the current installed CLI version is older than the latest version. - * @returns `true` when the installed version is older. - */ - private async checkCLILatestVersion(verbose = false, next = false): Promise { - const { version: installedCLIVersion } = require('../package.json'); - - const LatestCLIManifest = await fetchPackageManifest( - `@angular/cli@${next ? 'next' : 'latest'}`, - this.logger, - { - verbose, - usingYarn: this.packageManager === PackageManager.Yarn, - }, - ); - - return semver.lt(installedCLIVersion, LatestCLIManifest.version); - } -} - -/** - * @return Whether or not the working directory has Git changes to commit. - */ -function hasChangesToCommit(): boolean { - // List all modified files not covered by .gitignore. - const files = execSync('git ls-files -m -d -o --exclude-standard').toString(); - - // If any files are returned, then there must be something to commit. - return files !== ''; -} - -/** - * Precondition: Must have pending changes to commit, they do not need to be staged. - * Postcondition: The Git working tree is committed and the repo is clean. - * @param message The commit message to use. - */ -function createCommit(message: string) { - // Stage entire working tree for commit. - execSync('git add -A', { encoding: 'utf8', stdio: 'pipe' }); - - // Commit with the message passed via stdin to avoid bash escaping issues. - execSync('git commit --no-verify -F -', { encoding: 'utf8', stdio: 'pipe', input: message }); -} - -/** - * @return The Git SHA hash of the HEAD commit. Returns null if unable to retrieve the hash. - */ -function findCurrentGitSha(): string | null { - try { - const hash = execSync('git rev-parse HEAD', {encoding: 'utf8', stdio: 'pipe'}); - - return hash.trim(); - } catch { - return null; - } -} - -function getShortHash(commitHash: string): string { - return commitHash.slice(0, 9); -} - -function coerceVersionNumber(version: string | undefined): string | null { - if (!version) { - return null; - } - - if (!version.match(/^\d{1,30}\.\d{1,30}\.\d{1,30}/)) { - const match = version.match(/^\d{1,30}(\.\d{1,30})*/); - - if (!match) { - return null; - } - - if (!match[1]) { - version = version.substr(0, match[0].length) + '.0.0' + version.substr(match[0].length); - } else if (!match[2]) { - version = version.substr(0, match[0].length) + '.0' + version.substr(match[0].length); - } else { - return null; - } - } - - return semver.valid(version); -} diff --git a/packages/angular/cli/commands/update-long.md b/packages/angular/cli/commands/update-long.md deleted file mode 100644 index 93be9c0f3c78..000000000000 --- a/packages/angular/cli/commands/update-long.md +++ /dev/null @@ -1,17 +0,0 @@ -Perform a basic update to the current stable release of the core framework and CLI by running the following command. - -``` -ng update @angular/cli @angular/core -``` - -To update to the next beta or pre-release version, use the `--next=true` option. - -To update from one major version to another, use the format -`ng update @angular/cli@^ @angular/core@^`. - -We recommend that you always update to the latest patch version, as it contains fixes we released since the initial major release. -For example, use the following command to take the latest 9.x.x version and use that to update. - -`ng update @angular/cli@^9 @angular/core@^9` - -For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.io/). diff --git a/packages/angular/cli/commands/update.json b/packages/angular/cli/commands/update.json deleted file mode 100644 index 5432cfb2b850..000000000000 --- a/packages/angular/cli/commands/update.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/update.json", - "description": "Updates your application and its dependencies. See https://update.angular.io/", - "$longDescription": "./update-long.md", - - "$scope": "all", - "$aliases": [], - "$type": "schematics", - "$impl": "./update-impl#UpdateCommand", - - "type": "object", - "allOf": [ - { - "$ref": "./definitions.json#/definitions/base" - }, - { - "type": "object", - "properties": { - "packages": { - "description": "The names of package(s) to update.", - "type": "array", - "items": { - "type": "string" - }, - "$default": { - "$source": "argv" - } - }, - "force": { - "description": "If false, will error out if installed packages are incompatible with the update.", - "default": false, - "type": "boolean" - }, - "all": { - "description": "Whether to update all packages in package.json.", - "default": false, - "type": "boolean" - }, - "next": { - "description": "Use the largest version, including beta and RCs.", - "default": false, - "type": "boolean" - }, - "migrateOnly": { - "description": "Only perform a migration, does not update the installed version.", - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "description": "The name of the migration to run." - } - ] - }, - "from": { - "description": "Version from which to migrate from. Only available with a single package being updated, and only on migration only.", - "type": "string" - }, - "to": { - "description": "Version up to which to apply migrations. Only available with a single package being updated, and only on migrations only. Requires from to be specified. Default to the installed version detected.", - "type": "string" - }, - "allowDirty": { - "description": "Whether to allow updating when the repository contains modified or untracked files.", - "type": "boolean" - }, - "verbose": { - "description": "Display additional details about internal operations during execution.", - "type": "boolean", - "default": false - }, - "createCommits": { - "description": "Create source control commits for updates and migrations.", - "type": "boolean", - "default": false, - "aliases": ["C"] - } - } - } - ] -} diff --git a/packages/angular/cli/commands/version-impl.ts b/packages/angular/cli/commands/version-impl.ts deleted file mode 100644 index a840cd3955a1..000000000000 --- a/packages/angular/cli/commands/version-impl.ts +++ /dev/null @@ -1,212 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { JsonParseMode, isJsonObject, parseJson } from '@angular-devkit/core'; -import * as child_process from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; -import { Command } from '../models/command'; -import { colors } from '../utilities/color'; -import { findUp } from '../utilities/find-up'; -import { Schema as VersionCommandSchema } from './version'; - -export class VersionCommand extends Command { - public static aliases = ['v']; - - async run() { - const pkg = require(path.resolve(__dirname, '..', 'package.json')); - let projPkg; - try { - projPkg = require(path.resolve(this.workspace.root, 'package.json')); - } catch { - projPkg = undefined; - } - - const patterns = [ - /^@angular\/.*/, - /^@angular-devkit\/.*/, - /^@bazel\/.*/, - /^@ngtools\/.*/, - /^@nguniversal\/.*/, - /^@schematics\/.*/, - /^rxjs$/, - /^typescript$/, - /^ng-packagr$/, - /^webpack$/, - ]; - - const maybeNodeModules = findUp('node_modules', __dirname); - const packageRoot = projPkg - ? path.resolve(this.workspace.root, 'node_modules') - : maybeNodeModules; - - const packageNames = [ - ...Object.keys((pkg && pkg['dependencies']) || {}), - ...Object.keys((pkg && pkg['devDependencies']) || {}), - ...Object.keys((projPkg && projPkg['dependencies']) || {}), - ...Object.keys((projPkg && projPkg['devDependencies']) || {}), - ]; - - if (packageRoot != null) { - // Add all node_modules and node_modules/@*/* - const nodePackageNames = fs.readdirSync(packageRoot).reduce((acc, name) => { - if (name.startsWith('@')) { - return acc.concat( - fs.readdirSync(path.resolve(packageRoot, name)).map(subName => name + '/' + subName), - ); - } else { - return acc.concat(name); - } - }, []); - - packageNames.push(...nodePackageNames); - } - - const versions = packageNames - .filter(x => patterns.some(p => p.test(x))) - .reduce( - (acc, name) => { - if (name in acc) { - return acc; - } - - acc[name] = this.getVersion(name, packageRoot, maybeNodeModules); - - return acc; - }, - {} as { [module: string]: string }, - ); - - let ngCliVersion = pkg.version; - if (!__dirname.match(/node_modules/)) { - let gitBranch = '??'; - try { - const gitRefName = child_process.execSync('git rev-parse --abbrev-ref HEAD', { - cwd: __dirname, - encoding: 'utf8', - stdio: 'pipe', - }); - gitBranch = gitRefName.replace('\n', ''); - } catch {} - - ngCliVersion = `local (v${pkg.version}, branch: ${gitBranch})`; - } - let angularCoreVersion = ''; - const angularSameAsCore: string[] = []; - - if (projPkg) { - // Filter all angular versions that are the same as core. - angularCoreVersion = versions['@angular/core']; - if (angularCoreVersion) { - for (const angularPackage of Object.keys(versions)) { - if ( - versions[angularPackage] == angularCoreVersion && - angularPackage.startsWith('@angular/') - ) { - angularSameAsCore.push(angularPackage.replace(/^@angular\//, '')); - delete versions[angularPackage]; - } - } - - // Make sure we list them in alphabetical order. - angularSameAsCore.sort(); - } - } - - const namePad = ' '.repeat( - Object.keys(versions).sort((a, b) => b.length - a.length)[0].length + 3, - ); - const asciiArt = ` - _ _ ____ _ ___ - / \\ _ __ __ _ _ _| | __ _ _ __ / ___| | |_ _| - / △ \\ | '_ \\ / _\` | | | | |/ _\` | '__| | | | | | | - / ___ \\| | | | (_| | |_| | | (_| | | | |___| |___ | | - /_/ \\_\\_| |_|\\__, |\\__,_|_|\\__,_|_| \\____|_____|___| - |___/ - ` - .split('\n') - .map(x => colors.red(x)) - .join('\n'); - - this.logger.info(asciiArt); - this.logger.info( - ` - Angular CLI: ${ngCliVersion} - Node: ${process.versions.node} - OS: ${process.platform} ${process.arch} - - Angular: ${angularCoreVersion} - ... ${angularSameAsCore - .reduce((acc, name) => { - // Perform a simple word wrap around 60. - if (acc.length == 0) { - return [name]; - } - const line = acc[acc.length - 1] + ', ' + name; - if (line.length > 60) { - acc.push(name); - } else { - acc[acc.length - 1] = line; - } - - return acc; - }, []) - .join('\n... ')} - Ivy Workspace: ${projPkg ? this.getIvyWorkspace() : ''} - - Package${namePad.slice(7)}Version - -------${namePad.replace(/ /g, '-')}------------------ - ${Object.keys(versions) - .map(module => `${module}${namePad.slice(module.length)}${versions[module]}`) - .sort() - .join('\n')} - `.replace(/^ {6}/gm, ''), - ); - } - - private getVersion( - moduleName: string, - projectNodeModules: string | null, - cliNodeModules: string | null, - ): string { - try { - if (projectNodeModules) { - const modulePkg = require(path.resolve(projectNodeModules, moduleName, 'package.json')); - - return modulePkg.version; - } - } catch {} - - try { - if (cliNodeModules) { - const modulePkg = require(path.resolve(cliNodeModules, moduleName, 'package.json')); - - return modulePkg.version + ' (cli-only)'; - } - } catch {} - - return ''; - } - - private getIvyWorkspace(): string { - try { - const content = fs.readFileSync(path.resolve(this.workspace.root, 'tsconfig.json'), 'utf-8'); - const tsConfig = parseJson(content, JsonParseMode.Loose); - if (!isJsonObject(tsConfig)) { - return ''; - } - - const { angularCompilerOptions } = tsConfig; - - return isJsonObject(angularCompilerOptions) && angularCompilerOptions.enableIvy === false - ? 'No' - : 'Yes'; - } catch { - return ''; - } - } -} diff --git a/packages/angular/cli/commands/version.json b/packages/angular/cli/commands/version.json deleted file mode 100644 index 795eb654b7a5..000000000000 --- a/packages/angular/cli/commands/version.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/version.json", - "description": "Outputs Angular CLI version.", - "$longDescription": "", - - "$aliases": [ "v" ], - "$scope": "all", - "$impl": "./version-impl#VersionCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/commands/xi18n-impl.ts b/packages/angular/cli/commands/xi18n-impl.ts deleted file mode 100644 index 5412750138df..000000000000 --- a/packages/angular/cli/commands/xi18n-impl.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import { ArchitectCommand } from '../models/architect-command'; -import { Arguments } from '../models/interface'; -import { Schema as Xi18nCommandSchema } from './xi18n'; - -export class Xi18nCommand extends ArchitectCommand { - public readonly target = 'extract-i18n'; - public readonly multiTarget: true; - - public async run(options: Xi18nCommandSchema & Arguments) { - const version = process.version.substr(1).split('.'); - if (Number(version[0]) === 12 && Number(version[1]) === 0) { - this.logger.error( - 'Due to a defect in Node.js 12.0, the command is not supported on this Node.js version. ' - + 'Please upgrade to Node.js 12.1 or later.'); - - return 1; - } - - return this.runArchitectTarget(options); - } -} diff --git a/packages/angular/cli/commands/xi18n.json b/packages/angular/cli/commands/xi18n.json deleted file mode 100644 index c48d958440fa..000000000000 --- a/packages/angular/cli/commands/xi18n.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "http://json-schema.org/schema", - "$id": "ng-cli://commands/xi18n.json", - "description": "Extracts i18n messages from source code.", - "$longDescription": "", - - "$aliases": ["i18n-extract"], - "$scope": "in", - "$type": "architect", - "$impl": "./xi18n-impl#Xi18nCommand", - - "type": "object", - "allOf": [ - { "$ref": "./definitions.json#/definitions/architect" }, - { "$ref": "./definitions.json#/definitions/base" } - ] -} diff --git a/packages/angular/cli/index.d.ts b/packages/angular/cli/index.d.ts new file mode 100644 index 000000000000..3e9cf84418cf --- /dev/null +++ b/packages/angular/cli/index.d.ts @@ -0,0 +1,19 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export declare class Version { + readonly full: string; + readonly major: string; + readonly minor: string; + readonly patch: string; + constructor(full: string); +} + +export declare const VERSION: Version; + +export default function execute(options: { cliArgs: string[] }): Promise; diff --git a/packages/angular/cli/lib/cli/index.ts b/packages/angular/cli/lib/cli/index.ts index 071e7ae92731..3c72f5668a1c 100644 --- a/packages/angular/cli/lib/cli/index.ts +++ b/packages/angular/cli/lib/cli/index.ts @@ -1,86 +1,86 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import { createConsoleLogger } from '@angular-devkit/core/node'; -import { format } from 'util'; -import { runCommand } from '../../models/command-runner'; -import { colors, removeColor, supportsColor } from '../../utilities/color'; -import { getWorkspaceRaw } from '../../utilities/config'; -import { writeErrorToLogFile } from '../../utilities/log-file'; -import { getWorkspaceDetails } from '../../utilities/project'; -const debugEnv = process.env['NG_DEBUG']; -const isDebug = - debugEnv !== undefined && - debugEnv !== '0' && - debugEnv.toLowerCase() !== 'false'; +import { logging } from '@angular-devkit/core'; +import { format, stripVTControlCharacters } from 'node:util'; +import { CommandModuleError } from '../../src/command-builder/command-module'; +import { runCommand } from '../../src/command-builder/command-runner'; +import { colors, supportColor } from '../../src/utilities/color'; +import { ngDebug } from '../../src/utilities/environment-options'; +import { writeErrorToLogFile } from '../../src/utilities/log-file'; +import { isNodeVersionMinSupported, supportedNodeVersions } from '../../src/utilities/node-version'; -// tslint:disable: no-console -export default async function(options: { testing?: boolean; cliArgs: string[] }) { +export { VERSION } from '../../src/utilities/version'; + +/* eslint-disable no-console */ +export default async function execute(options: { cliArgs: string[] }) { // This node version check ensures that the requirements of the project instance of the CLI are met - const version = process.versions.node.split('.').map(part => Number(part)); - if (version[0] < 10 || version[0] === 11 || (version[0] === 10 && version[1] < 13)) { + + if (!isNodeVersionMinSupported()) { process.stderr.write( `Node.js version ${process.version} detected.\n` + - 'The Angular CLI requires a minimum Node.js version of either v10.13 or v12.0.\n\n' + - 'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n', + `The Angular CLI requires a minimum of v${supportedNodeVersions[0]}.\n\n` + + 'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n', ); return 3; } - const logger = createConsoleLogger(isDebug, process.stdout, process.stderr, { - info: s => (supportsColor ? s : removeColor(s)), - debug: s => (supportsColor ? s : removeColor(s)), - warn: s => (supportsColor ? colors.bold.yellow(s) : removeColor(s)), - error: s => (supportsColor ? colors.bold.red(s) : removeColor(s)), - fatal: s => (supportsColor ? colors.bold.red(s) : removeColor(s)), + const colorLevels: Record string> = { + info: (s) => s, + debug: (s) => s, + warn: (s) => colors.bold(colors.yellow(s)), + error: (s) => colors.bold(colors.red(s)), + fatal: (s) => colors.bold(colors.red(s)), + }; + const logger = new logging.IndentLogger('cli-main-logger'); + const logInfo = console.log; + const logWarn = console.warn; + const logError = console.error; + const useColor = supportColor(); + + const loggerFinished = logger.forEach((entry) => { + if (!ngDebug && entry.level === 'debug') { + return; + } + + const color = useColor ? colorLevels[entry.level] : stripVTControlCharacters; + const message = color(entry.message); + + switch (entry.level) { + case 'warn': + case 'fatal': + case 'error': + logError(message); + break; + default: + logInfo(message); + break; + } }); // Redirect console to logger - console.log = function() { - logger.info(format.apply(null, arguments)); + console.info = console.log = function (...args) { + logger.info(format(...args)); }; - console.info = function() { - logger.info(format.apply(null, arguments)); + console.warn = function (...args) { + logger.warn(format(...args)); }; - console.warn = function() { - logger.warn(format.apply(null, arguments)); + console.error = function (...args) { + logger.error(format(...args)); }; - console.error = function() { - logger.error(format.apply(null, arguments)); - }; - - let projectDetails = getWorkspaceDetails(); - if (projectDetails === null) { - const [, localPath] = getWorkspaceRaw('local'); - if (localPath !== null) { - logger.fatal( - `An invalid configuration file was found ['${localPath}'].` + - ' Please delete the file before running the command.', - ); - - return 1; - } - - projectDetails = { root: process.cwd() }; - } try { - const maybeExitCode = await runCommand(options.cliArgs, logger, projectDetails); - if (typeof maybeExitCode === 'number') { - console.assert(Number.isInteger(maybeExitCode)); - - return maybeExitCode; - } - - return 0; + return await runCommand(options.cliArgs, logger); } catch (err) { - if (err instanceof Error) { + if (err instanceof CommandModuleError) { + logger.fatal(`Error: ${err.message}`); + } else if (err instanceof Error) { try { const logPath = writeErrorToLogFile(err); logger.fatal( @@ -90,7 +90,7 @@ export default async function(options: { testing?: boolean; cliArgs: string[] }) } catch (e) { logger.fatal( `An unhandled exception occurred: ${err.message}\n` + - `Fatal error writing debug log file: ${e.message}`, + `Fatal error writing debug log file: ${e}`, ); if (err.stack) { logger.fatal(err.stack); @@ -103,15 +103,18 @@ export default async function(options: { testing?: boolean; cliArgs: string[] }) } else if (typeof err === 'number') { // Log nothing. } else { - logger.fatal('An unexpected error occurred: ' + JSON.stringify(err)); - } - - if (options.testing) { - // tslint:disable-next-line: no-debugger - debugger; - throw err; + logger.fatal(`An unexpected error occurred: ${err}`); } return 1; + } finally { + logger.complete(); + await loggerFinished; + + // Restore original console methods so that late consumers + // (e.g. process.on('exit') handlers) still produce output. + console.log = console.info = logInfo; + console.warn = logWarn; + console.error = logError; } } diff --git a/packages/angular/cli/lib/config/schema.json b/packages/angular/cli/lib/config/schema.json deleted file mode 100644 index 097e26f38bbb..000000000000 --- a/packages/angular/cli/lib/config/schema.json +++ /dev/null @@ -1,2095 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "id": "https://angular.io/schemas/cli-1/schema", - "title": "Angular CLI Configuration", - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "version": { - "$ref": "#/definitions/fileVersion" - }, - "cli": { - "$ref": "#/definitions/cliOptions" - }, - "schematics": { - "$ref": "#/definitions/schematicOptions" - }, - "newProjectRoot": { - "type": "string", - "description": "Path where new projects will be created." - }, - "defaultProject": { - "type": "string", - "description": "Default project name used in commands." - }, - "projects": { - "type": "object", - "patternProperties": { - "^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$": { - "$ref": "#/definitions/project" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false, - "required": [ - "version" - ], - "definitions": { - "cliOptions": { - "type": "object", - "properties": { - "defaultCollection": { - "description": "The default schematics collection to use.", - "type": "string" - }, - "packageManager": { - "description": "Specify which package manager tool to use.", - "type": "string", - "enum": [ "npm", "cnpm", "yarn", "pnpm" ] - }, - "warnings": { - "description": "Control CLI specific console warnings", - "type": "object", - "properties": { - "versionMismatch": { - "description": "Show a warning when the global version is newer than the local one.", - "type": "boolean" - }, - "typescriptMismatch": { - "description": "Show a warning when the TypeScript version is incompatible.", - "type": "boolean", - "x-deprecated": true - } - } - }, - "analytics": { - "type": ["boolean", "string"], - "description": "Share anonymous usage data with the Angular Team at Google." - } - }, - "additionalProperties": false - }, - "schematicOptions": { - "type": "object", - "properties": { - "@schematics/angular:component": { - "type": "object", - "properties": { - "changeDetection": { - "description": "Specifies the change detection strategy.", - "enum": ["Default", "OnPush"], - "type": "string", - "default": "Default", - "alias": "c" - }, - "displayBlock": { - "description": "Specifies if the style will contain `:host { display: block; }`.", - "type": "boolean", - "default": false, - "alias": "b" - }, - "entryComponent": { - "type": "boolean", - "default": false, - "description": "Specifies if the component is an entry component of declaring module." - }, - "export": { - "type": "boolean", - "default": false, - "description": "Specifies if declaring module exports the component." - }, - "flat": { - "type": "boolean", - "description": "Flag to indicate if a directory is created.", - "default": false - }, - "inlineStyle": { - "description": "Specifies if the style will be in the ts file.", - "type": "boolean", - "default": false, - "alias": "s" - }, - "inlineTemplate": { - "description": "Specifies if the template will be in the ts file.", - "type": "boolean", - "default": false, - "alias": "t" - }, - "module": { - "type": "string", - "description": "Allows specification of the declaring module.", - "alias": "m" - }, - "prefix": { - "type": "string", - "format": "html-selector", - "description": "The prefix to apply to generated selectors.", - "alias": "p" - }, - "selector": { - "type": "string", - "format": "html-selector", - "description": "The selector to use for the component." - }, - "skipImport": { - "type": "boolean", - "description": "Flag to skip the module import.", - "default": false - }, - "style": { - "description": "The file extension or preprocessor to use for style files.", - "type": "string", - "default": "css", - "enum": [ - "css", - "scss", - "sass", - "less", - "styl" - ] - }, - "viewEncapsulation": { - "description": "Specifies the view encapsulation strategy.", - "enum": ["Emulated", "Native", "None", "ShadowDom"], - "type": "string", - "alias": "v" - }, - "skipTests": { - "type": "boolean", - "description": "When true, does not create test files.", - "default": false - } - } - }, - "@schematics/angular:directive": { - "type": "object", - "properties": { - "export": { - "type": "boolean", - "default": false, - "description": "Specifies if declaring module exports the directive." - }, - "flat": { - "type": "boolean", - "description": "Flag to indicate if a directory is created.", - "default": true - }, - "module": { - "type": "string", - "description": "Allows specification of the declaring module.", - "alias": "m" - }, - "prefix": { - "type": "string", - "format": "html-selector", - "description": "The prefix to apply to generated selectors.", - "default": "app", - "alias": "p" - }, - "selector": { - "type": "string", - "format": "html-selector", - "description": "The selector to use for the directive." - }, - "skipImport": { - "type": "boolean", - "description": "Flag to skip the module import.", - "default": false - }, - "skipTests": { - "type": "boolean", - "description": "When true, does not create test files.", - "default": false - } - } - }, - "@schematics/angular:module": { - "type": "object", - "properties": { - "routing": { - "type": "boolean", - "description": "Generates a routing module.", - "default": false - }, - "routingScope": { - "enum": ["Child", "Root"], - "type": "string", - "description": "The scope for the generated routing.", - "default": "Child" - }, - "flat": { - "type": "boolean", - "description": "Flag to indicate if a directory is created.", - "default": false - }, - "commonModule": { - "type": "boolean", - "description": "Flag to control whether the CommonModule is imported.", - "default": true, - "visible": false - }, - "module": { - "type": "string", - "description": "Allows specification of the declaring module.", - "alias": "m" - } - } - }, - "@schematics/angular:service": { - "type": "object", - "properties": { - "flat": { - "type": "boolean", - "default": true, - "description": "Flag to indicate if a directory is created." - }, - "skipTests": { - "type": "boolean", - "description": "When true, does not create test files.", - "default": false - } - } - }, - "@schematics/angular:pipe": { - "type": "object", - "properties": { - "flat": { - "type": "boolean", - "default": true, - "description": "Flag to indicate if a directory is created." - }, - "skipTests": { - "type": "boolean", - "description": "When true, does not create test files.", - "default": false - }, - "skipImport": { - "type": "boolean", - "default": false, - "description": "Allows for skipping the module import." - }, - "module": { - "type": "string", - "default": "", - "description": "Allows specification of the declaring module.", - "alias": "m" - }, - "export": { - "type": "boolean", - "default": false, - "description": "Specifies if declaring module exports the pipe." - } - } - }, - "@schematics/angular:class": { - "type": "object", - "properties": { - "skipTests": { - "type": "boolean", - "description": "When true, does not create test files.", - "default": false - } - } - } - }, - "additionalProperties": { - "type": "object" - } - }, - "fileVersion": { - "type": "integer", - "description": "File format version", - "minimum": 1 - }, - "project": { - "type": "object", - "properties": { - "cli": { - "$ref": "#/definitions/cliOptions" - }, - "schematics": { - "$ref": "#/definitions/schematicOptions" - }, - "prefix": { - "type": "string", - "format": "html-selector", - "description": "The prefix to apply to generated selectors." - }, - "root": { - "type": "string", - "description": "Root of the project files." - }, - "i18n": { - "$ref": "#/definitions/project/definitions/i18n" - }, - "sourceRoot": { - "type": "string", - "description": "The root of the source files, assets and index.html file structure." - }, - "projectType": { - "type": "string", - "description": "Project type.", - "enum": [ - "application", - "library" - ] - }, - "architect": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/project/definitions/target" - } - }, - "targets": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/project/definitions/target" - } - } - }, - "required": [ - "root", - "projectType" - ], - "anyOf": [ - { - "required": ["architect"], - "not": { - "required": ["targets"] - } - }, - { - "required": ["targets"], - "not": { - "required": ["architect"] - } - }, - { - "not": { - "required": [ - "targets", - "architect" - ] - } - } - ], - "additionalProperties": false, - "patternProperties": { - "^[a-z]{1,3}-.*": {} - }, - "definitions": { - "i18n": { - "description": "Project i18n options", - "type": "object", - "properties": { - "sourceLocale": { - "oneOf": [ - { - "type": "string", - "description": "Specifies the source locale of the application.", - "default": "en-US", - "$comment": "IETF BCP 47 language tag (simplified)", - "pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$" - }, - { - "type": "object", - "description": "Localization options to use for the source locale", - "properties": { - "code": { - "type": "string", - "description": "Specifies the locale code of the source locale", - "pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$" - }, - "baseHref": { - "type": "string", - "description": "HTML base HREF to use for the locale (defaults to the locale code)" - } - }, - "additionalProperties": false - } - ] - }, - "locales": { - "type": "object", - "additionalProperties": false, - "patternProperties": { - "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$": { - "oneOf": [ - { - "type": "string", - "description": "Localization file to use for i18n" - }, - { - "type": "object", - "description": "Localization options to use for the locale", - "properties": { - "translation": { - "type": "string", - "description": "Localization file to use for i18n" - }, - "baseHref": { - "type": "string", - "description": "HTML base HREF to use for the locale (defaults to the locale code)" - } - }, - "additionalProperties": false - } - ] - } - } - } - }, - "additionalProperties": false - }, - "target": { - "oneOf": [ - { - "$comment": "Extendable target with custom builder", - "type": "object", - "properties": { - "builder": { - "type": "string", - "description": "The builder used for this package.", - "not": { - "enum": [ - "@angular-devkit/build-angular:app-shell", - "@angular-devkit/build-angular:browser", - "@angular-devkit/build-angular:dev-server", - "@angular-devkit/build-angular:extract-i18n", - "@angular-devkit/build-angular:karma", - "@angular-devkit/build-angular:protractor", - "@angular-devkit/build-angular:server", - "@angular-devkit/build-angular:tslint" - ] - } - }, - "options": { - "type": "object" - }, - "configurations": { - "type": "object", - "description": "A map of alternative target options.", - "additionalProperties": { - "type": "object" - } - } - }, - "required": [ - "builder" - ] - }, - { - "type": "object", - "properties": { - "builder": { "const": "@angular-devkit/build-angular:app-shell" }, - "options": { "$ref": "#/definitions/targetOptions/definitions/appShell" }, - "configurations": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/targetOptions/definitions/appShell" } - } - } - }, - { - "type": "object", - "properties": { - "builder": { "const": "@angular-devkit/build-angular:browser" }, - "options": { "$ref": "#/definitions/targetOptions/definitions/browser" }, - "configurations": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/targetOptions/definitions/browser" } - } - } - }, - { - "type": "object", - "properties": { - "builder": { "const": "@angular-devkit/build-angular:dev-server" }, - "options": { "$ref": "#/definitions/targetOptions/definitions/devServer" }, - "configurations": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/targetOptions/definitions/devServer" } - } - } - }, - { - "type": "object", - "properties": { - "builder": { "const": "@angular-devkit/build-angular:extract-i18n" }, - "options": { "$ref": "#/definitions/targetOptions/definitions/extracti18n" }, - "configurations": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/targetOptions/definitions/extracti18n" } - } - } - }, - { - "type": "object", - "properties": { - "builder": { "const": "@angular-devkit/build-angular:karma" }, - "options": { "$ref": "#/definitions/targetOptions/definitions/karma" }, - "configurations": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/targetOptions/definitions/karma" } - } - } - }, - { - "type": "object", - "properties": { - "builder": { "const": "@angular-devkit/build-angular:protractor" }, - "options": { "$ref": "#/definitions/targetOptions/definitions/protractor" }, - "configurations": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/targetOptions/definitions/protractor" } - } - } - }, - { - "type": "object", - "properties": { - "builder": { "const": "@angular-devkit/build-angular:server" }, - "options": { "$ref": "#/definitions/targetOptions/definitions/server" }, - "configurations": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/targetOptions/definitions/server" } - } - } - }, - { - "type": "object", - "properties": { - "builder": { "const": "@angular-devkit/build-angular:tslint" }, - "options": { "$ref": "#/definitions/targetOptions/definitions/tslint" }, - "configurations": { - "type": "object", - "additionalProperties": { "$ref": "#/definitions/targetOptions/definitions/tslint" } - } - } - } - ] - } - } - }, - "global": { - "type": "object", - "properties": { - "$schema": { - "type": "string", - "format": "uri" - }, - "version": { - "$ref": "#/definitions/fileVersion" - }, - "cli": { - "$ref": "#/definitions/cliOptions" - }, - "schematics": { - "$ref": "#/definitions/schematicOptions" - } - }, - "required": [ - "version" - ] - }, - "targetOptions": { - "type": "null", - "definitions": { - "appShell": { - "description": "App Shell target options for Architect.", - "type": "object", - "properties": { - "browserTarget": { - "type": "string", - "description": "Target to build." - }, - "serverTarget": { - "type": "string", - "description": "Server target to use for rendering the app shell." - }, - "appModuleBundle": { - "type": "string", - "description": "Script that exports the Server AppModule to render. This should be the main JavaScript outputted by the server target. By default we will resolve the outputPath of the serverTarget and find a bundle named 'main' in it (whether or not there's a hash tag)." - }, - "route": { - "type": "string", - "description": "The route to render.", - "default": "/" - }, - "inputIndexPath": { - "type": "string", - "description": "The input path for the index.html file. By default uses the output index.html of the browser target." - }, - "outputIndexPath": { - "type": "string", - "description": "The output path of the index.html file. By default will overwrite the input file." - } - }, - "additionalProperties": false - }, - "browser": { - "title": "Webpack browser schema for Architect.", - "description": "Browser target options", - "properties": { - "assets": { - "type": "array", - "description": "List of static application assets.", - "default": [], - "items": { - "$ref": "#/definitions/targetOptions/definitions/browser/definitions/assetPattern" - } - }, - "main": { - "type": "string", - "description": "The name of the main entry-point file." - }, - "polyfills": { - "type": "string", - "description": "The name of the polyfills file." - }, - "tsConfig": { - "type": "string", - "description": "The name of the TypeScript configuration file." - }, - "scripts": { - "description": "Global scripts to be included in the build.", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/targetOptions/definitions/browser/definitions/extraEntryPoint" - } - }, - "styles": { - "description": "Global styles to be included in the build.", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/targetOptions/definitions/browser/definitions/extraEntryPoint" - } - }, - "stylePreprocessorOptions": { - "description": "Options to pass to style preprocessors.", - "type": "object", - "properties": { - "includePaths": { - "description": "Paths to include. Paths will be resolved to project root.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - } - }, - "additionalProperties": false - }, - "optimization": { - "description": "Enables optimization of the build output.", - "oneOf": [ - { - "type": "object", - "properties": { - "scripts": { - "type": "boolean", - "description": "Enables optimization of the scripts output.", - "default": true - }, - "styles": { - "type": "boolean", - "description": "Enables optimization of the styles output.", - "default": true - } - }, - "additionalProperties": false - }, - { - "type": "boolean" - } - ] - }, - "fileReplacements": { - "description": "Replace files with other files in the build.", - "type": "array", - "items": { - "$ref": "#/definitions/targetOptions/definitions/browser/definitions/fileReplacement" - }, - "default": [] - }, - "outputPath": { - "type": "string", - "description": "Path where output will be placed." - }, - "resourcesOutputPath": { - "type": "string", - "description": "The path where style resources will be placed, relative to outputPath." - }, - "aot": { - "type": "boolean", - "description": "Build using Ahead of Time compilation.", - "default": false - }, - "sourceMap": { - "description": "Output sourcemaps.", - "default": true, - "oneOf": [ - { - "type": "object", - "properties": { - "scripts": { - "type": "boolean", - "description": "Output sourcemaps for all scripts.", - "default": true - }, - "styles": { - "type": "boolean", - "description": "Output sourcemaps for all styles.", - "default": true - }, - "hidden": { - "type": "boolean", - "description": "Output sourcemaps used for error reporting tools.", - "default": false - }, - "vendor": { - "type": "boolean", - "description": "Resolve vendor packages sourcemaps.", - "default": false - } - }, - "additionalProperties": false - }, - { - "type": "boolean" - } - ] - }, - "vendorSourceMap": { - "type": "boolean", - "description": "Resolve vendor packages sourcemaps.", - "default": false - }, - "evalSourceMap": { - "type": "boolean", - "description": "Output in-file eval sourcemaps.", - "default": false - }, - "vendorChunk": { - "type": "boolean", - "description": "Use a separate bundle containing only vendor libraries.", - "default": true - }, - "commonChunk": { - "type": "boolean", - "description": "Use a separate bundle containing code used across multiple bundles.", - "default": true - }, - "baseHref": { - "type": "string", - "description": "Base url for the application being built." - }, - "deployUrl": { - "type": "string", - "description": "URL where files will be deployed." - }, - "verbose": { - "type": "boolean", - "description": "Adds more details to output logging.", - "default": false - }, - "progress": { - "type": "boolean", - "description": "Log progress to the console while building.", - "default": true - }, - "localize": { - "$ref": "#/definitions/buildersOptions/localize" - }, - "i18nMissingTranslation": { - "$ref": "#/definitions/buildersOptions/missingTranslation" - }, - "i18nFile": { - "type": "string", - "description": "Localization file to use for i18n.", - "x-deprecated": "Deprecated since 9.0" - }, - "i18nFormat": { - "type": "string", - "description": "Format of the localization file specified with --i18n-file.", - "x-deprecated": "Deprecated since 9.0" - }, - "i18nLocale": { - "type": "string", - "description": "Locale to use for i18n.", - "x-deprecated": "Deprecated since 9.0" - }, - "extractCss": { - "type": "boolean", - "description": "Extract css from global styles onto css files instead of js ones.", - "default": false - }, - "watch": { - "type": "boolean", - "description": "Run build when files change.", - "default": false - }, - "outputHashing": { - "type": "string", - "description": "Define the output filename cache-busting hashing mode.", - "default": "none", - "enum": [ - "none", - "all", - "media", - "bundles" - ] - }, - "poll": { - "type": "number", - "description": "Enable and define the file watching poll time period in milliseconds." - }, - "deleteOutputPath": { - "type": "boolean", - "description": "Delete the output path before building.", - "default": true - }, - "preserveSymlinks": { - "type": "boolean", - "description": "Do not use the real path when resolving modules." - }, - "extractLicenses": { - "type": "boolean", - "description": "Extract all licenses in a separate file, in the case of production builds only.", - "default": true - }, - "showCircularDependencies": { - "type": "boolean", - "description": "Show circular dependency warnings on builds.", - "default": true - }, - "buildOptimizer": { - "type": "boolean", - "description": "Enables @angular-devkit/build-optimizer optimizations when using the 'aot' option.", - "default": false - }, - "namedChunks": { - "type": "boolean", - "description": "Use file name for lazy loaded chunks.", - "default": true - }, - "subresourceIntegrity": { - "type": "boolean", - "description": "Enables the use of subresource integrity validation.", - "default": false - }, - "serviceWorker": { - "type": "boolean", - "description": "Generates a service worker config for production builds.", - "default": false - }, - "ngswConfigPath": { - "type": "string", - "description": "Path to ngsw-config.json." - }, - "skipAppShell": { - "type": "boolean", - "description": "Flag to prevent building an app shell.", - "default": false - }, - "index": { - "description": "Configures the generation of the application's HTML index.", - "oneOf": [ - { - "type": "string", - "description": "The path of a file to use for the application's HTML index. The filename of the specified path will be used for the generated file and will be created in the root of the application's configured output path." - }, - { - "type": "object", - "description": "", - "properties": { - "input": { - "type": "string", - "minLength": 1, - "description": "The path of a file to use for the application's generated HTML index." - }, - "output": { - "type": "string", - "minLength": 1, - "default": "index.html", - "description": "The output path of the application's generated HTML index file. The full provided path will be used and will be considered relative to the application's configured output path." - } - }, - "required": ["input"] - } - ] - }, - "statsJson": { - "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", - "default": false - }, - "forkTypeChecker": { - "type": "boolean", - "description": "Run the TypeScript type checker in a forked process.", - "default": true - }, - "lazyModules": { - "description": "List of additional NgModule files that will be lazy loaded. Lazy router modules with be discovered automatically.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, - "budgets": { - "description": "Budget thresholds to ensure parts of your application stay within boundaries which you set.", - "type": "array", - "items": { - "$ref": "#/definitions/targetOptions/definitions/browser/definitions/budget" - }, - "default": [] - }, - "es5BrowserSupport": { - "description": "Enables conditionally loaded ES2015 polyfills.", - "type": "boolean", - "default": false - }, - "rebaseRootRelativeCssUrls": { - "description": "Change root relative URLs in stylesheets to include base HREF and deploy URL. Use only for compatibility and transition. The behavior of this option is non-standard and will be removed in the next major release.", - "type": "boolean", - "default": false, - "x-deprecated": true - }, - "webWorkerTsConfig": { - "type": "string", - "description": "TypeScript configuration for Web Worker modules." - }, - "crossOrigin": { - "type": "string", - "description": "Define the crossorigin attribute setting of elements that provide CORS support.", - "default": "none", - "enum": [ - "none", - "anonymous", - "use-credentials" - ] - }, - "allowedCommonJsDependencies": { - "description": "A list of CommonJS packages that are allowed to be used without a built time warning.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false, - "definitions": { - "assetPattern": { - "oneOf": [ - { - "type": "object", - "properties": { - "glob": { - "type": "string", - "description": "The pattern to match." - }, - "input": { - "type": "string", - "description": "The input path dir in which to apply 'glob'. Defaults to the project root." - }, - "output": { - "type": "string", - "description": "Absolute path within the output." - }, - "ignore": { - "description": "An array of globs to ignore.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false, - "required": [ - "glob", - "input", - "output" - ] - }, - { - "type": "string", - "description": "The file to include." - } - ] - }, - "fileReplacement": { - "oneOf": [ - { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "replaceWith": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "src", - "replaceWith" - ] - }, - { - "type": "object", - "properties": { - "replace": { - "type": "string" - }, - "with": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "replace", - "with" - ] - } - ] - }, - "extraEntryPoint": { - "oneOf": [ - { - "type": "object", - "properties": { - "input": { - "type": "string", - "description": "The file to include." - }, - "bundleName": { - "type": "string", - "description": "The bundle name for this extra entry point." - }, - "lazy": { - "type": "boolean", - "description": "If the bundle will be lazy loaded.", - "default": false - }, - "inject": { - "type": "boolean", - "description": "If the bundle will be referenced in the HTML file.", - "default": true - } - }, - "additionalProperties": false, - "required": [ - "input" - ] - }, - { - "type": "string", - "description": "The file to include." - } - ] - }, - "budget": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "The type of budget.", - "enum": [ - "all", - "allScript", - "any", - "anyScript", - "anyComponentStyle", - "bundle", - "initial" - ] - }, - "name": { - "type": "string", - "description": "The name of the bundle." - }, - "baseline": { - "type": "string", - "description": "The baseline size for comparison." - }, - "maximumWarning": { - "type": "string", - "description": "The maximum threshold for warning relative to the baseline." - }, - "maximumError": { - "type": "string", - "description": "The maximum threshold for error relative to the baseline." - }, - "minimumWarning": { - "type": "string", - "description": "The minimum threshold for warning relative to the baseline." - }, - "minimumError": { - "type": "string", - "description": "The minimum threshold for error relative to the baseline." - }, - "warning": { - "type": "string", - "description": "The threshold for warning relative to the baseline (min & max)." - }, - "error": { - "type": "string", - "description": "The threshold for error relative to the baseline (min & max)." - } - }, - "additionalProperties": false, - "required": [ - "type" - ] - } - } - }, - "devServer": { - "description": "Dev Server target options for Architect.", - "type": "object", - "properties": { - "browserTarget": { - "type": "string", - "description": "Target to serve." - }, - "port": { - "type": "number", - "description": "Port to listen on.", - "default": 4200 - }, - "host": { - "type": "string", - "description": "Host to listen on.", - "default": "localhost" - }, - "proxyConfig": { - "type": "string", - "description": "Proxy configuration file." - }, - "ssl": { - "type": "boolean", - "description": "Serve using HTTPS.", - "default": false - }, - "sslKey": { - "type": "string", - "description": "SSL key to use for serving HTTPS." - }, - "sslCert": { - "type": "string", - "description": "SSL certificate to use for serving HTTPS." - }, - "open": { - "type": "boolean", - "description": "When true, open the live-reload URL in default browser.", - "default": false, - "alias": "o" - }, - "liveReload": { - "type": "boolean", - "description": "When true, reload the page on change using live-reload.", - "default": true - }, - "publicHost": { - "type": "string", - "description": "The URL that the browser client (or live-reload client, if enabled) should use to connect to the development server. Use for a complex dev server setup, such as one with reverse proxies." - }, - "allowedHosts": { - "type": "array", - "description": "Whitelist of hosts that are allowed to access the dev server.", - "default": [], - "items": { - "type": "string" - } - }, - "servePath": { - "type": "string", - "description": "The pathname where the app will be served." - }, - "disableHostCheck": { - "type": "boolean", - "description": "When true, don't verify that connected clients are part of allowed hosts.", - "default": false - }, - "hmr": { - "type": "boolean", - "description": "When true, enable hot module replacement.", - "default": false - }, - "watch": { - "type": "boolean", - "description": "When true, rebuild on change.", - "default": true - }, - "hmrWarning": { - "type": "boolean", - "description": "When true, show a warning when the --hmr option is enabled.", - "default": true - }, - "servePathDefaultWarning": { - "type": "boolean", - "description": "When true, show a warning when deploy-url/base-href use unsupported serve path values.", - "default": true - }, - "optimization": { - "description": "Enable optimization of the build output.", - "oneOf": [ - { - "type": "object", - "properties": { - "scripts": { - "type": "boolean", - "description": "When true, enable optimization of the scripts output.", - "default": true - }, - "styles": { - "type": "boolean", - "description": "When true, enable optimization of the styles output.", - "default": true - } - }, - "additionalProperties": false - }, - { - "type": "boolean" - } - ] - }, - "aot": { - "type": "boolean", - "description": "Build using ahead-of-time compilation." - }, - "sourceMap": { - "description": "When true, output sourcemaps.", - "default": true, - "oneOf": [ - { - "type": "object", - "properties": { - "scripts": { - "type": "boolean", - "description": "When true, output sourcemaps for all scripts.", - "default": true - }, - "styles": { - "type": "boolean", - "description": "When true, output sourcemaps for all styles.", - "default": true - }, - "vendor": { - "type": "boolean", - "description": "When true, resolve vendor packages sourcemaps.", - "default": false - } - }, - "additionalProperties": false - }, - { - "type": "boolean" - } - ] - }, - "vendorSourceMap": { - "type": "boolean", - "description": "When true, resolve vendor packages sourcemaps.", - "default": false - }, - "evalSourceMap": { - "type": "boolean", - "description": "When true, output in-file eval sourcemaps." - }, - "vendorChunk": { - "type": "boolean", - "description": "When true, use a separate bundle containing only vendor libraries." - }, - "commonChunk": { - "type": "boolean", - "description": "When true, use a separate bundle containing code used across multiple bundles." - }, - "baseHref": { - "type": "string", - "description": "Base url for the application being built." - }, - "deployUrl": { - "type": "string", - "description": "URL where files will be deployed." - }, - "verbose": { - "type": "boolean", - "description": "When true, add more details to output logging." - }, - "progress": { - "type": "boolean", - "description": "When true, log progress to the console while building." - } - }, - "additionalProperties": false - }, - "extracti18n": { - "description": "Extract i18n target options for Architect.", - "type": "object", - "properties": { - "browserTarget": { - "type": "string", - "description": "Target to extract from." - }, - "format": { - "type": "string", - "description": "Output format for the generated file.", - "default": "xlf", - "enum": [ - "xmb", - "xlf", - "xlif", - "xliff", - "xlf2", - "xliff2" - ] - }, - "i18nFormat": { - "type": "string", - "description": "Output format for the generated file.", - "default": "xlf", - "x-deprecated": "Use 'format' option instead.", - "enum": [ - "xmb", - "xlf", - "xlif", - "xliff", - "xlf2", - "xliff2" - ] - }, - "i18nLocale": { - "type": "string", - "description": "Specifies the source language of the application.", - "x-deprecated": "Use 'i18n' project level sub-option 'sourceLocale' instead." - }, - "progress": { - "type": "boolean", - "description": "Log progress to the console.", - "default": true - }, - "outputPath": { - "type": "string", - "description": "Path where output will be placed." - }, - "outFile": { - "type": "string", - "description": "Name of the file to output." - } - }, - "additionalProperties": false - }, - "karma": { - "description": "Karma target options for Architect.", - "type": "object", - "properties": { - "main": { - "type": "string", - "description": "The name of the main entry-point file." - }, - "tsConfig": { - "type": "string", - "description": "The name of the TypeScript configuration file." - }, - "karmaConfig": { - "type": "string", - "description": "The name of the Karma configuration file." - }, - "polyfills": { - "type": "string", - "description": "The name of the polyfills file." - }, - "assets": { - "type": "array", - "description": "List of static application assets.", - "default": [], - "items": { - "$ref": "#/definitions/targetOptions/definitions/karma/definitions/assetPattern" - } - }, - "scripts": { - "description": "Global scripts to be included in the build.", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/targetOptions/definitions/karma/definitions/extraEntryPoint" - } - }, - "styles": { - "description": "Global styles to be included in the build.", - "type": "array", - "default": [], - "items": { - "$ref": "#/definitions/targetOptions/definitions/karma/definitions/extraEntryPoint" - } - }, - "stylePreprocessorOptions": { - "description": "Options to pass to style preprocessors", - "type": "object", - "properties": { - "includePaths": { - "description": "Paths to include. Paths will be resolved to project root.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - } - }, - "additionalProperties": false - }, - "environment": { - "type": "string", - "description": "Defines the build environment." - }, - "sourceMap": { - "description": "Output sourcemaps.", - "default": true, - "oneOf": [ - { - "type": "object", - "properties": { - "scripts": { - "type": "boolean", - "description": "Output sourcemaps for all scripts.", - "default": true - }, - "styles": { - "type": "boolean", - "description": "Output sourcemaps for all styles.", - "default": true - }, - "vendor": { - "type": "boolean", - "description": "Resolve vendor packages sourcemaps.", - "default": false - } - }, - "additionalProperties": false - }, - { - "type": "boolean" - } - ] - }, - "progress": { - "type": "boolean", - "description": "Log progress to the console while building.", - "default": true - }, - "watch": { - "type": "boolean", - "description": "Run build when files change.", - "default": true - }, - "poll": { - "type": "number", - "description": "Enable and define the file watching poll time period in milliseconds." - }, - "preserveSymlinks": { - "type": "boolean", - "description": "Do not use the real path when resolving modules." - }, - "browsers": { - "type": "string", - "description": "Override which browsers tests are run against." - }, - "codeCoverage": { - "type": "boolean", - "description": "Output a code coverage report.", - "default": false - }, - "codeCoverageExclude": { - "type": "array", - "description": "Globs to exclude from code coverage.", - "items": { - "type": "string" - }, - "default": [] - }, - "fileReplacements": { - "description": "Replace files with other files in the build.", - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "replaceWith": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "src", - "replaceWith" - ] - }, - { - "type": "object", - "properties": { - "replace": { - "type": "string" - }, - "with": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "replace", - "with" - ] - } - ] - }, - "default": [] - }, - "reporters": { - "type": "array", - "description": "Karma reporters to use. Directly passed to the karma runner.", - "items": { - "type": "string" - } - }, - "webWorkerTsConfig": { - "type": "string", - "description": "TypeScript configuration for Web Worker modules." - } - }, - "additionalProperties": false, - "definitions": { - "assetPattern": { - "oneOf": [ - { - "type": "object", - "properties": { - "glob": { - "type": "string", - "description": "The pattern to match." - }, - "input": { - "type": "string", - "description": "The input path dir in which to apply 'glob'. Defaults to the project root." - }, - "output": { - "type": "string", - "description": "Absolute path within the output." - }, - "ignore": { - "description": "An array of globs to ignore.", - "type": "array", - "items": { - "type": "string" - } - } - }, - "additionalProperties": false, - "required": [ - "glob", - "input", - "output" - ] - }, - { - "type": "string", - "description": "The file to include." - } - ] - }, - "extraEntryPoint": { - "oneOf": [ - { - "type": "object", - "properties": { - "input": { - "type": "string", - "description": "The file to include." - }, - "bundleName": { - "type": "string", - "description": "The bundle name for this extra entry point." - }, - "lazy": { - "type": "boolean", - "description": "If the bundle will be lazy loaded.", - "default": false - }, - "inject": { - "type": "boolean", - "description": "If the bundle will be referenced in the HTML file.", - "default": true - } - }, - "additionalProperties": false, - "required": [ - "input" - ] - }, - { - "type": "string", - "description": "The file to include." - } - ] - } - } - }, - "protractor": { - "description": "Protractor target options for Architect.", - "type": "object", - "properties": { - "protractorConfig": { - "type": "string", - "description": "The name of the Protractor configuration file." - }, - "devServerTarget": { - "type": "string", - "description": "Dev server target to run tests against." - }, - "grep": { - "type": "string", - "description": "Execute specs whose names match the pattern, which is internally compiled to a RegExp." - }, - "invertGrep": { - "type": "boolean", - "description": "Invert the selection specified by the 'grep' option.", - "default": false - }, - "specs": { - "type": "array", - "description": "Override specs in the protractor config.", - "default": [], - "items": { - "type": "string", - "description": "Spec name." - } - }, - "suite": { - "type": "string", - "description": "Override suite in the protractor config." - }, - "elementExplorer": { - "type": "boolean", - "description": "Start Protractor's Element Explorer for debugging.", - "default": false - }, - "webdriverUpdate": { - "type": "boolean", - "description": "Try to update webdriver.", - "default": true - }, - "serve": { - "type": "boolean", - "description": "Compile and Serve the app.", - "default": true - }, - "port": { - "type": "number", - "description": "The port to use to serve the application." - }, - "host": { - "type": "string", - "description": "Host to listen on.", - "default": "localhost" - }, - "baseUrl": { - "type": "string", - "description": "Base URL for protractor to connect to." - } - }, - "additionalProperties": false - }, - "server": { - "title": "Angular Webpack Architect Builder Schema", - "properties": { - "main": { - "type": "string", - "description": "The name of the main entry-point file." - }, - "tsConfig": { - "type": "string", - "default": "tsconfig.app.json", - "description": "The name of the TypeScript configuration file." - }, - "stylePreprocessorOptions": { - "description": "Options to pass to style preprocessors", - "type": "object", - "properties": { - "includePaths": { - "description": "Paths to include. Paths will be resolved to project root.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - } - }, - "additionalProperties": false - }, - "optimization": { - "description": "Enables optimization of the build output.", - "oneOf": [ - { - "type": "object", - "properties": { - "scripts": { - "type": "boolean", - "description": "Enables optimization of the scripts output.", - "default": true - }, - "styles": { - "type": "boolean", - "description": "Enables optimization of the styles output.", - "default": true - } - }, - "additionalProperties": false - }, - { - "type": "boolean" - } - ] - }, - "fileReplacements": { - "description": "Replace files with other files in the build.", - "type": "array", - "items": { - "$ref": "#/definitions/targetOptions/definitions/server/definitions/fileReplacement" - }, - "default": [] - }, - "outputPath": { - "type": "string", - "description": "Path where output will be placed." - }, - "resourcesOutputPath": { - "type": "string", - "description": "The path where style resources will be placed, relative to outputPath." - }, - "sourceMap": { - "description": "Output sourcemaps.", - "default": true, - "oneOf": [ - { - "type": "object", - "properties": { - "scripts": { - "type": "boolean", - "description": "Output sourcemaps for all scripts.", - "default": true - }, - "styles": { - "type": "boolean", - "description": "Output sourcemaps for all styles.", - "default": true - }, - "hidden": { - "type": "boolean", - "description": "Output sourcemaps used for error reporting tools.", - "default": false - }, - "vendor": { - "type": "boolean", - "description": "Resolve vendor packages sourcemaps.", - "default": false - } - }, - "additionalProperties": false - }, - { - "type": "boolean" - } - ] - }, - "vendorSourceMap": { - "type": "boolean", - "description": "Resolve vendor packages sourcemaps.", - "default": false - }, - "evalSourceMap": { - "type": "boolean", - "description": "Output in-file eval sourcemaps.", - "default": false - }, - "vendorChunk": { - "type": "boolean", - "description": "Use a separate bundle containing only vendor libraries.", - "default": true - }, - "commonChunk": { - "type": "boolean", - "description": "Use a separate bundle containing code used across multiple bundles.", - "default": true - }, - "verbose": { - "type": "boolean", - "description": "Adds more details to output logging.", - "default": false - }, - "progress": { - "type": "boolean", - "description": "Log progress to the console while building.", - "default": true - }, - "localize": { - "$ref": "#/definitions/buildersOptions/localize" - }, - "i18nFile": { - "type": "string", - "description": "Localization file to use for i18n.", - "x-deprecated": "Deprecated since 9.0" - }, - "i18nFormat": { - "type": "string", - "description": "Format of the localization file specified with --i18n-file.", - "x-deprecated": "Deprecated since 9.0" - }, - "i18nLocale": { - "type": "string", - "description": "Locale to use for i18n.", - "x-deprecated": "Deprecated since 9.0" - }, - "i18nMissingTranslation": { - "$ref": "#/definitions/buildersOptions/missingTranslation" - }, - "outputHashing": { - "type": "string", - "description": "Define the output filename cache-busting hashing mode.", - "default": "none", - "enum": [ - "none", - "all", - "media", - "bundles" - ] - }, - "deleteOutputPath": { - "type": "boolean", - "description": "delete-output-path", - "default": true - }, - "preserveSymlinks": { - "type": "boolean", - "description": "Do not use the real path when resolving modules." - }, - "extractLicenses": { - "type": "boolean", - "description": "Extract all licenses in a separate file, in the case of production builds only.", - "default": true - }, - "showCircularDependencies": { - "type": "boolean", - "description": "Show circular dependency warnings on builds.", - "default": true - }, - "namedChunks": { - "type": "boolean", - "description": "Use file name for lazy loaded chunks.", - "default": true - }, - "bundleDependencies": { - "description": "Available on server platform only. Which external dependencies to bundle into the module. By default, all of node_modules will be bundled.", - "default": true, - "oneOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "enum": [ - "none", - "all" - ] - } - ] - }, - "externalDependencies": { - "description": "Exclude the listed external dependencies from being bundled into the bundle. Instead, the created bundle relies on these dependencies to be available during runtime.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - }, - "statsJson": { - "type": "boolean", - "description": "Generates a 'stats.json' file which can be analyzed using tools such as 'webpack-bundle-analyzer'.", - "default": false - }, - "forkTypeChecker": { - "type": "boolean", - "description": "Run the TypeScript type checker in a forked process.", - "default": true - }, - "lazyModules": { - "description": "List of additional NgModule files that will be lazy loaded. Lazy router modules with be discovered automatically.", - "type": "array", - "items": { - "type": "string" - }, - "default": [] - } - }, - "additionalProperties": false, - "definitions": { - "fileReplacement": { - "oneOf": [ - { - "type": "object", - "properties": { - "src": { - "type": "string" - }, - "replaceWith": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "src", - "replaceWith" - ] - }, - { - "type": "object", - "properties": { - "replace": { - "type": "string" - }, - "with": { - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "replace", - "with" - ] - } - ] - } - } - }, - "tslint": { - "description": "TSlint target options for Architect.", - "type": "object", - "properties": { - "tslintConfig": { - "type": "string", - "description": "The name of the TSLint configuration file." - }, - "tsConfig": { - "description": "The name of the TypeScript configuration file.", - "oneOf": [ - { "type": "string" }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - }, - "fix": { - "type": "boolean", - "description": "Fixes linting errors (may overwrite linted files).", - "default": false - }, - "typeCheck": { - "type": "boolean", - "description": "Controls the type check for linting.", - "default": false - }, - "force": { - "type": "boolean", - "description": "Succeeds even if there was linting errors.", - "default": false - }, - "silent": { - "type": "boolean", - "description": "Show output text.", - "default": false - }, - "format": { - "type": "string", - "description": "Output format (prose, json, stylish, verbose, pmd, msbuild, checkstyle, vso, fileslist, codeFrame).", - "default": "stylish", - "anyOf": [ - { - "enum": [ - "checkstyle", - "codeFrame", - "filesList", - "json", - "junit", - "msbuild", - "pmd", - "prose", - "stylish", - "tap", - "verbose", - "vso" - ] - }, - { "minLength": 1 } - ] - }, - "exclude": { - "type": "array", - "description": "Files to exclude from linting.", - "default": [], - "items": { - "type": "string" - } - }, - "files": { - "type": "array", - "description": "Files to include in linting.", - "default": [], - "items": { - "type": "string" - } - } - }, - "additionalProperties": false - } - } - }, - "buildersOptions": { - "missingTranslation": { - "type": "string", - "description": "How to handle missing translations for i18n.", - "enum": ["warning", "error", "ignore"], - "default": "warning" - }, - "localize": { - "oneOf": [ - { - "type": "boolean", - "description": "Translate all locales." - }, - { - "type": "array", - "description": "List of locales ID's to translate.", - "minItems": 1, - "items": { - "type": "string", - "pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$" - } - } - ] - } - } - } -} diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json new file mode 100644 index 000000000000..00bd43311e03 --- /dev/null +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -0,0 +1,863 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "ng-cli://config/schema.json", + "title": "Angular CLI Workspace Configuration", + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "$ref": "#/definitions/fileVersion" + }, + "cli": { + "$ref": "#/definitions/cliOptions" + }, + "schematics": { + "$ref": "#/definitions/schematicOptions" + }, + "newProjectRoot": { + "type": "string", + "description": "Path where new projects will be created." + }, + "projects": { + "type": "object", + "patternProperties": { + "^(?:@[a-zA-Z0-9._-]+/)?[a-zA-Z0-9._-]+$": { + "$ref": "#/definitions/project" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "required": ["version"], + "definitions": { + "cliOptions": { + "type": "object", + "properties": { + "schematicCollections": { + "type": "array", + "description": "The list of schematic collections to use.", + "items": { + "type": "string", + "uniqueItems": true + } + }, + "packageManager": { + "description": "Specify which package manager tool to use.", + "type": "string", + "enum": ["npm", "yarn", "pnpm", "bun"] + }, + "warnings": { + "description": "Control CLI specific console warnings", + "type": "object", + "properties": { + "versionMismatch": { + "description": "Show a warning when the global version is newer than the local one.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "analytics": { + "type": ["boolean", "string"], + "description": "Share pseudonymous usage data with the Angular Team at Google." + }, + "cache": { + "description": "Control disk cache.", + "type": "object", + "properties": { + "environment": { + "description": "Configure in which environment disk cache is enabled.", + "type": "string", + "enum": ["local", "ci", "all"] + }, + "enabled": { + "description": "Configure whether disk caching is enabled.", + "type": "boolean" + }, + "path": { + "description": "Cache base path.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "cliGlobalOptions": { + "type": "object", + "properties": { + "schematicCollections": { + "type": "array", + "description": "The list of schematic collections to use.", + "items": { + "type": "string", + "uniqueItems": true + } + }, + "packageManager": { + "description": "Specify which package manager tool to use.", + "type": "string", + "enum": ["npm", "yarn", "pnpm", "bun"] + }, + "warnings": { + "description": "Control CLI specific console warnings", + "type": "object", + "properties": { + "versionMismatch": { + "description": "Show a warning when the global version is newer than the local one.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "analytics": { + "type": ["boolean", "string"], + "description": "Share pseudonymous usage data with the Angular Team at Google." + }, + "completion": { + "type": "object", + "description": "Angular CLI completion settings.", + "properties": { + "prompted": { + "type": "boolean", + "description": "Whether the user has been prompted to add completion command prompt." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "schematicOptions": { + "type": "object", + "properties": { + "@schematics/angular:application": { + "$ref": "../../../../schematics/angular/application/schema.json" + }, + "@schematics/angular:class": { + "$ref": "../../../../schematics/angular/class/schema.json" + }, + "@schematics/angular:component": { + "$ref": "../../../../schematics/angular/component/schema.json" + }, + "@schematics/angular:directive": { + "$ref": "../../../../schematics/angular/directive/schema.json" + }, + "@schematics/angular:enum": { + "$ref": "../../../../schematics/angular/enum/schema.json" + }, + "@schematics/angular:guard": { + "$ref": "../../../../schematics/angular/guard/schema.json" + }, + "@schematics/angular:interceptor": { + "$ref": "../../../../schematics/angular/interceptor/schema.json" + }, + "@schematics/angular:interface": { + "$ref": "../../../../schematics/angular/interface/schema.json" + }, + "@schematics/angular:library": { + "$ref": "../../../../schematics/angular/library/schema.json" + }, + "@schematics/angular:pipe": { + "$ref": "../../../../schematics/angular/pipe/schema.json" + }, + "@schematics/angular:ng-new": { + "$ref": "../../../../schematics/angular/ng-new/schema.json" + }, + "@schematics/angular:resolver": { + "$ref": "../../../../schematics/angular/resolver/schema.json" + }, + "@schematics/angular:service": { + "$ref": "../../../../schematics/angular/service/schema.json" + }, + "@schematics/angular:web-worker": { + "$ref": "../../../../schematics/angular/web-worker/schema.json" + } + }, + "additionalProperties": true + }, + "fileVersion": { + "type": "integer", + "description": "File format version", + "minimum": 1 + }, + "project": { + "type": "object", + "properties": { + "cli": { + "schematicCollections": { + "type": "array", + "description": "The list of schematic collections to use.", + "items": { + "type": "string", + "uniqueItems": true + } + } + }, + "schematics": { + "$ref": "#/definitions/schematicOptions" + }, + "prefix": { + "type": "string", + "format": "html-selector", + "description": "The prefix to apply to generated selectors." + }, + "root": { + "type": "string", + "description": "Root of the project files." + }, + "i18n": { + "$ref": "#/definitions/project/definitions/i18n" + }, + "sourceRoot": { + "type": "string", + "description": "The root of the source files, assets and index.html file structure." + }, + "projectType": { + "type": "string", + "description": "Project type.", + "enum": ["application", "library"] + }, + "architect": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/project/definitions/target" + } + }, + "targets": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/project/definitions/target" + } + } + }, + "required": ["root", "projectType"], + "anyOf": [ + { + "required": ["architect"], + "not": { + "required": ["targets"] + } + }, + { + "required": ["targets"], + "not": { + "required": ["architect"] + } + }, + { + "not": { + "required": ["targets", "architect"] + } + } + ], + "additionalProperties": false, + "patternProperties": { + "^[a-z]{1,3}-.*": {} + }, + "definitions": { + "i18n": { + "description": "Project i18n options", + "type": "object", + "properties": { + "sourceLocale": { + "oneOf": [ + { + "type": "string", + "description": "Specifies the source locale of the application.", + "default": "en-US", + "$comment": "IETF BCP 47 language tag (simplified)", + "pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$" + }, + { + "type": "object", + "description": "Localization options to use for the source locale.", + "properties": { + "code": { + "type": "string", + "description": "Specifies the locale code of the source locale.", + "pattern": "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$" + }, + "baseHref": { + "type": "string", + "description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided." + }, + "subPath": { + "type": "string", + "description": "Defines the subpath for accessing this locale. It serves as the HTML base HREF and the directory name for the output. Defaults to the locale code if not specified.", + "pattern": "^[\\w-]*$" + } + }, + "anyOf": [ + { + "required": ["subPath"], + "not": { + "required": ["baseHref"] + } + }, + { + "required": ["baseHref"], + "not": { + "required": ["subPath"] + } + }, + { + "not": { + "required": ["baseHref", "subPath"] + } + } + ], + "additionalProperties": false + } + ] + }, + "locales": { + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^[a-zA-Z]{2,3}(-[a-zA-Z]{4})?(-([a-zA-Z]{2}|[0-9]{3}))?(-[a-zA-Z]{5,8})?(-x(-[a-zA-Z0-9]{1,8})+)?$": { + "oneOf": [ + { + "type": "string", + "description": "Localization file to use for i18n." + }, + { + "type": "array", + "description": "Localization files to use for i18n.", + "items": { + "type": "string", + "uniqueItems": true + } + }, + { + "type": "object", + "description": "Localization options to use for the locale.", + "properties": { + "translation": { + "oneOf": [ + { + "type": "string", + "description": "Localization file to use for i18n." + }, + { + "type": "array", + "description": "Localization files to use for i18n.", + "items": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "baseHref": { + "type": "string", + "description": "Specifies the HTML base HREF for the locale. Defaults to the locale code if not provided." + }, + "subPath": { + "type": "string", + "description": "Defines the URL segment for accessing this locale. It serves as the HTML base HREF and the directory name for the output. Defaults to the locale code if not specified.", + "pattern": "^[\\w-]*$" + } + }, + "anyOf": [ + { + "required": ["subPath"], + "not": { + "required": ["baseHref"] + } + }, + { + "required": ["baseHref"], + "not": { + "required": ["subPath"] + } + }, + { + "not": { + "required": ["baseHref", "subPath"] + } + } + ], + "additionalProperties": false + } + ] + } + } + } + }, + "additionalProperties": false + }, + "target": { + "oneOf": [ + { + "$comment": "Extendable target with custom builder", + "type": "object", + "properties": { + "builder": { + "type": "string", + "description": "The builder used for this package.", + "not": { + "enum": [ + "@angular/build:application", + "@angular/build:library", + "@angular/build:dev-server", + "@angular/build:extract-i18n", + "@angular/build:karma", + "@angular/build:ng-packagr", + "@angular/build:unit-test", + "@angular-devkit/build-angular:application", + "@angular-devkit/build-angular:app-shell", + "@angular-devkit/build-angular:browser", + "@angular-devkit/build-angular:browser-esbuild", + "@angular-devkit/build-angular:dev-server", + "@angular-devkit/build-angular:extract-i18n", + "@angular-devkit/build-angular:karma", + "@angular-devkit/build-angular:ng-packagr", + "@angular-devkit/build-angular:prerender", + "@angular-devkit/build-angular:server", + "@angular-devkit/build-angular:ssr-dev-server" + ] + } + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "type": "object" + }, + "configurations": { + "type": "object", + "description": "A map of alternative target options.", + "additionalProperties": { + "type": "object" + } + } + }, + "additionalProperties": false, + "required": ["builder"] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:application" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:application" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:library" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/library/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/library/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:app-shell" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/app-shell/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/app-shell/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:browser" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/browser/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/browser/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:browser-esbuild" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/browser-esbuild/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/browser-esbuild/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:dev-server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:dev-server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/dev-server/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:extract-i18n" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:extract-i18n" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/extract-i18n/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/extract-i18n/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:unit-test" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/unit-test/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/unit-test/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:karma" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/karma/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/karma/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:karma" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/karma/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/karma/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:prerender" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/prerender/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/prerender/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:ssr-dev-server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/ssr-dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/ssr-dev-server/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/server/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular-devkit/build-angular:ng-packagr" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/ng-packagr/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular_devkit/build_angular/src/builders/ng-packagr/schema.json" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:ng-packagr" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/ng-packagr/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/ng-packagr/schema.json" + } + } + } + } + ] + } + } + }, + "global": { + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "version": { + "$ref": "#/definitions/fileVersion" + }, + "cli": { + "$ref": "#/definitions/cliGlobalOptions" + }, + "schematics": { + "$ref": "#/definitions/schematicOptions" + } + }, + "required": ["version"] + } + } +} diff --git a/packages/angular/cli/lib/init.ts b/packages/angular/cli/lib/init.ts index 817b3386a7c6..a503d9a87f70 100644 --- a/packages/angular/cli/lib/init.ts +++ b/packages/angular/cli/lib/init.ts @@ -1,80 +1,48 @@ /** * @license - * Copyright Google Inc. All Rights Reserved. + * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import 'symbol-observable'; -// symbol polyfill must go first -// tslint:disable-next-line:ordered-imports import-groups -import { tags } from '@angular-devkit/core'; -import * as fs from 'fs'; -import * as path from 'path'; -import { SemVer } from 'semver'; -import { Duplex } from 'stream'; -import { colors } from '../utilities/color'; -import { isWarningEnabled } from '../utilities/config'; - -const packageJson = require('../package.json'); - -function _fromPackageJson(cwd = process.cwd()): SemVer | null { - do { - const packageJsonPath = path.join(cwd, 'node_modules/@angular/cli/package.json'); - if (fs.existsSync(packageJsonPath)) { - const content = fs.readFileSync(packageJsonPath, 'utf-8'); - if (content) { - const { version } = JSON.parse(content); - if (version) { - return new SemVer(version); - } - } - } - // Check the parent. - cwd = path.dirname(cwd); - } while (cwd != path.dirname(cwd)); +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import * as path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { SemVer, major } from 'semver'; +import { disableVersionCheck } from '../src/utilities/environment-options'; +import { VERSION } from '../src/utilities/version'; - return null; -} - -// Check if we need to profile this CLI run. -if (process.env['NG_CLI_PROFILING']) { - let profiler: { - startProfiling: (name?: string, recsamples?: boolean) => void; - stopProfiling: (name?: string) => any; // tslint:disable-line:no-any - }; - try { - profiler = require('v8-profiler-node8'); // tslint:disable-line:no-implicit-dependencies - } catch (err) { - throw new Error( - `Could not require 'v8-profiler-node8'. You must install it separetely with ` + - `'npm install v8-profiler-node8 --no-save'.\n\nOriginal error:\n\n${err}`, - ); - } - - profiler.startProfiling(); - - const exitHandler = (options: { cleanup?: boolean; exit?: boolean }) => { - if (options.cleanup) { - const cpuProfile = profiler.stopProfiling(); - fs.writeFileSync( - path.resolve(process.cwd(), process.env.NG_CLI_PROFILING || '') + '.cpuprofile', - JSON.stringify(cpuProfile), - ); - } +/** + * Angular CLI versions prior to v14 may not exit correctly if not forcibly exited + * via `process.exit()`. When bootstrapping, `forceExit` will be set to `true` + * if the local CLI version is less than v14 to prevent the CLI from hanging on + * exit in those cases. + */ +let forceExit = false; - if (options.exit) { - process.exit(); +(async (): Promise => { + /** + * On Windows, ensure process.cwd() uses uppercase drive letter casing. + * Windows cmd.exe and VS Code terminals often preserve lowercase drive letters (e.g. c:\...), + * which causes ESM module duplication in Node.js (e.g. loading "vitest" twice) and path + * lookup mismatches in tools like Vite and esbuild. + */ + if (process.platform === 'win32') { + const cwd = process.cwd(); + if (/^[a-z]:/.test(cwd)) { + try { + process.chdir(cwd[0].toUpperCase() + cwd.slice(1)); + } catch { + // Ignore failure to change directory + } } - }; - process.on('exit', () => exitHandler({ cleanup: true })); - process.on('SIGINT', () => exitHandler({ exit: true })); - process.on('uncaughtException', () => exitHandler({ exit: true })); -} + // Ensure Windows CreateProcess does not search the current directory for bare executable names. + process.env['NoDefaultCurrentDirectoryInExePath'] = '1'; + } -(async () => { /** * Disable Browserslist old data warning as otherwise with every release we'd need to update this dependency * which is cumbersome considering we pin versions and the warning is not user actionable. @@ -83,59 +51,98 @@ if (process.env['NG_CLI_PROFILING']) { */ process.env.BROWSERSLIST_IGNORE_OLD_DATA = '1'; - const disableVersionCheckEnv = process.env['NG_DISABLE_VERSION_CHECK']; + const rawCommandName = process.argv[2]; + /** * Disable CLI version mismatch checks and forces usage of the invoked CLI * instead of invoking the local installed version. + * + * When running `ng new` always favor the global version. As in some + * cases orphan `node_modules` would cause the non global CLI to be used. + * @see: https://github.com/angular/angular-cli/issues/14603 */ - const disableVersionCheck = - disableVersionCheckEnv !== undefined && - disableVersionCheckEnv !== '0' && - disableVersionCheckEnv.toLowerCase() !== 'false'; - - if (disableVersionCheck) { + if (disableVersionCheck || rawCommandName === 'new') { return (await import('./cli')).default; } let cli; + try { - const projectLocalCli = require.resolve('@angular/cli', { paths: [process.cwd()] }); + // No error implies a projectLocalCli, which will load whatever + // version of ng-cli you have installed in a local package.json + const cwdRequire = createRequire(process.cwd() + '/'); + const projectLocalCli = cwdRequire.resolve('@angular/cli'); + cli = await import(pathToFileURL(projectLocalCli).href); + + const globalVersion = new SemVer(VERSION.full); + + // Older versions might not have the VERSION export + let localVersion = cli.VERSION?.full; + if (!localVersion) { + try { + const localPackageJson = await readFile( + path.join(path.dirname(projectLocalCli), '../../package.json'), + 'utf-8', + ); + localVersion = (JSON.parse(localPackageJson) as { version: string }).version; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Version mismatch check skipped. Unable to retrieve local version: ' + error); + } + } + + // Ensure older versions of the CLI fully exit + const localMajorVersion = major(localVersion); + if (localMajorVersion > 0 && localMajorVersion < 14) { + forceExit = true; - // This was run from a global, check local version. - const globalVersion = new SemVer(packageJson['version']); - let localVersion; - let shouldWarn = false; + // Versions prior to 14 didn't implement completion command. + if (rawCommandName === 'completion') { + return null; + } + } + let isGlobalGreater = false; try { - localVersion = _fromPackageJson(); - shouldWarn = localVersion != null && globalVersion.compare(localVersion) > 0; - } catch (e) { - // tslint:disable-next-line no-console - console.error(e); - shouldWarn = true; + isGlobalGreater = localVersion > 0 && globalVersion.compare(localVersion) > 0; + } catch (error) { + // eslint-disable-next-line no-console + console.error('Version mismatch check skipped. Unable to compare local version: ' + error); } - if (shouldWarn && await isWarningEnabled('versionMismatch')) { - const warning = colors.yellow(tags.stripIndents` - Your global Angular CLI version (${globalVersion}) is greater than your local - version (${localVersion}). The local Angular CLI version is used. - - To disable this warning use "ng config -g cli.warnings.versionMismatch false". - `); - // Don't show warning colorised on `ng completion` - if (process.argv[2] !== 'completion') { - // tslint:disable-next-line no-console - console.error(warning); + // When using the completion command, don't show the warning as otherwise this will break completion. + if ( + isGlobalGreater && + rawCommandName !== '--get-yargs-completions' && + rawCommandName !== 'completion' + ) { + // If using the update command and the global version is greater, use the newer update command + // This allows improvements in update to be used in older versions that do not have bootstrapping + if ( + rawCommandName === 'update' && + cli.VERSION && + cli.VERSION.major - globalVersion.major <= 1 + ) { + cli = await import('./cli'); } else { - // tslint:disable-next-line no-console - console.error(warning); - process.exit(1); + try { + const { isWarningEnabled } = await import('../src/utilities/config'); + if (await isWarningEnabled('versionMismatch')) { + // Otherwise, use local version and warn if global is newer than local + const warning = + `Your global Angular CLI version (${globalVersion}) is greater than your local ` + + `version (${localVersion}). The local Angular CLI version is used.\n\n` + + 'To disable this warning use "ng config -g cli.warnings.versionMismatch false".'; + + const { colors } = await import('../src/utilities/color'); + // eslint-disable-next-line no-console + console.error(colors.yellow(warning)); + } + } catch { + // Ignore errors during warning check to avoid falling back to global CLI + } } } - - // No error implies a projectLocalCli, which will load whatever - // version of ng-cli you have installed in a local package.json - cli = await import(projectLocalCli); } catch { // If there is an error, resolve could not find the ng-cli // library from a package.json. Instead, include it from a relative @@ -144,32 +151,29 @@ if (process.env['NG_CLI_PROFILING']) { cli = await import('./cli'); } - if ('default' in cli) { + // Support both ESM and CommonJS local CLI packages. When importing older CommonJS + // packages with an `__esModule` default export, Node.js wraps the exports in an ESM + // namespace requiring the default export to be unwrapped multiple times. + let depth = 0; + while (typeof cli === 'object' && cli !== null && 'default' in cli && depth++ < 3) { cli = cli['default']; } return cli; -})().then(cli => { - // This is required to support 1.x local versions with a 6+ global - let standardInput; - try { - standardInput = process.stdin; - } catch (e) { - delete process.stdin; - process.stdin = new Duplex(); - standardInput = process.stdin; - } - - return cli({ - cliArgs: process.argv.slice(2), - inputStream: standardInput, - outputStream: process.stdout, +})() + .then((cli) => + cli?.({ + cliArgs: process.argv.slice(2), + }), + ) + .then((exitCode = 0) => { + if (forceExit) { + process.exit(exitCode); + } + process.exitCode = exitCode; + }) + .catch((err: Error) => { + // eslint-disable-next-line no-console + console.error('Unknown error: ' + err.toString()); + process.exit(127); }); -}).then((exitCode: number) => { - process.exit(exitCode); -}) -.catch((err: Error) => { - // tslint:disable-next-line no-console - console.error('Unknown error: ' + err.toString()); - process.exit(127); -}); diff --git a/packages/angular/cli/models/analytics.ts b/packages/angular/cli/models/analytics.ts deleted file mode 100644 index 7a151b4f5d53..000000000000 --- a/packages/angular/cli/models/analytics.ts +++ /dev/null @@ -1,663 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { analytics, json, tags } from '@angular-devkit/core'; -import * as child_process from 'child_process'; -import * as debug from 'debug'; -import { writeFileSync } from 'fs'; -import * as inquirer from 'inquirer'; -import * as os from 'os'; -import * as ua from 'universal-analytics'; -import { v4 as uuidV4 } from 'uuid'; -import { colors } from '../utilities/color'; -import { getWorkspace, getWorkspaceRaw } from '../utilities/config'; -import { isTTY } from '../utilities/tty'; - -// tslint:disable: no-console -const analyticsDebug = debug('ng:analytics'); // Generate analytics, including settings and users. -const analyticsLogDebug = debug('ng:analytics:log'); // Actual logs of events. - -const BYTES_PER_GIGABYTES = 1024 * 1024 * 1024; - -let _defaultAngularCliPropertyCache: string; -export const AnalyticsProperties = { - AngularCliProd: 'UA-8594346-29', - AngularCliStaging: 'UA-8594346-32', - get AngularCliDefault(): string { - if (_defaultAngularCliPropertyCache) { - return _defaultAngularCliPropertyCache; - } - - const v = require('../package.json').version; - - // The logic is if it's a full version then we should use the prod GA property. - if (/^\d+\.\d+\.\d+$/.test(v) && v !== '0.0.0') { - _defaultAngularCliPropertyCache = AnalyticsProperties.AngularCliProd; - } else { - _defaultAngularCliPropertyCache = AnalyticsProperties.AngularCliStaging; - } - - return _defaultAngularCliPropertyCache; - }, -}; - -/** - * This is the ultimate safelist for checking if a package name is safe to report to analytics. - */ -export const analyticsPackageSafelist = [ - /^@angular\//, - /^@angular-devkit\//, - /^@ngtools\//, - '@schematics/angular', - '@schematics/schematics', - '@schematics/update', -]; - -export function isPackageNameSafeForAnalytics(name: string) { - return analyticsPackageSafelist.some(pattern => { - if (typeof pattern == 'string') { - return pattern === name; - } else { - return pattern.test(name); - } - }); -} - -/** - * Attempt to get the Windows Language Code string. - * @private - */ -function _getWindowsLanguageCode(): string | undefined { - if (!os.platform().startsWith('win')) { - return undefined; - } - - try { - // This is true on Windows XP, 7, 8 and 10 AFAIK. Would return empty string or fail if it - // doesn't work. - return child_process - .execSync('wmic.exe os get locale') - .toString() - .trim(); - } catch (_) {} - - return undefined; -} - -/** - * Get a language code. - * @private - */ -function _getLanguage() { - // Note: Windows does not expose the configured language by default. - return ( - process.env.LANG || // Default Unix env variable. - process.env.LC_CTYPE || // For C libraries. Sometimes the above isn't set. - process.env.LANGSPEC || // For Windows, sometimes this will be set (not always). - _getWindowsLanguageCode() || - '??' - ); // ¯\_(ツ)_/¯ -} - -/** - * Return the number of CPUs. - * @private - */ -function _getCpuCount() { - const cpus = os.cpus(); - - // Return "(count)x(average speed)". - return cpus.length; -} - -/** - * Get the first CPU's speed. It's very rare to have multiple CPUs of different speed (in most - * non-ARM configurations anyway), so that's all we care about. - * @private - */ -function _getCpuSpeed() { - const cpus = os.cpus(); - - return Math.floor(cpus[0].speed); -} - -/** - * Get the amount of memory, in megabytes. - * @private - */ -function _getRamSize() { - // Report in gigabytes (or closest). Otherwise it's too much noise. - return Math.round(os.totalmem() / BYTES_PER_GIGABYTES); -} - -/** - * Get the Node name and version. This returns a string like "Node 10.11", or "io.js 3.5". - * @private - */ -function _getNodeVersion() { - // We use any here because p.release is a new Node construct in Node 10 (and our typings are the - // minimal version of Node we support). - const p = process as any; // tslint:disable-line:no-any - const name = - (typeof p.release == 'object' && typeof p.release.name == 'string' && p.release.name) || - process.argv0; - - return name + ' ' + process.version; -} - -/** - * Get a numerical MAJOR.MINOR version of node. We report this as a metric. - * @private - */ -function _getNumericNodeVersion() { - const p = process.version; - const m = p.match(/\d+\.\d+/); - - return (m && m[0] && parseFloat(m[0])) || 0; -} - -// These are just approximations of UA strings. We just try to fool Google Analytics to give us the -// data we want. -// See https://developers.whatismybrowser.com/useragents/ -const osVersionMap: { [os: string]: { [release: string]: string } } = { - darwin: { - '1.3.1': '10_0_4', - '1.4.1': '10_1_0', - '5.1': '10_1_1', - '5.2': '10_1_5', - '6.0.1': '10_2', - '6.8': '10_2_8', - '7.0': '10_3_0', - '7.9': '10_3_9', - '8.0': '10_4_0', - '8.11': '10_4_11', - '9.0': '10_5_0', - '9.8': '10_5_8', - '10.0': '10_6_0', - '10.8': '10_6_8', - // We stop here because we try to math out the version for anything greater than 10, and it - // works. Those versions are standardized using a calculation now. - }, - win32: { - '6.3.9600': 'Windows 8.1', - '6.2.9200': 'Windows 8', - '6.1.7601': 'Windows 7 SP1', - '6.1.7600': 'Windows 7', - '6.0.6002': 'Windows Vista SP2', - '6.0.6000': 'Windows Vista', - '5.1.2600': 'Windows XP', - }, -}; - -/** - * Build a fake User Agent string for OSX. This gets sent to Analytics so it shows the proper OS, - * versions and others. - * @private - */ -function _buildUserAgentStringForOsx() { - let v = osVersionMap.darwin[os.release()]; - - if (!v) { - // Remove 4 to tie Darwin version to OSX version, add other info. - const x = parseFloat(os.release()); - if (x > 10) { - v = `10_` + (x - 4).toString().replace('.', '_'); - } - } - - const cpuModel = os.cpus()[0].model.match(/^[a-z]+/i); - const cpu = cpuModel ? cpuModel[0] : os.cpus()[0].model; - - return `(Macintosh; ${cpu} Mac OS X ${v || os.release()})`; -} - -/** - * Build a fake User Agent string for Windows. This gets sent to Analytics so it shows the proper - * OS, versions and others. - * @private - */ -function _buildUserAgentStringForWindows() { - return `(Windows NT ${os.release()})`; -} - -/** - * Build a fake User Agent string for Linux. This gets sent to Analytics so it shows the proper OS, - * versions and others. - * @private - */ -function _buildUserAgentStringForLinux() { - return `(X11; Linux i686; ${os.release()}; ${os.cpus()[0].model})`; -} - -/** - * Build a fake User Agent string. This gets sent to Analytics so it shows the proper OS version. - * @private - */ -function _buildUserAgentString() { - switch (os.platform()) { - case 'darwin': - return _buildUserAgentStringForOsx(); - - case 'win32': - return _buildUserAgentStringForWindows(); - - case 'linux': - return _buildUserAgentStringForLinux(); - - default: - return os.platform() + ' ' + os.release(); - } -} - -/** - * Implementation of the Analytics interface for using `universal-analytics` package. - */ -export class UniversalAnalytics implements analytics.Analytics { - private _ua: ua.Visitor; - private _dirty = false; - private _metrics: (string | number)[] = []; - private _dimensions: (string | number)[] = []; - - /** - * @param trackingId The Google Analytics ID. - * @param uid A User ID. - */ - constructor(trackingId: string, uid: string) { - this._ua = ua(trackingId, uid, { - enableBatching: true, - batchSize: 5, - }); - - // Add persistent params for appVersion. - this._ua.set('ds', 'cli'); - this._ua.set('ua', _buildUserAgentString()); - this._ua.set('ul', _getLanguage()); - - // @angular/cli with version. - this._ua.set('an', require('../package.json').name); - this._ua.set('av', require('../package.json').version); - - // We use the application ID for the Node version. This should be "node 10.10.0". - // We also use a custom metrics, but - this._ua.set('aid', _getNodeVersion()); - - // We set custom metrics for values we care about. - this._dimensions[analytics.NgCliAnalyticsDimensions.CpuCount] = _getCpuCount(); - this._dimensions[analytics.NgCliAnalyticsDimensions.CpuSpeed] = _getCpuSpeed(); - this._dimensions[analytics.NgCliAnalyticsDimensions.RamInGigabytes] = _getRamSize(); - this._dimensions[analytics.NgCliAnalyticsDimensions.NodeVersion] = _getNumericNodeVersion(); - } - - /** - * Creates the dimension and metrics variables to pass to universal-analytics. - * @private - */ - private _customVariables(options: analytics.CustomDimensionsAndMetricsOptions) { - const additionals: { [key: string]: boolean | number | string } = {}; - this._dimensions.forEach((v, i) => (additionals['cd' + i] = v)); - (options.dimensions || []).forEach((v, i) => (additionals['cd' + i] = v)); - this._metrics.forEach((v, i) => (additionals['cm' + i] = v)); - (options.metrics || []).forEach((v, i) => (additionals['cm' + i] = v)); - - return additionals; - } - - event(ec: string, ea: string, options: analytics.EventOptions = {}) { - const vars = this._customVariables(options); - analyticsLogDebug('event ec=%j, ea=%j, %j', ec, ea, vars); - - const { label: el, value: ev } = options; - this._dirty = true; - this._ua.event({ ec, ea, el, ev, ...vars }); - } - screenview(cd: string, an: string, options: analytics.ScreenviewOptions = {}) { - const vars = this._customVariables(options); - analyticsLogDebug('screenview cd=%j, an=%j, %j', cd, an, vars); - - const { appVersion: av, appId: aid, appInstallerId: aiid } = options; - this._dirty = true; - this._ua.screenview({ cd, an, av, aid, aiid, ...vars }); - } - pageview(dp: string, options: analytics.PageviewOptions = {}) { - const vars = this._customVariables(options); - analyticsLogDebug('pageview dp=%j, %j', dp, vars); - - const { hostname: dh, title: dt } = options; - this._dirty = true; - this._ua.pageview({ dp, dh, dt, ...vars }); - } - timing(utc: string, utv: string, utt: string | number, options: analytics.TimingOptions = {}) { - const vars = this._customVariables(options); - analyticsLogDebug('timing utc=%j, utv=%j, utl=%j, %j', utc, utv, utt, vars); - - const { label: utl } = options; - this._dirty = true; - this._ua.timing({ utc, utv, utt, utl, ...vars }); - } - - flush(): Promise { - if (!this._dirty) { - return Promise.resolve(); - } - - this._dirty = false; - - return new Promise(resolve => this._ua.send(resolve)); - } -} - -/** - * Set analytics settings. This does not work if the user is not inside a project. - * @param level Which config to use. "global" for user-level, and "local" for project-level. - * @param value Either a user ID, true to generate a new User ID, or false to disable analytics. - */ -export function setAnalyticsConfig(level: 'global' | 'local', value: string | boolean) { - analyticsDebug('setting %s level analytics to: %s', level, value); - const [config, configPath] = getWorkspaceRaw(level); - if (!config || !configPath) { - throw new Error(`Could not find ${level} workspace.`); - } - - const configValue = config.value; - const cli: json.JsonValue = configValue['cli'] || (configValue['cli'] = {}); - - if (!json.isJsonObject(cli)) { - throw new Error(`Invalid config found at ${configPath}. CLI should be an object.`); - } - - if (value === true) { - value = uuidV4(); - } - cli['analytics'] = value; - - const output = JSON.stringify(configValue, null, 2); - writeFileSync(configPath, output); - analyticsDebug('done'); -} - -/** - * Prompt the user for usage gathering permission. - * @param force Whether to ask regardless of whether or not the user is using an interactive shell. - * @return Whether or not the user was shown a prompt. - */ -export async function promptGlobalAnalytics(force = false) { - analyticsDebug('prompting global analytics.'); - if (force || isTTY()) { - const answers = await inquirer.prompt<{ analytics: boolean }>([ - { - type: 'confirm', - name: 'analytics', - message: tags.stripIndents` - Would you like to share anonymous usage data with the Angular Team at Google under - Google’s Privacy Policy at https://policies.google.com/privacy? For more details and - how to change this setting, see http://angular.io/analytics. - `, - default: false, - }, - ]); - - setAnalyticsConfig('global', answers.analytics); - - if (answers.analytics) { - console.log(''); - console.log(tags.stripIndent` - Thank you for sharing anonymous usage data. If you change your mind, the following - command will disable this feature entirely: - - ${colors.yellow('ng analytics off')} - `); - console.log(''); - - // Send back a ping with the user `optin`. - const ua = new UniversalAnalytics(AnalyticsProperties.AngularCliDefault, 'optin'); - ua.pageview('/telemetry/optin'); - await ua.flush(); - } else { - // Send back a ping with the user `optout`. This is the only thing we send. - const ua = new UniversalAnalytics(AnalyticsProperties.AngularCliDefault, 'optout'); - ua.pageview('/telemetry/optout'); - await ua.flush(); - } - - return true; - } else { - analyticsDebug('Either STDOUT or STDIN are not TTY and we skipped the prompt.'); - } - - return false; -} - -/** - * Prompt the user for usage gathering permission for the local project. Fails if there is no - * local workspace. - * @param force Whether to ask regardless of whether or not the user is using an interactive shell. - * @return Whether or not the user was shown a prompt. - */ -export async function promptProjectAnalytics(force = false): Promise { - analyticsDebug('prompting user'); - const [config, configPath] = getWorkspaceRaw('local'); - if (!config || !configPath) { - throw new Error(`Could not find a local workspace. Are you in a project?`); - } - - if (force || isTTY()) { - const answers = await inquirer.prompt<{ analytics: boolean }>([ - { - type: 'confirm', - name: 'analytics', - message: tags.stripIndents` - Would you like to share anonymous usage data about this project with the Angular Team at - Google under Google’s Privacy Policy at https://policies.google.com/privacy? For more - details and how to change this setting, see http://angular.io/analytics. - - `, - default: false, - }, - ]); - - setAnalyticsConfig('local', answers.analytics); - - if (answers.analytics) { - console.log(''); - console.log(tags.stripIndent` - Thank you for sharing anonymous usage data. Would you change your mind, the following - command will disable this feature entirely: - - ${colors.yellow('ng analytics project off')} - `); - console.log(''); - - // Send back a ping with the user `optin`. - const ua = new UniversalAnalytics(AnalyticsProperties.AngularCliDefault, 'optin'); - ua.pageview('/telemetry/project/optin'); - await ua.flush(); - } else { - // Send back a ping with the user `optout`. This is the only thing we send. - const ua = new UniversalAnalytics(AnalyticsProperties.AngularCliDefault, 'optout'); - ua.pageview('/telemetry/project/optout'); - await ua.flush(); - } - - return true; - } - - return false; -} - -export async function hasGlobalAnalyticsConfiguration(): Promise { - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; - - if (analyticsConfig !== null && analyticsConfig !== undefined) { - return true; - } - } catch {} - - return false; -} - -/** - * Get the global analytics object for the user. This returns an instance of UniversalAnalytics, - * or undefined if analytics are disabled. - * - * If any problem happens, it is considered the user has been opting out of analytics. - */ -export async function getGlobalAnalytics(): Promise { - analyticsDebug('getGlobalAnalytics'); - const propertyId = AnalyticsProperties.AngularCliDefault; - - if ('NG_CLI_ANALYTICS' in process.env) { - if (process.env['NG_CLI_ANALYTICS'] == 'false' || process.env['NG_CLI_ANALYTICS'] == '') { - analyticsDebug('NG_CLI_ANALYTICS is false'); - - return undefined; - } - if (process.env['NG_CLI_ANALYTICS'] === 'ci') { - analyticsDebug('Running in CI mode'); - - return new UniversalAnalytics(propertyId, 'ci'); - } - } - - // If anything happens we just keep the NOOP analytics. - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig: string | undefined | null | { uid?: string } = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analytics']; - analyticsDebug('Client Analytics config found: %j', analyticsConfig); - - if (analyticsConfig === false) { - analyticsDebug('Analytics disabled. Ignoring all analytics.'); - - return undefined; - } else if (analyticsConfig === undefined || analyticsConfig === null) { - analyticsDebug('Analytics settings not found. Ignoring all analytics.'); - - // globalWorkspace can be null if there is no file. analyticsConfig would be null in this - // case. Since there is no file, the user hasn't answered and the expected return value is - // undefined. - return undefined; - } else { - let uid: string | undefined = undefined; - if (typeof analyticsConfig == 'string') { - uid = analyticsConfig; - } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { - uid = analyticsConfig['uid']; - } - - analyticsDebug('client id: %j', uid); - if (uid == undefined) { - return undefined; - } - - return new UniversalAnalytics(propertyId, uid); - } - } catch (err) { - analyticsDebug('Error happened during reading of analytics config: %s', err.message); - - return undefined; - } -} - -export async function hasWorkspaceAnalyticsConfiguration(): Promise { - try { - const globalWorkspace = await getWorkspace('local'); - const analyticsConfig: string | undefined | null | { uid?: string } = globalWorkspace - && globalWorkspace.getCli() - && globalWorkspace.getCli()['analytics']; - - if (analyticsConfig !== undefined) { - return true; - } - } catch {} - - return false; -} - -/** - * Get the workspace analytics object for the user. This returns an instance of UniversalAnalytics, - * or undefined if analytics are disabled. - * - * If any problem happens, it is considered the user has been opting out of analytics. - */ -export async function getWorkspaceAnalytics(): Promise { - analyticsDebug('getWorkspaceAnalytics'); - try { - const globalWorkspace = await getWorkspace('local'); - const analyticsConfig: string | undefined | null | { uid?: string } = globalWorkspace - && globalWorkspace.getCli() - && globalWorkspace.getCli()['analytics']; - analyticsDebug('Workspace Analytics config found: %j', analyticsConfig); - - if (analyticsConfig === false) { - analyticsDebug('Analytics disabled. Ignoring all analytics.'); - - return undefined; - } else if (analyticsConfig === undefined || analyticsConfig === null) { - analyticsDebug('Analytics settings not found. Ignoring all analytics.'); - - return undefined; - } else { - let uid: string | undefined = undefined; - if (typeof analyticsConfig == 'string') { - uid = analyticsConfig; - } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { - uid = analyticsConfig['uid']; - } - - analyticsDebug('client id: %j', uid); - if (uid == undefined) { - return undefined; - } - - return new UniversalAnalytics(AnalyticsProperties.AngularCliDefault, uid); - } - } catch (err) { - analyticsDebug('Error happened during reading of analytics config: %s', err.message); - - return undefined; - } - -} - -/** - * Return the usage analytics sharing setting, which is either a property string (GA-XXXXXXX-XX), - * or undefined if no sharing. - */ -export async function getSharedAnalytics(): Promise { - analyticsDebug('getSharedAnalytics'); - - const envVarName = 'NG_CLI_ANALYTICS_SHARE'; - if (envVarName in process.env) { - if (process.env[envVarName] == 'false' || process.env[envVarName] == '') { - analyticsDebug('NG_CLI_ANALYTICS is false'); - - return undefined; - } - } - - // If anything happens we just keep the NOOP analytics. - try { - const globalWorkspace = await getWorkspace('global'); - const analyticsConfig = - globalWorkspace && globalWorkspace.getCli() && globalWorkspace.getCli()['analyticsSharing']; - - if (!analyticsConfig || !analyticsConfig.tracking || !analyticsConfig.uuid) { - return undefined; - } else { - analyticsDebug('Analytics sharing info: %j', analyticsConfig); - - return new UniversalAnalytics(analyticsConfig.tracking, analyticsConfig.uuid); - } - } catch (err) { - analyticsDebug('Error happened during reading of analytics sharing config: %s', err.message); - - return undefined; - } -} diff --git a/packages/angular/cli/models/architect-command.ts b/packages/angular/cli/models/architect-command.ts deleted file mode 100644 index 5e64a6bff178..000000000000 --- a/packages/angular/cli/models/architect-command.ts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { Architect, Target } from '@angular-devkit/architect'; -import { WorkspaceNodeModulesArchitectHost } from '@angular-devkit/architect/node'; -import { json, schema, tags, workspaces } from '@angular-devkit/core'; -import { NodeJsSyncHost } from '@angular-devkit/core/node'; -import { BepJsonWriter } from '../utilities/bep'; -import { parseJsonSchemaToOptions } from '../utilities/json-schema'; -import { isPackageNameSafeForAnalytics } from './analytics'; -import { BaseCommandOptions, Command } from './command'; -import { Arguments, Option } from './interface'; -import { parseArguments } from './parser'; - -export interface ArchitectCommandOptions extends BaseCommandOptions { - project?: string; - configuration?: string; - prod?: boolean; - target?: string; -} - -export abstract class ArchitectCommand< - T extends ArchitectCommandOptions = ArchitectCommandOptions -> extends Command { - protected _architect: Architect; - protected _architectHost: WorkspaceNodeModulesArchitectHost; - protected _workspace: workspaces.WorkspaceDefinition; - protected _registry: json.schema.SchemaRegistry; - - // If this command supports running multiple targets. - protected multiTarget = false; - - target: string | undefined; - missingTargetError: string | undefined; - - public async initialize(options: T & Arguments): Promise { - await super.initialize(options); - - this._registry = new json.schema.CoreSchemaRegistry(); - this._registry.addPostTransform(json.schema.transforms.addUndefinedDefaults); - - const { workspace } = await workspaces.readWorkspace( - this.workspace.root, - workspaces.createWorkspaceHost(new NodeJsSyncHost()), - ); - this._workspace = workspace; - - this._architectHost = new WorkspaceNodeModulesArchitectHost(workspace, this.workspace.root); - this._architect = new Architect(this._architectHost, this._registry); - - if (!this.target) { - if (options.help) { - // This is a special case where we just return. - return; - } - - const specifier = this._makeTargetSpecifier(options); - if (!specifier.project || !specifier.target) { - throw new Error('Cannot determine project or target for command.'); - } - - return; - } - - const commandLeftovers = options['--']; - let projectName = options.project; - const targetProjectNames: string[] = []; - for (const [name, project] of this._workspace.projects) { - if (project.targets.has(this.target)) { - targetProjectNames.push(name); - } - } - - if (targetProjectNames.length === 0) { - throw new Error(this.missingTargetError || `No projects support the '${this.target}' target.`); - } - - if (projectName && !targetProjectNames.includes(projectName)) { - throw new Error(this.missingTargetError || - `Project '${projectName}' does not support the '${this.target}' target.`); - } - - if (!projectName && commandLeftovers && commandLeftovers.length > 0) { - const builderNames = new Set(); - const leftoverMap = new Map(); - let potentialProjectNames = new Set(targetProjectNames); - for (const name of targetProjectNames) { - const builderName = await this._architectHost.getBuilderNameForTarget({ - project: name, - target: this.target, - }); - - if (this.multiTarget) { - builderNames.add(builderName); - } - - const builderDesc = await this._architectHost.resolveBuilder(builderName); - const optionDefs = await parseJsonSchemaToOptions( - this._registry, - builderDesc.optionSchema as json.JsonObject, - ); - const parsedOptions = parseArguments([...commandLeftovers], optionDefs); - const builderLeftovers = parsedOptions['--'] || []; - leftoverMap.set(name, { optionDefs, parsedOptions }); - - potentialProjectNames = new Set(builderLeftovers.filter(x => potentialProjectNames.has(x))); - } - - if (potentialProjectNames.size === 1) { - projectName = [...potentialProjectNames][0]; - - // remove the project name from the leftovers - const optionInfo = leftoverMap.get(projectName); - if (optionInfo) { - const locations = []; - let i = 0; - while (i < commandLeftovers.length) { - i = commandLeftovers.indexOf(projectName, i + 1); - if (i === -1) { - break; - } - locations.push(i); - } - delete optionInfo.parsedOptions['--']; - for (const location of locations) { - const tempLeftovers = [...commandLeftovers]; - tempLeftovers.splice(location, 1); - const tempArgs = parseArguments([...tempLeftovers], optionInfo.optionDefs); - delete tempArgs['--']; - if (JSON.stringify(optionInfo.parsedOptions) === JSON.stringify(tempArgs)) { - options['--'] = tempLeftovers; - break; - } - } - } - } - - if (!projectName && this.multiTarget && builderNames.size > 1) { - throw new Error(tags.oneLine` - Architect commands with command line overrides cannot target different builders. The - '${this.target}' target would run on projects ${targetProjectNames.join()} which have the - following builders: ${'\n ' + [...builderNames].join('\n ')} - `); - } - } - - if (!projectName && !this.multiTarget) { - const defaultProjectName = this._workspace.extensions['defaultProject'] as string; - if (targetProjectNames.length === 1) { - projectName = targetProjectNames[0]; - } else if (defaultProjectName && targetProjectNames.includes(defaultProjectName)) { - projectName = defaultProjectName; - } else if (options.help) { - // This is a special case where we just return. - return; - } else { - throw new Error(this.missingTargetError || 'Cannot determine project or target for command.'); - } - } - - options.project = projectName; - - const builderConf = await this._architectHost.getBuilderNameForTarget({ - project: projectName || (targetProjectNames.length > 0 ? targetProjectNames[0] : ''), - target: this.target, - }); - const builderDesc = await this._architectHost.resolveBuilder(builderConf); - - this.description.options.push( - ...(await parseJsonSchemaToOptions( - this._registry, - builderDesc.optionSchema as json.JsonObject, - )), - ); - - // Update options to remove analytics from options if the builder isn't safelisted. - for (const o of this.description.options) { - if (o.userAnalytics && !isPackageNameSafeForAnalytics(builderConf)) { - o.userAnalytics = undefined; - } - } - } - - async run(options: ArchitectCommandOptions & Arguments) { - return await this.runArchitectTarget(options); - } - - protected async runBepTarget( - command: string, - configuration: Target, - overrides: json.JsonObject, - buildEventLog: string, - ): Promise { - const bep = new BepJsonWriter(buildEventLog); - - // Send start - bep.writeBuildStarted(command); - - let last = 1; - let rebuild = false; - const run = await this._architect.scheduleTarget(configuration, overrides, { - logger: this.logger, - }); - await run.output.forEach(event => { - last = event.success ? 0 : 1; - - if (rebuild) { - // NOTE: This will have an incorrect timestamp but this cannot be fixed - // until builders report additional status events - bep.writeBuildStarted(command); - } else { - rebuild = true; - } - - bep.writeBuildFinished(last); - }); - - await run.stop(); - - return last; - } - - protected async runSingleTarget( - target: Target, - targetOptions: string[], - commandOptions: ArchitectCommandOptions & Arguments, - ) { - // We need to build the builderSpec twice because architect does not understand - // overrides separately (getting the configuration builds the whole project, including - // overrides). - const builderConf = await this._architectHost.getBuilderNameForTarget(target); - const builderDesc = await this._architectHost.resolveBuilder(builderConf); - const targetOptionArray = await parseJsonSchemaToOptions( - this._registry, - builderDesc.optionSchema as json.JsonObject, - ); - const overrides = parseArguments(targetOptions, targetOptionArray, this.logger); - - const allowAdditionalProperties = - typeof builderDesc.optionSchema === 'object' && builderDesc.optionSchema.additionalProperties; - - if (overrides['--'] && !allowAdditionalProperties) { - (overrides['--'] || []).forEach(additional => { - this.logger.fatal(`Unknown option: '${additional.split(/=/)[0]}'`); - }); - - return 1; - } - - if (commandOptions.buildEventLog && ['build', 'serve'].includes(this.description.name)) { - // The build/serve commands supports BEP messaging - this.logger.warn('BEP support is experimental and subject to change.'); - - return this.runBepTarget( - this.description.name, - target, - overrides as json.JsonObject, - commandOptions.buildEventLog as string, - ); - } else { - const run = await this._architect.scheduleTarget(target, overrides as json.JsonObject, { - logger: this.logger, - analytics: isPackageNameSafeForAnalytics(builderConf) ? this.analytics : undefined, - }); - - const { error, success } = await run.output.toPromise(); - await run.stop(); - - if (error) { - this.logger.error(error); - } - - return success ? 0 : 1; - } - } - - protected async runArchitectTarget( - options: ArchitectCommandOptions & Arguments, - ): Promise { - const extra = options['--'] || []; - - try { - const targetSpec = this._makeTargetSpecifier(options); - if (!targetSpec.project && this.target) { - // This runs each target sequentially. - // Running them in parallel would jumble the log messages. - let result = 0; - for (const project of this.getProjectNamesByTarget(this.target)) { - result |= await this.runSingleTarget( - { ...targetSpec, project } as Target, - extra, - options, - ); - } - - return result; - } else { - return await this.runSingleTarget(targetSpec, extra, options); - } - } catch (e) { - if (e instanceof schema.SchemaValidationException) { - const newErrors: schema.SchemaValidatorError[] = []; - for (const schemaError of e.errors) { - if (schemaError.keyword === 'additionalProperties') { - const unknownProperty = schemaError.params.additionalProperty; - if (unknownProperty in options) { - const dashes = unknownProperty.length === 1 ? '-' : '--'; - this.logger.fatal(`Unknown option: '${dashes}${unknownProperty}'`); - continue; - } - } - newErrors.push(schemaError); - } - - if (newErrors.length > 0) { - this.logger.error(new schema.SchemaValidationException(newErrors).message); - } - - return 1; - } else { - throw e; - } - } - } - - private getProjectNamesByTarget(targetName: string): string[] { - const allProjectsForTargetName: string[] = []; - for (const [name, project] of this._workspace.projects) { - if (project.targets.has(targetName)) { - allProjectsForTargetName.push(name); - } - } - - if (this.multiTarget) { - // For multi target commands, we always list all projects that have the target. - return allProjectsForTargetName; - } else { - // For single target commands, we try the default project first, - // then the full list if it has a single project, then error out. - const maybeDefaultProject = this._workspace.extensions['defaultProject'] as string; - if (maybeDefaultProject && allProjectsForTargetName.includes(maybeDefaultProject)) { - return [maybeDefaultProject]; - } - - if (allProjectsForTargetName.length === 1) { - return allProjectsForTargetName; - } - - throw new Error(`Could not determine a single project for the '${targetName}' target.`); - } - } - - private _makeTargetSpecifier(commandOptions: ArchitectCommandOptions): Target { - let project, target, configuration; - - if (commandOptions.target) { - [project, target, configuration] = commandOptions.target.split(':'); - - if (commandOptions.configuration) { - configuration = commandOptions.configuration; - } - } else { - project = commandOptions.project; - target = this.target; - if (commandOptions.prod) { - // The --prod flag will always be the first configuration, available to be overwritten - // by following configurations. - configuration = 'production'; - } - if (commandOptions.configuration) { - configuration = - `${configuration ? `${configuration},` : ''}${commandOptions.configuration}`; - } - } - - if (!project) { - project = ''; - } - if (!target) { - target = ''; - } - - return { - project, - configuration: configuration || '', - target, - }; - } -} diff --git a/packages/angular/cli/models/command-runner.ts b/packages/angular/cli/models/command-runner.ts deleted file mode 100644 index 4aa7100dc916..000000000000 --- a/packages/angular/cli/models/command-runner.ts +++ /dev/null @@ -1,267 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { - JsonParseMode, - analytics, - isJsonObject, - json, - logging, - schema, - strings, - tags, -} from '@angular-devkit/core'; -import * as debug from 'debug'; -import { readFileSync } from 'fs'; -import { join, resolve } from 'path'; -import { parseJsonSchemaToCommandDescription } from '../utilities/json-schema'; -import { - getGlobalAnalytics, - getSharedAnalytics, - getWorkspaceAnalytics, - hasWorkspaceAnalyticsConfiguration, - promptProjectAnalytics, -} from './analytics'; -import { Command } from './command'; -import { CommandDescription, CommandWorkspace } from './interface'; -import * as parser from './parser'; - -const analyticsDebug = debug('ng:analytics:commands'); - -// NOTE: Update commands.json if changing this. It's still deep imported in one CI validation -const standardCommands = { - 'add': '../commands/add.json', - 'analytics': '../commands/analytics.json', - 'build': '../commands/build.json', - 'deploy': '../commands/deploy.json', - 'config': '../commands/config.json', - 'doc': '../commands/doc.json', - 'e2e': '../commands/e2e.json', - 'make-this-awesome': '../commands/easter-egg.json', - 'generate': '../commands/generate.json', - 'get': '../commands/deprecated.json', - 'set': '../commands/deprecated.json', - 'help': '../commands/help.json', - 'lint': '../commands/lint.json', - 'new': '../commands/new.json', - 'run': '../commands/run.json', - 'serve': '../commands/serve.json', - 'test': '../commands/test.json', - 'update': '../commands/update.json', - 'version': '../commands/version.json', - 'xi18n': '../commands/xi18n.json', -}; - -export interface CommandMapOptions { - [key: string]: string; -} - -/** - * Create the analytics instance. - * @private - */ -async function _createAnalytics(workspace: boolean, skipPrompt = false): Promise { - let config = await getGlobalAnalytics(); - // If in workspace and global analytics is enabled, defer to workspace level - if (workspace && config) { - const skipAnalytics = - skipPrompt || - (process.env['NG_CLI_ANALYTICS'] && - (process.env['NG_CLI_ANALYTICS'].toLowerCase() === 'false' || - process.env['NG_CLI_ANALYTICS'] === '0')); - // TODO: This should honor the `no-interactive` option. - // It is currently not an `ng` option but rather only an option for specific commands. - // The concept of `ng`-wide options are needed to cleanly handle this. - if (!skipAnalytics && !(await hasWorkspaceAnalyticsConfiguration())) { - await promptProjectAnalytics(); - } - config = await getWorkspaceAnalytics(); - } - - const maybeSharedAnalytics = await getSharedAnalytics(); - - if (config && maybeSharedAnalytics) { - return new analytics.MultiAnalytics([config, maybeSharedAnalytics]); - } else if (config) { - return config; - } else if (maybeSharedAnalytics) { - return maybeSharedAnalytics; - } else { - return new analytics.NoopAnalytics(); - } -} - -async function loadCommandDescription( - name: string, - path: string, - registry: json.schema.CoreSchemaRegistry, -): Promise { - const schemaPath = resolve(__dirname, path); - const schemaContent = readFileSync(schemaPath, 'utf-8'); - const schema = json.parseJson(schemaContent, JsonParseMode.Loose, { path: schemaPath }); - if (!isJsonObject(schema)) { - throw new Error('Invalid command JSON loaded from ' + JSON.stringify(schemaPath)); - } - - return parseJsonSchemaToCommandDescription(name, schemaPath, registry, schema); -} - -/** - * Run a command. - * @param args Raw unparsed arguments. - * @param logger The logger to use. - * @param workspace Workspace information. - * @param commands The map of supported commands. - * @param options Additional options. - */ -export async function runCommand( - args: string[], - logger: logging.Logger, - workspace: CommandWorkspace, - commands: CommandMapOptions = standardCommands, - options: { analytics?: analytics.Analytics } = {}, -): Promise { - // This registry is exclusively used for flattening schemas, and not for validating. - const registry = new schema.CoreSchemaRegistry([]); - registry.registerUriHandler((uri: string) => { - if (uri.startsWith('ng-cli://')) { - const content = readFileSync(join(__dirname, '..', uri.substr('ng-cli://'.length)), 'utf-8'); - - return Promise.resolve(JSON.parse(content)); - } else { - return null; - } - }); - - let commandName: string | undefined = undefined; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - if (!arg.startsWith('-')) { - commandName = arg; - args.splice(i, 1); - break; - } - } - - let description: CommandDescription | null = null; - - // if no commands were found, use `help`. - if (!commandName) { - if (args.length === 1 && args[0] === '--version') { - commandName = 'version'; - } else { - commandName = 'help'; - } - - if (!(commandName in commands)) { - logger.error(tags.stripIndent` - The "${commandName}" command seems to be disabled. - This is an issue with the CLI itself. If you see this comment, please report it and - provide your repository. - `); - - return 1; - } - } - - if (commandName in commands) { - description = await loadCommandDescription(commandName, commands[commandName], registry); - } else { - const commandNames = Object.keys(commands); - - // Optimize loading for common aliases - if (commandName.length === 1) { - commandNames.sort((a, b) => { - const aMatch = a[0] === commandName; - const bMatch = b[0] === commandName; - if (aMatch && !bMatch) { - return -1; - } else if (!aMatch && bMatch) { - return 1; - } else { - return 0; - } - }); - } - - for (const name of commandNames) { - const aliasDesc = await loadCommandDescription(name, commands[name], registry); - const aliases = aliasDesc.aliases; - - if (aliases && aliases.some(alias => alias === commandName)) { - commandName = name; - description = aliasDesc; - break; - } - } - } - - if (!description) { - const commandsDistance = {} as { [name: string]: number }; - const name = commandName; - const allCommands = Object.keys(commands).sort((a, b) => { - if (!(a in commandsDistance)) { - commandsDistance[a] = strings.levenshtein(a, name); - } - if (!(b in commandsDistance)) { - commandsDistance[b] = strings.levenshtein(b, name); - } - - return commandsDistance[a] - commandsDistance[b]; - }); - - logger.error(tags.stripIndent` - The specified command ("${commandName}") is invalid. For a list of available options, - run "ng help". - - Did you mean "${allCommands[0]}"? - `); - - return 1; - } - - try { - const parsedOptions = parser.parseArguments(args, description.options, logger); - Command.setCommandMap(async () => { - const map: Record = {}; - for (const [name, path] of Object.entries(commands)) { - map[name] = await loadCommandDescription(name, path, registry); - } - - return map; - }); - - const analytics = - options.analytics || - (await _createAnalytics(!!workspace.configFile, description.name === 'update')); - const context = { workspace, analytics }; - const command = new description.impl(context, description, logger); - - // Flush on an interval (if the event loop is waiting). - let analyticsFlushPromise = Promise.resolve(); - setInterval(() => { - analyticsFlushPromise = analyticsFlushPromise.then(() => analytics.flush()); - }, 1000); - - const result = await command.validateAndRun(parsedOptions); - - // Flush one last time. - await analyticsFlushPromise.then(() => analytics.flush()); - - return result; - } catch (e) { - if (e instanceof parser.ParseArgumentException) { - logger.fatal('Cannot parse arguments. See below for the reasons.'); - logger.fatal(' ' + e.comments.join('\n ')); - - return 1; - } else { - throw e; - } - } -} diff --git a/packages/angular/cli/models/command.ts b/packages/angular/cli/models/command.ts deleted file mode 100644 index 6519393aae2f..000000000000 --- a/packages/angular/cli/models/command.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -// tslint:disable:no-global-tslint-disable no-any -import { analytics, logging, strings, tags } from '@angular-devkit/core'; -import * as path from 'path'; -import { colors } from '../utilities/color'; -import { getWorkspace } from '../utilities/config'; -import { - Arguments, - CommandContext, - CommandDescription, - CommandDescriptionMap, - CommandScope, - CommandWorkspace, - Option, - SubCommandDescription, -} from './interface'; - -export interface BaseCommandOptions { - help?: boolean | string; -} - -export abstract class Command { - public allowMissingWorkspace = false; - public workspace: CommandWorkspace; - public analytics: analytics.Analytics; - - protected static commandMap: () => Promise; - static setCommandMap(map: () => Promise) { - this.commandMap = map; - } - - constructor( - context: CommandContext, - public readonly description: CommandDescription, - protected readonly logger: logging.Logger, - ) { - this.workspace = context.workspace; - this.analytics = context.analytics || new analytics.NoopAnalytics(); - } - - async initialize(options: T & Arguments): Promise { - return; - } - - async printHelp(options: T & Arguments): Promise { - await this.printHelpUsage(); - await this.printHelpOptions(); - - return 0; - } - - async printJsonHelp(_options: T & Arguments): Promise { - this.logger.info(JSON.stringify(this.description)); - - return 0; - } - - protected async printHelpUsage() { - this.logger.info(this.description.description); - - const name = this.description.name; - const args = this.description.options.filter(x => x.positional !== undefined); - const opts = this.description.options.filter(x => x.positional === undefined); - - const argDisplay = args && args.length > 0 ? ' ' + args.map(a => `<${a.name}>`).join(' ') : ''; - const optionsDisplay = opts && opts.length > 0 ? ` [options]` : ``; - - this.logger.info(`usage: ng ${name}${argDisplay}${optionsDisplay}`); - this.logger.info(''); - } - - protected async printHelpSubcommand(subcommand: SubCommandDescription) { - this.logger.info(subcommand.description); - - await this.printHelpOptions(subcommand.options); - } - - protected async printHelpOptions(options: Option[] = this.description.options) { - const args = options.filter(opt => opt.positional !== undefined); - const opts = options.filter(opt => opt.positional === undefined); - - const formatDescription = (description: string) => - ` ${description.replace(/\n/g, '\n ')}`; - - if (args.length > 0) { - this.logger.info(`arguments:`); - args.forEach(o => { - this.logger.info(` ${colors.cyan(o.name)}`); - if (o.description) { - this.logger.info(formatDescription(o.description)); - } - }); - } - if (options.length > 0) { - if (args.length > 0) { - this.logger.info(''); - } - this.logger.info(`options:`); - opts - .filter(o => !o.hidden) - .sort((a, b) => a.name.localeCompare(b.name)) - .forEach(o => { - const aliases = - o.aliases && o.aliases.length > 0 - ? '(' + o.aliases.map(a => `-${a}`).join(' ') + ')' - : ''; - this.logger.info(` ${colors.cyan('--' + strings.dasherize(o.name))} ${aliases}`); - if (o.description) { - this.logger.info(formatDescription(o.description)); - } - }); - } - } - - async validateScope(scope?: CommandScope): Promise { - switch (scope === undefined ? this.description.scope : scope) { - case CommandScope.OutProject: - if (this.workspace.configFile) { - this.logger.fatal(tags.oneLine` - The ${this.description.name} command requires to be run outside of a project, but a - project definition was found at "${path.join( - this.workspace.root, - this.workspace.configFile, - )}". - `); - throw 1; - } - break; - case CommandScope.InProject: - if (!this.workspace.configFile || (await getWorkspace('local')) === null) { - this.logger.fatal(tags.oneLine` - The ${this.description.name} command requires to be run in an Angular project, but a - project definition could not be found. - `); - throw 1; - } - break; - case CommandScope.Everywhere: - // Can't miss this. - break; - } - } - - async reportAnalytics( - paths: string[], - options: T & Arguments, - dimensions: (boolean | number | string)[] = [], - metrics: (boolean | number | string)[] = [], - ): Promise { - for (const option of this.description.options) { - const ua = option.userAnalytics; - const v = options[option.name]; - - if (v !== undefined && !Array.isArray(v) && ua) { - dimensions[ua] = v; - } - } - - this.analytics.pageview('/command/' + paths.join('/'), { dimensions, metrics }); - } - - abstract async run(options: T & Arguments): Promise; - - async validateAndRun(options: T & Arguments): Promise { - if (!(options.help === true || options.help === 'json' || options.help === 'JSON')) { - await this.validateScope(); - } - await this.initialize(options); - - if (options.help === true) { - return this.printHelp(options); - } else if (options.help === 'json' || options.help === 'JSON') { - return this.printJsonHelp(options); - } else { - const startTime = +new Date(); - await this.reportAnalytics([this.description.name], options); - const result = await this.run(options); - const endTime = +new Date(); - - this.analytics.timing(this.description.name, 'duration', endTime - startTime); - - return result; - } - } -} diff --git a/packages/angular/cli/models/error.ts b/packages/angular/cli/models/error.ts deleted file mode 100644 index 5d9d323ed103..000000000000 --- a/packages/angular/cli/models/error.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -export class NgToolkitError extends Error { - constructor(message?: string) { - super(); - - if (message) { - this.message = message; - } else { - this.message = this.constructor.name; - } - } -} diff --git a/packages/angular/cli/models/interface.ts b/packages/angular/cli/models/interface.ts deleted file mode 100644 index 17292c95269b..000000000000 --- a/packages/angular/cli/models/interface.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { analytics, json, logging } from '@angular-devkit/core'; - -/** - * Value type of arguments. - */ -export type Value = number | string | boolean | (number | string | boolean)[]; - -/** - * An object representing parsed arguments from the command line. - */ -export interface Arguments { - [argName: string]: Value | undefined; - - /** - * Extra arguments that were not parsed. Will be omitted if all arguments were parsed. - */ - '--'?: string[]; -} - -/** - * The base interface for Command, understood by the command runner. - */ -export interface CommandInterface { - printHelp(options: T): Promise; - printJsonHelp(options: T): Promise; - validateAndRun(options: T): Promise; -} - -/** - * Command constructor. - */ -export interface CommandConstructor { - new( - context: CommandContext, - description: CommandDescription, - logger: logging.Logger, - ): CommandInterface; -} - -/** - * A CLI workspace information. - */ -export interface CommandWorkspace { - root: string; - configFile?: string; -} - -/** - * A command runner context. - */ -export interface CommandContext { - workspace: CommandWorkspace; - - // This feel is optional for backward compatibility. - analytics?: analytics.Analytics; -} - -/** - * Value types of an Option. - */ -export enum OptionType { - Any = 'any', - Array = 'array', - Boolean = 'boolean', - Number = 'number', - String = 'string', -} - -/** - * An option description. This is exposed when using `ng --help=json`. - */ -export interface Option { - /** - * The name of the option. - */ - name: string; - - /** - * A short description of the option. - */ - description: string; - - /** - * The type of option value. If multiple types exist, this type will be the first one, and the - * types array will contain all types accepted. - */ - type: OptionType; - - /** - * {@see type} - */ - types?: OptionType[]; - - /** - * If this field is set, only values contained in this field are valid. This array can be mixed - * types (strings, numbers, boolean). For example, if this field is "enum: ['hello', true]", - * then "type" will be either string or boolean, types will be at least both, and the values - * accepted will only be either 'hello' or true (not false or any other string). - * This mean that prefixing with `no-` will not work on this field. - */ - enum?: Value[]; - - /** - * If this option maps to a subcommand in the parent command, will contain all the subcommands - * supported. There is a maximum of 1 subcommand Option per command, and the type of this - * option will always be "string" (no other types). The value of this option will map into - * this map and return the extra information. - */ - subcommands?: { - [name: string]: SubCommandDescription; - }; - - /** - * Aliases supported by this option. - */ - aliases: string[]; - - /** - * Whether this option is required or not. - */ - required?: boolean; - - /** - * Format field of this option. - */ - format?: string; - - /** - * Whether this option should be hidden from the help output. It will still show up in JSON help. - */ - hidden?: boolean; - - /** - * Default value of this option. - */ - default?: string | number | boolean; - - /** - * If this option can be used as an argument, the position of the argument. Otherwise omitted. - */ - positional?: number; - - /** - * Deprecation. If this flag is not false a warning will be shown on the console. Either `true` - * or a string to show the user as a notice. - */ - deprecated?: boolean | string; - - /** - * Smart default object. - */ - $default?: OptionSmartDefault; - - /** - * Whether or not to report this option to the Angular Team, and which custom field to use. - * If this is falsey, do not report this option. - */ - userAnalytics?: number; -} - -/** - * Scope of the command. - */ -export enum CommandScope { - InProject = 'in', - OutProject = 'out', - Everywhere = 'all', - - Default = InProject, -} - -/** - * A description of a command and its options. - */ -export interface SubCommandDescription { - /** - * The name of the subcommand. - */ - name: string; - - /** - * Short description (1-2 lines) of this sub command. - */ - description: string; - - /** - * A long description of the sub command, in Markdown format. - */ - longDescription?: string; - - /** - * Additional notes about usage of this sub command, in Markdown format. - */ - usageNotes?: string; - - /** - * List of all supported options. - */ - options: Option[]; - - /** - * Aliases supported for this sub command. - */ - aliases: string[]; -} - -/** - * A description of a command, its metadata. - */ -export interface CommandDescription extends SubCommandDescription { - /** - * Scope of the command, whether it can be executed in a project, outside of a project or - * anywhere. - */ - scope: CommandScope; - - /** - * Whether this command should be hidden from a list of all commands. - */ - hidden: boolean; - - /** - * The constructor of the command, which should be extending the abstract Command<> class. - */ - impl: CommandConstructor; -} - -export interface OptionSmartDefault { - $source: string; - [key: string]: json.JsonValue; -} - -export interface CommandDescriptionMap { - [key: string]: CommandDescription; -} diff --git a/packages/angular/cli/models/parser.ts b/packages/angular/cli/models/parser.ts deleted file mode 100644 index a3a84045e7ff..000000000000 --- a/packages/angular/cli/models/parser.ts +++ /dev/null @@ -1,403 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - * - */ -import { BaseException, logging, strings } from '@angular-devkit/core'; -import { Arguments, Option, OptionType, Value } from './interface'; - - -export class ParseArgumentException extends BaseException { - constructor( - public readonly comments: string[], - public readonly parsed: Arguments, - public readonly ignored: string[], - ) { - super(`One or more errors occurred while parsing arguments:\n ${comments.join('\n ')}`); - } -} - - -function _coerceType(str: string | undefined, type: OptionType, v?: Value): Value | undefined { - switch (type) { - case OptionType.Any: - if (Array.isArray(v)) { - return v.concat(str || ''); - } - - return _coerceType(str, OptionType.Boolean, v) !== undefined - ? _coerceType(str, OptionType.Boolean, v) - : _coerceType(str, OptionType.Number, v) !== undefined - ? _coerceType(str, OptionType.Number, v) - : _coerceType(str, OptionType.String, v); - - case OptionType.String: - return str || ''; - - case OptionType.Boolean: - switch (str) { - case 'false': - return false; - - case undefined: - case '': - case 'true': - return true; - - default: - return undefined; - } - - case OptionType.Number: - if (str === undefined) { - return 0; - } else if (str === '') { - return undefined; - } else if (Number.isFinite(+str)) { - return +str; - } else { - return undefined; - } - - case OptionType.Array: - return Array.isArray(v) - ? v.concat(str || '') - : v === undefined - ? [str || ''] - : [v + '', str || '']; - - default: - return undefined; - } -} - -function _coerce(str: string | undefined, o: Option | null, v?: Value): Value | undefined { - if (!o) { - return _coerceType(str, OptionType.Any, v); - } else { - const types = o.types || [o.type]; - - // Try all the types one by one and pick the first one that returns a value contained in the - // enum. If there's no enum, just return the first one that matches. - for (const type of types) { - const maybeResult = _coerceType(str, type, v); - if (maybeResult !== undefined && (!o.enum || o.enum.includes(maybeResult))) { - return maybeResult; - } - } - - return undefined; - } -} - - -function _getOptionFromName(name: string, options: Option[]): Option | undefined { - const camelName = /(-|_)/.test(name) - ? strings.camelize(name) - : name; - - for (const option of options) { - if (option.name === name || option.name === camelName) { - return option; - } - - if (option.aliases.some(x => x === name || x === camelName)) { - return option; - } - } - - return undefined; -} - -function _removeLeadingDashes(key: string): string { - const from = key.startsWith('--') ? 2 : key.startsWith('-') ? 1 : 0; - - return key.substr(from); -} - -function _assignOption( - arg: string, - nextArg: string | undefined, - { options, parsedOptions, leftovers, ignored, errors, warnings }: { - options: Option[], - parsedOptions: Arguments, - positionals: string[], - leftovers: string[], - ignored: string[], - errors: string[], - warnings: string[], - }, -) { - const from = arg.startsWith('--') ? 2 : 1; - let consumedNextArg = false; - let key = arg.substr(from); - let option: Option | null = null; - let value: string | undefined = ''; - const i = arg.indexOf('='); - - // If flag is --no-abc AND there's no equal sign. - if (i == -1) { - if (key.startsWith('no')) { - // Only use this key if the option matching the rest is a boolean. - const from = key.startsWith('no-') ? 3 : 2; - const maybeOption = _getOptionFromName(strings.camelize(key.substr(from)), options); - if (maybeOption && maybeOption.type == 'boolean') { - value = 'false'; - option = maybeOption; - } - } - - if (option === null) { - // Set it to true if it's a boolean and the next argument doesn't match true/false. - const maybeOption = _getOptionFromName(key, options); - if (maybeOption) { - value = nextArg; - let shouldShift = true; - - if (value && value.startsWith('-') && _coerce(undefined, maybeOption) !== undefined) { - // Verify if not having a value results in a correct parse, if so don't shift. - shouldShift = false; - } - - // Only absorb it if it leads to a better value. - if (shouldShift && _coerce(value, maybeOption) !== undefined) { - consumedNextArg = true; - } else { - value = ''; - } - option = maybeOption; - } - } - } else { - key = arg.substring(0, i); - option = _getOptionFromName(_removeLeadingDashes(key), options) || null; - if (option) { - value = arg.substring(i + 1); - } - } - - if (option === null) { - if (nextArg && !nextArg.startsWith('-')) { - leftovers.push(arg, nextArg); - consumedNextArg = true; - } else { - leftovers.push(arg); - } - } else { - const v = _coerce(value, option, parsedOptions[option.name]); - if (v !== undefined) { - if (parsedOptions[option.name] !== v) { - if (parsedOptions[option.name] !== undefined && option.type !== OptionType.Array) { - warnings.push( - `Option ${JSON.stringify(option.name)} was already specified with value ` - + `${JSON.stringify(parsedOptions[option.name])}. The new value ${JSON.stringify(v)} ` - + `will override it.`, - ); - } - - parsedOptions[option.name] = v; - - if (option.deprecated !== undefined && option.deprecated !== false) { - warnings.push(`Option ${JSON.stringify(option.name)} is deprecated${ - typeof option.deprecated == 'string' ? ': ' + option.deprecated : '.'}`); - } - } - } else { - let error = `Argument ${key} could not be parsed using value ${JSON.stringify(value)}.`; - if (option.enum) { - error += ` Valid values are: ${option.enum.map(x => JSON.stringify(x)).join(', ')}.`; - } else { - error += `Valid type(s) is: ${(option.types || [option.type]).join(', ')}`; - } - - errors.push(error); - ignored.push(arg); - } - } - - return consumedNextArg; -} - - -/** - * Parse the arguments in a consistent way, but without having any option definition. This tries - * to assess what the user wants in a free form. For example, using `--name=false` will set the - * name properties to a boolean type. - * This should only be used when there's no schema available or if a schema is "true" (anything is - * valid). - * - * @param args Argument list to parse. - * @returns An object that contains a property per flags from the args. - */ -export function parseFreeFormArguments(args: string[]): Arguments { - const parsedOptions: Arguments = {}; - const leftovers = []; - - for (let arg = args.shift(); arg !== undefined; arg = args.shift()) { - if (arg == '--') { - leftovers.push(...args); - break; - } - - if (arg.startsWith('--')) { - const eqSign = arg.indexOf('='); - let name: string; - let value: string | undefined; - if (eqSign !== -1) { - name = arg.substring(2, eqSign); - value = arg.substring(eqSign + 1); - } else { - name = arg.substr(2); - value = args.shift(); - } - - const v = _coerce(value, null, parsedOptions[name]); - if (v !== undefined) { - parsedOptions[name] = v; - } - } else if (arg.startsWith('-')) { - arg.split('').forEach(x => parsedOptions[x] = true); - } else { - leftovers.push(arg); - } - } - - if (leftovers.length) { - parsedOptions['--'] = leftovers; - } - - return parsedOptions; -} - - -/** - * Parse the arguments in a consistent way, from a list of standardized options. - * The result object will have a key per option name, with the `_` key reserved for positional - * arguments, and `--` will contain everything that did not match. Any key that don't have an - * option will be pushed back in `--` and removed from the object. If you need to validate that - * there's no additionalProperties, you need to check the `--` key. - * - * @param args The argument array to parse. - * @param options List of supported options. {@see Option}. - * @param logger Logger to use to warn users. - * @returns An object that contains a property per option. - */ -export function parseArguments( - args: string[], - options: Option[] | null, - logger?: logging.Logger, -): Arguments { - if (options === null) { - options = []; - } - - const leftovers: string[] = []; - const positionals: string[] = []; - const parsedOptions: Arguments = {}; - - const ignored: string[] = []; - const errors: string[] = []; - const warnings: string[] = []; - - const state = { options, parsedOptions, positionals, leftovers, ignored, errors, warnings }; - - for (let argIndex = 0; argIndex < args.length; argIndex++) { - const arg = args[argIndex]; - let consumedNextArg = false; - - if (arg == '--') { - // If we find a --, we're done. - leftovers.push(...args.slice(argIndex + 1)); - break; - } - - if (arg.startsWith('--')) { - consumedNextArg = _assignOption(arg, args[argIndex + 1], state); - } else if (arg.startsWith('-')) { - // Argument is of form -abcdef. Starts at 1 because we skip the `-`. - for (let i = 1; i < arg.length; i++) { - const flag = arg[i]; - // If the next character is an '=', treat it as a long flag. - if (arg[i + 1] == '=') { - const f = '-' + flag + arg.slice(i + 1); - consumedNextArg = _assignOption(f, args[argIndex + 1], state); - break; - } - // Treat the last flag as `--a` (as if full flag but just one letter). We do this in - // the loop because it saves us a check to see if the arg is just `-`. - if (i == arg.length - 1) { - const arg = '-' + flag; - consumedNextArg = _assignOption(arg, args[argIndex + 1], state); - } else { - const maybeOption = _getOptionFromName(flag, options); - if (maybeOption) { - const v = _coerce(undefined, maybeOption, parsedOptions[maybeOption.name]); - if (v !== undefined) { - parsedOptions[maybeOption.name] = v; - } - } - } - } - } else { - positionals.push(arg); - } - - if (consumedNextArg) { - argIndex++; - } - } - - // Deal with positionals. - // TODO(hansl): this is by far the most complex piece of code in this file. Try to refactor it - // simpler. - if (positionals.length > 0) { - let pos = 0; - for (let i = 0; i < positionals.length;) { - let found = false; - let incrementPos = false; - let incrementI = true; - - // We do this with a found flag because more than 1 option could have the same positional. - for (const option of options) { - // If any option has this positional and no value, AND fit the type, we need to remove it. - if (option.positional === pos) { - const coercedValue = _coerce(positionals[i], option, parsedOptions[option.name]); - if (parsedOptions[option.name] === undefined && coercedValue !== undefined) { - parsedOptions[option.name] = coercedValue; - found = true; - } else { - incrementI = false; - } - incrementPos = true; - } - } - - if (found) { - positionals.splice(i--, 1); - } - if (incrementPos) { - pos++; - } - if (incrementI) { - i++; - } - } - } - - if (positionals.length > 0 || leftovers.length > 0) { - parsedOptions['--'] = [...positionals, ...leftovers]; - } - - if (warnings.length > 0 && logger) { - warnings.forEach(message => logger.warn(message)); - } - - if (errors.length > 0) { - throw new ParseArgumentException(errors, parsedOptions, ignored); - } - - return parsedOptions; -} diff --git a/packages/angular/cli/models/parser_spec.ts b/packages/angular/cli/models/parser_spec.ts deleted file mode 100644 index 86b9cba71211..000000000000 --- a/packages/angular/cli/models/parser_spec.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - * - */ -// tslint:disable:no-global-tslint-disable no-big-function -import { logging } from '@angular-devkit/core'; -import { Arguments, Option, OptionType } from './interface'; -import { ParseArgumentException, parseArguments } from './parser'; - -describe('parseArguments', () => { - const options: Option[] = [ - { name: 'bool', aliases: [ 'b' ], type: OptionType.Boolean, description: '' }, - { name: 'num', aliases: [ 'n' ], type: OptionType.Number, description: '' }, - { name: 'str', aliases: [ 's' ], type: OptionType.String, description: '' }, - { name: 'strUpper', aliases: [ 'S' ], type: OptionType.String, description: '' }, - { name: 'helloWorld', aliases: [], type: OptionType.String, description: '' }, - { name: 'helloBool', aliases: [], type: OptionType.Boolean, description: '' }, - { name: 'arr', aliases: [ 'a' ], type: OptionType.Array, description: '' }, - { name: 'p1', positional: 0, aliases: [], type: OptionType.String, description: '' }, - { name: 'p2', positional: 1, aliases: [], type: OptionType.String, description: '' }, - { name: 'p3', positional: 2, aliases: [], type: OptionType.Number, description: '' }, - { name: 't1', aliases: [], type: OptionType.Boolean, - types: [OptionType.Boolean, OptionType.String], description: '' }, - { name: 't2', aliases: [], type: OptionType.Boolean, - types: [OptionType.Boolean, OptionType.Number], description: '' }, - { name: 't3', aliases: [], type: OptionType.Number, - types: [OptionType.Number, OptionType.Any], description: '' }, - { name: 'e1', aliases: [], type: OptionType.String, enum: ['hello', 'world'], description: '' }, - { name: 'e2', aliases: [], type: OptionType.String, enum: ['hello', ''], description: '' }, - { name: 'e3', aliases: [], type: OptionType.Boolean, - types: [OptionType.Boolean, OptionType.String], enum: ['json', true, false], - description: '' }, - ]; - - const tests: { [test: string]: Partial | ['!!!', Partial, string[]] } = { - '-S=b': { strUpper: 'b' }, - '--bool': { bool: true }, - '--bool=1': ['!!!', {}, ['--bool=1']], - '--bool ': { bool: true, p1: '' }, - '-- --bool=1': { '--': ['--bool=1'] }, - '--bool=yellow': ['!!!', {}, ['--bool=yellow']], - '--bool=true': { bool: true }, - '--bool=false': { bool: false }, - '--no-bool': { bool: false }, - '--no-bool=true': { '--': ['--no-bool=true'] }, - '--b=true': { bool: true }, - '--b=false': { bool: false }, - '--b true': { bool: true }, - '--b false': { bool: false }, - '--bool --num': { bool: true, num: 0 }, - '--bool --num=true': ['!!!', { bool: true }, ['--num=true']], - '-- --bool --num=true': { '--': ['--bool', '--num=true'] }, - '--bool=true --num': { bool: true, num: 0 }, - '--bool true --num': { bool: true, num: 0 }, - '--bool=false --num': { bool: false, num: 0 }, - '--bool false --num': { bool: false, num: 0 }, - '--str false --num': { str: 'false', num: 0 }, - '--str=false --num': { str: 'false', num: 0 }, - '--str=false --num1': { str: 'false', '--': ['--num1'] }, - '--str=false val1 --num1': { str: 'false', p1: 'val1', '--': ['--num1'] }, - '--str=false val1 val2': { str: 'false', p1: 'val1', p2: 'val2' }, - '--str=false val1 val2 --num1': { str: 'false', p1: 'val1', p2: 'val2', '--': ['--num1'] }, - '--str=false val1 --num1 val2': { str: 'false', p1: 'val1', '--': ['--num1', 'val2'] }, - '--bool --bool=false': { bool: false }, - '--bool --bool=false --bool': { bool: true }, - '--num=1 --num=2 --num=3': { num: 3 }, - '--str=1 --str=2 --str=3': { str: '3' }, - 'val1 --num=1 val2': { num: 1, p1: 'val1', p2: 'val2' }, - '--p1=val1 --num=1 val2': { num: 1, p1: 'val1', p2: 'val2' }, - '--p1=val1 --num=1 --p2=val2 val3': { num: 1, p1: 'val1', p2: 'val2', '--': ['val3'] }, - '--bool val1 --etc --num val2 --v': [ - '!!!', - { bool: true, p1: 'val1', p2: 'val2', '--': ['--etc', '--v'] }, - ['--num' ], - ], - '--bool val1 --etc --num=1 val2 --v': { bool: true, num: 1, p1: 'val1', p2: 'val2', - '--': ['--etc', '--v'] }, - '--arr=a d': { arr: ['a'], p1: 'd' }, - '--arr=a --arr=b --arr c d': { arr: ['a', 'b', 'c'], p1: 'd' }, - '--arr=1 --arr --arr c d': { arr: ['1', '', 'c'], p1: 'd' }, - '--arr=1 --arr --arr c d e': { arr: ['1', '', 'c'], p1: 'd', p2: 'e' }, - '--str=1': { str: '1' }, - '--str=': { str: '' }, - '--str ': { str: '' }, - '--str ': { str: '', p1: '' }, - '--str ': { str: '', p1: '', p2: '', '--': [''] }, - '--hello-world=1': { helloWorld: '1' }, - '--hello-bool': { helloBool: true }, - '--helloBool': { helloBool: true }, - '--no-helloBool': { helloBool: false }, - '--noHelloBool': { helloBool: false }, - '--noBool': { bool: false }, - '-b': { bool: true }, - '-b=true': { bool: true }, - '-sb': { bool: true, str: '' }, - '-s=b': { str: 'b' }, - '-bs': { bool: true, str: '' }, - '--t1=true': { t1: true }, - '--t1': { t1: true }, - '--t1 --num': { t1: true, num: 0 }, - '--no-t1': { t1: false }, - '--t1=yellow': { t1: 'yellow' }, - '--no-t1=true': { '--': ['--no-t1=true'] }, - '--t1=123': { t1: '123' }, - '--t2=true': { t2: true }, - '--t2': { t2: true }, - '--no-t2': { t2: false }, - '--t2=yellow': ['!!!', {}, ['--t2=yellow']], - '--no-t2=true': { '--': ['--no-t2=true'] }, - '--t2=123': { t2: 123 }, - '--t3=a': { t3: 'a' }, - '--t3': { t3: 0 }, - '--t3 true': { t3: true }, - '--e1 hello': { e1: 'hello' }, - '--e1=hello': { e1: 'hello' }, - '--e1 yellow': ['!!!', { p1: 'yellow' }, ['--e1']], - '--e1=yellow': ['!!!', {}, ['--e1=yellow']], - '--e1': ['!!!', {}, ['--e1']], - '--e1 true': ['!!!', { p1: 'true' }, ['--e1']], - '--e1=true': ['!!!', {}, ['--e1=true']], - '--e2 hello': { e2: 'hello' }, - '--e2=hello': { e2: 'hello' }, - '--e2 yellow': { p1: 'yellow', e2: '' }, - '--e2=yellow': ['!!!', {}, ['--e2=yellow']], - '--e2': { e2: '' }, - '--e2 true': { p1: 'true', e2: '' }, - '--e2=true': ['!!!', {}, ['--e2=true']], - '--e3 json': { e3: 'json' }, - '--e3=json': { e3: 'json' }, - '--e3 yellow': { p1: 'yellow', e3: true }, - '--e3=yellow': ['!!!', {}, ['--e3=yellow']], - '--e3': { e3: true }, - '--e3 true': { e3: true }, - '--e3=true': { e3: true }, - 'a b c 1': { p1: 'a', p2: 'b', '--': ['c', '1'] }, - - '-p=1 -c=prod': {'--': ['-p=1', '-c=prod'] }, - '--p --c': {'--': ['--p', '--c'] }, - '--p=123': {'--': ['--p=123'] }, - '--p -c': {'--': ['--p', '-c'] }, - '-p --c': {'--': ['-p', '--c'] }, - '-p --c 123': {'--': ['-p', '--c', '123'] }, - '--c 123 -p': {'--': ['--c', '123', '-p'] }, - }; - - Object.entries(tests).forEach(([str, expected]) => { - it(`works for ${str}`, () => { - try { - const originalArgs = str.split(' '); - const args = originalArgs.slice(); - - const actual = parseArguments(args, options); - - expect(Array.isArray(expected)).toBe(false); - expect(actual).toEqual(expected as Arguments); - expect(args).toEqual(originalArgs); - } catch (e) { - if (!(e instanceof ParseArgumentException)) { - throw e; - } - - // The expected values are an array. - expect(Array.isArray(expected)).toBe(true); - expect(e.parsed).toEqual(expected[1] as Arguments); - expect(e.ignored).toEqual(expected[2] as string[]); - } - }); - }); - - it('handles deprecation', () => { - const options = [ - { name: 'bool', aliases: [], type: OptionType.Boolean, description: '' }, - { name: 'depr', aliases: [], type: OptionType.Boolean, description: '', deprecated: true }, - { name: 'deprM', aliases: [], type: OptionType.Boolean, description: '', deprecated: 'ABCD' }, - ]; - - const logger = new logging.Logger(''); - const messages: string[] = []; - - logger.subscribe(entry => messages.push(entry.message)); - - let result = parseArguments(['--bool'], options, logger); - expect(result).toEqual({ bool: true }); - expect(messages).toEqual([]); - - result = parseArguments(['--depr'], options, logger); - expect(result).toEqual({ depr: true }); - expect(messages.length).toEqual(1); - expect(messages[0]).toMatch(/\bdepr\b/); - messages.shift(); - - result = parseArguments(['--depr', '--bool'], options, logger); - expect(result).toEqual({ depr: true, bool: true }); - expect(messages.length).toEqual(1); - expect(messages[0]).toMatch(/\bdepr\b/); - messages.shift(); - - result = parseArguments(['--depr', '--bool', '--deprM'], options, logger); - expect(result).toEqual({ depr: true, deprM: true, bool: true }); - expect(messages.length).toEqual(2); - expect(messages[0]).toMatch(/\bdepr\b/); - expect(messages[1]).toMatch(/\bdeprM\b/); - expect(messages[1]).toMatch(/\bABCD\b/); - messages.shift(); - }); - - it('handles a flag being added multiple times', () => { - const options = [ - { name: 'bool', aliases: [], type: OptionType.Boolean, description: '' }, - ]; - - const logger = new logging.Logger(''); - const messages: string[] = []; - - logger.subscribe(entry => messages.push(entry.message)); - - let result = parseArguments(['--bool'], options, logger); - expect(result).toEqual({ bool: true }); - expect(messages).toEqual([]); - - result = parseArguments(['--bool', '--bool'], options, logger); - expect(result).toEqual({ bool: true }); - expect(messages).toEqual([]); - - result = parseArguments(['--bool', '--bool=false'], options, logger); - expect(result).toEqual({ bool: false }); - expect(messages.length).toEqual(1); - expect(messages[0]).toMatch(/\bbool\b.*\btrue\b.*\bfalse\b/); - messages.shift(); - - result = parseArguments(['--bool', '--bool=false', '--bool=false'], options, logger); - expect(result).toEqual({ bool: false }); - expect(messages.length).toEqual(1); - expect(messages[0]).toMatch(/\bbool\b.*\btrue\b.*\bfalse\b/); - messages.shift(); - }); -}); diff --git a/packages/angular/cli/models/schematic-command.ts b/packages/angular/cli/models/schematic-command.ts deleted file mode 100644 index e2c7803cb968..000000000000 --- a/packages/angular/cli/models/schematic-command.ts +++ /dev/null @@ -1,643 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ -import { - json, - logging, - normalize, - schema, - strings, - tags, - virtualFs, - workspaces, -} from '@angular-devkit/core'; -import { NodeJsSyncHost } from '@angular-devkit/core/node'; -import { - DryRunEvent, - UnsuccessfulWorkflowExecution, - formats, - workflow, -} from '@angular-devkit/schematics'; -import { - FileSystemCollection, - FileSystemEngine, - FileSystemSchematic, - FileSystemSchematicDescription, - NodeWorkflow, - validateOptionsWithSchema, -} from '@angular-devkit/schematics/tools'; -import * as inquirer from 'inquirer'; -import * as systemPath from 'path'; -import { colors } from '../utilities/color'; -import { - getProjectByCwd, - getSchematicDefaults, - getWorkspace, - getWorkspaceRaw, -} from '../utilities/config'; -import { parseJsonSchemaToOptions } from '../utilities/json-schema'; -import { getPackageManager } from '../utilities/package-manager'; -import { isTTY } from '../utilities/tty'; -import { isPackageNameSafeForAnalytics } from './analytics'; -import { BaseCommandOptions, Command } from './command'; -import { Arguments, CommandContext, CommandDescription, Option } from './interface'; -import { parseArguments, parseFreeFormArguments } from './parser'; - -export interface BaseSchematicSchema { - debug?: boolean; - dryRun?: boolean; - force?: boolean; - interactive?: boolean; - defaults?: boolean; - packageRegistry?: string; -} - -export interface RunSchematicOptions extends BaseSchematicSchema { - collectionName: string; - schematicName: string; - additionalOptions?: { [key: string]: {} }; - schematicOptions?: string[]; - showNothingDone?: boolean; -} - -export class UnknownCollectionError extends Error { - constructor(collectionName: string) { - super(`Invalid collection (${collectionName}).`); - } -} - -export abstract class SchematicCommand< - T extends BaseSchematicSchema & BaseCommandOptions -> extends Command { - readonly allowPrivateSchematics: boolean = false; - private _host = new NodeJsSyncHost(); - private _workspace: workspaces.WorkspaceDefinition; - protected _workflow: NodeWorkflow; - - protected defaultCollectionName = '@schematics/angular'; - protected collectionName = this.defaultCollectionName; - protected schematicName?: string; - - constructor(context: CommandContext, description: CommandDescription, logger: logging.Logger) { - super(context, description, logger); - } - - public async initialize(options: T & Arguments) { - await this._loadWorkspace(); - await this.createWorkflow(options); - - if (this.schematicName) { - // Set the options. - const collection = this.getCollection(this.collectionName); - const schematic = this.getSchematic(collection, this.schematicName, true); - const options = await parseJsonSchemaToOptions( - this._workflow.registry, - schematic.description.schemaJson || {}, - ); - - this.description.options.push(...options.filter(x => !x.hidden)); - - // Remove any user analytics from schematics that are NOT part of our safelist. - for (const o of this.description.options) { - if (o.userAnalytics && !isPackageNameSafeForAnalytics(this.collectionName)) { - o.userAnalytics = undefined; - } - } - } - } - - public async printHelp(options: T & Arguments) { - await super.printHelp(options); - this.logger.info(''); - - const subCommandOption = this.description.options.filter(x => x.subcommands)[0]; - - if (!subCommandOption || !subCommandOption.subcommands) { - return 0; - } - - const schematicNames = Object.keys(subCommandOption.subcommands); - - if (schematicNames.length > 1) { - this.logger.info('Available Schematics:'); - - const namesPerCollection: { [c: string]: string[] } = {}; - schematicNames.forEach(name => { - let [collectionName, schematicName] = name.split(/:/, 2); - if (!schematicName) { - schematicName = collectionName; - collectionName = this.collectionName; - } - - if (!namesPerCollection[collectionName]) { - namesPerCollection[collectionName] = []; - } - - namesPerCollection[collectionName].push(schematicName); - }); - - const defaultCollection = await this.getDefaultSchematicCollection(); - Object.keys(namesPerCollection).forEach(collectionName => { - const isDefault = defaultCollection == collectionName; - this.logger.info(` Collection "${collectionName}"${isDefault ? ' (default)' : ''}:`); - - namesPerCollection[collectionName].forEach(schematicName => { - this.logger.info(` ${schematicName}`); - }); - }); - } else if (schematicNames.length == 1) { - this.logger.info('Help for schematic ' + schematicNames[0]); - await this.printHelpSubcommand(subCommandOption.subcommands[schematicNames[0]]); - } - - return 0; - } - - async printHelpUsage() { - const subCommandOption = this.description.options.filter(x => x.subcommands)[0]; - - if (!subCommandOption || !subCommandOption.subcommands) { - return; - } - - const schematicNames = Object.keys(subCommandOption.subcommands); - if (schematicNames.length == 1) { - this.logger.info(this.description.description); - - const opts = this.description.options.filter(x => x.positional === undefined); - const [collectionName, schematicName] = schematicNames[0].split(/:/)[0]; - - // Display if this is not the default collectionName, - // otherwise just show the schematicName. - const displayName = - collectionName == (await this.getDefaultSchematicCollection()) - ? schematicName - : schematicNames[0]; - - const schematicOptions = subCommandOption.subcommands[schematicNames[0]].options; - const schematicArgs = schematicOptions.filter(x => x.positional !== undefined); - const argDisplay = - schematicArgs.length > 0 - ? ' ' + schematicArgs.map(a => `<${strings.dasherize(a.name)}>`).join(' ') - : ''; - - this.logger.info(tags.oneLine` - usage: ng ${this.description.name} ${displayName}${argDisplay} - ${opts.length > 0 ? `[options]` : ``} - `); - this.logger.info(''); - } else { - await super.printHelpUsage(); - } - } - - protected getEngine(): FileSystemEngine { - return this._workflow.engine; - } - - protected getCollection(collectionName: string): FileSystemCollection { - const engine = this.getEngine(); - const collection = engine.createCollection(collectionName); - - if (collection === null) { - throw new UnknownCollectionError(collectionName); - } - - return collection; - } - - protected getSchematic( - collection: FileSystemCollection, - schematicName: string, - allowPrivate?: boolean, - ): FileSystemSchematic { - return collection.createSchematic(schematicName, allowPrivate); - } - - protected setPathOptions(options: Option[], workingDir: string) { - if (workingDir === '') { - return {}; - } - - return options - .filter(o => o.format === 'path') - .map(o => o.name) - .reduce( - (acc, curr) => { - acc[curr] = workingDir; - - return acc; - }, - {} as { [name: string]: string }, - ); - } - - /* - * Runtime hook to allow specifying customized workflow - */ - protected async createWorkflow(options: BaseSchematicSchema): Promise { - if (this._workflow) { - return this._workflow; - } - - const { force, dryRun } = options; - const fsHost = new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(this.workspace.root)); - - const workflow = new NodeWorkflow(fsHost, { - force, - dryRun, - packageManager: await getPackageManager(this.workspace.root), - packageRegistry: options.packageRegistry, - root: normalize(this.workspace.root), - registry: new schema.CoreSchemaRegistry(formats.standardFormats), - resolvePaths: !!this.workspace.configFile - // Workspace - ? [process.cwd(), this.workspace.root, __dirname] - // Global - : [__dirname, process.cwd()], - }); - workflow.engineHost.registerContextTransform(context => { - // This is run by ALL schematics, so if someone uses `externalSchematics(...)` which - // is safelisted, it would move to the right analytics (even if their own isn't). - const collectionName: string = context.schematic.collection.description.name; - if (isPackageNameSafeForAnalytics(collectionName)) { - return { - ...context, - analytics: this.analytics, - }; - } else { - return context; - } - }); - - const getProjectName = () => { - if (this._workspace) { - const projectNames = getProjectsByPath(this._workspace, process.cwd(), this.workspace.root); - - if (projectNames.length === 1) { - return projectNames[0]; - } else { - if (projectNames.length > 1) { - this.logger.warn(tags.oneLine` - Two or more projects are using identical roots. - Unable to determine project using current working directory. - Using default workspace project instead. - `); - } - - const defaultProjectName = this._workspace.extensions['defaultProject']; - if (typeof defaultProjectName === 'string' && defaultProjectName) { - return defaultProjectName; - } - } - } - - return undefined; - }; - - const defaultOptionTransform = async ( - schematic: FileSystemSchematicDescription, - current: {}, - ) => ({ - ...(await getSchematicDefaults(schematic.collection.name, schematic.name, getProjectName())), - ...current, - }); - workflow.engineHost.registerOptionsTransform(defaultOptionTransform); - - if (options.defaults) { - workflow.registry.addPreTransform(schema.transforms.addUndefinedDefaults); - } else { - workflow.registry.addPostTransform(schema.transforms.addUndefinedDefaults); - } - - workflow.engineHost.registerOptionsTransform(validateOptionsWithSchema(workflow.registry)); - - workflow.registry.addSmartDefaultProvider('projectName', getProjectName); - - if (options.interactive !== false && isTTY()) { - workflow.registry.usePromptProvider((definitions: Array) => { - const questions: inquirer.Questions = definitions.map(definition => { - const question: inquirer.Question = { - name: definition.id, - message: definition.message, - default: definition.default, - }; - - const validator = definition.validator; - if (validator) { - question.validate = input => validator(input); - } - - switch (definition.type) { - case 'confirmation': - question.type = 'confirm'; - break; - case 'list': - question.type = !!definition.multiselect ? 'checkbox' : 'list'; - question.choices = - definition.items && - definition.items.map(item => { - if (typeof item == 'string') { - return item; - } else { - return { - name: item.label, - value: item.value, - }; - } - }); - break; - default: - question.type = definition.type; - break; - } - - return question; - }); - - return inquirer.prompt(questions); - }); - } - - return (this._workflow = workflow); - } - - protected async getDefaultSchematicCollection(): Promise { - let workspace = await getWorkspace('local'); - - if (workspace) { - const project = getProjectByCwd(workspace); - if (project && workspace.getProjectCli(project)) { - const value = workspace.getProjectCli(project)['defaultCollection']; - if (typeof value == 'string') { - return value; - } - } - if (workspace.getCli()) { - const value = workspace.getCli()['defaultCollection']; - if (typeof value == 'string') { - return value; - } - } - } - - workspace = await getWorkspace('global'); - if (workspace && workspace.getCli()) { - const value = workspace.getCli()['defaultCollection']; - if (typeof value == 'string') { - return value; - } - } - - return this.defaultCollectionName; - } - - protected async runSchematic(options: RunSchematicOptions) { - const { schematicOptions, debug, dryRun } = options; - let { collectionName, schematicName } = options; - - let nothingDone = true; - let loggingQueue: string[] = []; - let error = false; - - const workflow = this._workflow; - - const workingDir = normalize(systemPath.relative(this.workspace.root, process.cwd())); - - // Get the option object from the schematic schema. - const schematic = this.getSchematic( - this.getCollection(collectionName), - schematicName, - this.allowPrivateSchematics, - ); - // Update the schematic and collection name in case they're not the same as the ones we - // received in our options, e.g. after alias resolution or extension. - collectionName = schematic.collection.description.name; - schematicName = schematic.description.name; - - // TODO: Remove warning check when 'targets' is default - if (collectionName !== this.defaultCollectionName) { - const [ast, configPath] = getWorkspaceRaw('local'); - if (ast) { - const projectsKeyValue = ast.properties.find(p => p.key.value === 'projects'); - if (!projectsKeyValue || projectsKeyValue.value.kind !== 'object') { - return; - } - - const positions: json.Position[] = []; - for (const projectKeyValue of projectsKeyValue.value.properties) { - const projectNode = projectKeyValue.value; - if (projectNode.kind !== 'object') { - continue; - } - const targetsKeyValue = projectNode.properties.find(p => p.key.value === 'targets'); - if (targetsKeyValue) { - positions.push(targetsKeyValue.start); - } - } - - if (positions.length > 0) { - const warning = tags.oneLine` - WARNING: This command may not execute successfully. - The package/collection may not support the 'targets' field within '${configPath}'. - This can be corrected by renaming the following 'targets' fields to 'architect': - `; - - const locations = positions - .map((p, i) => `${i + 1}) Line: ${p.line + 1}; Column: ${p.character + 1}`) - .join('\n'); - - this.logger.warn(warning + '\n' + locations + '\n'); - } - } - } - - // Set the options of format "path". - let o: Option[] | null = null; - let args: Arguments; - - if (!schematic.description.schemaJson) { - args = await this.parseFreeFormArguments(schematicOptions || []); - } else { - o = await parseJsonSchemaToOptions(workflow.registry, schematic.description.schemaJson); - args = await this.parseArguments(schematicOptions || [], o); - } - - const allowAdditionalProperties = - typeof schematic.description.schemaJson === 'object' && schematic.description.schemaJson.additionalProperties; - - if (args['--'] && !allowAdditionalProperties) { - args['--'].forEach(additional => { - this.logger.fatal(`Unknown option: '${additional.split(/=/)[0]}'`); - }); - - return 1; - } - - const pathOptions = o ? this.setPathOptions(o, workingDir) : {}; - let input = { ...pathOptions, ...args }; - - // Read the default values from the workspace. - const projectName = input.project !== undefined ? '' + input.project : null; - const defaults = await getSchematicDefaults(collectionName, schematicName, projectName); - input = { - ...defaults, - ...input, - ...options.additionalOptions, - }; - - workflow.reporter.subscribe((event: DryRunEvent) => { - nothingDone = false; - - // Strip leading slash to prevent confusion. - const eventPath = event.path.startsWith('/') ? event.path.substr(1) : event.path; - - switch (event.kind) { - case 'error': - error = true; - const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist.'; - this.logger.warn(`ERROR! ${eventPath} ${desc}.`); - break; - case 'update': - loggingQueue.push(tags.oneLine` - ${colors.white('UPDATE')} ${eventPath} (${event.content.length} bytes) - `); - break; - case 'create': - loggingQueue.push(tags.oneLine` - ${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes) - `); - break; - case 'delete': - loggingQueue.push(`${colors.yellow('DELETE')} ${eventPath}`); - break; - case 'rename': - const eventToPath = event.to.startsWith('/') ? event.to.substr(1) : event.to; - loggingQueue.push(`${colors.blue('RENAME')} ${eventPath} => ${eventToPath}`); - break; - } - }); - - workflow.lifeCycle.subscribe(event => { - if (event.kind == 'end' || event.kind == 'post-tasks-start') { - if (!error) { - // Output the logging queue, no error happened. - loggingQueue.forEach(log => this.logger.info(log)); - } - - loggingQueue = []; - error = false; - } - }); - - return new Promise(resolve => { - workflow - .execute({ - collection: collectionName, - schematic: schematicName, - options: input, - debug: debug, - logger: this.logger, - allowPrivate: this.allowPrivateSchematics, - }) - .subscribe({ - error: (err: Error) => { - // In case the workflow was not successful, show an appropriate error message. - if (err instanceof UnsuccessfulWorkflowExecution) { - // "See above" because we already printed the error. - this.logger.fatal('The Schematic workflow failed. See above.'); - } else if (debug) { - this.logger.fatal(`An error occured:\n${err.message}\n${err.stack}`); - } else { - this.logger.fatal(err.message); - } - - resolve(1); - }, - complete: () => { - const showNothingDone = !(options.showNothingDone === false); - if (nothingDone && showNothingDone) { - this.logger.info('Nothing to be done.'); - } - if (dryRun) { - this.logger.warn(`\nNOTE: The "dryRun" flag means no changes were made.`); - } - resolve(); - }, - }); - }); - } - - protected async parseFreeFormArguments(schematicOptions: string[]) { - return parseFreeFormArguments(schematicOptions); - } - - protected async parseArguments( - schematicOptions: string[], - options: Option[] | null, - ): Promise { - return parseArguments(schematicOptions, options, this.logger); - } - - private async _loadWorkspace() { - if (this._workspace) { - return; - } - - try { - const { workspace } = await workspaces.readWorkspace( - this.workspace.root, - workspaces.createWorkspaceHost(this._host), - ); - this._workspace = workspace; - } catch (err) { - if (!this.allowMissingWorkspace) { - // Ignore missing workspace - throw err; - } - } - } -} - -function getProjectsByPath( - workspace: workspaces.WorkspaceDefinition, - path: string, - root: string, -): string[] { - if (workspace.projects.size === 1) { - return Array.from(workspace.projects.keys()); - } - - const isInside = (base: string, potential: string): boolean => { - const absoluteBase = systemPath.resolve(root, base); - const absolutePotential = systemPath.resolve(root, potential); - const relativePotential = systemPath.relative(absoluteBase, absolutePotential); - if (!relativePotential.startsWith('..') && !systemPath.isAbsolute(relativePotential)) { - return true; - } - - return false; - }; - - const projects = Array.from(workspace.projects.entries()) - .map(([name, project]) => [systemPath.resolve(root, project.root), name] as [string, string]) - .filter(tuple => isInside(tuple[0], path)) - // Sort tuples by depth, with the deeper ones first. Since the first member is a path and - // we filtered all invalid paths, the longest will be the deepest (and in case of equality - // the sort is stable and the first declared project will win). - .sort((a, b) => b[0].length - a[0].length); - - if (projects.length === 1) { - return [projects[0][1]]; - } else if (projects.length > 1) { - const firstPath = projects[0][0]; - - return projects.filter(v => v[0] === firstPath).map(v => v[1]); - } - - return []; -} diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index a241bd2b6575..f558e7a6293d 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -1,60 +1,48 @@ { "name": "@angular/cli", - "version": "0.0.0", + "version": "0.0.0-PLACEHOLDER", "description": "CLI tool for Angular", "main": "lib/cli/index.js", + "typings": "index.d.ts", + "type": "module", "bin": { - "ng": "./bin/ng" + "ng": "bin/ng.js" }, "keywords": [ "angular", "angular-cli", "Angular CLI" ], - "scripts": { - "postinstall": "node ./bin/postinstall/script.js" + "imports": { + "#version": "./bin/version.js" }, - "repository": { - "type": "git", - "url": "https://github.com/angular/angular-cli.git" - }, - "author": "Angular Authors", - "license": "MIT", - "bugs": { - "url": "https://github.com/angular/angular-cli/issues" - }, - "homepage": "https://github.com/angular/angular-cli", "dependencies": { - "@angular-devkit/architect": "0.0.0", - "@angular-devkit/core": "0.0.0", - "@angular-devkit/schematics": "0.0.0", - "@schematics/angular": "0.0.0", - "@schematics/update": "0.0.0", - "@yarnpkg/lockfile": "1.1.0", - "ansi-colors": "4.1.1", - "debug": "4.1.1", - "ini": "1.3.5", - "inquirer": "7.1.0", - "npm-package-arg": "8.0.1", - "npm-pick-manifest": "6.1.0", - "open": "7.0.3", - "pacote": "9.5.12", - "read-package-tree": "5.3.1", - "rimraf": "3.0.2", - "semver": "7.2.2", - "symbol-observable": "1.2.0", - "universal-analytics": "0.4.20", - "uuid": "7.0.3" + "@angular-devkit/architect": "workspace:0.0.0-EXPERIMENTAL-PLACEHOLDER", + "@angular-devkit/core": "workspace:0.0.0-PLACEHOLDER", + "@angular-devkit/schematics": "workspace:0.0.0-PLACEHOLDER", + "@inquirer/prompts": "8.7.3", + "@listr2/prompt-adapter-inquirer": "4.3.1", + "@modelcontextprotocol/server": "2.3.1", + "@schematics/angular": "workspace:0.0.0-PLACEHOLDER", + "jsonc-parser": "3.3.1", + "listr2": "11.2.0", + "npm-package-arg": "14.0.0", + "parse5-html-rewriting-stream": "8.0.1", + "semver": "7.8.5", + "yargs": "18.2.0", + "zod": "4.6.7" }, "ng-update": { "migrations": "@schematics/angular/migrations/migration-collection.json", "packageGroup": { - "@angular/cli": "0.0.0", - "@angular-devkit/build-angular": "0.0.0", - "@angular-devkit/build-ng-packagr": "0.0.0", - "@angular-devkit/build-webpack": "0.0.0", - "@angular-devkit/core": "0.0.0", - "@angular-devkit/schematics": "0.0.0" + "@angular/cli": "0.0.0-PLACEHOLDER", + "@angular/build": "0.0.0-PLACEHOLDER", + "@angular/ssr": "0.0.0-PLACEHOLDER", + "@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER", + "@angular-devkit/build-angular": "0.0.0-PLACEHOLDER", + "@angular-devkit/build-webpack": "0.0.0-EXPERIMENTAL-PLACEHOLDER", + "@angular-devkit/core": "0.0.0-PLACEHOLDER", + "@angular-devkit/schematics": "0.0.0-PLACEHOLDER" } } } diff --git a/packages/angular/cli/plugins/karma.js b/packages/angular/cli/plugins/karma.js deleted file mode 100644 index 821352a15b43..000000000000 --- a/packages/angular/cli/plugins/karma.js +++ /dev/null @@ -1,4 +0,0 @@ -throw new Error( - 'In Angular CLI >6.0 the Karma plugin is now exported by "@angular-devkit/build-angular" instead.\n' - + 'Please replace "@angular/cli" with "@angular-devkit/build-angular" in your "karma.conf.js" file.' -); diff --git a/packages/angular/cli/src/analytics/analytics-collector.ts b/packages/angular/cli/src/analytics/analytics-collector.ts new file mode 100644 index 000000000000..7520b16916ae --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-collector.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { randomUUID } from 'node:crypto'; +import * as https from 'node:https'; +import * as os from 'node:os'; +import * as querystring from 'node:querystring'; +import * as semver from 'semver'; +import { ngDebug } from '../utilities/environment-options'; +import { assertIsError } from '../utilities/error'; +import { VERSION } from '../utilities/version'; +import { + EventCustomDimension, + EventCustomMetric, + PrimitiveTypes, + RequestParameter, + UserCustomDimension, +} from './analytics-parameters'; + +const TRACKING_ID_PROD = 'G-VETNJBW8L4'; +const TRACKING_ID_STAGING = 'G-TBMPRL1BTM'; + +export class AnalyticsCollector { + private trackingEventsQueue: Record[] | undefined; + private readonly requestParameterStringified: string; + private readonly userParameters: Record; + + constructor( + private logger: logging.Logger, + userId: string, + packageManagerInfo: { name: string; version: string | undefined }, + ) { + const requestParameters: Partial> = { + [RequestParameter.ProtocolVersion]: 2, + [RequestParameter.ClientId]: userId, + [RequestParameter.UserId]: userId, + [RequestParameter.TrackingId]: + /^\d+\.\d+\.\d+$/.test(VERSION.full) && VERSION.full !== '0.0.0' + ? TRACKING_ID_PROD + : TRACKING_ID_STAGING, + + // Built-in user properties + [RequestParameter.SessionId]: randomUUID(), + [RequestParameter.UserAgentArchitecture]: os.arch(), + [RequestParameter.UserAgentPlatform]: os.platform(), + [RequestParameter.UserAgentPlatformVersion]: os.release(), + [RequestParameter.UserAgentMobile]: 0, + [RequestParameter.SessionEngaged]: 1, + // The below is needed for tech details to be collected. + [RequestParameter.UserAgentFullVersionList]: + 'Google%20Chrome;111.0.5563.64|Not(A%3ABrand;8.0.0.0|Chromium;111.0.5563.64', + }; + + if (ngDebug) { + requestParameters[RequestParameter.DebugView] = 1; + } + + this.requestParameterStringified = querystring.stringify(requestParameters); + + const parsedVersion = semver.parse(process.version); + const packageManagerVersion = packageManagerInfo.version; + + this.userParameters = { + // While architecture is being collect by GA as UserAgentArchitecture. + // It doesn't look like there is a way to query this. Therefore we collect this as a custom user dimension too. + [UserCustomDimension.OsArchitecture]: os.arch(), + // While User ID is being collected by GA, this is not visible in reports/for filtering. + [UserCustomDimension.UserId]: userId, + [UserCustomDimension.NodeVersion]: parsedVersion + ? `${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}` + : 'other', + [UserCustomDimension.NodeMajorVersion]: parsedVersion?.major, + [UserCustomDimension.PackageManager]: packageManagerInfo.name, + [UserCustomDimension.PackageManagerVersion]: packageManagerVersion, + [UserCustomDimension.PackageManagerMajorVersion]: packageManagerVersion + ? +packageManagerVersion.split('.', 1)[0] + : undefined, + [UserCustomDimension.AngularCLIVersion]: VERSION.full, + [UserCustomDimension.AngularCLIMajorVersion]: VERSION.major, + }; + } + + reportWorkspaceInfoEvent( + parameters: Partial>, + ): void { + this.event('workspace_info', parameters); + } + + reportRebuildRunEvent( + parameters: Partial< + Record + >, + ): void { + this.event('run_rebuild', parameters); + } + + reportBuildRunEvent( + parameters: Partial< + Record + >, + ): void { + this.event('run_build', parameters); + } + + reportArchitectRunEvent(parameters: Partial>): void { + this.event('run_architect', parameters); + } + + reportSchematicRunEvent(parameters: Partial>): void { + this.event('run_schematic', parameters); + } + + reportCommandRunEvent(command: string): void { + this.event('run_command', { [EventCustomDimension.Command]: command }); + } + + private event(eventName: string, parameters?: Record): void { + this.trackingEventsQueue ??= []; + this.trackingEventsQueue.push({ + ...this.userParameters, + ...parameters, + 'en': eventName, + }); + } + + /** + * Flush on an interval (if the event loop is waiting). + * + * @returns a method that when called will terminate the periodic + * flush and call flush one last time. + */ + periodFlush(): () => Promise { + let analyticsFlushPromise = Promise.resolve(); + const analyticsFlushInterval = setInterval(() => { + if (this.trackingEventsQueue?.length) { + analyticsFlushPromise = analyticsFlushPromise.then(() => this.flush()); + } + }, 4000); + + return () => { + clearInterval(analyticsFlushInterval); + + // Flush one last time. + return analyticsFlushPromise.then(() => this.flush()); + }; + } + + async flush(): Promise { + const pendingTrackingEvents = this.trackingEventsQueue; + this.logger.debug(`Analytics flush size. ${pendingTrackingEvents?.length}.`); + + if (!pendingTrackingEvents?.length) { + return; + } + + // The below is needed so that if flush is called multiple times, + // we don't report the same event multiple times. + this.trackingEventsQueue = undefined; + + try { + await this.send(pendingTrackingEvents); + } catch (error) { + // Failure to report analytics shouldn't crash the CLI. + assertIsError(error); + this.logger.debug(`Send analytics error. ${error.message}.`); + } + } + + private async send(data: Record[]): Promise { + return new Promise((resolve, reject) => { + const request = https.request( + { + host: 'www.google-analytics.com', + method: 'POST', + path: '/g/collect?' + this.requestParameterStringified, + headers: { + // The below is needed for tech details to be collected even though we provide our own information from the OS Node.js module + 'user-agent': + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36', + }, + }, + (response) => { + // The below is needed as otherwise the response will never close which will cause the CLI not to terminate. + response.on('data', () => {}); + + if (response.statusCode !== 200 && response.statusCode !== 204) { + reject( + new Error(`Analytics reporting failed with status code: ${response.statusCode}.`), + ); + } else { + resolve(); + } + }, + ); + + request.on('error', reject); + const queryParameters = data.map((p) => querystring.stringify(p)).join('\n'); + request.write(queryParameters); + request.end(); + }); + } +} diff --git a/packages/angular/cli/src/analytics/analytics-parameters.mts b/packages/angular/cli/src/analytics/analytics-parameters.mts new file mode 100644 index 000000000000..8a667dd9d2b8 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-parameters.mts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** This is a copy of analytics-parameters.ts and is needed for `yarn admin validate-user-analytics` due to ts-node. */ + +/** + * GA built-in request parameters + * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet + * @see http://go/depot/google3/analytics/container_tag/templates/common/gold/mpv2_schema.js + */ +export enum RequestParameter { + ClientId = 'cid', + DebugView = '_dbg', + GtmVersion = 'gtm', + Language = 'ul', + NewToSite = '_nsi', + NonInteraction = 'ni', + PageLocation = 'dl', + PageTitle = 'dt', + ProtocolVersion = 'v', + SessionEngaged = 'seg', + SessionId = 'sid', + SessionNumber = 'sct', + SessionStart = '_ss', + TrackingId = 'tid', + TrafficType = 'tt', + UserAgentArchitecture = 'uaa', + UserAgentBitness = 'uab', + UserAgentFullVersionList = 'uafvl', + UserAgentMobile = 'uamb', + UserAgentModel = 'uam', + UserAgentPlatform = 'uap', + UserAgentPlatformVersion = 'uapv', + UserId = 'uid', +} + +/** + * User scoped custom dimensions. + * @remarks + * - User custom dimensions limit is 25. + * - `up.*` string type. + * - `upn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum UserCustomDimension { + UserId = 'up.ng_user_id', + OsArchitecture = 'up.ng_os_architecture', + NodeVersion = 'up.ng_node_version', + NodeMajorVersion = 'upn.ng_node_major_version', + AngularCLIVersion = 'up.ng_cli_version', + AngularCLIMajorVersion = 'upn.ng_cli_major_version', + PackageManager = 'up.ng_package_manager', + PackageManagerVersion = 'up.ng_pkg_manager_version', + PackageManagerMajorVersion = 'upn.ng_pkg_manager_major_v', +} + +/** + * Event scoped custom dimensions. + * @remarks + * - Event custom dimensions limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomDimension { + Command = 'ep.ng_command', + SchematicCollectionName = 'ep.ng_schematic_collection_name', + SchematicName = 'ep.ng_schematic_name', + Standalone = 'ep.ng_standalone', + SSR = 'ep.ng_ssr', + Style = 'ep.ng_style', + Routing = 'ep.ng_routing', + InlineTemplate = 'ep.ng_inline_template', + InlineStyle = 'ep.ng_inline_style', + BuilderTarget = 'ep.ng_builder_target', + Aot = 'ep.ng_aot', + Optimization = 'ep.ng_optimization', +} + +/** + * Event scoped custom mertics. + * @remarks + * - Event scoped custom mertics limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomMetric { + AllChunksCount = 'epn.ng_all_chunks_count', + LazyChunksCount = 'epn.ng_lazy_chunks_count', + InitialChunksCount = 'epn.ng_initial_chunks_count', + ChangedChunksCount = 'epn.ng_changed_chunks_count', + DurationInMs = 'epn.ng_duration_ms', + CssSizeInBytes = 'epn.ng_css_size_bytes', + JsSizeInBytes = 'epn.ng_js_size_bytes', + NgComponentCount = 'epn.ng_component_count', + AllProjectsCount = 'epn.all_projects_count', + LibraryProjectsCount = 'epn.libs_projects_count', + ApplicationProjectsCount = 'epn.apps_projects_count', +} diff --git a/packages/angular/cli/src/analytics/analytics-parameters.ts b/packages/angular/cli/src/analytics/analytics-parameters.ts new file mode 100644 index 000000000000..08ee5d72a684 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-parameters.ts @@ -0,0 +1,107 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** Any changes in this file needs to be done in the mts version. */ + +export type PrimitiveTypes = string | number | boolean; + +/** + * GA built-in request parameters + * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet + * @see http://go/depot/google3/analytics/container_tag/templates/common/gold/mpv2_schema.js + */ +export enum RequestParameter { + ClientId = 'cid', + DebugView = '_dbg', + GtmVersion = 'gtm', + Language = 'ul', + NewToSite = '_nsi', + NonInteraction = 'ni', + PageLocation = 'dl', + PageTitle = 'dt', + ProtocolVersion = 'v', + SessionEngaged = 'seg', + SessionId = 'sid', + SessionNumber = 'sct', + SessionStart = '_ss', + TrackingId = 'tid', + TrafficType = 'tt', + UserAgentArchitecture = 'uaa', + UserAgentBitness = 'uab', + UserAgentFullVersionList = 'uafvl', + UserAgentMobile = 'uamb', + UserAgentModel = 'uam', + UserAgentPlatform = 'uap', + UserAgentPlatformVersion = 'uapv', + UserId = 'uid', +} + +/** + * User scoped custom dimensions. + * @remarks + * - User custom dimensions limit is 25. + * - `up.*` string type. + * - `upn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum UserCustomDimension { + UserId = 'up.ng_user_id', + OsArchitecture = 'up.ng_os_architecture', + NodeVersion = 'up.ng_node_version', + NodeMajorVersion = 'upn.ng_node_major_version', + AngularCLIVersion = 'up.ng_cli_version', + AngularCLIMajorVersion = 'upn.ng_cli_major_version', + PackageManager = 'up.ng_package_manager', + PackageManagerVersion = 'up.ng_pkg_manager_version', + PackageManagerMajorVersion = 'upn.ng_pkg_manager_major_v', +} + +/** + * Event scoped custom dimensions. + * @remarks + * - Event custom dimensions limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomDimension { + Command = 'ep.ng_command', + SchematicCollectionName = 'ep.ng_schematic_collection_name', + SchematicName = 'ep.ng_schematic_name', + Standalone = 'ep.ng_standalone', + SSR = 'ep.ng_ssr', + Style = 'ep.ng_style', + Routing = 'ep.ng_routing', + InlineTemplate = 'ep.ng_inline_template', + InlineStyle = 'ep.ng_inline_style', + BuilderTarget = 'ep.ng_builder_target', + Aot = 'ep.ng_aot', + Optimization = 'ep.ng_optimization', +} + +/** + * Event scoped custom mertics. + * @remarks + * - Event scoped custom mertics limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomMetric { + AllChunksCount = 'epn.ng_all_chunks_count', + LazyChunksCount = 'epn.ng_lazy_chunks_count', + InitialChunksCount = 'epn.ng_initial_chunks_count', + ChangedChunksCount = 'epn.ng_changed_chunks_count', + DurationInMs = 'epn.ng_duration_ms', + CssSizeInBytes = 'epn.ng_css_size_bytes', + JsSizeInBytes = 'epn.ng_js_size_bytes', + NgComponentCount = 'epn.ng_component_count', + AllProjectsCount = 'epn.all_projects_count', + LibraryProjectsCount = 'epn.libs_projects_count', + ApplicationProjectsCount = 'epn.apps_projects_count', +} diff --git a/packages/angular/cli/src/analytics/analytics.ts b/packages/angular/cli/src/analytics/analytics.ts new file mode 100644 index 000000000000..7991904842bd --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { json, tags } from '@angular-devkit/core'; +import { randomUUID } from 'node:crypto'; +import type { CommandContext } from '../command-builder/definitions'; +import { colors } from '../utilities/color'; +import { getWorkspace } from '../utilities/config'; +import { analyticsDisabled } from '../utilities/environment-options'; +import { askConfirmation } from '../utilities/prompt'; +import { isTTY } from '../utilities/tty'; + +/* eslint-disable no-console */ + +/** + * This is the ultimate safelist for checking if a package name is safe to report to analytics. + */ +export const analyticsPackageSafelist = [ + /^@angular\//, + /^@angular-devkit\//, + /^@nguniversal\//, + '@schematics/angular', +]; + +export function isPackageNameSafeForAnalytics(name: string): boolean { + return analyticsPackageSafelist.some((pattern) => { + if (typeof pattern == 'string') { + return pattern === name; + } else { + return pattern.test(name); + } + }); +} + +/** + * Set analytics settings. This does not work if the user is not inside a project. + * @param global Which config to use. "global" for user-level, and "local" for project-level. + * @param value Either a user ID, true to generate a new User ID, or false to disable analytics. + */ +export async function setAnalyticsConfig(global: boolean, value: string | boolean): Promise { + const level = global ? 'global' : 'local'; + const workspace = await getWorkspace(level); + if (!workspace) { + throw new Error(`Could not find ${level} workspace.`); + } + + const cli = (workspace.extensions['cli'] ??= {}); + if (!workspace || !json.isJsonObject(cli)) { + throw new Error(`Invalid config found at ${workspace.filePath}. CLI should be an object.`); + } + + cli.analytics = value === true ? randomUUID() : value; + await workspace.save(); +} + +/** + * Prompt the user for usage gathering permission. + * @param force Whether to ask regardless of whether or not the user is using an interactive shell. + * @return Whether or not the user was shown a prompt. + */ +export async function promptAnalytics( + context: CommandContext, + global: boolean, + force = false, +): Promise { + const level = global ? 'global' : 'local'; + const workspace = await getWorkspace(level); + if (!workspace) { + throw new Error(`Could not find a ${level} workspace. Are you in a project?`); + } + + if (force || isTTY()) { + const answer = await askConfirmation( + ` +Would you like to share pseudonymous usage data about this project with the Angular Team +at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more +details and how to change this setting, see https://angular.dev/cli/analytics. + + `, + false, + ); + + await setAnalyticsConfig(global, answer); + + if (answer) { + console.log(''); + console.log( + tags.stripIndent` + Thank you for sharing pseudonymous usage data. Should you change your mind, the following + command will disable this feature entirely: + + ${colors.yellow(`ng analytics disable${global ? ' --global' : ''}`)} + `, + ); + console.log(''); + } + + process.stderr.write(await getAnalyticsInfoString(context)); + + return true; + } + + return false; +} + +/** + * Get the analytics user id. + * + * @returns + * - `string` user id. + * - `false` when disabled. + * - `undefined` when not configured. + */ +async function getAnalyticsUserIdForLevel( + level: 'local' | 'global', +): Promise { + if (analyticsDisabled) { + return false; + } + + const workspace = await getWorkspace(level); + const analyticsConfig: string | undefined | null | { uid?: string } | boolean = + workspace?.getCli()?.['analytics']; + + if (analyticsConfig === false) { + return false; + } else if (analyticsConfig === undefined || analyticsConfig === null) { + return undefined; + } else { + if (typeof analyticsConfig == 'string') { + return analyticsConfig; + } else if (typeof analyticsConfig == 'object' && typeof analyticsConfig['uid'] == 'string') { + return analyticsConfig['uid']; + } + + return undefined; + } +} + +export async function getAnalyticsUserId( + context: CommandContext, + skipPrompt = false, +): Promise { + const { workspace } = context; + // Global config takes precedence over local config only for the disabled check. + // IE: + // global: disabled & local: enabled = disabled + // global: id: 123 & local: id: 456 = 456 + + // check global + const globalConfig = await getAnalyticsUserIdForLevel('global'); + if (globalConfig === false) { + return undefined; + } + + // Not disabled globally, check locally or not set globally and command is run outside of workspace example: `ng new` + if (workspace || globalConfig === undefined) { + const level = workspace ? 'local' : 'global'; + let localOrGlobalConfig = await getAnalyticsUserIdForLevel(level); + if (localOrGlobalConfig === undefined) { + if (!skipPrompt) { + // config is unset, prompt user. + // TODO: This should honor the `no-interactive` option. + // It is currently not an `ng` option but rather only an option for specific commands. + // The concept of `ng`-wide options are needed to cleanly handle this. + await promptAnalytics(context, !workspace /** global */); + localOrGlobalConfig = await getAnalyticsUserIdForLevel(level); + } + } + + if (localOrGlobalConfig === false) { + return undefined; + } else if (typeof localOrGlobalConfig === 'string') { + return localOrGlobalConfig; + } + } + + return globalConfig; +} + +function analyticsConfigValueToHumanFormat(value: unknown): 'enabled' | 'disabled' | 'not set' { + if (value === false) { + return 'disabled'; + } else if (typeof value === 'string' || value === true) { + return 'enabled'; + } else { + return 'not set'; + } +} + +export async function getAnalyticsInfoString(context: CommandContext): Promise { + const analyticsInstance = await getAnalyticsUserId(context, true /** skipPrompt */); + + const { globalConfiguration, workspace: localWorkspace } = context; + const globalSetting = globalConfiguration?.getCli()?.['analytics']; + const localSetting = localWorkspace?.getCli()?.['analytics']; + + return ( + tags.stripIndents` + Global setting: ${analyticsConfigValueToHumanFormat(globalSetting)} + Local setting: ${ + localWorkspace + ? analyticsConfigValueToHumanFormat(localSetting) + : 'No local workspace configuration file.' + } + Effective status: ${analyticsInstance ? 'enabled' : 'disabled'} + ` + '\n' + ); +} diff --git a/packages/angular/cli/src/command-builder/architect-base-command-module.ts b/packages/angular/cli/src/command-builder/architect-base-command-module.ts new file mode 100644 index 000000000000..fb3508777d74 --- /dev/null +++ b/packages/angular/cli/src/command-builder/architect-base-command-module.ts @@ -0,0 +1,297 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Architect, Target } from '@angular-devkit/architect'; +import { + NodeModulesBuilderInfo, + WorkspaceNodeModulesArchitectHost, +} from '@angular-devkit/architect/node'; +import { json } from '@angular-devkit/core'; +import { createRequire } from 'node:module'; +import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; +import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters'; +import { assertIsError } from '../utilities/error'; +import { askConfirmation, askQuestion } from '../utilities/prompt'; +import { isTTY } from '../utilities/tty'; +import { + CommandModule, + CommandModuleError, + CommandModuleImplementation, + CommandScope, + OtherOptions, +} from './command-module'; +import { Option, parseJsonSchemaToOptions } from './utilities/json-schema'; + +export interface MissingTargetChoice { + name: string; + value: string; +} + +export abstract class ArchitectBaseCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + override scope = CommandScope.In; + protected readonly missingTargetChoices: MissingTargetChoice[] | undefined; + + protected async runSingleTarget(target: Target, options: OtherOptions): Promise { + const architectHost = this.getArchitectHost(); + + let builderName: string; + try { + builderName = await architectHost.getBuilderNameForTarget(target); + } catch (e) { + assertIsError(e); + + return this.onMissingTarget(e.message); + } + + const isAngularBuild = builderName.startsWith('@angular/build:'); + + const { logger } = this.context; + const run = await this.getArchitect(isAngularBuild).scheduleTarget( + target, + options as json.JsonObject, + { + logger, + }, + ); + + const analytics = isPackageNameSafeForAnalytics(builderName) + ? await this.getAnalytics() + : undefined; + + let outputSubscription; + if (analytics) { + analytics.reportArchitectRunEvent({ + [EventCustomDimension.BuilderTarget]: builderName, + }); + + let firstRun = true; + outputSubscription = run.output.subscribe(({ stats }) => { + const parameters = this.builderStatsToAnalyticsParameters(stats, builderName); + if (!parameters) { + return; + } + + if (firstRun) { + firstRun = false; + analytics.reportBuildRunEvent(parameters); + } else { + analytics.reportRebuildRunEvent(parameters); + } + }); + } + + try { + const { error, success } = await run.lastOutput; + if (error) { + logger.error(error); + } + + return success ? 0 : 1; + } finally { + await run.stop(); + outputSubscription?.unsubscribe(); + } + } + + private builderStatsToAnalyticsParameters( + stats: json.JsonValue, + builderName: string, + ): Partial< + | Record + | undefined + > { + if (!stats || typeof stats !== 'object' || !('durationInMs' in stats)) { + return undefined; + } + + const { + optimization, + allChunksCount, + aot, + lazyChunksCount, + initialChunksCount, + durationInMs, + changedChunksCount, + cssSizeInBytes, + jsSizeInBytes, + ngComponentCount, + } = stats; + + return { + [EventCustomDimension.BuilderTarget]: builderName, + [EventCustomDimension.Aot]: aot, + [EventCustomDimension.Optimization]: optimization, + [EventCustomMetric.AllChunksCount]: allChunksCount, + [EventCustomMetric.LazyChunksCount]: lazyChunksCount, + [EventCustomMetric.InitialChunksCount]: initialChunksCount, + [EventCustomMetric.ChangedChunksCount]: changedChunksCount, + [EventCustomMetric.DurationInMs]: durationInMs, + [EventCustomMetric.JsSizeInBytes]: jsSizeInBytes, + [EventCustomMetric.CssSizeInBytes]: cssSizeInBytes, + [EventCustomMetric.NgComponentCount]: ngComponentCount, + }; + } + + private _architectHost: WorkspaceNodeModulesArchitectHost | undefined; + protected getArchitectHost(): WorkspaceNodeModulesArchitectHost { + if (this._architectHost) { + return this._architectHost; + } + + const workspace = this.getWorkspaceOrThrow(); + + return (this._architectHost = new WorkspaceNodeModulesArchitectHost( + workspace, + workspace.basePath, + )); + } + + private _architect: Architect | undefined; + protected getArchitect(skipUndefinedArrayTransform: boolean): Architect { + if (this._architect) { + return this._architect; + } + + const registry = new json.schema.CoreSchemaRegistry(); + if (skipUndefinedArrayTransform) { + registry.addPostTransform(json.schema.transforms.addUndefinedObjectDefaults); + } else { + registry.addPostTransform(json.schema.transforms.addUndefinedDefaults); + } + registry.useXDeprecatedProvider((msg) => this.context.logger.warn(msg)); + + const architectHost = this.getArchitectHost(); + + return (this._architect = new Architect(architectHost, registry)); + } + + protected async getArchitectTargetOptions(target: Target): Promise { + const architectHost = this.getArchitectHost(); + let builderConf: string; + + try { + builderConf = await architectHost.getBuilderNameForTarget(target); + } catch { + return []; + } + + let builderDesc: NodeModulesBuilderInfo; + try { + builderDesc = await architectHost.resolveBuilder(builderConf); + } catch (e) { + assertIsError(e); + if (e.code === 'MODULE_NOT_FOUND') { + this.warnOnMissingNodeModules(); + throw new CommandModuleError(`Could not find the '${builderConf}' builder's node package.`); + } + + throw e; + } + + return parseJsonSchemaToOptions( + new json.schema.CoreSchemaRegistry(), + builderDesc.optionSchema as json.JsonObject, + true, + ); + } + + private warnOnMissingNodeModules(): void { + const basePath = this.context.workspace?.basePath; + if (!basePath) { + return; + } + + const workspaceResolve = createRequire(basePath + '/').resolve; + + try { + workspaceResolve('@angular/core'); + + return; + } catch {} + + this.context.logger.warn( + `Node packages may not be installed. Try installing with '${this.context.packageManager.name} install'.`, + ); + } + + protected getArchitectTarget(): string { + return this.commandName; + } + + protected async onMissingTarget(defaultMessage: string): Promise<1> { + const { logger } = this.context; + const choices = this.missingTargetChoices; + + if (!choices?.length) { + logger.error(defaultMessage); + + return 1; + } + + const missingTargetMessage = + `Cannot find "${this.getArchitectTarget()}" target for the specified project.\n` + + `You can add a package that implements these capabilities.\n\n` + + `For example:\n` + + choices.map(({ name, value }) => ` ${name}: ng add ${value}`).join('\n') + + '\n'; + + if (isTTY()) { + // Use prompts to ask the user if they'd like to install a package. + logger.warn(missingTargetMessage); + + const packageToInstall = await this.getMissingTargetPackageToInstall(choices); + if (packageToInstall) { + // Example run: `ng add angular-eslint`. + const AddCommandModule = (await import('../commands/add/cli')).default; + await new AddCommandModule(this.context).run({ + interactive: true, + force: false, + dryRun: false, + defaults: false, + collection: packageToInstall, + }); + } + } else { + // Non TTY display error message. + logger.error(missingTargetMessage); + } + + return 1; + } + + private async getMissingTargetPackageToInstall( + choices: MissingTargetChoice[], + ): Promise { + if (choices.length === 1) { + // Single choice + const { name, value } = choices[0]; + if (await askConfirmation(`Would you like to add ${name} now?`, true, false)) { + return value; + } + + return null; + } + + // Multiple choice + return askQuestion( + `Would you like to add a package with "${this.getArchitectTarget()}" capabilities now?`, + [ + { + name: 'No', + value: null, + }, + ...choices, + ], + 0, + null, + ); + } +} diff --git a/packages/angular/cli/src/command-builder/architect-command-module.ts b/packages/angular/cli/src/command-builder/architect-command-module.ts new file mode 100644 index 000000000000..98e270cf1dad --- /dev/null +++ b/packages/angular/cli/src/command-builder/architect-command-module.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Target } from '@angular-devkit/architect'; +import { workspaces } from '@angular-devkit/core'; +import { Argv } from 'yargs'; +import { getProjectByCwd } from '../utilities/config'; +import { memoize } from '../utilities/memoize'; +import { ArchitectBaseCommandModule } from './architect-base-command-module'; +import { + CommandModuleError, + CommandModuleImplementation, + Options, + OtherOptions, +} from './command-module'; + +export interface ArchitectCommandArgs { + configuration?: string; + project?: string; +} + +export abstract class ArchitectCommandModule + extends ArchitectBaseCommandModule + implements CommandModuleImplementation +{ + abstract readonly multiTarget: boolean; + + findDefaultBuilderName?( + project: workspaces.ProjectDefinition, + target: Target, + ): Promise; + + async builder(argv: Argv): Promise> { + const target = this.getArchitectTarget(); + + // Add default builder if target is not in project and a command default is provided + if (this.findDefaultBuilderName && this.context.workspace) { + for (const [project, projectDefinition] of this.context.workspace.projects) { + const targetDefinition = projectDefinition.targets.get(target); + if (targetDefinition?.builder) { + continue; + } + + const defaultBuilder = await this.findDefaultBuilderName(projectDefinition, { + project, + target, + }); + if (!defaultBuilder) { + continue; + } + + if (targetDefinition) { + targetDefinition.builder = defaultBuilder; + } else { + projectDefinition.targets.set(target, { + builder: defaultBuilder, + }); + } + } + } + + const project = this.getArchitectProject(); + const { jsonHelp, getYargsCompletions, help } = this.context.args.options; + + const localYargs: Argv = argv + .positional('project', { + describe: 'The name of the project to build. Can be an application or a library.', + type: 'string', + // Hide choices from JSON help so that we don't display them in AIO. + choices: jsonHelp ? undefined : this.getProjectChoices(), + }) + .option('configuration', { + describe: + `One or more named builder configurations as a comma-separated ` + + `list as specified in the "configurations" section in angular.json.\n` + + `The builder uses the named configurations to run the given target.\n` + + `For more information, see https://angular.dev/reference/configs/workspace-config#alternate-build-configurations.`, + alias: 'c', + type: 'string', + // Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid. + // Also, hide choices from JSON help so that we don't display them in AIO. + choices: + (getYargsCompletions || help) && !jsonHelp && project + ? this.getConfigurationChoices(project) + : undefined, + }) + .strict(); + + if (!project) { + return localYargs; + } + + const schemaOptions = await this.getArchitectTargetOptions({ + project, + target, + }); + + return this.addSchemaOptionsToCommand(localYargs, schemaOptions); + } + + async run(options: Options & OtherOptions): Promise { + const originalProcessTitle = process.title; + try { + const target = this.getArchitectTarget(); + const { configuration = '', project, ...architectOptions } = options; + + if (project) { + process.title = `${originalProcessTitle} (${project})`; + + return await this.runSingleTarget({ configuration, target, project }, architectOptions); + } + + // This runs each target sequentially. + // Running them in parallel would jumble the log messages. + let result = 0; + const projectNames = this.getProjectNamesByTarget(target); + if (!projectNames) { + return this.onMissingTarget('Cannot determine project or target for command.'); + } + + for (const project of projectNames) { + process.title = `${originalProcessTitle} (${project})`; + result |= await this.runSingleTarget({ configuration, target, project }, architectOptions); + } + + return result; + } finally { + process.title = originalProcessTitle; + } + } + + private getArchitectProject(): string | undefined { + const { options, positional } = this.context.args; + const [, projectName] = positional; + + if (projectName) { + return projectName; + } + + // Yargs allows positional args to be used as flags. + if (typeof options['project'] === 'string') { + return options['project']; + } + + const target = this.getArchitectTarget(); + const projectFromTarget = this.getProjectNamesByTarget(target); + + return projectFromTarget?.length ? projectFromTarget[0] : undefined; + } + + @memoize + private getProjectNamesByTarget(target: string): string[] | undefined { + const workspace = this.getWorkspaceOrThrow(); + const allProjectsForTargetName: string[] = []; + + for (const [name, project] of workspace.projects) { + if (project.targets.has(target)) { + allProjectsForTargetName.push(name); + } + } + + if (allProjectsForTargetName.length === 0) { + return undefined; + } + + if (this.multiTarget) { + // For multi target commands, we always list all projects that have the target. + return allProjectsForTargetName; + } else { + if (allProjectsForTargetName.length === 1) { + return allProjectsForTargetName; + } + + const maybeProject = getProjectByCwd(workspace); + if (maybeProject) { + return allProjectsForTargetName.includes(maybeProject) ? [maybeProject] : undefined; + } + + const { getYargsCompletions, help } = this.context.args.options; + if (!getYargsCompletions && !help) { + // Only issue the below error when not in help / completion mode. + throw new CommandModuleError( + 'Cannot determine project for command.\n' + + 'This is a multi-project workspace and more than one project supports this command. ' + + `Run "ng ${this.command}" to execute the command for a specific project or change the current ` + + 'working directory to a project directory.\n\n' + + `Available projects are:\n${allProjectsForTargetName + .sort() + .map((p) => `- ${p}`) + .join('\n')}`, + ); + } + } + + return undefined; + } + + /** @returns a sorted list of project names to be used for auto completion. */ + private getProjectChoices(): string[] | undefined { + const { workspace } = this.context; + + return workspace ? [...workspace.projects.keys()].sort() : undefined; + } + + /** @returns a sorted list of configuration names to be used for auto completion. */ + private getConfigurationChoices(project: string): string[] | undefined { + const projectDefinition = this.context.workspace?.projects.get(project); + if (!projectDefinition) { + return undefined; + } + + const target = this.getArchitectTarget(); + const configurations = projectDefinition.targets.get(target)?.configurations; + + return configurations ? Object.keys(configurations).sort() : undefined; + } +} diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts new file mode 100644 index 000000000000..ec92bc452d5b --- /dev/null +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -0,0 +1,291 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { schema } from '@angular-devkit/core'; +import type { ArgumentsCamelCase, Argv, CommandModule as YargsCommandModule } from 'yargs'; +import { Parser as yargsParser } from 'yargs/helpers'; +import { getAnalyticsUserId } from '../analytics/analytics'; +import { AnalyticsCollector } from '../analytics/analytics-collector'; +import { EventCustomDimension, EventCustomMetric } from '../analytics/analytics-parameters'; +import { PackageManagerError } from '../package-managers'; +import { considerSettingUpAutocompletion } from '../utilities/completion'; +import { AngularWorkspace } from '../utilities/config'; +import { memoize } from '../utilities/memoize'; +import { CommandContext, CommandScope, Options, OtherOptions } from './definitions'; +import { Option, addSchemaOptionsToCommand } from './utilities/json-schema'; + +export { CommandScope }; +export type { CommandContext, Options, OtherOptions }; + +export interface CommandModuleImplementation extends Omit< + YargsCommandModule<{}, T>, + 'builder' | 'handler' +> { + /** Scope in which the command can be executed in. */ + scope: CommandScope; + + /** Long description for the command in JSON help text. */ + longDescription?: string; + + /** Relative path to the long description file for the command in JSON help text. */ + longDescriptionRelativePath?: string; + + /** Object declaring the options the command accepts, or a function accepting and returning a yargs instance. */ + builder(argv: Argv): Promise> | Argv; + + /** A function which will be passed the parsed argv. */ + run(options: Options & OtherOptions): Promise | number | void; +} + +export interface FullDescribe { + describe?: string; + longDescription?: string; + longDescriptionRelativePath?: string; +} + +export abstract class CommandModule implements CommandModuleImplementation { + abstract readonly command: string; + abstract readonly describe: string | false; + readonly longDescription?: string; + readonly longDescriptionRelativePath?: string; + protected readonly shouldReportAnalytics: boolean = true; + readonly scope: CommandScope = CommandScope.Both; + + private readonly optionsWithAnalytics = new Map< + string, + EventCustomDimension | EventCustomMetric + >(); + + constructor(protected readonly context: CommandContext) {} + + /** + * Description object which contains the long command descroption. + * This is used to generate JSON help wich is used in AIO. + * + * `false` will result in a hidden command. + */ + public get fullDescribe(): FullDescribe | false { + if (this.describe === false) { + return false; + } + + const description: FullDescribe = { + describe: this.describe, + }; + + if (this.longDescription) { + description.longDescription = this.longDescription.replace(/\r\n/g, '\n'); + description.longDescriptionRelativePath = + this.longDescriptionRelativePath ?? + `@angular/cli/src/commands/${this.commandName}/long-description.md`; + } + + return description; + } + + protected get commandName(): string { + return this.command.split(' ', 1)[0]; + } + + abstract builder(argv: Argv): Promise> | Argv; + abstract run(options: Options & OtherOptions): Promise | number | void; + + async handler(args: ArgumentsCamelCase & OtherOptions): Promise { + const { _, $0, ...options } = args; + const { logger } = this.context; + + // Camelize options as yargs will return the object in kebab-case when camel casing is disabled. + const camelCasedOptions: Record = {}; + for (const [key, value] of Object.entries(options)) { + camelCasedOptions[yargsParser.camelCase(key)] = value; + } + + // Set up autocompletion if appropriate. + const autocompletionExitCode = await considerSettingUpAutocompletion(this.commandName, logger); + if (autocompletionExitCode !== undefined) { + process.exitCode = autocompletionExitCode; + + return; + } + + // Gather and report analytics. + const analytics = await this.getAnalytics(); + const stopPeriodicFlushes = analytics && analytics.periodFlush(); + + let exitCode: number | void | undefined; + try { + if (analytics) { + this.reportCommandRunAnalytics(analytics); + this.reportWorkspaceInfoAnalytics(analytics); + } + + exitCode = await this.run(camelCasedOptions as Options & OtherOptions); + } catch (e) { + if (e instanceof schema.SchemaValidationException) { + logger.fatal(`Error: ${e.message}`); + exitCode = 1; + } else if (e instanceof PackageManagerError) { + const output = e.stderr || e.stdout; + logger.fatal( + `Error: Package installation failed: ${e.message}${output ? `\nOutput: ${output}` : ''}`, + ); + exitCode = 1; + } else { + throw e; + } + } finally { + await stopPeriodicFlushes?.(); + + if (typeof exitCode === 'number' && exitCode > 0) { + process.exitCode = exitCode; + } + } + } + + @memoize + protected async getAnalytics(): Promise { + if (!this.shouldReportAnalytics) { + return undefined; + } + + const userId = await getAnalyticsUserId( + this.context, + // Don't prompt on `ng update`, 'ng version' or `ng analytics`. + ['version', 'update', 'analytics'].includes(this.commandName), + ); + + if (!userId) { + return undefined; + } + + let version: string | undefined; + try { + version = await this.context.packageManager.getVersion(); + } catch { + // Ignore errors if the package manager is not available. + } + + return new AnalyticsCollector(this.context.logger, userId, { + name: this.context.packageManager.name, + version, + }); + } + + /** + * Adds schema options to a command also this keeps track of options that are required for analytics. + * **Note:** This method should be called from the command bundler method. + */ + protected addSchemaOptionsToCommand(localYargs: Argv, options: Option[]): Argv { + const optionsWithAnalytics = addSchemaOptionsToCommand( + localYargs, + options, + // This should only be done when `--help` is used otherwise default will override options set in angular.json. + /* includeDefaultValues= */ this.context.args.options.help, + ); + + // Record option of analytics. + for (const [name, userAnalytics] of optionsWithAnalytics) { + this.optionsWithAnalytics.set(name, userAnalytics); + } + + return localYargs; + } + + protected getWorkspaceOrThrow(): AngularWorkspace { + const { workspace } = this.context; + if (!workspace) { + throw new CommandModuleError('A workspace is required for this command.'); + } + + return workspace; + } + + /** + * Flush on an interval (if the event loop is waiting). + * + * @returns a method that when called will terminate the periodic + * flush and call flush one last time. + */ + protected getAnalyticsParameters( + options: (Options & OtherOptions) | OtherOptions, + ): Partial> { + const parameters: Partial< + Record + > = {}; + + const validEventCustomDimensionAndMetrics = new Set([ + ...Object.values(EventCustomDimension), + ...Object.values(EventCustomMetric), + ]); + + for (const [name, ua] of this.optionsWithAnalytics) { + if (!validEventCustomDimensionAndMetrics.has(ua)) { + continue; + } + + const value = options[name]; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + parameters[ua] = value; + } else if (Array.isArray(value)) { + // GA doesn't allow array as values. + parameters[ua] = value.sort().join(', '); + } + } + + return parameters; + } + + private reportCommandRunAnalytics(analytics: AnalyticsCollector): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const internalMethods = (this.context.yargsInstance as any).getInternalMethods(); + // $0 generate component [name] -> generate_component + // $0 add -> add + const fullCommand = (internalMethods.getUsageInstance().getUsage()[0][0] as string) + .split(' ') + .filter((x) => { + const code = x.charCodeAt(0); + + return code >= 97 && code <= 122; + }) + .join('_'); + + analytics.reportCommandRunEvent(fullCommand); + } + + private reportWorkspaceInfoAnalytics(analytics: AnalyticsCollector): void { + const { workspace } = this.context; + if (!workspace) { + return; + } + + let applicationProjectsCount = 0; + let librariesProjectsCount = 0; + for (const project of workspace.projects.values()) { + switch (project.extensions['projectType']) { + case 'application': + applicationProjectsCount++; + break; + case 'library': + librariesProjectsCount++; + break; + } + } + + analytics.reportWorkspaceInfoEvent({ + [EventCustomMetric.AllProjectsCount]: librariesProjectsCount + applicationProjectsCount, + [EventCustomMetric.ApplicationProjectsCount]: applicationProjectsCount, + [EventCustomMetric.LibraryProjectsCount]: librariesProjectsCount, + }); + } +} + +/** + * Creates an known command module error. + * This is used so during executation we can filter between known validation error and real non handled errors. + */ +export class CommandModuleError extends Error {} diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts new file mode 100644 index 000000000000..62de365dee99 --- /dev/null +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -0,0 +1,245 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonValue, isJsonObject, logging } from '@angular-devkit/core'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import yargs from 'yargs'; +import { Parser as yargsParser } from 'yargs/helpers'; +import { getCacheConfig } from '../commands/cache/utilities'; +import { + CommandConfig, + CommandNames, + RootCommands, + RootCommandsAliases, +} from '../commands/command-config'; +import { createPackageManager } from '../package-managers'; +import { ConfiguredPackageManagerInfo } from '../package-managers/factory'; +import { colors } from '../utilities/color'; +import { AngularWorkspace, getProjectByCwd, getWorkspace } from '../utilities/config'; +import { assertIsError } from '../utilities/error'; +import { VERSION } from '../utilities/version'; +import { CommandContext, CommandModuleError } from './command-module'; +import { + CommandModuleConstructor, + addCommandModuleToYargs, + demandCommandFailureMessage, +} from './utilities/command'; +import { jsonHelpUsage } from './utilities/json-help'; +import { createNormalizeOptionsMiddleware } from './utilities/normalize-options-middleware'; + +export async function runCommand(args: string[], logger: logging.Logger): Promise { + const { + $0, + _, + help = false, + dryRun = false, + jsonHelp = false, + getYargsCompletions = false, + ...rest + } = yargsParser(args, { + boolean: ['help', 'json-help', 'get-yargs-completions', 'dry-run'], + alias: { 'collection': 'c' }, + }); + + // When `getYargsCompletions` is true the scriptName 'ng' at index 0 is not removed. + const positional = getYargsCompletions ? _.slice(1) : _; + + let workspace: AngularWorkspace | undefined; + let globalConfiguration: AngularWorkspace; + try { + [workspace, globalConfiguration] = await Promise.all([ + getWorkspace('local'), + getWorkspace('global'), + ]); + } catch (e) { + assertIsError(e); + logger.fatal(e.message); + + return 1; + } + + const root = workspace?.basePath ?? process.cwd(); + const cacheConfig = workspace && getCacheConfig(workspace); + const packageManager = await createPackageManager({ + cwd: root, + logger, + dryRun: dryRun || help || jsonHelp || getYargsCompletions, + tempDirectory: cacheConfig?.enabled ? cacheConfig.path : undefined, + configuredPackageManager: await getConfiguredPackageManager( + root, + workspace, + globalConfiguration, + ), + }); + + const localYargs = yargs(args); + const context: CommandContext = { + globalConfiguration, + workspace, + logger, + currentDirectory: process.cwd(), + yargsInstance: localYargs, + root, + packageManager, + args: { + positional: positional.map((v) => v.toString()), + options: { + help, + jsonHelp, + getYargsCompletions, + ...rest, + }, + }, + }; + + for (const CommandModule of await getCommandsToRegister(positional[0])) { + addCommandModuleToYargs(CommandModule, context); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const usageInstance = (localYargs as any).getInternalMethods().getUsageInstance(); + if (jsonHelp) { + usageInstance.help = () => jsonHelpUsage(localYargs); + } else if (!help) { + // Yargs eagerly caches and formats the full command help (including table wrapping + // and Unicode string-width calculations via cliui) whenever a subcommand executes, + // in case the command fails. Because showHelpOnFail is disabled, this formatted text + // is never used. Skipping this work avoids non-trivial synchronous overhead during startup. + usageInstance.cacheHelpMessage = () => {}; + } + + // Add default command to support version option when no subcommand is specified + localYargs.command('*', false, (builder) => + builder.version('version', 'Show Angular CLI version.', VERSION.full), + ); + + await localYargs + .scriptName('ng') + // https://github.com/yargs/yargs/blob/main/docs/advanced.md#customizing-yargs-parser + .parserConfiguration({ + 'populate--': true, + 'unknown-options-as-args': false, + 'dot-notation': false, + 'boolean-negation': true, + 'strip-aliased': true, + 'strip-dashed': true, + 'camel-case-expansion': false, + }) + .option('json-help', { + describe: 'Show help in JSON format.', + implies: ['help'], + hidden: true, + type: 'boolean', + }) + .help('help', 'Shows a help message for this command in the console.') + // A complete list of strings can be found: https://github.com/yargs/yargs/blob/main/locales/en.json + .updateStrings({ + 'Commands:': colors.cyan('Commands:'), + 'Options:': colors.cyan('Options:'), + 'Positionals:': colors.cyan('Arguments:'), + 'deprecated': colors.yellow('deprecated'), + 'deprecated: %s': colors.yellow('deprecated:') + ' %s', + 'Did you mean %s?': 'Unknown command. Did you mean %s?', + }) + .epilogue('For more information, see https://angular.dev/cli/.\n') + .demandCommand(1, demandCommandFailureMessage) + .recommendCommands() + .middleware(createNormalizeOptionsMiddleware(localYargs)) + .version(false) + .showHelpOnFail(false) + .strict() + .fail((msg, err) => { + throw msg + ? // Validation failed example: `Unknown argument:` + new CommandModuleError(msg) + : // Unknown exception, re-throw. + err; + }) + .wrap(localYargs.terminalWidth()) + .parseAsync(); + + return +(process.exitCode ?? 0); +} + +/** + * Get the commands that need to be registered. + * @returns One or more command factories that needs to be registered. + */ +async function getCommandsToRegister( + commandName: string | number, +): Promise { + const commands: CommandConfig[] = []; + if (commandName in RootCommands) { + commands.push(RootCommands[commandName as CommandNames]); + } else if (commandName in RootCommandsAliases) { + commands.push(RootCommandsAliases[commandName]); + } else { + // Unknown command, register every possible command. + Object.values(RootCommands).forEach((c) => commands.push(c)); + } + + return Promise.all(commands.map((command) => command.factory().then((m) => m.default))); +} + +/** + * Gets the configured package manager by checking package.json, or the local and global angular.json files. + * + * @param root The root directory of the workspace. + * @param localWorkspace The local workspace. + * @param globalWorkspace The global workspace. + * @returns The package manager name and version. + */ +async function getConfiguredPackageManager( + root: string, + localWorkspace: AngularWorkspace | undefined, + globalWorkspace: AngularWorkspace, +): Promise { + let result: ConfiguredPackageManagerInfo | undefined; + + try { + const packageJsonPath = join(root, 'package.json'); + const pkgJson = JSON.parse(await readFile(packageJsonPath, 'utf-8')) as JsonValue; + result = getPackageManager(pkgJson); + } catch {} + + if (result) { + return result; + } + + if (localWorkspace) { + const project = getProjectByCwd(localWorkspace); + if (project) { + result = getPackageManager(localWorkspace.projects.get(project)?.extensions['cli']); + } + + result ??= getPackageManager(localWorkspace.extensions['cli']); + } + + result ??= getPackageManager(globalWorkspace.extensions['cli']); + + return result; +} + +/** + * Get the package manager name from a JSON value. + * @param source The JSON value to get the package manager name from. + * @returns The package manager name and version. + */ +function getPackageManager( + source: JsonValue | undefined, +): ConfiguredPackageManagerInfo | undefined { + if (source && isJsonObject(source)) { + const value = source['packageManager']; + if (typeof value === 'string') { + return value.split('@', 2) as unknown as ConfiguredPackageManagerInfo; + } + } + + return undefined; +} diff --git a/packages/angular/cli/src/command-builder/definitions.ts b/packages/angular/cli/src/command-builder/definitions.ts new file mode 100644 index 000000000000..27c6dc8a6ae9 --- /dev/null +++ b/packages/angular/cli/src/command-builder/definitions.ts @@ -0,0 +1,47 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import type { Argv, CamelCaseKey } from 'yargs' with { 'resolution-mode': 'require' }; +import type { PackageManager } from '../package-managers/package-manager'; +import { AngularWorkspace } from '../utilities/config'; + +export enum CommandScope { + /** Command can only run inside an Angular workspace. */ + In, + + /** Command can only run outside an Angular workspace. */ + Out, + + /** Command can run inside and outside an Angular workspace. */ + Both, +} + +export interface CommandContext { + currentDirectory: string; + root: string; + workspace?: AngularWorkspace; + globalConfiguration: AngularWorkspace; + logger: logging.Logger; + packageManager: PackageManager; + yargsInstance: Argv<{}>; + + /** Arguments parsed in free-from without parser configuration. */ + args: { + positional: string[]; + options: { + help: boolean; + jsonHelp: boolean; + getYargsCompletions: boolean; + } & Record; + }; +} + +export type Options = { [key in keyof T as CamelCaseKey]: T[key] }; + +export type OtherOptions = Record; diff --git a/packages/angular/cli/src/command-builder/schematics-command-module.ts b/packages/angular/cli/src/command-builder/schematics-command-module.ts new file mode 100644 index 000000000000..e2561fd1c273 --- /dev/null +++ b/packages/angular/cli/src/command-builder/schematics-command-module.ts @@ -0,0 +1,429 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonValue, normalize as devkitNormalize, schema } from '@angular-devkit/core'; +import { Collection, UnsuccessfulWorkflowExecution, formats } from '@angular-devkit/schematics'; +import { + FileSystemCollectionDescription, + FileSystemSchematicDescription, + NodeWorkflow, +} from '@angular-devkit/schematics/tools'; +import { relative } from 'node:path'; +import { Argv } from 'yargs'; +import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; +import { EventCustomDimension } from '../analytics/analytics-parameters'; +import { getProjectByCwd, getSchematicDefaults } from '../utilities/config'; +import { assertIsError } from '../utilities/error'; +import { memoize } from '../utilities/memoize'; +import { formatFiles } from '../utilities/prettier'; +import { isTTY } from '../utilities/tty'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, + Options, + OtherOptions, +} from './command-module'; +import { Option, parseJsonSchemaToOptions } from './utilities/json-schema'; +import { SchematicEngineHost } from './utilities/schematic-engine-host'; +import { subscribeToWorkflow } from './utilities/schematic-workflow'; + +export const DEFAULT_SCHEMATICS_COLLECTION = '@schematics/angular'; + +export interface SchematicsCommandArgs { + interactive: boolean; + force: boolean; + 'dry-run': boolean; + defaults: boolean; +} + +export interface SchematicsExecutionOptions extends Options { + packageRegistry?: string; +} + +export abstract class SchematicsCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + override scope = CommandScope.In; + protected readonly allowPrivateSchematics: boolean = false; + + async builder(argv: Argv): Promise> { + return argv + .option('interactive', { + describe: 'Enable interactive input prompts.', + type: 'boolean', + default: true, + }) + .option('dry-run', { + describe: 'Run through and reports activity without writing out results.', + type: 'boolean', + alias: ['d'], + default: false, + }) + .option('defaults', { + describe: 'Disable interactive input prompts for options with a default.', + type: 'boolean', + default: false, + }) + .option('force', { + describe: 'Force overwriting of existing files.', + type: 'boolean', + default: false, + }) + .strict(); + } + + /** Get schematic schema options.*/ + protected async getSchematicOptions( + collection: Collection, + schematicName: string, + workflow: NodeWorkflow, + ): Promise { + const schematic = collection.createSchematic(schematicName, true); + const { schemaJson } = schematic.description; + + if (!schemaJson) { + return []; + } + + return parseJsonSchemaToOptions(workflow.registry, schemaJson); + } + + @memoize + protected getOrCreateWorkflowForBuilder(collectionName: string): NodeWorkflow { + return new NodeWorkflow(this.context.root, { + resolvePaths: this.getResolvePaths(collectionName), + engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), + }); + } + + @memoize + protected async getOrCreateWorkflowForExecution( + collectionName: string, + options: SchematicsExecutionOptions, + ): Promise { + const { logger, root, packageManager } = this.context; + const { force, dryRun, packageRegistry } = options; + + const workflow = new NodeWorkflow(root, { + force, + dryRun, + packageManager: packageManager.name, + // A schema registry is required to allow customizing addUndefinedDefaults + registry: new schema.CoreSchemaRegistry(formats.standardFormats), + packageRegistry, + resolvePaths: this.getResolvePaths(collectionName), + schemaValidation: true, + optionTransforms: [ + // Add configuration file defaults + async (schematic, current) => { + const projectName = + typeof current?.project === 'string' ? current.project : this.getProjectName(); + + return { + ...(await getSchematicDefaults(schematic.collection.name, schematic.name, projectName)), + ...current, + }; + }, + ], + engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), + }); + + workflow.registry.addPostTransform(schema.transforms.addUndefinedDefaults); + workflow.registry.useXDeprecatedProvider((msg) => logger.warn(msg)); + workflow.registry.addSmartDefaultProvider('projectName', () => this.getProjectName()); + + const workingDir = devkitNormalize(relative(this.context.root, process.cwd())); + workflow.registry.addSmartDefaultProvider('workingDirectory', () => + workingDir === '' ? undefined : workingDir, + ); + + workflow.engineHost.registerOptionsTransform(async (schematic, options) => { + const { + collection: { name: collectionName }, + name: schematicName, + } = schematic; + + const analytics = isPackageNameSafeForAnalytics(collectionName) + ? await this.getAnalytics() + : undefined; + + analytics?.reportSchematicRunEvent({ + [EventCustomDimension.SchematicCollectionName]: collectionName, + [EventCustomDimension.SchematicName]: schematicName, + ...this.getAnalyticsParameters(options as unknown as {}), + }); + + return options; + }); + + if (options.interactive !== false && isTTY()) { + workflow.registry.usePromptProvider(async (definitions: Array) => { + let prompts: typeof import('@inquirer/prompts') | undefined; + const answers: Record = {}; + + for (const definition of definitions) { + if (options.defaults && definition.default !== undefined) { + continue; + } + + // Only load prompt package if needed + prompts ??= await import('@inquirer/prompts'); + + switch (definition.type) { + case 'confirmation': + answers[definition.id] = await prompts.confirm({ + message: definition.message, + default: definition.default as boolean | undefined, + }); + break; + case 'list': + if (!definition.items?.length) { + continue; + } + + answers[definition.id] = await ( + definition.multiselect ? prompts.checkbox : prompts.select + )({ + message: definition.message, + validate: (values) => { + if (!definition.validator) { + return true; + } + + return definition.validator(Object.values(values).map(({ value }) => value)); + }, + default: definition.multiselect ? undefined : definition.default, + choices: definition.items?.map((item) => + typeof item == 'string' + ? { + name: item, + value: item, + checked: + definition.multiselect && Array.isArray(definition.default) + ? definition.default?.includes(item) + : item === definition.default, + } + : { + ...item, + name: item.label, + value: item.value, + checked: + definition.multiselect && Array.isArray(definition.default) + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + definition.default?.includes(item.value as any) + : item.value === definition.default, + }, + ), + }); + break; + case 'input': { + let finalValue: JsonValue | undefined; + answers[definition.id] = await prompts.input({ + message: definition.message, + default: definition.default as string | undefined, + async validate(value) { + if (definition.validator === undefined) { + return true; + } + + let lastValidation: ReturnType = false; + for (const type of definition.propertyTypes) { + let potential; + switch (type) { + case 'string': + potential = String(value); + break; + case 'integer': + case 'number': + potential = Number(value); + break; + default: + potential = value; + break; + } + lastValidation = await definition.validator(potential); + + // Can be a string if validation fails + if (lastValidation === true) { + finalValue = potential; + + return true; + } + } + + return lastValidation; + }, + }); + + // Use validated value if present. + // This ensures the correct type is inserted into the final schema options. + if (finalValue !== undefined) { + answers[definition.id] = finalValue; + } + break; + } + } + } + + return answers; + }); + } + + return workflow; + } + + @memoize + protected async getSchematicCollections(): Promise> { + const getSchematicCollections = ( + configSection: Record | undefined, + ): Set | undefined => { + if (!configSection) { + return undefined; + } + + const { schematicCollections } = configSection; + if (Array.isArray(schematicCollections)) { + return new Set(schematicCollections); + } + + return undefined; + }; + + const { workspace, globalConfiguration } = this.context; + if (workspace) { + const project = getProjectByCwd(workspace); + if (project) { + const value = getSchematicCollections(workspace.getProjectCli(project)); + if (value) { + return value; + } + } + } + + const value = + getSchematicCollections(workspace?.getCli()) ?? + getSchematicCollections(globalConfiguration.getCli()); + if (value) { + return value; + } + + return new Set([DEFAULT_SCHEMATICS_COLLECTION]); + } + + protected parseSchematicInfo( + schematic: string | undefined, + ): [collectionName: string | undefined, schematicName: string | undefined] { + if (schematic?.includes(':')) { + const [collectionName, schematicName] = schematic.split(':', 2); + + return [collectionName, schematicName]; + } + + return [undefined, schematic]; + } + + protected async runSchematic(options: { + executionOptions: SchematicsExecutionOptions; + schematicOptions: OtherOptions; + collectionName: string; + schematicName: string; + }): Promise { + const { logger } = this.context; + const { schematicOptions, executionOptions, collectionName, schematicName } = options; + const workflow = await this.getOrCreateWorkflowForExecution(collectionName, executionOptions); + + if (!schematicName) { + throw new Error('schematicName cannot be undefined.'); + } + + const { unsubscribe, files } = subscribeToWorkflow(workflow, logger); + + try { + await workflow + .execute({ + collection: collectionName, + schematic: schematicName, + options: schematicOptions, + logger, + allowPrivate: this.allowPrivateSchematics, + }) + .toPromise(); + + if (!files.size) { + logger.info('Nothing to be done.'); + } + + if (executionOptions.dryRun) { + logger.warn(`\nNOTE: The "--dry-run" option means no changes were made.`); + + return 0; + } + + if (files.size) { + // Note: we could use a task executor to format the files but this is simpler. + try { + await formatFiles(this.context.root, files); + } catch (error) { + assertIsError(error); + + logger.warn( + `WARNING: Formatting of files failed with the following error: ${error.message}`, + ); + } + } + + return 0; + } catch (err) { + // In case the workflow was not successful, show an appropriate error message. + if (err instanceof UnsuccessfulWorkflowExecution) { + // "See above" because we already printed the error. + logger.fatal('The Schematic workflow failed. See above.'); + } else { + assertIsError(err); + logger.fatal(err.message); + } + + return 1; + } finally { + unsubscribe(); + } + } + + private getProjectName(): string | undefined { + const { workspace } = this.context; + if (!workspace) { + return undefined; + } + + const projectName = getProjectByCwd(workspace); + if (projectName) { + return projectName; + } + + return undefined; + } + + private getResolvePaths(collectionName: string): string[] { + const { workspace, root } = this.context; + if (collectionName[0] === '.') { + // Resolve relative collections from the location of `angular.json` + return [root]; + } + + return workspace + ? // Workspace + collectionName === DEFAULT_SCHEMATICS_COLLECTION + ? // Favor import.meta.dirname for @schematics/angular to use the build-in version + [import.meta.dirname, process.cwd(), root] + : [process.cwd(), root, import.meta.dirname] + : // Global + [import.meta.dirname, process.cwd()]; + } +} diff --git a/packages/angular/cli/src/command-builder/utilities/command.ts b/packages/angular/cli/src/command-builder/utilities/command.ts new file mode 100644 index 000000000000..8b019aba9064 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/command.ts @@ -0,0 +1,64 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { + CommandContext, + CommandModule, + CommandModuleError, + CommandModuleImplementation, + CommandScope, +} from '../command-module'; + +export const demandCommandFailureMessage = `You need to specify a command before moving on. Use '--help' to view the available commands.`; +export type CommandModuleConstructor = Partial & { + new (context: CommandContext): Partial & CommandModule; +}; + +export function addCommandModuleToYargs( + commandModule: U, + context: CommandContext, +): void { + const cmd = new commandModule(context); + const { + args: { + options: { jsonHelp }, + }, + workspace, + } = context; + + const describe = jsonHelp ? cmd.fullDescribe : cmd.describe; + + context.yargsInstance.command({ + command: cmd.command, + aliases: cmd.aliases, + describe: + // We cannot add custom fields in help, such as long command description which is used in AIO. + // Therefore, we get around this by adding a complex object as a string which we later parse when generating the help files. + typeof describe === 'object' ? JSON.stringify(describe) : describe, + deprecated: cmd.deprecated, + builder: (argv) => { + // Skip scope validation when running with '--json-help' since it's easier to generate the output for all commands this way. + const isInvalidScope = + !jsonHelp && + ((cmd.scope === CommandScope.In && !workspace) || + (cmd.scope === CommandScope.Out && workspace)); + + if (isInvalidScope) { + throw new CommandModuleError( + `This command is not available when running the Angular CLI ${ + workspace ? 'inside' : 'outside' + } a workspace.`, + ); + } + + return cmd.builder(argv) as Argv; + }, + handler: (args) => cmd.handler(args), + }); +} diff --git a/packages/angular/cli/src/command-builder/utilities/json-help.ts b/packages/angular/cli/src/command-builder/utilities/json-help.ts new file mode 100644 index 000000000000..0d5c6a53a1e6 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/json-help.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { FullDescribe } from '../command-module'; + +interface JsonHelpOption { + name: string; + type?: string; + deprecated: boolean | string; + aliases?: string[]; + default?: string; + required?: boolean; + positional?: number; + enum?: string[]; + description?: string; +} + +interface JsonHelpDescription { + shortDescription?: string; + longDescription?: string; + longDescriptionRelativePath?: string; +} + +interface JsonHelpSubcommand extends JsonHelpDescription { + name: string; + aliases: string[]; + deprecated: string | boolean; +} + +export interface JsonHelp extends JsonHelpDescription { + name: string; + command: string; + options: JsonHelpOption[]; + subcommands?: JsonHelpSubcommand[]; +} + +const yargsDefaultCommandRegExp = /^\$0|\*/; + +export function jsonHelpUsage(localYargs: Argv): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const localYargsInstance = localYargs as any; + const { + deprecatedOptions, + alias: aliases, + array, + string, + boolean, + number, + choices, + demandedOptions, + default: defaultVal, + hiddenOptions = [], + } = localYargsInstance.getOptions(); + + const internalMethods = localYargsInstance.getInternalMethods(); + const usageInstance = internalMethods.getUsageInstance(); + const context = internalMethods.getContext(); + const descriptions = usageInstance.getDescriptions(); + const groups = localYargsInstance.getGroups(); + const positional = groups[usageInstance.getPositionalGroupName()] as string[] | undefined; + const seen = new Set(); + const hidden = new Set(hiddenOptions); + const normalizeOptions: JsonHelpOption[] = []; + const allAliases = new Set([...Object.values(aliases).flat()]); + + // Reverted order of https://github.com/yargs/yargs/blob/971e351705f0fbc5566c6ed1dfd707fa65e11c0d/lib/usage.ts#L419-L424 + for (const [names, type] of [ + [number, 'number'], + [array, 'array'], + [string, 'string'], + [boolean, 'boolean'], + ]) { + for (const name of names) { + if (allAliases.has(name) || hidden.has(name) || seen.has(name)) { + // Ignore hidden, aliases and already visited option. + continue; + } + + seen.add(name); + const positionalIndex = positional?.indexOf(name) ?? -1; + const alias = aliases[name]; + + normalizeOptions.push({ + name, + type, + deprecated: deprecatedOptions[name], + aliases: alias?.length > 0 ? alias : undefined, + default: defaultVal[name], + required: demandedOptions[name], + enum: choices[name], + description: descriptions[name]?.replace('__yargsString__:', ''), + positional: positionalIndex >= 0 ? positionalIndex : undefined, + }); + } + } + + // https://github.com/yargs/yargs/blob/00e4ebbe3acd438e73fdb101e75b4f879eb6d345/lib/usage.ts#L124 + const subcommands = ( + usageInstance.getCommands() as [ + name: string, + description: string, + isDefault: boolean, + aliases: string[], + deprecated: string | boolean, + ][] + ) + .map(([name, rawDescription, isDefault, aliases, deprecated]) => ({ + name: name.split(' ', 1)[0].replace(yargsDefaultCommandRegExp, ''), + command: name.replace(yargsDefaultCommandRegExp, ''), + default: isDefault || undefined, + ...parseDescription(rawDescription), + aliases, + deprecated, + })) + .sort((a, b) => a.name.localeCompare(b.name)); + + const [command, rawDescription] = usageInstance.getUsage()[0] ?? []; + const defaultSubCommand = subcommands.find((x) => x.default)?.command ?? ''; + const otherSubcommands = subcommands.filter((s) => !s.default); + + const output: JsonHelp = { + name: [...context.commands].pop(), + command: `${command?.replace(yargsDefaultCommandRegExp, localYargsInstance['$0'])}${defaultSubCommand}`, + ...parseDescription(rawDescription), + options: normalizeOptions.sort((a, b) => a.name.localeCompare(b.name)), + subcommands: otherSubcommands.length ? otherSubcommands : undefined, + }; + + return JSON.stringify(output, undefined, 2); +} + +function parseDescription(rawDescription: string): JsonHelpDescription { + try { + const { + longDescription, + describe: shortDescription, + longDescriptionRelativePath, + } = JSON.parse(rawDescription) as FullDescribe; + + return { + shortDescription, + longDescriptionRelativePath, + longDescription, + }; + } catch { + return { + shortDescription: rawDescription, + }; + } +} diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema.ts b/packages/angular/cli/src/command-builder/utilities/json-schema.ts new file mode 100644 index 000000000000..0a4215be8eed --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/json-schema.ts @@ -0,0 +1,493 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isJsonObject, json, strings } from '@angular-devkit/core'; +import type { Arguments, Argv, PositionalOptions, Options as YargsOptions } from 'yargs'; +import { EventCustomDimension } from '../../analytics/analytics-parameters'; + +/** + * An option description that can be used by yargs to create a command. + * See: https://github.com/yargs/yargs/blob/main/docs/options.mjs + */ +export interface Option extends YargsOptions { + /** + * The name of the option. + */ + name: string; + + /** + * Whether this option is required or not. + */ + required?: boolean; + + /** + * Format field of this option. + */ + format?: string; + + /** + * Whether this option should be hidden from the help output. It will still show up in JSON help. + */ + hidden?: boolean; + + /** + * If this option can be used as an argument, the position of the argument. Otherwise omitted. + */ + positional?: number; + + /** + * Whether or not to report this option to the Angular Team, and which custom field to use. + * If this is falsey, do not report this option. + */ + userAnalytics?: string; + + /** + * Type of the values in a key/value pair field. + */ + itemValueType?: 'string'; +} + +/** + * A Yargs check function that validates that the given options are in the form of `key=value`. + * @param keyValuePairOptions A set of options that should be in the form of `key=value`. + * @param args The parsed arguments. + * @returns `true` if the options are valid, otherwise an error is thrown. + */ +function checkStringMap(keyValuePairOptions: Set, args: Arguments): boolean { + for (const key of keyValuePairOptions) { + const value = args[key]; + if (!Array.isArray(value)) { + // Value has been parsed. + continue; + } + + for (const pair of value) { + if (pair === undefined) { + continue; + } + + if (!pair.includes('=')) { + throw new Error( + `Invalid value for argument: ${key}, Given: '${pair}', Expected key=value pair`, + ); + } + } + } + + return true; +} + +/** + * A Yargs coerce function that converts an array of `key=value` strings to an object. + * @param value An array of `key=value` strings. + * @returns An object with the keys and values from the input array. + */ +function coerceToStringMap( + value: (string | undefined)[], +): Record | (string | undefined)[] { + const stringMap: Record = {}; + for (const pair of value) { + // This happens when the flag isn't passed at all. + if (pair === undefined) { + continue; + } + + const eqIdx = pair.indexOf('='); + if (eqIdx === -1) { + // In the case it is not valid skip processing this option and handle the error in `checkStringMap` + return value; + } + + const key = pair.slice(0, eqIdx); + stringMap[key] = pair.slice(eqIdx + 1); + } + + return stringMap; +} + +/** + * Checks if a JSON schema node represents a string map. + * A string map is an object with `additionalProperties` of type `string`. + * @param node The JSON schema node to check. + * @returns `true` if the node represents a string map, otherwise `false`. + */ +function isStringMap(node: json.JsonObject): boolean { + // Exclude fields with more specific kinds of properties. + if (node.properties || node.patternProperties) { + return false; + } + + // Restrict to additionalProperties with string values. + return ( + json.isJsonObject(node.additionalProperties) && + !node.additionalProperties.enum && + node.additionalProperties.type === 'string' + ); +} + +const SUPPORTED_PRIMITIVE_TYPES = new Set(['boolean', 'number', 'string']); + +/** + * Checks if a string is a supported primitive type. + * @param value The string to check. + * @returns `true` if the string is a supported primitive type, otherwise `false`. + */ +function isSupportedPrimitiveType(value: string): boolean { + return SUPPORTED_PRIMITIVE_TYPES.has(value); +} + +/** + * Recursively checks if a JSON schema for an array's items is a supported primitive type. + * It supports `oneOf` and `anyOf` keywords. + * @param schema The JSON schema for the array's items. + * @returns `true` if the schema is a supported primitive type, otherwise `false`. + */ +function isSupportedArrayItemSchema(schema: json.JsonObject): boolean { + if (typeof schema.type === 'string' && isSupportedPrimitiveType(schema.type)) { + return true; + } + + if (json.isJsonArray(schema.enum)) { + return true; + } + + if (json.isJsonArray(schema.items)) { + return schema.items.some((item) => isJsonObject(item) && isSupportedArrayItemSchema(item)); + } + + if ( + json.isJsonArray(schema.oneOf) && + schema.oneOf.some((item) => isJsonObject(item) && isSupportedArrayItemSchema(item)) + ) { + return true; + } + + if ( + json.isJsonArray(schema.anyOf) && + schema.anyOf.some((item) => isJsonObject(item) && isSupportedArrayItemSchema(item)) + ) { + return true; + } + + return false; +} + +/** + * Gets the supported types for a JSON schema node. + * @param current The JSON schema node to get the supported types for. + * @returns An array of supported types. + */ +function getSupportedTypes( + current: json.JsonObject, +): ReadonlyArray<'string' | 'number' | 'boolean' | 'array' | 'object'> { + const typeSet = json.schema.getTypesOfSchema(current); + + if (typeSet.size === 0) { + return []; + } + + return [...typeSet].filter((type) => { + switch (type) { + case 'boolean': + case 'number': + case 'string': + return true; + case 'array': + return isJsonObject(current.items) && isSupportedArrayItemSchema(current.items); + case 'object': + return isStringMap(current); + default: + return false; + } + }) as ReadonlyArray<'string' | 'number' | 'boolean' | 'array' | 'object'>; +} + +/** + * Gets the enum values for a JSON schema node. + * @param current The JSON schema node to get the enum values for. + * @returns An array of enum values. + */ +function getEnumValues( + current: json.JsonObject, +): ReadonlyArray | undefined { + if (json.isJsonArray(current.enum)) { + return current.enum.sort() as ReadonlyArray; + } + + if (isJsonObject(current.items)) { + const enumValues = getEnumValues(current.items); + if (enumValues?.length) { + return enumValues; + } + } + + if (typeof current.type === 'string' && isSupportedPrimitiveType(current.type)) { + return []; + } + + const subSchemas = + (json.isJsonArray(current.oneOf) && current.oneOf) || + (json.isJsonArray(current.anyOf) && current.anyOf); + + if (subSchemas) { + // Find the first enum. + for (const subSchema of subSchemas) { + if (isJsonObject(subSchema)) { + const enumValues = getEnumValues(subSchema); + if (enumValues) { + return enumValues; + } + } + } + } + + return []; +} + +/** + * Gets the default value for a JSON schema node. + * @param current The JSON schema node to get the default value for. + * @param type The type of the JSON schema node. + * @returns The default value, or `undefined` if there is no default value. + */ +function getDefaultValue( + current: json.JsonObject, + type: string, +): string | number | boolean | unknown[] | undefined { + const defaultValue = current.default; + if (defaultValue === undefined) { + return undefined; + } + + if (type === 'array') { + return Array.isArray(defaultValue) && defaultValue.length > 0 ? defaultValue : undefined; + } + + if (typeof defaultValue === type) { + return defaultValue as string | number | boolean; + } + + return undefined; +} + +/** + * Gets the aliases for a JSON schema node. + * @param current The JSON schema node to get the aliases for. + * @returns An array of aliases. + */ +function getAliases(current: json.JsonObject): string[] { + if (json.isJsonArray(current.aliases)) { + return [...current.aliases].map(String); + } + + if (current.alias) { + return [String(current.alias)]; + } + + return []; +} + +/** + * Parses a JSON schema to a list of options that can be used by yargs. + * + * @param registry A schema registry to use for flattening the schema. + * @param schema The JSON schema to parse. + * @param interactive Whether to prompt the user for missing options. + * @returns A list of options. + * + * @note The schema definition are not resolved at this stage. This means that `$ref` will not be resolved, + * and custom keywords like `x-prompt` will not be processed. + */ +export async function parseJsonSchemaToOptions( + registry: json.schema.SchemaRegistry, + schema: json.JsonObject, + interactive = true, +): Promise { + const options: Option[] = []; + + function visitor( + current: json.JsonObject | json.JsonArray, + pointer: json.schema.JsonPointer, + parentSchema?: json.JsonObject | json.JsonArray, + ) { + if ( + !parentSchema || + json.isJsonArray(current) || + pointer.split(/\/(?:properties|items|definitions)\//g).length > 2 + ) { + // Ignore root, arrays, and subitems. + return; + } + + if (pointer.includes('/not/')) { + // We don't support anyOf/not. + throw new Error('The "not" keyword is not supported in JSON Schema.'); + } + + const ptr = json.schema.parseJsonPointer(pointer); + if (ptr[ptr.length - 2] !== 'properties') { + // Skip any non-property items. + return; + } + const name = ptr.at(-1) as string; + + const types = getSupportedTypes(current); + + if (types.length === 0) { + // This means it's not usable on the command line. e.g. an Object. + return; + } + + const [type] = types; + const $default = current.$default; + const $defaultIndex = + isJsonObject($default) && $default['$source'] === 'argv' ? $default['index'] : undefined; + const positional: number | undefined = + typeof $defaultIndex === 'number' ? $defaultIndex : undefined; + + let required = json.isJsonArray(schema.required) && schema.required.includes(name); + if (required && interactive && current['x-prompt']) { + required = false; + } + + const visible = current.visible !== false; + const xDeprecated = current['x-deprecated']; + const enumValues = getEnumValues(current); + + const option: Option = { + name, + description: String(current.description ?? ''), + default: getDefaultValue(current, type), + choices: enumValues?.length ? enumValues : undefined, + required, + alias: getAliases(current), + format: typeof current.format === 'string' ? current.format : undefined, + hidden: !!current.hidden || !visible, + userAnalytics: + typeof current['x-user-analytics'] === 'string' ? current['x-user-analytics'] : undefined, + deprecated: xDeprecated === true || typeof xDeprecated === 'string' ? xDeprecated : undefined, + positional, + ...(type === 'object' + ? { + type: 'array', + itemValueType: 'string', + } + : { + type, + }), + }; + + options.push(option); + } + + const flattenedSchema = await registry.ɵflatten(schema); + json.schema.visitJsonSchema(flattenedSchema, visitor); + + // Sort by positional and name. + return options.sort((a, b) => { + if (a.positional) { + return b.positional ? a.positional - b.positional : a.name.localeCompare(b.name); + } else if (b.positional) { + return -1; + } + + return a.name.localeCompare(b.name); + }); +} + +/** + * Adds schema options to a command also this keeps track of options that are required for analytics. + * **Note:** This method should be called from the command bundler method. + * + * @returns A map from option name to analytics configuration. + */ +export function addSchemaOptionsToCommand( + localYargs: Argv, + options: Option[], + includeDefaultValues: boolean, +): Map { + const booleanOptionsWithNoPrefix = new Set(); + const keyValuePairOptions = new Set(); + const optionsWithAnalytics = new Map(); + + for (const option of options) { + const { + default: defaultVal, + positional, + deprecated, + description, + alias, + userAnalytics, + type, + itemValueType, + hidden, + name, + choices, + } = option; + + let dashedName = strings.dasherize(name); + + // Handle options which have been defined in the schema with `no` prefix. + if (type === 'boolean' && dashedName.startsWith('no-')) { + dashedName = dashedName.slice(3); + booleanOptionsWithNoPrefix.add(dashedName); + } + + if (itemValueType) { + keyValuePairOptions.add(dashedName); + } + + const sharedOptions: YargsOptions & PositionalOptions = { + alias, + hidden, + description, + deprecated, + choices, + coerce: itemValueType ? coerceToStringMap : undefined, + // This should only be done when `--help` is used otherwise default will override options set in angular.json. + ...(includeDefaultValues ? { default: defaultVal } : {}), + }; + + if (positional === undefined) { + localYargs = localYargs.option(dashedName, { + array: itemValueType ? true : undefined, + type: itemValueType ?? type, + ...sharedOptions, + }); + } else { + localYargs = localYargs.positional(dashedName, { + type: type === 'array' || type === 'count' ? 'string' : type, + ...sharedOptions, + }); + } + + // Record option of analytics. + if (userAnalytics !== undefined) { + optionsWithAnalytics.set(name, userAnalytics as EventCustomDimension); + } + } + + // Valid key/value options + if (keyValuePairOptions.size) { + localYargs.check(checkStringMap.bind(null, keyValuePairOptions), false); + } + + // Handle options which have been defined in the schema with `no` prefix. + if (booleanOptionsWithNoPrefix.size) { + localYargs.middleware((options: Arguments) => { + for (const key of booleanOptionsWithNoPrefix) { + if (key in options) { + options[`no-${key}`] = !options[key]; + delete options[key]; + } + } + }, false); + } + + return optionsWithAnalytics; +} diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts new file mode 100644 index 000000000000..496a2094b62c --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/json-schema_spec.ts @@ -0,0 +1,349 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonObject, schema } from '@angular-devkit/core'; +import type { Argv } from 'yargs'; +import yargs from 'yargs'; + +import { Option, addSchemaOptionsToCommand, parseJsonSchemaToOptions } from './json-schema'; + +describe('parseJsonSchemaToOptions', () => { + describe('without required fields in schema', () => { + const parse = async (args: string[]) => { + // Yargs only exposes the parse errors as proper errors when using the + // callback syntax. This unwraps that ugly workaround so tests can just + // use simple .toThrow/.toEqual assertions. + return localYargs.parseAsync(args); + }; + + let localYargs: Argv; + let options: Option[]; + + beforeAll(async () => { + const jsonSchema = { + 'type': 'object', + 'properties': { + 'maxSize': { + 'type': 'number', + }, + 'ssr': { + 'type': 'string', + 'enum': ['always', 'surprise-me', 'never'], + }, + 'arrayWithChoices': { + 'type': 'array', + 'default': ['default-array'], + 'items': { + 'type': 'string', + 'enum': ['always', 'never', 'default-array'], + }, + }, + 'extendable': { + 'type': 'object', + 'properties': {}, + 'additionalProperties': { + 'type': 'string', + }, + }, + 'someDefine': { + 'type': 'object', + 'additionalProperties': { + 'type': 'string', + }, + }, + 'arrayWithChoicesInOneOf': { + 'type': 'array', + 'items': { + 'oneOf': [ + { + 'enum': ['default', 'verbose'], + }, + { + 'type': 'array', + 'minItems': 1, + 'maxItems': 2, + 'items': [ + { + 'enum': ['default', 'verbose'], + }, + { + 'type': 'object', + }, + ], + }, + ], + }, + }, + 'arrayWithComplexAnyOf': { + 'type': 'array', + 'items': { + 'oneOf': [ + { + 'anyOf': [ + { + 'type': 'string', + }, + { + 'enum': ['default', 'verbose'], + }, + ], + }, + { + 'type': 'array', + 'minItems': 1, + 'maxItems': 2, + 'items': [ + { + 'anyOf': [ + { + 'type': 'string', + }, + { + 'enum': ['default', 'verbose'], + }, + ], + }, + { + 'type': 'object', + }, + ], + }, + ], + }, + }, + }, + }; + + const registry = new schema.CoreSchemaRegistry(); + options = await parseJsonSchemaToOptions( + registry, + jsonSchema as unknown as JsonObject, + false, + ); + }); + + beforeEach(async () => { + // Create a fresh yargs for each call. The yargs object is stateful and + // calling .parse multiple times on the same instance isn't safe. + localYargs = yargs().exitProcess(false).strict().fail(false).wrap(1_000); + + addSchemaOptionsToCommand(localYargs, options, true); + }); + + describe('type=number', () => { + it('parses valid option value', async () => { + expect(await parse(['--max-size', '42'])).toEqual( + jasmine.objectContaining({ + 'maxSize': 42, + }), + ); + }); + }); + + describe('type=array, enum', () => { + it('parses valid option value when specified once', async () => { + expect(await parse(['--arrayWithChoices', 'always', 'never'])).toEqual( + jasmine.objectContaining({ + 'arrayWithChoices': ['always', 'never'], + }), + ); + }); + + it('parses valid option value when specified multiple times', async () => { + expect( + await parse(['--arrayWithChoices', 'always', '--arrayWithChoices', 'never']), + ).toEqual( + jasmine.objectContaining({ + 'arrayWithChoices': ['always', 'never'], + }), + ); + }); + + it('rejects non-enum values', async () => { + await expectAsync(parse(['--arrayWithChoices', 'yes'])).toBeRejectedWithError( + /Argument: array-with-choices, Given: "yes", Choices:/, + ); + }); + + it('should add default value to help', async () => { + expect(await localYargs.getHelp()).toContain('[default: ["default-array"]]'); + }); + }); + + describe('type=array, enum in oneOf', () => { + it('parses valid option value when specified once', async () => { + expect(await parse(['--arrayWithChoicesInOneOf', 'default', 'verbose'])).toEqual( + jasmine.objectContaining({ + 'arrayWithChoicesInOneOf': ['default', 'verbose'], + }), + ); + }); + + it('parses valid option value when specified multiple times', async () => { + expect( + await parse([ + '--arrayWithChoicesInOneOf', + 'default', + '--arrayWithChoicesInOneOf', + 'verbose', + ]), + ).toEqual( + jasmine.objectContaining({ + 'arrayWithChoicesInOneOf': ['default', 'verbose'], + }), + ); + }); + + it('rejects non-enum values', async () => { + await expectAsync(parse(['--arrayWithChoicesInOneOf', 'yes'])).toBeRejectedWithError( + /Argument: array-with-choices-in-one-of, Given: "yes", Choices:/, + ); + }); + }); + + describe('type=array, anyOf', () => { + it('parses valid option value when specified once', async () => { + expect(await parse(['--arrayWithComplexAnyOf', 'default', 'something-else'])).toEqual( + jasmine.objectContaining({ + 'arrayWithComplexAnyOf': ['default', 'something-else'], + }), + ); + }); + + it('parses valid option value when specified multiple times', async () => { + expect( + await parse([ + '--arrayWithComplexAnyOf', + 'default', + '--arrayWithComplexAnyOf', + 'something-else', + ]), + ).toEqual( + jasmine.objectContaining({ + 'arrayWithComplexAnyOf': ['default', 'something-else'], + }), + ); + }); + }); + + describe('type=string, enum', () => { + it('parses valid option value', async () => { + expect(await parse(['--ssr', 'never'])).toEqual( + jasmine.objectContaining({ + 'ssr': 'never', + }), + ); + }); + + it('rejects non-enum values', async () => { + await expectAsync(parse(['--ssr', 'yes'])).toBeRejectedWithError( + /Argument: ssr, Given: "yes", Choices:/, + ); + }); + }); + + describe('type=object', () => { + it('ignores fields that define specific properties', async () => { + await expectAsync(parse(['--extendable', 'a=b'])).toBeRejectedWithError( + /Unknown argument: extendable/, + ); + }); + + it('rejects invalid values for string maps', async () => { + await expectAsync(parse(['--some-define', 'foo'])).toBeRejectedWithError( + /Invalid value for argument: some-define, Given: 'foo', Expected key=value pair/, + ); + await expectAsync(parse(['--some-define', '42'])).toBeRejectedWithError( + /Invalid value for argument: some-define, Given: '42', Expected key=value pair/, + ); + }); + + it('aggregates an object value', async () => { + expect( + await parse([ + '--some-define', + 'A_BOOLEAN=true', + '--some-define', + 'AN_INTEGER=42', + // Ensure we can handle '=' inside of string values. + '--some-define=A_STRING="❤️=❤️"', + '--some-define', + 'AN_UNQUOTED_STRING=❤️=❤️', + ]), + ).toEqual( + jasmine.objectContaining({ + 'someDefine': { + 'A_BOOLEAN': 'true', + 'AN_INTEGER': '42', + 'A_STRING': '"❤️=❤️"', + 'AN_UNQUOTED_STRING': '❤️=❤️', + }, + }), + ); + }); + }); + }); + + describe('with required positional argument', () => { + it('marks the required argument as required', async () => { + const jsonSchema = { + '$id': 'FakeSchema', + 'title': 'Fake Schema', + 'type': 'object', + 'required': ['a'], + 'properties': { + 'b': { + 'type': 'string', + 'description': 'b.', + '$default': { + '$source': 'argv', + 'index': 1, + }, + }, + 'a': { + 'type': 'string', + 'description': 'a.', + '$default': { + '$source': 'argv', + 'index': 0, + }, + }, + 'optC': { + 'type': 'string', + 'description': 'optC', + }, + 'optA': { + 'type': 'string', + 'description': 'optA', + }, + 'optB': { + 'type': 'string', + 'description': 'optB', + }, + }, + }; + const registry = new schema.CoreSchemaRegistry(); + const options = await parseJsonSchemaToOptions(registry, jsonSchema, /* interactive= */ true); + + expect(options.find((opt) => opt.name === 'a')).toEqual( + jasmine.objectContaining({ + name: 'a', + positional: 0, + required: true, + }), + ); + expect(options.find((opt) => opt.name === 'b')).toEqual( + jasmine.objectContaining({ + name: 'b', + positional: 1, + required: false, + }), + ); + }); + }); +}); diff --git a/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts new file mode 100644 index 000000000000..792f09f7a97b --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { Arguments, Argv } from 'yargs'; + +/** + * A Yargs middleware that normalizes non Array options when the argument has been provided multiple times. + * + * By default, when an option is non array and it is provided multiple times in the command line, yargs + * will not override it's value but instead it will be changed to an array unless `duplicate-arguments-array` is disabled. + * But this option also have an effect on real array options which isn't desired. + * + * See: https://github.com/yargs/yargs-parser/pull/163#issuecomment-516566614 + */ +export function createNormalizeOptionsMiddleware(localeYargs: Argv): (args: Arguments) => void { + return (args: Arguments) => { + // `getOptions` is not included in the types even though it's public API. + // https://github.com/yargs/yargs/issues/2098 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { array } = (localeYargs as any).getOptions(); + const arrayOptions = new Set(array); + + for (const [key, value] of Object.entries(args)) { + if (key !== '_' && Array.isArray(value) && !arrayOptions.has(key)) { + const newValue = value.pop(); + // eslint-disable-next-line no-console + console.warn( + `Option '${key}' has been specified multiple times. The value '${newValue}' will be used.`, + ); + args[key] = newValue; + } + } + }; +} diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts new file mode 100644 index 000000000000..067d4cd00460 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts @@ -0,0 +1,231 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { RuleFactory, SchematicsException, Tree } from '@angular-devkit/schematics'; +import { FileSystemCollectionDesc, NodeModulesEngineHost } from '@angular-devkit/schematics/tools'; +import { parse as parseJson } from 'jsonc-parser'; +import { readFileSync } from 'node:fs'; +import { Module, createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { Script } from 'node:vm'; +import { assertIsError } from '../../utilities/error'; + +/** + * Environment variable to control schematic package redirection + */ +const schematicRedirectVariable = process.env['NG_SCHEMATIC_REDIRECT']?.toLowerCase(); + +function shouldWrapSchematic( + schematicFile: string, + schematicEncapsulation: boolean | undefined, +): boolean { + // Check environment variable if present + switch (schematicRedirectVariable) { + case '0': + case 'false': + case 'off': + case 'none': + return false; + case 'all': + return true; + } + + const normalizedSchematicFile = schematicFile.replace(/\\/g, '/'); + // Never wrap the internal update schematic when executed directly + // It communicates with the update command via `global` + // But we still want to redirect schematics located in `@angular/cli/node_modules`. + if ( + normalizedSchematicFile.includes('node_modules/@angular/cli/') && + !normalizedSchematicFile.includes('node_modules/@angular/cli/node_modules/') + ) { + return false; + } + + // @angular/pwa uses dynamic imports which causes `[1] 2468039 segmentation fault` when wrapped. + // We should remove this when make `importModuleDynamically` work. + // See: https://nodejs.org/docs/latest-v14.x/api/vm.html + if (normalizedSchematicFile.includes('@angular/pwa')) { + return false; + } + + // Check for first-party Angular schematic packages + // Angular schematics are safe to use in the wrapped VM context + const isFirstParty = /\/node_modules\/@(?:angular|schematics|nguniversal)\//.test( + normalizedSchematicFile, + ); + + // Use value of defined option if present, otherwise default to first-party usage. + return schematicEncapsulation ?? isFirstParty; +} + +export class SchematicEngineHost extends NodeModulesEngineHost { + protected override _resolveReferenceString( + refString: string, + parentPath: string, + collectionDescription?: FileSystemCollectionDesc, + ) { + const [path, name] = refString.split('#', 2); + // Mimic behavior of ExportStringRef class used in default behavior + const fullPath = path[0] === '.' ? resolve(parentPath ?? process.cwd(), path) : path; + + const referenceRequire = createRequire(import.meta.url); + const schematicFile = referenceRequire.resolve(fullPath, { paths: [parentPath] }); + + if (shouldWrapSchematic(schematicFile, collectionDescription?.encapsulation)) { + const schematicPath = dirname(schematicFile); + + const moduleCache = new Map(); + const factoryInitializer = wrap( + schematicFile, + schematicPath, + moduleCache, + name || 'default', + ) as () => RuleFactory<{}>; + + const factory = factoryInitializer(); + if (!factory || typeof factory !== 'function') { + return null; + } + + return { ref: factory, path: schematicPath }; + } + + // All other schematics use default behavior + return super._resolveReferenceString(refString, parentPath, collectionDescription); + } +} + +/** + * Minimal shim modules for legacy deep imports of `@schematics/angular` + */ +const legacyModules: Record = { + '@schematics/angular/utility/config': { + getWorkspace(host: Tree) { + const path = '/.angular.json'; + const data = host.read(path); + if (!data) { + throw new SchematicsException(`Could not find (${path})`); + } + + return parseJson(data.toString(), [], { allowTrailingComma: true }); + }, + }, + '@schematics/angular/utility/project': { + buildDefaultPath(project: { sourceRoot?: string; root: string; projectType: string }): string { + const root = project.sourceRoot ? `/${project.sourceRoot}/` : `/${project.root}/src/`; + + return `${root}${project.projectType === 'application' ? 'app' : 'lib'}`; + }, + }, +}; + +/** + * Wrap a JavaScript file in a VM context to allow specific Angular dependencies to be redirected. + * This VM setup is ONLY intended to redirect dependencies. + * + * @param schematicFile A JavaScript schematic file path that should be wrapped. + * @param schematicDirectory A directory that will be used as the location of the JavaScript file. + * @param moduleCache A map to use for caching repeat module usage and proper `instanceof` support. + * @param exportName An optional name of a specific export to return. Otherwise, return all exports. + */ +function wrap( + schematicFile: string, + schematicDirectory: string, + moduleCache: Map, + exportName?: string, +): () => unknown { + const hostRequire = createRequire(import.meta.url); + const schematicRequire = createRequire(schematicFile); + + const customRequire = function (id: string) { + if (legacyModules[id]) { + // Provide compatibility modules for older versions of @angular/cdk + return legacyModules[id]; + } else if (id.startsWith('schematics:')) { + // Schematics built-in modules use the `schematics` scheme (similar to the Node.js `node` scheme) + const builtinId = id.slice(11); + const builtinModule = loadBuiltinModule(builtinId); + if (!builtinModule) { + throw new Error( + `Unknown schematics built-in module '${id}' requested from schematic '${schematicFile}'`, + ); + } + + return builtinModule; + } else if (id.startsWith('@angular-devkit/') || id.startsWith('@schematics/')) { + // Files should not redirect `@angular/core` and instead use the direct + // dependency if available. This allows old major version migrations to continue to function + // even though the latest major version may have breaking changes in `@angular/core`. + if (id.startsWith('@angular-devkit/core')) { + try { + return schematicRequire(id); + } catch (e) { + assertIsError(e); + if (e.code !== 'MODULE_NOT_FOUND') { + throw e; + } + } + } + + // Resolve from inside the `@angular/cli` project + return hostRequire(id); + } else if (id.startsWith('.') || id.startsWith('@angular/cdk')) { + // Wrap relative files inside the schematic collection + // Also wrap `@angular/cdk`, it contains helper utilities that import core schematic packages + + // Resolve from the original file + const modulePath = schematicRequire.resolve(id); + + // Use cached module if available + const cachedModule = moduleCache.get(modulePath); + if (cachedModule) { + return cachedModule; + } + + // Do not wrap vendored third-party packages or JSON files + if ( + !/[/\\]node_modules[/\\]@schematics[/\\]angular[/\\]third_party[/\\]/.test(modulePath) && + !modulePath.endsWith('.json') + ) { + // Wrap module and save in cache + const wrappedModule = wrap(modulePath, dirname(modulePath), moduleCache)(); + moduleCache.set(modulePath, wrappedModule); + + return wrappedModule; + } + } + + // All others are required directly from the original file + return schematicRequire(id); + }; + + // Setup a wrapper function to capture the module's exports + const schematicCode = readFileSync(schematicFile, 'utf8'); + const script = new Script(Module.wrap(schematicCode), { + filename: schematicFile, + lineOffset: 1, + }); + const schematicModule = new Module(schematicFile); + const moduleFactory = script.runInThisContext(); + + return () => { + moduleFactory( + schematicModule.exports, + customRequire, + schematicModule, + schematicFile, + schematicDirectory, + ); + + return exportName ? schematicModule.exports[exportName] : schematicModule.exports; + }; +} + +function loadBuiltinModule(id: string): unknown { + return undefined; +} diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts new file mode 100644 index 000000000000..c4134ef8ce66 --- /dev/null +++ b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { logging } from '@angular-devkit/core'; +import { NodeWorkflow } from '@angular-devkit/schematics/tools'; +import { colors } from '../../utilities/color'; + +function removeLeadingSlash(value: string): string { + return value[0] === '/' ? value.slice(1) : value; +} + +export function subscribeToWorkflow( + workflow: NodeWorkflow, + logger: logging.LoggerApi, +): { + files: Set; + error: boolean; + unsubscribe: () => void; +} { + const files = new Set(); + let error = false; + let logs: string[] = []; + + const reporterSubscription = workflow.reporter.subscribe((event) => { + // Strip leading slash to prevent confusion. + const eventPath = removeLeadingSlash(event.path); + + switch (event.kind) { + case 'error': + error = true; + logger.error( + `ERROR! ${eventPath} ${event.description == 'alreadyExist' ? 'already exists' : 'does not exist'}.`, + ); + break; + case 'update': + logs.push( + // TODO: `as unknown` was necessary during TS 5.9 update. Figure out a long-term solution. + `${colors.cyan('UPDATE')} ${eventPath} (${(event.content as unknown as Buffer).length} bytes)`, + ); + files.add(eventPath); + break; + case 'create': + logs.push( + // TODO: `as unknown` was necessary during TS 5.9 update. Figure out a long-term solution. + `${colors.green('CREATE')} ${eventPath} (${(event.content as unknown as Buffer).length} bytes)`, + ); + files.add(eventPath); + break; + case 'delete': + logs.push(`${colors.yellow('DELETE')} ${eventPath}`); + files.add(eventPath); + break; + case 'rename': { + const newFilename = removeLeadingSlash(event.to); + + logs.push(`${colors.blue('RENAME')} ${eventPath} => ${newFilename}`); + files.add(newFilename); + + break; + } + } + }); + + const lifecycleSubscription = workflow.lifeCycle.subscribe((event) => { + if (event.kind == 'end' || event.kind == 'post-tasks-start') { + if (!error) { + // Output the logging queue, no error happened. + logs.forEach((log) => logger.info(log)); + } + + logs = []; + error = false; + } + }); + + return { + files, + error, + unsubscribe: () => { + reporterSubscription.unsubscribe(); + lifecycleSubscription.unsubscribe(); + }, + }; +} diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts new file mode 100644 index 000000000000..d7efd10906d9 --- /dev/null +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -0,0 +1,893 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Listr, ListrRenderer, ListrTaskWrapper, color, figures } from 'listr2'; +import assert from 'node:assert'; +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { basename, dirname, join } from 'node:path'; +import npa from 'npm-package-arg'; +import semver, { Range, compare, intersects, prerelease, satisfies, valid } from 'semver'; +import { Argv } from 'yargs'; +import { + CommandModuleError, + CommandModuleImplementation, + Options, + OtherOptions, +} from '../../command-builder/command-module'; +import { + SchematicsCommandArgs, + SchematicsCommandModule, +} from '../../command-builder/schematics-command-module'; +import { NgAddSaveDependency, PackageManifest, PackageMetadata } from '../../package-managers'; +import { assertIsError } from '../../utilities/error'; +import { isTTY } from '../../utilities/tty'; +import { VERSION } from '../../utilities/version'; +import longDescription from './long-description.md'; + +class CommandError extends Error {} + +interface AddCommandArgs extends SchematicsCommandArgs { + collection: string; + verbose?: boolean; + registry?: string; + 'skip-confirmation'?: boolean; +} + +interface AddCommandTaskContext { + packageIdentifier: npa.Result; + isExactVersion: boolean; + savePackage?: NgAddSaveDependency; + collectionName?: string; + executeSchematic: AddCommandModule['executeSchematic']; + getPeerDependencyConflicts: AddCommandModule['getPeerDependencyConflicts']; + dryRun?: boolean; + hasSchematics?: boolean; + homepage?: string; +} + +type AddCommandTaskWrapper = ListrTaskWrapper< + AddCommandTaskContext, + typeof ListrRenderer, + typeof ListrRenderer +>; + +/** + * The set of packages that should have certain versions excluded from consideration + * when attempting to find a compatible version for a package. + * The key is a package name and the value is a SemVer range of versions to exclude. + */ +const packageVersionExclusions: Record = { + // @angular/localize@9.x and earlier versions as well as @angular/localize@10.0 prereleases do not have peer dependencies setup. + '@angular/localize': '<10.0.0', + // @angular/material@7.x versions have unbounded peer dependency ranges (>=7.0.0). + '@angular/material': '7.x', +}; + +const DEFAULT_CONFLICT_DISPLAY_LIMIT = 5; + +/** + * A map of packages to built-in schematics. + * This is used for packages that do not have a native `ng-add` schematic. + */ +const BUILT_IN_SCHEMATICS = { + tailwindcss: { + collection: '@schematics/angular', + name: 'tailwind', + }, + '@vitest/browser-playwright': { + collection: '@schematics/angular', + name: 'vitest-browser', + }, + '@vitest/browser-webdriverio': { + collection: '@schematics/angular', + name: 'vitest-browser', + }, + '@vitest/browser-preview': { + collection: '@schematics/angular', + name: 'vitest-browser', + }, +} as const; + +export default class AddCommandModule + extends SchematicsCommandModule + implements CommandModuleImplementation +{ + command = 'add '; + describe = 'Adds support for an external library to your project.'; + override longDescription = longDescription; + protected override allowPrivateSchematics = true; + private readonly schematicName = 'ng-add'; + private rootRequire = createRequire(this.context.root + '/'); + #projectVersionCache = new Map(); + #rootManifestCache: PackageManifest | null = null; + + override async builder(argv: Argv): Promise> { + const localYargs = (await super.builder(argv)) + .positional('collection', { + description: 'The package to be added.', + type: 'string', + demandOption: true, + }) + .option('registry', { description: 'The NPM registry to use.', type: 'string' }) + .option('verbose', { + description: 'Display additional details about internal operations during execution.', + type: 'boolean', + default: false, + }) + .option('skip-confirmation', { + description: + 'Skip asking a confirmation prompt before installing and executing the package. ' + + 'Ensure package name is correct prior to using this option.', + type: 'boolean', + default: false, + }) + .check(({ registry }) => { + if (registry === undefined) { + return true; + } + + if (typeof registry === 'string' && URL.canParse(registry)) { + return true; + } + + throw new CommandModuleError('Option --registry must be a valid URL.'); + }) + // Prior to downloading we don't know the full schema and therefore we cannot be strict on the options. + // Possibly in the future update the logic to use the following syntax: + // `ng add @angular/localize -- --package-options`. + .strict(false); + + const collectionName = this.getCollectionName(); + if (!collectionName) { + return localYargs; + } + + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); + + try { + const collection = workflow.engine.createCollection(collectionName); + const options = await this.getSchematicOptions(collection, this.schematicName, workflow); + + return this.addSchemaOptionsToCommand(localYargs, options); + } catch (error) { + // During `ng add` prior to the downloading of the package + // we are not able to resolve and create a collection. + // Or when the collection value is a path to a tarball. + } + + return localYargs; + } + + async run(options: Options & OtherOptions): Promise { + this.#projectVersionCache.clear(); + this.#rootManifestCache = null; + const { logger } = this.context; + const { collection, skipConfirmation } = options; + + let packageIdentifier; + try { + packageIdentifier = npa(collection); + } catch (e) { + assertIsError(e); + logger.error(e.message); + + return 1; + } + + if ( + packageIdentifier.name && + packageIdentifier.registry && + this.isPackageInstalled(packageIdentifier.name) + ) { + const validVersion = await this.isProjectVersionValid(packageIdentifier); + if (validVersion) { + // Already installed so just run schematic + logger.info('Skipping installation: Package already installed'); + + return this.executeSchematic({ ...options, collection: packageIdentifier.name }); + } + } + + const taskContext = { + packageIdentifier, + isExactVersion: packageIdentifier.type === 'version', + executeSchematic: this.executeSchematic.bind(this), + getPeerDependencyConflicts: this.getPeerDependencyConflicts.bind(this), + dryRun: options.dryRun, + } as AddCommandTaskContext; + + const tasks = new Listr( + [ + { + title: 'Determining Package Manager', + task: (_context, task) => + (task.output = `Using package manager: ${color.dim(this.context.packageManager.name)}`), + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Searching for compatible package version', + enabled: packageIdentifier.type === 'range' && packageIdentifier.rawSpec === '*', + task: (context, task) => this.findCompatiblePackageVersionTask(context, task, options), + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Loading package information', + task: (context, task) => this.loadPackageInfoTask(context, task, options), + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Confirming installation', + enabled: !skipConfirmation && !options.dryRun, + task: (context, task) => this.confirmInstallationTask(context, task), + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Installing package', + skip: (context) => { + if (context.dryRun) { + return `Skipping package installation. Would install package ${color.blue( + context.packageIdentifier.toString(), + )}.`; + } + + return false; + }, + task: (context, task) => this.installPackageTask(context, task, options), + rendererOptions: { bottomBar: Infinity }, + }, + // TODO: Rework schematic execution as a task and insert here + ], + {/* options */}, + ); + + try { + const result = await tasks.run(taskContext); + assert(result.collectionName, 'Collection name should always be available'); + + let shouldCleanUp = false; + if (!result.hasSchematics && !options.dryRun) { + const packageJsonPath = this.resolvePackageJson(result.collectionName); + if (packageJsonPath && existsSync(packageJsonPath)) { + try { + const localManifest = JSON.parse(await fs.readFile(packageJsonPath, 'utf-8')); + if (localManifest.schematics) { + result.hasSchematics = true; + if (localManifest['ng-add']?.save === false) { + shouldCleanUp = true; + } + } else { + await this.cleanUpTemporaryDependency(result.collectionName); + shouldCleanUp = false; + } + } catch {} + } + } + + // Check if the installed package has actual add actions and not just schematic support + if (result.hasSchematics && !options.dryRun) { + const workflow = this.getOrCreateWorkflowForBuilder(result.collectionName); + const collection = workflow.engine.createCollection(result.collectionName); + + // listSchematicNames cannot be used here since it does not list private schematics. + // Most `ng-add` schematics are marked as private. + // TODO: Consider adding a `hasSchematic` helper to the schematic collection object. + try { + collection.createSchematic(this.schematicName, true); + } catch { + result.hasSchematics = false; + } + } + + if (!result.hasSchematics) { + // Fallback to a built-in schematic if the package does not have an `ng-add` schematic + const packageName = result.packageIdentifier.name; + if (packageName) { + const builtInSchematic = + BUILT_IN_SCHEMATICS[packageName as keyof typeof BUILT_IN_SCHEMATICS]; + if (builtInSchematic) { + logger.info( + `The ${color.blue(packageName)} package does not provide \`ng add\` actions.`, + ); + logger.info('The Angular CLI will use built-in actions to add it to your project.'); + + return this.executeSchematic({ + ...options, + collection: builtInSchematic.collection, + schematicName: builtInSchematic.name, + package: packageName, + }); + } + } + + let message = options.dryRun + ? 'The package does not provide any `ng add` actions, so no further actions would be taken.' + : 'Package installed successfully. The package does not provide any `ng add` actions, so no further actions were taken.'; + + if (result.homepage) { + message += `\nFor more information about this package, visit its homepage at ${result.homepage}`; + } + logger.info(message); + + return; + } + + if (options.dryRun) { + logger.info("The package's `ng add` actions would be executed next."); + + return; + } + + const schematicExitCode = await this.executeSchematic({ + ...options, + collection: result.collectionName, + }); + + if (shouldCleanUp) { + await this.cleanUpTemporaryDependency(result.collectionName); + } + + return schematicExitCode; + } catch (e) { + if (e instanceof CommandError) { + logger.error(e.message); + + return 1; + } + + throw e; + } + } + + private async findCompatiblePackageVersionTask( + context: AddCommandTaskContext, + task: AddCommandTaskWrapper, + options: Options, + ): Promise { + const { registry, verbose } = options; + const { packageIdentifier } = context; + const { packageManager } = this.context; + const packageName = packageIdentifier.name; + + assert(packageName, 'Registry package identifiers should always have a name.'); + + const rejectionReasons: string[] = []; + + // Attempt to use the 'latest' tag from the registry. + try { + const latestManifest = await packageManager.getManifest(`${packageName}@latest`, { + registry, + }); + + if (latestManifest) { + const conflicts = await this.getPeerDependencyConflicts(latestManifest); + if (!conflicts) { + context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); + task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`; + + return; + } + rejectionReasons.push(...conflicts); + } + } catch (e) { + assertIsError(e); + throw new CommandError(`Unable to load package information from registry: ${e.message}`); + } + + // 'latest' is invalid or not found, search for most recent matching package. + task.output = + 'Could not find a compatible version with `latest`. Searching for a compatible version.'; + + let packageMetadata; + try { + packageMetadata = await packageManager.getRegistryMetadata(packageName, { + registry, + }); + } catch (e) { + assertIsError(e); + throw new CommandError(`Unable to load package information from registry: ${e.message}`); + } + + if (!packageMetadata) { + throw new CommandError('Unable to load package information from registry.'); + } + + // Allow prelease versions if the CLI itself is a prerelease or locally built. + const allowPrereleases = !!prerelease(VERSION.full) || VERSION.full === '0.0.0'; + const potentialVersions = this.#getPotentialVersions(packageMetadata, allowPrereleases); + + // Heuristic-based search: Check the latest release of each major version first. + const majorVersions = this.#getMajorVersions(potentialVersions); + let found = await this.#findCompatibleVersion(context, majorVersions, { + registry, + verbose, + rejectionReasons, + }); + + // Exhaustive search: If no compatible major version is found, fall back to checking all versions. + if (!found) { + const checkedVersions = new Set(majorVersions); + const remainingVersions = potentialVersions.filter((v) => !checkedVersions.has(v)); + found = await this.#findCompatibleVersion(context, remainingVersions, { + registry, + verbose, + rejectionReasons, + }); + } + + if (!found) { + let message = `Unable to find compatible package.`; + if (rejectionReasons.length > 0) { + message += + '\nThis is often because of incompatible peer dependencies.\n' + + 'These versions were rejected due to the following conflicts:\n' + + rejectionReasons + .slice(0, verbose ? undefined : DEFAULT_CONFLICT_DISPLAY_LIMIT) + .map((r) => ` - ${r}`) + .join('\n'); + } + task.output = message; + } else { + task.output = `Found compatible package version: ${color.blue( + context.packageIdentifier.toString(), + )}.`; + } + } + + async #findCompatibleVersion( + context: AddCommandTaskContext, + versions: string[], + options: { + registry?: string; + verbose?: boolean; + rejectionReasons: string[]; + }, + ): Promise { + const { packageIdentifier } = context; + const { packageManager } = this.context; + const { registry, verbose, rejectionReasons } = options; + const packageName = packageIdentifier.name; + assert(packageName, 'Package name must be defined.'); + + for (const version of versions) { + const manifest = await packageManager.getManifest(`${packageName}@${version}`, { + registry, + }); + if (!manifest) { + continue; + } + + const conflicts = await this.getPeerDependencyConflicts(manifest); + if (conflicts) { + if (verbose || rejectionReasons.length < DEFAULT_CONFLICT_DISPLAY_LIMIT) { + rejectionReasons.push(...conflicts); + } + continue; + } + + context.packageIdentifier = npa.resolve(manifest.name, manifest.version); + + return manifest; + } + + return null; + } + + #getPotentialVersions(packageMetadata: PackageMetadata, allowPrereleases: boolean): string[] { + const versionExclusions = packageVersionExclusions[packageMetadata.name]; + const latestVersion = packageMetadata['dist-tags']['latest']; + + const versions = Object.values(packageMetadata.versions).filter((version) => { + // Latest tag has already been checked + if (latestVersion && version === latestVersion) { + return false; + } + + // Prerelease versions are not stable and should not be considered by default + if (!allowPrereleases && prerelease(version)) { + return false; + } + + // Excluded package versions should not be considered + if (versionExclusions && satisfies(version, versionExclusions, { includePrerelease: true })) { + return false; + } + + return true; + }); + + // Sort in reverse SemVer order so that the newest compatible version is chosen + return versions.sort((a, b) => compare(b, a, true)); + } + + #getMajorVersions(versions: string[]): string[] { + const majorVersions = new Map(); + for (const version of versions) { + const major = semver.major(version); + const existing = majorVersions.get(major); + if (!existing || semver.gt(version, existing)) { + majorVersions.set(major, version); + } + } + + return [...majorVersions.values()].sort((a, b) => compare(b, a, true)); + } + + private async loadPackageInfoTask( + context: AddCommandTaskContext, + task: AddCommandTaskWrapper, + options: Options, + ): Promise { + const { registry } = options; + + let manifest; + try { + manifest = await this.context.packageManager.getManifest(context.packageIdentifier, { + registry, + }); + } catch (e) { + assertIsError(e); + throw new CommandError( + `Unable to fetch package information for '${context.packageIdentifier}': ${e.message}`, + ); + } + + if (!manifest) { + throw new CommandError( + `Unable to fetch package information for '${context.packageIdentifier}'.`, + ); + } + + // Avoid fully resolving the package version from the registry again in later steps + if (context.packageIdentifier.registry) { + assert(context.packageIdentifier.name, 'Registry package identifier must have a name'); + context.packageIdentifier = npa.resolve( + context.packageIdentifier.name, + // `save-prefix` option is ignored by some package managers so the caret is needed to ensure + // that the value in the project package.json is correct. + (context.isExactVersion ? '' : '^') + manifest.version, + ); + } + + context.hasSchematics = !!manifest.schematics; + context.savePackage = manifest['ng-add']?.save; + context.collectionName = manifest.name; + context.homepage = manifest.homepage; + + if (await this.getPeerDependencyConflicts(manifest)) { + task.output = color.yellow( + figures.warning + + ' Package has unmet peer dependencies. Adding the package may not succeed.', + ); + } + } + + private async confirmInstallationTask( + context: AddCommandTaskContext, + task: AddCommandTaskWrapper, + ): Promise { + if (!isTTY()) { + task.output = + `'--skip-confirmation' can be used to bypass installation confirmation. ` + + `Ensure package name is correct prior to '--skip-confirmation' option usage.`; + throw new CommandError('No terminal detected'); + } + + const { ListrInquirerPromptAdapter } = await import('@listr2/prompt-adapter-inquirer'); + const { confirm } = await import('@inquirer/prompts'); + const shouldProceed = await task.prompt(ListrInquirerPromptAdapter).run(confirm, { + message: + `The package ${color.blue(context.packageIdentifier.toString())} will be installed and executed.\n` + + 'Would you like to proceed?', + default: true, + theme: { prefix: '' }, + }); + + if (!shouldProceed) { + throw new CommandError('Command aborted'); + } + } + + private async cleanUpTemporaryDependency(packageName: string): Promise { + try { + this.context.logger.info(`Cleaning up temporary dependency '${packageName}'...`); + + // 1. Remove from root package.json + const projectManifest = await this.getProjectManifest(); + if (projectManifest) { + if (projectManifest.dependencies) { + delete projectManifest.dependencies[packageName]; + } + if (projectManifest.devDependencies) { + delete projectManifest.devDependencies[packageName]; + } + + await fs.writeFile( + join(this.context.root, 'package.json'), + JSON.stringify(projectManifest, null, 2) + '\n', + ); + } + + // 2. Silent install pass to prune files from node_modules and update the lockfile + await this.context.packageManager.install({ ignoreScripts: true }); + } catch (error) { + this.context.logger.warn( + `Failed to clean up temporary dependency '${packageName}': ` + + `${error instanceof Error ? error.message : error}`, + ); + } + } + + private async installPackageTask( + context: AddCommandTaskContext, + task: AddCommandTaskWrapper, + options: Options, + ): Promise { + const { registry } = options; + const { packageIdentifier, savePackage } = context; + const { packageManager } = this.context; + + // Only show if installation will actually occur + task.title = 'Installing package'; + + if (context.savePackage === false) { + task.title += ' in temporary location'; + + // Temporary packages are located in a different directory + // Hence we need to resolve them using the temp path + const { workingDirectory } = await packageManager.acquireTempPackage( + packageIdentifier.toString(), + { + registry, + }, + ); + + const tempRequire = createRequire(workingDirectory + '/'); + assert(context.collectionName, 'Collection name should always be available'); + const resolvedCollectionPath = tempRequire.resolve( + join(context.collectionName, 'package.json'), + ); + + context.collectionName = dirname(resolvedCollectionPath); + } else { + await packageManager.add( + packageIdentifier.toString(), + 'none', + savePackage === 'devDependencies', + false, + true, + { + registry, + }, + ); + } + } + + private async isProjectVersionValid(packageIdentifier: npa.Result): Promise { + if (!packageIdentifier.name) { + return false; + } + + const installedVersion = await this.findProjectVersion(packageIdentifier.name); + if (!installedVersion) { + return false; + } + + if (packageIdentifier.rawSpec === '*') { + return true; + } + + if ( + packageIdentifier.type === 'range' && + packageIdentifier.fetchSpec && + packageIdentifier.fetchSpec !== '*' + ) { + return satisfies(installedVersion, packageIdentifier.fetchSpec); + } + + if (packageIdentifier.type === 'version') { + const v1 = valid(packageIdentifier.fetchSpec); + const v2 = valid(installedVersion); + + return v1 !== null && v1 === v2; + } + + return false; + } + + private getCollectionName(): string | undefined { + const [, collectionName] = this.context.args.positional; + if (!collectionName) { + return undefined; + } + + // The CLI argument may specify also a version, like `ng add @my/lib@13.0.0`, + // but here we need only the name of the package, like `@my/lib`. + try { + const packageName = npa(collectionName).name; + if (packageName) { + return packageName; + } + } catch (e) { + assertIsError(e); + this.context.logger.error(e.message); + } + + return collectionName; + } + + private isPackageInstalled(name: string): boolean { + return !!this.resolvePackageJson(name); + } + + private executeSchematic( + options: Options & OtherOptions & { schematicName?: string }, + ): Promise { + const { + verbose, + skipConfirmation, + interactive, + force, + dryRun, + registry, + defaults, + collection: collectionName, + schematicName, + ...schematicOptions + } = options; + + return this.runSchematic({ + schematicOptions, + schematicName: schematicName ?? this.schematicName, + collectionName, + executionOptions: { + interactive, + force, + dryRun, + defaults, + packageRegistry: registry, + }, + }); + } + + private async findProjectVersion(name: string): Promise { + const cachedVersion = this.#projectVersionCache.get(name); + if (cachedVersion !== undefined) { + return cachedVersion; + } + + const installedPackagePath = this.resolvePackageJson(name); + if (installedPackagePath) { + try { + const installedPackage = JSON.parse( + await fs.readFile(installedPackagePath, 'utf-8'), + ) as PackageManifest; + this.#projectVersionCache.set(name, installedPackage.version); + + return installedPackage.version; + } catch {} + } + + const projectManifest = await this.getProjectManifest(); + if (projectManifest) { + const version = + projectManifest.dependencies?.[name] || projectManifest.devDependencies?.[name]; + if (version) { + this.#projectVersionCache.set(name, version); + + return version; + } + } + + this.#projectVersionCache.set(name, null); + + return null; + } + + private async getProjectManifest(): Promise { + if (this.#rootManifestCache) { + return this.#rootManifestCache; + } + + const { root } = this.context; + try { + this.#rootManifestCache = JSON.parse( + await fs.readFile(join(root, 'package.json'), 'utf-8'), + ) as PackageManifest; + + return this.#rootManifestCache; + } catch { + return null; + } + } + + private resolvePackageJson(name: string): string | undefined { + try { + return this.rootRequire.resolve(join(name, 'package.json')); + } catch (e) { + assertIsError(e); + if (e.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') { + try { + const mainPath = this.rootRequire.resolve(name); + let directory = dirname(mainPath); + + // Stop at the node_modules boundary or the root of the file system + while (directory && basename(directory) !== 'node_modules') { + const packageJsonPath = join(directory, 'package.json'); + if (existsSync(packageJsonPath)) { + return packageJsonPath; + } + + const parent = dirname(directory); + if (parent === directory) { + break; + } + directory = parent; + } + } catch (e) { + assertIsError(e); + this.context.logger.debug( + `Failed to resolve package '${name}' during fallback: ${e.message}`, + ); + } + } + } + + return undefined; + } + + private async getPeerDependencyConflicts(manifest: PackageManifest): Promise { + if (!manifest.peerDependencies) { + return false; + } + + const checks = Object.entries(manifest.peerDependencies).map(async ([peer, range]) => { + let peerIdentifier; + try { + peerIdentifier = npa.resolve(peer, range); + } catch { + this.context.logger.warn(`Invalid peer dependency ${peer} found in package.`); + + return null; + } + + if (peerIdentifier.type !== 'version' && peerIdentifier.type !== 'range') { + // type === 'tag' | 'file' | 'directory' | 'remote' | 'git' + // Cannot accurately compare these as the tag/location may have changed since install. + return null; + } + + try { + const version = await this.findProjectVersion(peer); + if (!version) { + return null; + } + + const options = { includePrerelease: true }; + if ( + !intersects(version, peerIdentifier.rawSpec, options) && + !satisfies(version, peerIdentifier.rawSpec, options) + ) { + return ( + `Package "${manifest.name}@${manifest.version}" has an incompatible peer dependency to "` + + `${peer}@${peerIdentifier.rawSpec}" (requires "${version}" in project).` + ); + } + } catch { + // Not found or invalid so ignore + } + + return null; + }); + + const conflicts = (await Promise.all(checks)).filter((result): result is string => !!result); + + return conflicts.length > 0 && conflicts; + } +} diff --git a/packages/angular/cli/src/commands/add/long-description.md b/packages/angular/cli/src/commands/add/long-description.md new file mode 100644 index 000000000000..347b3a5971aa --- /dev/null +++ b/packages/angular/cli/src/commands/add/long-description.md @@ -0,0 +1,7 @@ +Adds the npm package for a published library to your workspace, and configures +the project in the current working directory to use that library, as specified by the library's schematic. +For example, adding `@angular/pwa` configures your project for PWA support: + +```bash +ng add @angular/pwa +``` diff --git a/packages/angular/cli/src/commands/analytics/cli.ts b/packages/angular/cli/src/commands/analytics/cli.ts new file mode 100644 index 000000000000..bcd67b0bba45 --- /dev/null +++ b/packages/angular/cli/src/commands/analytics/cli.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + Options, +} from '../../command-builder/command-module'; +import { + addCommandModuleToYargs, + demandCommandFailureMessage, +} from '../../command-builder/utilities/command'; +import { AnalyticsInfoCommandModule } from './info/cli'; +import longDescription from './long-description.md'; +import { + AnalyticsDisableModule, + AnalyticsEnableModule, + AnalyticsPromptModule, +} from './settings/cli'; + +export default class AnalyticsCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'analytics'; + describe = 'Configures the gathering of Angular CLI usage metrics.'; + override longDescription = longDescription; + + builder(localYargs: Argv): Argv { + const subcommands = [ + AnalyticsInfoCommandModule, + AnalyticsDisableModule, + AnalyticsEnableModule, + AnalyticsPromptModule, + ].sort(); // sort by class name. + + for (const module of subcommands) { + addCommandModuleToYargs(module, this.context); + } + + return localYargs.demandCommand(1, demandCommandFailureMessage).strict(); + } + + run(_options: Options<{}>): void {} +} diff --git a/packages/angular/cli/src/commands/analytics/info/cli.ts b/packages/angular/cli/src/commands/analytics/info/cli.ts new file mode 100644 index 000000000000..93b34fd06716 --- /dev/null +++ b/packages/angular/cli/src/commands/analytics/info/cli.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { getAnalyticsInfoString } from '../../../analytics/analytics'; +import { + CommandModule, + CommandModuleImplementation, + Options, +} from '../../../command-builder/command-module'; + +export class AnalyticsInfoCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'info'; + describe = 'Prints analytics gathering and reporting configuration in the console.'; + + builder(localYargs: Argv): Argv { + return localYargs.strict(); + } + + async run(_options: Options<{}>): Promise { + this.context.logger.info(await getAnalyticsInfoString(this.context)); + } +} diff --git a/packages/angular/cli/src/commands/analytics/long-description.md b/packages/angular/cli/src/commands/analytics/long-description.md new file mode 100644 index 000000000000..69ee9ad7ee00 --- /dev/null +++ b/packages/angular/cli/src/commands/analytics/long-description.md @@ -0,0 +1,20 @@ +You can help the Angular Team to prioritize features and improvements by permitting the Angular team to send command-line command usage statistics to Google. +The Angular Team does not collect usage statistics unless you explicitly opt in. When installing the Angular CLI you are prompted to allow global collection of usage statistics. +If you say no or skip the prompt, no data is collected. + +### What is collected? + +Usage analytics include the commands and selected flags for each execution. +Usage analytics may include the following information: + +- Your operating system \(macOS, Linux distribution, Windows\) and its version. +- Package manager name and version \(local version only\). +- Node.js version \(local version only\). +- Angular CLI version \(local version only\). +- Command name that was run. +- Workspace information, the number of application and library projects. +- For schematics commands \(add, generate and new\), the schematic collection and name and a list of selected flags. +- For build commands \(build, serve\), the builder name, the number and size of bundles \(initial and lazy\), compilation units, the time it took to build and rebuild, and basic Angular-specific API usage. + +Only Angular owned and developed schematics and builders are reported. +Third-party schematics and builders do not send data to the Angular Team. diff --git a/packages/angular/cli/src/commands/analytics/settings/cli.ts b/packages/angular/cli/src/commands/analytics/settings/cli.ts new file mode 100644 index 000000000000..24105de2b060 --- /dev/null +++ b/packages/angular/cli/src/commands/analytics/settings/cli.ts @@ -0,0 +1,80 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { + getAnalyticsInfoString, + promptAnalytics, + setAnalyticsConfig, +} from '../../../analytics/analytics'; +import { + CommandModule, + CommandModuleImplementation, + Options, +} from '../../../command-builder/command-module'; + +interface AnalyticsCommandArgs { + global: boolean; +} + +abstract class AnalyticsSettingModule + extends CommandModule + implements CommandModuleImplementation +{ + builder(localYargs: Argv): Argv { + return localYargs + .option('global', { + description: `Configure analytics gathering and reporting globally in the caller's home directory.`, + alias: ['g'], + type: 'boolean', + default: false, + }) + .strict(); + } + + abstract override run({ global }: Options): Promise; +} + +export class AnalyticsDisableModule + extends AnalyticsSettingModule + implements CommandModuleImplementation +{ + command = 'disable'; + aliases = 'off'; + describe = 'Disables analytics gathering and reporting for the user.'; + + async run({ global }: Options): Promise { + await setAnalyticsConfig(global, false); + process.stderr.write(await getAnalyticsInfoString(this.context)); + } +} + +export class AnalyticsEnableModule + extends AnalyticsSettingModule + implements CommandModuleImplementation +{ + command = 'enable'; + aliases = 'on'; + describe = 'Enables analytics gathering and reporting for the user.'; + async run({ global }: Options): Promise { + await setAnalyticsConfig(global, true); + process.stderr.write(await getAnalyticsInfoString(this.context)); + } +} + +export class AnalyticsPromptModule + extends AnalyticsSettingModule + implements CommandModuleImplementation +{ + command = 'prompt'; + describe = 'Prompts the user to set the analytics gathering status interactively.'; + + async run({ global }: Options): Promise { + await promptAnalytics(this.context, global, true); + } +} diff --git a/packages/angular/cli/src/commands/build/cli.ts b/packages/angular/cli/src/commands/build/cli.ts new file mode 100644 index 000000000000..c722f70709e3 --- /dev/null +++ b/packages/angular/cli/src/commands/build/cli.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; +import { RootCommands } from '../command-config'; +import longDescription from './long-description.md'; + +export default class BuildCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + multiTarget = false; + command = 'build [project]'; + aliases = RootCommands['build'].aliases; + describe = + 'Compiles an Angular application or library into an output directory named dist/ at the given output path.'; + override longDescription = longDescription; +} diff --git a/packages/angular/cli/src/commands/build/long-description.md b/packages/angular/cli/src/commands/build/long-description.md new file mode 100644 index 000000000000..b2c14d8f23fe --- /dev/null +++ b/packages/angular/cli/src/commands/build/long-description.md @@ -0,0 +1,18 @@ +The command can be used to build a project of type "application" or "library". +When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, `poll` and `watch` options are applied. +All other options apply only to building applications. + +The application builder uses the [esbuild](https://esbuild.github.io/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration. +A "development" configuration is created by default when you use the CLI to create the project, and you can use that configuration by specifying the `--configuration development`. + +The configuration options generally correspond to the command options. +You can override individual configuration defaults by specifying the corresponding options on the command line. +The command can accept option names given in dash-case. +Note that in the configuration file, you must specify names in camelCase. + +Some additional options can only be set through the configuration file, +either by direct editing or with the `ng config` command. +These include `assets`, `styles`, and `scripts` objects that provide runtime-global resources to include in the project. +Resources in CSS, such as images and fonts, are automatically written and fingerprinted at the root of the output folder. + +For further details, see [Workspace Configuration](reference/configs/workspace-config). diff --git a/packages/angular/cli/src/commands/cache/clean/cli.ts b/packages/angular/cli/src/commands/cache/clean/cli.ts new file mode 100644 index 000000000000..492699271b54 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/clean/cli.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { rm } from 'node:fs/promises'; +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, +} from '../../../command-builder/command-module'; +import { getCacheConfig } from '../utilities'; + +export class CacheCleanModule extends CommandModule implements CommandModuleImplementation { + command = 'clean'; + describe = 'Deletes persistent disk cache from disk.'; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + return localYargs.strict(); + } + + run(): Promise { + const { path } = getCacheConfig(this.context.workspace); + + return rm(path, { + force: true, + recursive: true, + maxRetries: 3, + }); + } +} diff --git a/packages/angular/cli/src/commands/cache/cli.ts b/packages/angular/cli/src/commands/cache/cli.ts new file mode 100644 index 000000000000..4ce2b7ebd1e4 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/cli.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, + Options, +} from '../../command-builder/command-module'; +import { + addCommandModuleToYargs, + demandCommandFailureMessage, +} from '../../command-builder/utilities/command'; +import { CacheCleanModule } from './clean/cli'; +import { CacheInfoCommandModule } from './info/cli'; +import longDescription from './long-description.md'; +import { CacheDisableModule, CacheEnableModule } from './settings/cli'; + +export default class CacheCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'cache'; + describe = 'Configure persistent disk cache and retrieve cache statistics.'; + override longDescription = longDescription; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + const subcommands = [ + CacheEnableModule, + CacheDisableModule, + CacheCleanModule, + CacheInfoCommandModule, + ].sort(); + + for (const module of subcommands) { + addCommandModuleToYargs(module, this.context); + } + + return localYargs.demandCommand(1, demandCommandFailureMessage).strict(); + } + + run(_options: Options<{}>): void {} +} diff --git a/packages/angular/cli/src/commands/cache/info/cli.ts b/packages/angular/cli/src/commands/cache/info/cli.ts new file mode 100644 index 000000000000..6fd2df0414c7 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/info/cli.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import * as fs from 'node:fs/promises'; +import { join } from 'node:path'; +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, +} from '../../../command-builder/command-module'; +import { colors } from '../../../utilities/color'; +import { isCI } from '../../../utilities/environment-options'; +import { getCacheConfig } from '../utilities'; + +export class CacheInfoCommandModule extends CommandModule implements CommandModuleImplementation { + command = 'info'; + describe = 'Prints persistent disk cache configuration and statistics in the console.'; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + return localYargs.strict(); + } + + async run(): Promise { + const cacheConfig = getCacheConfig(this.context.workspace); + const { path, environment, enabled } = cacheConfig; + + const effectiveStatus = this.effectiveEnabledStatus(cacheConfig); + const sizeOnDisk = await this.getSizeOfDirectory(path); + + const info: { label: string; value: string }[] = [ + { + label: 'Enabled', + value: enabled ? colors.green('Yes') : colors.red('No'), + }, + { + label: 'Environment', + value: colors.cyan(environment), + }, + { + label: 'Path', + value: colors.cyan(path), + }, + { + label: 'Size on disk', + value: colors.cyan(sizeOnDisk), + }, + { + label: 'Effective Status', + value: + (effectiveStatus ? colors.green('Enabled') : colors.red('Disabled')) + + ' (current machine)', + }, + ]; + + const maxLabelLength = Math.max(...info.map((l) => l.label.length)); + + const output = info + .map(({ label, value }) => colors.bold(label.padEnd(maxLabelLength + 2)) + `: ${value}`) + .join('\n'); + + this.context.logger.info(`\n${colors.bold('Cache Information')}\n\n${output}\n`); + } + + private async getSizeOfDirectory(path: string): Promise { + const directoriesStack = [path]; + let size = 0; + + while (directoriesStack.length) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const dirPath = directoriesStack.pop()!; + let entries: string[] = []; + + try { + entries = await fs.readdir(dirPath); + } catch {} + + for (const entry of entries) { + const entryPath = join(dirPath, entry); + const stats = await fs.stat(entryPath); + + if (stats.isDirectory()) { + directoriesStack.push(entryPath); + } + + size += stats.size; + } + } + + return this.formatSize(size); + } + + private formatSize(size: number): string { + if (size <= 0) { + return '0 bytes'; + } + + const abbreviations = ['bytes', 'kB', 'MB', 'GB']; + const index = Math.floor(Math.log(size) / Math.log(1024)); + const roundedSize = size / Math.pow(1024, index); + // bytes don't have a fraction + const fractionDigits = index === 0 ? 0 : 2; + + return `${roundedSize.toFixed(fractionDigits)} ${abbreviations[index]}`; + } + + private effectiveEnabledStatus(cacheConfig: { enabled: boolean; environment: string }): boolean { + const { enabled, environment } = cacheConfig; + + if (enabled) { + switch (environment) { + case 'ci': + return isCI; + case 'local': + return !isCI; + } + } + + return enabled; + } +} diff --git a/packages/angular/cli/src/commands/cache/long-description.md b/packages/angular/cli/src/commands/cache/long-description.md new file mode 100644 index 000000000000..3ebfec598c4e --- /dev/null +++ b/packages/angular/cli/src/commands/cache/long-description.md @@ -0,0 +1,53 @@ +Angular CLI saves a number of cachable operations on disk by default. + +When you re-run the same build, the build system restores the state of the previous build and re-uses previously performed operations, which decreases the time taken to build and test your applications and libraries. + +To amend the default cache settings, add the `cli.cache` object to your [Workspace Configuration](reference/configs/workspace-config). +The object goes under `cli.cache` at the top level of the file, outside the `projects` sections. + +```jsonc +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { + "cache": { + // ... + }, + }, + "projects": {}, +} +``` + +For more information, see [cache options](reference/configs/workspace-config#cache-options). + +### Cache environments + +By default, disk cache is only enabled for local environments. The value of environment can be one of the following: + +- `all` - allows disk cache on all machines. +- `local` - allows disk cache only on development machines. +- `ci` - allows disk cache only on continuous integration (CI) systems. + +To change the environment setting to `all`, run the following command: + +```bash +ng config cli.cache.environment all +``` + +For more information, see `environment` in [cache options](reference/configs/workspace-config#cache-options). + +
+ +The Angular CLI checks for the presence and value of the `CI` environment variable to determine in which environment it is running. + +
+ +### Cache path + +By default, `.angular/cache` is used as a base directory to store cache results. + +To change this path to `.cache/ng`, run the following command: + +```bash +ng config cli.cache.path ".cache/ng" +``` diff --git a/packages/angular/cli/src/commands/cache/settings/cli.ts b/packages/angular/cli/src/commands/cache/settings/cli.ts new file mode 100644 index 000000000000..5de2d5496475 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/settings/cli.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleImplementation, + CommandScope, +} from '../../../command-builder/command-module'; +import { updateCacheConfig } from '../utilities'; + +export class CacheDisableModule extends CommandModule implements CommandModuleImplementation { + command = 'disable'; + aliases = 'off'; + describe = 'Disables persistent disk cache for all projects in the workspace.'; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): Promise { + return updateCacheConfig(this.getWorkspaceOrThrow(), 'enabled', false); + } +} + +export class CacheEnableModule extends CommandModule implements CommandModuleImplementation { + command = 'enable'; + aliases = 'on'; + describe = 'Enables disk cache for all projects in the workspace.'; + override scope = CommandScope.In; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): Promise { + return updateCacheConfig(this.getWorkspaceOrThrow(), 'enabled', true); + } +} diff --git a/packages/angular/cli/src/commands/cache/utilities.ts b/packages/angular/cli/src/commands/cache/utilities.ts new file mode 100644 index 000000000000..cca7e5bfaf50 --- /dev/null +++ b/packages/angular/cli/src/commands/cache/utilities.ts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { isJsonObject } from '@angular-devkit/core'; +import { existsSync, readFileSync, statSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { Cache, Environment } from '../../../lib/config/workspace-schema'; +import { AngularWorkspace } from '../../utilities/config'; + +export function updateCacheConfig( + workspace: AngularWorkspace, + key: K, + value: Cache[K], +): Promise { + const cli = (workspace.extensions['cli'] ??= {}) as Record>; + const cache = (cli['cache'] ??= {}); + cache[key] = value; + + return workspace.save(); +} + +function getCacheBasePath(workspaceRoot: string, cachePathSetting: string): string { + if (isAbsolute(cachePathSetting)) { + return cachePathSetting; + } + + try { + // Find the git directory, walking up from workspaceRoot if necessary + let currentDir = workspaceRoot; + while (true) { + const gitPath = join(currentDir, '.git'); + if (existsSync(gitPath)) { + const stat = statSync(gitPath); + if (stat.isFile()) { + // Could be a git worktree (or submodule) + const content = readFileSync(gitPath, 'utf8'); + const match = /^gitdir:\s*(.+)$/m.exec(content); + if (match) { + const gitdir = resolve(currentDir, match[1].trim()); + const commondirPath = join(gitdir, 'commondir'); + if (existsSync(commondirPath)) { + // It's a git worktree + const commondir = readFileSync(commondirPath, 'utf8').trim(); + const commonGitDir = resolve(gitdir, commondir); + const relativeWorkspacePath = relative(currentDir, workspaceRoot); + const mainWorkspaceRoot = resolve(dirname(commonGitDir), relativeWorkspacePath); + if (existsSync(mainWorkspaceRoot)) { + return resolve(mainWorkspaceRoot, cachePathSetting); + } + } + } + } + + break; + } + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + currentDir = parentDir; + } + } catch {} + + return resolve(workspaceRoot, cachePathSetting); +} + +export function getCacheConfig(workspace: AngularWorkspace | undefined): Required { + if (!workspace) { + throw new Error(`Cannot retrieve cache configuration as workspace is not defined.`); + } + + const defaultSettings: Required = { + path: getCacheBasePath(workspace.basePath, '.angular/cache'), + environment: Environment.Local, + enabled: true, + }; + + const cliSetting = workspace.extensions['cli']; + if (!cliSetting || !isJsonObject(cliSetting)) { + return defaultSettings; + } + + const cacheSettings = cliSetting['cache']; + if (!isJsonObject(cacheSettings)) { + return defaultSettings; + } + + const { + path = '.angular/cache', + environment = defaultSettings.environment, + enabled = defaultSettings.enabled, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } = cacheSettings as Record; + + return { + path: getCacheBasePath(workspace.basePath, path), + environment, + enabled, + }; +} diff --git a/packages/angular/cli/src/commands/cache/utilities_spec.ts b/packages/angular/cli/src/commands/cache/utilities_spec.ts new file mode 100644 index 000000000000..ad5e3ab4f69c --- /dev/null +++ b/packages/angular/cli/src/commands/cache/utilities_spec.ts @@ -0,0 +1,159 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workspaces } from '@angular-devkit/core'; +import assert from 'node:assert'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { AngularWorkspace } from '../../utilities/config'; +import { getCacheConfig } from './utilities'; + +describe('CLI cache config utilities', () => { + let tempDir: string; + + beforeEach(async () => { + const baseTmpDir = process.env['TEST_TMPDIR']; + assert(baseTmpDir, 'TEST_TMPDIR is not set'); + tempDir = await mkdtemp(join(baseTmpDir, 'angular-cli-cache-spec-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function mockWorkspace(basePath: string, cliExtension?: unknown): AngularWorkspace { + return { + basePath, + extensions: cliExtension ? { cli: cliExtension } : {}, + projects: {} as unknown as workspaces.ProjectDefinitionCollection, + filePath: join(basePath, 'angular.json'), + getCli: () => cliExtension, + getProjectCli: () => undefined, + save: () => Promise.resolve(), + } as unknown as AngularWorkspace; + } + + it('should resolve default cache path relative to workspace basePath in a standard repository', async () => { + const workspaceRoot = join(tempDir, 'project'); + await mkdir(join(workspaceRoot, '.git'), { recursive: true }); + + const config = getCacheConfig(mockWorkspace(workspaceRoot)); + + expect(config.path).toBe(resolve(workspaceRoot, '.angular/cache')); + }); + + it('should resolve default cache path relative to main repository root in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + // Create main repo structure + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree folder and .git file pointing to the main repo's worktree metadata folder + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Create the commondir file in the worktree metadata folder pointing back to the main .git dir + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const config = getCacheConfig(mockWorkspace(worktreeRoot)); + + expect(config.path).toBe(resolve(mainRepoRoot, '.angular/cache')); + }); + + it('should resolve default cache path relative to corresponding nested workspace in main repository for a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const mainWorkspaceRoot = join(mainRepoRoot, 'Site1/ClientApp'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeWorkspaceRoot = join(worktreeRoot, 'Site1/ClientApp'); + + // Create main repo with a nested Angular workspace directory + await mkdir(mainGitDir, { recursive: true }); + await mkdir(mainWorkspaceRoot, { recursive: true }); + + // Create worktree with the same nested Angular workspace structure and a .git file at the worktree root + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeWorkspaceRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Point the worktree metadata back to the main .git directory + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const config = getCacheConfig(mockWorkspace(worktreeWorkspaceRoot)); + + expect(config.path).toBe(resolve(mainWorkspaceRoot, '.angular/cache')); + }); + + it('should fall back to worktree workspace basePath when nested workspace does not exist in main repository', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + const worktreeWorkspaceRoot = join(worktreeRoot, 'NewSite/ClientApp'); + + // Create main repo without the 'NewSite/ClientApp' subdirectory (e.g., added only on the worktree branch) + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree with the new nested Angular workspace and link it to the main repo's .git directory + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeWorkspaceRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const config = getCacheConfig(mockWorkspace(worktreeWorkspaceRoot)); + + expect(config.path).toBe(resolve(worktreeWorkspaceRoot, '.angular/cache')); + }); + + it('should resolve custom relative cache path relative to main repository root in a git worktree', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const mainGitDir = join(mainRepoRoot, '.git'); + const worktreeRoot = join(tempDir, 'worktree'); + + // Create main repo structure + await mkdir(mainGitDir, { recursive: true }); + + // Create worktree folder and .git file pointing to the main repo's worktree metadata folder + const worktreeMetadataDir = join(mainGitDir, 'worktrees/wt-1'); + await mkdir(worktreeMetadataDir, { recursive: true }); + await mkdir(worktreeRoot, { recursive: true }); + await writeFile(join(worktreeRoot, '.git'), `gitdir: ${worktreeMetadataDir}`); + + // Create the commondir file in the worktree metadata folder pointing back to the main .git dir + await writeFile(join(worktreeMetadataDir, 'commondir'), '../..'); + + const config = getCacheConfig( + mockWorkspace(worktreeRoot, { cache: { path: 'custom/cache-dir' } }), + ); + + expect(config.path).toBe(resolve(mainRepoRoot, 'custom/cache-dir')); + }); + + it('should resolve cache path relative to workspace basePath in a git submodule', async () => { + const mainRepoRoot = join(tempDir, 'main-repo'); + const submoduleRoot = join(mainRepoRoot, 'submodule'); + + // Create main repo structure and submodule metadata folder + const submoduleGitDir = join(mainRepoRoot, '.git/modules/sub'); + await mkdir(submoduleGitDir, { recursive: true }); + await mkdir(submoduleRoot, { recursive: true }); + + // Create .git file in submodule pointing to the metadata folder + await writeFile(join(submoduleRoot, '.git'), `gitdir: ../.git/modules/sub`); + + // Submodules do NOT have a 'commondir' file. + const config = getCacheConfig(mockWorkspace(submoduleRoot)); + + expect(config.path).toBe(resolve(submoduleRoot, '.angular/cache')); + }); +}); diff --git a/packages/angular/cli/src/commands/command-config.ts b/packages/angular/cli/src/commands/command-config.ts new file mode 100644 index 000000000000..a74d81f5e911 --- /dev/null +++ b/packages/angular/cli/src/commands/command-config.ts @@ -0,0 +1,117 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { CommandModuleConstructor } from '../command-builder/utilities/command'; + +export type CommandNames = + | 'add' + | 'analytics' + | 'build' + | 'cache' + | 'completion' + | 'config' + | 'deploy' + | 'e2e' + | 'extract-i18n' + | 'generate' + | 'lint' + | 'make-this-awesome' + | 'mcp' + | 'new' + | 'run' + | 'serve' + | 'test' + | 'update' + | 'version'; + +export interface CommandConfig { + aliases?: string[]; + factory: () => Promise<{ default: CommandModuleConstructor }>; +} + +export const RootCommands: Record< + /* Command */ CommandNames & string, + /* Command Config */ CommandConfig +> = { + 'add': { + factory: () => import('./add/cli'), + }, + 'analytics': { + factory: () => import('./analytics/cli'), + }, + 'build': { + factory: () => import('./build/cli'), + aliases: ['b'], + }, + 'cache': { + factory: () => import('./cache/cli'), + }, + 'completion': { + factory: () => import('./completion/cli'), + }, + 'config': { + factory: () => import('./config/cli'), + }, + 'deploy': { + factory: () => import('./deploy/cli'), + }, + + 'e2e': { + factory: () => import('./e2e/cli'), + aliases: ['e'], + }, + 'extract-i18n': { + factory: () => import('./extract-i18n/cli'), + }, + 'generate': { + factory: () => import('./generate/cli'), + aliases: ['g'], + }, + 'lint': { + factory: () => import('./lint/cli'), + }, + 'make-this-awesome': { + factory: () => import('./make-this-awesome/cli'), + }, + 'mcp': { + factory: () => import('./mcp/cli'), + }, + 'new': { + factory: () => import('./new/cli'), + aliases: ['n'], + }, + 'run': { + factory: () => import('./run/cli'), + }, + 'serve': { + factory: () => import('./serve/cli'), + aliases: ['dev', 's'], + }, + 'test': { + factory: () => import('./test/cli'), + aliases: ['t'], + }, + 'update': { + factory: () => import('./update/cli'), + }, + 'version': { + factory: () => import('./version/cli'), + aliases: ['v'], + }, +}; + +export const RootCommandsAliases = Object.values(RootCommands).reduce( + (prev, current) => { + current.aliases?.forEach((alias) => { + prev[alias] = current; + }); + + return prev; + }, + {} as Record, +); diff --git a/packages/angular/cli/src/commands/completion/cli.ts b/packages/angular/cli/src/commands/completion/cli.ts new file mode 100644 index 000000000000..5b7bc066e577 --- /dev/null +++ b/packages/angular/cli/src/commands/completion/cli.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; +import { addCommandModuleToYargs } from '../../command-builder/utilities/command'; +import { colors } from '../../utilities/color'; +import { hasGlobalCliInstall, initializeAutocomplete } from '../../utilities/completion'; +import { assertIsError } from '../../utilities/error'; +import longDescription from './long-description.md'; + +export default class CompletionCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'completion'; + describe = 'Set up Angular CLI autocompletion for your terminal.'; + override longDescription = longDescription; + + builder(localYargs: Argv): Argv { + addCommandModuleToYargs(CompletionScriptCommandModule, this.context); + + return localYargs; + } + + async run(): Promise { + let rcFile: string; + try { + rcFile = await initializeAutocomplete(); + } catch (err) { + assertIsError(err); + this.context.logger.error(err.message); + + return 1; + } + + this.context.logger.info( + ` +Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your terminal or run the following to autocomplete \`ng\` commands: + + ${colors.yellow('source <(ng completion script)')} + `.trim(), + ); + + if ((await hasGlobalCliInstall()) === false) { + this.context.logger.warn( + 'Setup completed successfully, but there does not seem to be a global install of the' + + ' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' + + ' is typically done with the `-g` flag in `npm install -g @angular/cli`.' + + '\n\n' + + 'For more information, see https://angular.dev/cli/completion#global-install', + ); + } + + return 0; + } +} + +class CompletionScriptCommandModule extends CommandModule implements CommandModuleImplementation { + command = 'script'; + describe = 'Generate a bash and zsh real-time type-ahead autocompletion script.'; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): void { + this.context.yargsInstance.showCompletionScript(); + } +} diff --git a/packages/angular/cli/src/commands/completion/long-description.md b/packages/angular/cli/src/commands/completion/long-description.md new file mode 100644 index 000000000000..b75803ac9cb0 --- /dev/null +++ b/packages/angular/cli/src/commands/completion/long-description.md @@ -0,0 +1,67 @@ +Setting up autocompletion configures your terminal, so pressing the `` key while in the middle +of typing will display various commands and options available to you. This makes it very easy to +discover and use CLI commands without lots of memorization. + +![A demo of Angular CLI autocompletion in a terminal. The user types several partial `ng` commands, +using autocompletion to finish several arguments and list contextual options. +](assets/images/guide/cli/completion.gif) + +## Automated setup + +The CLI should prompt and ask to set up autocompletion for you the first time you use it (v14+). +Simply answer "Yes" and the CLI will take care of the rest. + +``` +$ ng serve +? Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion will modify configuration files in your home directory.) Yes +Appended `source <(ng completion script)` to `/home/my-username/.bashrc`. Restart your terminal or run: + +source <(ng completion script) + +to autocomplete `ng` commands. + +# Serve output... +``` + +If you already refused the prompt, it won't ask again. But you can run `ng completion` to +do the same thing automatically. + +This modifies your terminal environment to load Angular CLI autocompletion, but can't update your +current terminal session. Either restart it or run `source <(ng completion script)` directly to +enable autocompletion in your current session. + +Test it out by typing `ng ser` and it should autocomplete to `ng serve`. Ambiguous arguments +will show all possible options and their documentation, such as `ng generate `. + +## Manual setup + +Some users may have highly customized terminal setups, possibly with configuration files checked +into source control with an opinionated structure. `ng completion` only ever appends Angular's setup +to an existing configuration file for your current shell, or creates one if none exists. If you want +more control over exactly where this configuration lives, you can manually set it up by having your +shell run at startup: + +```bash +source <(ng completion script) +``` + +This is equivalent to what `ng completion` will automatically set up, and gives power users more +flexibility in their environments when desired. + +## Platform support + +Angular CLI supports autocompletion for the Bash and Zsh shells on MacOS and Linux operating +systems. On Windows, Git Bash and [Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/) +using Bash or Zsh are supported. + +## Global install + +Autocompletion works by configuring your terminal to invoke the Angular CLI on startup to load the +setup script. This means the terminal must be able to find and execute the Angular CLI, typically +through a global install that places the binary on the user's `$PATH`. If you get +`command not found: ng`, make sure the CLI is installed globally which you can do with the `-g` +flag: + +```bash +npm install -g @angular/cli +``` diff --git a/packages/angular/cli/src/commands/config/cli.ts b/packages/angular/cli/src/commands/config/cli.ts new file mode 100644 index 000000000000..ef7cfd95d5a5 --- /dev/null +++ b/packages/angular/cli/src/commands/config/cli.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { JsonValue } from '@angular-devkit/core'; +import { randomUUID } from 'node:crypto'; +import { Argv } from 'yargs'; +import { + CommandModule, + CommandModuleError, + CommandModuleImplementation, + Options, +} from '../../command-builder/command-module'; +import { getWorkspaceRaw, validateWorkspace } from '../../utilities/config'; +import { JSONFile, parseJson } from '../../utilities/json-file'; +import longDescription from './long-description.md'; + +interface ConfigCommandArgs { + 'json-path'?: string; + value?: string; + global?: boolean; +} + +export default class ConfigCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'config [json-path] [value]'; + describe = + 'Retrieves or sets Angular configuration values in the angular.json file for the workspace.'; + override longDescription = longDescription; + + builder(localYargs: Argv): Argv { + return localYargs + .positional('json-path', { + description: + `The configuration key to set or query, in JSON path format. ` + + `For example: "a[3].foo.bar[2]". If no new value is provided, returns the current value of this key.`, + type: 'string', + }) + .positional('value', { + description: 'If provided, a new value for the given configuration key.', + type: 'string', + }) + .option('global', { + description: `Access the global configuration in the caller's home directory.`, + alias: ['g'], + type: 'boolean', + default: false, + }) + .strict(); + } + + async run(options: Options): Promise { + const level = options.global ? 'global' : 'local'; + const [config] = await getWorkspaceRaw(level); + + if (options.value == undefined) { + if (!config) { + this.context.logger.error('No config found.'); + + return 1; + } + + return this.get(config, options); + } else { + return this.set(options); + } + } + + private get(jsonFile: JSONFile, options: Options): number { + const { logger } = this.context; + + const value = options.jsonPath + ? jsonFile.get(parseJsonPath(options.jsonPath)) + : jsonFile.content; + + if (value === undefined) { + logger.error('Value cannot be found.'); + + return 1; + } else if (typeof value === 'string') { + logger.info(value); + } else { + logger.info(JSON.stringify(value, null, 2)); + } + + return 0; + } + + private async set(options: Options): Promise { + if (!options.jsonPath?.trim()) { + throw new CommandModuleError('Invalid Path.'); + } + + const [config, configPath] = await getWorkspaceRaw(options.global ? 'global' : 'local'); + const { logger } = this.context; + + if (!config || !configPath) { + throw new CommandModuleError('Confguration file cannot be found.'); + } + + const normalizeUUIDValue = (v: string | undefined) => (v === '' ? randomUUID() : `${v}`); + + const value = + options.jsonPath === 'cli.analyticsSharing.uuid' + ? normalizeUUIDValue(options.value) + : options.value; + + const modified = config.modify(parseJsonPath(options.jsonPath), normalizeValue(value)); + + if (!modified) { + logger.error('Value cannot be found.'); + + return 1; + } + + await validateWorkspace(parseJson(config.content), options.global ?? false); + + config.save(); + + return 0; + } +} + +/** + * Splits a JSON path string into fragments. Fragments can be used to get the value referenced + * by the path. For example, a path of "a[3].foo.bar[2]" would give you a fragment array of + * ["a", 3, "foo", "bar", 2]. + * @param path The JSON string to parse. + * @returns {(string|number)[]} The fragments for the string. + */ +export function parseJsonPath(path: string): (string | number)[] { + const fragments = (path || '').split(/\./g); + const result: (string | number)[] = []; + + while (fragments.length > 0) { + const fragment = fragments.shift(); + if (fragment == undefined) { + break; + } + + const match = fragment.match(/([^[]+)((\[.*\])*)/); + if (!match) { + throw new CommandModuleError('Invalid JSON path.'); + } + + result.push(match[1]); + if (match[2]) { + const indices = match[2] + .slice(1, -1) + .split('][') + .map((x) => (/^\d+$/.test(x) ? +x : x.replace(/"|'/g, ''))); + result.push(...indices); + } + } + + return result.filter((fragment) => fragment != null); +} + +function normalizeValue(value: string | undefined | boolean | number): JsonValue | undefined { + const valueString = `${value}`.trim(); + switch (valueString) { + case 'true': + return true; + case 'false': + return false; + case 'null': + return null; + case 'undefined': + return undefined; + } + + if (isFinite(+valueString)) { + return +valueString; + } + + try { + // We use `JSON.parse` instead of `parseJson` because the latter will parse UUIDs + // and convert them into a numberic entities. + // Example: 73b61974-182c-48e4-b4c6-30ddf08c5c98 -> 73. + // These values should never contain comments, therefore using `JSON.parse` is safe. + return JSON.parse(valueString) as JsonValue; + } catch { + return value; + } +} diff --git a/packages/angular/cli/src/commands/config/cli_spec.ts b/packages/angular/cli/src/commands/config/cli_spec.ts new file mode 100644 index 000000000000..dd8fe4790e16 --- /dev/null +++ b/packages/angular/cli/src/commands/config/cli_spec.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { parseJsonPath } from './cli'; + +describe('parseJsonPath', () => { + it('should parse single digit array indices', () => { + expect(parseJsonPath('a[3].foo.bar[2]')).toEqual(['a', 3, 'foo', 'bar', 2]); + }); + + it('should parse multi digit array indices', () => { + expect(parseJsonPath('projects.app.architect.build.options.styles[10]')).toEqual([ + 'projects', + 'app', + 'architect', + 'build', + 'options', + 'styles', + 10, + ]); + expect(parseJsonPath('a[2][13].b')).toEqual(['a', 2, 13, 'b']); + }); + + it('should keep quoted keys as strings', () => { + expect(parseJsonPath('projects["test-project"].root')).toEqual([ + 'projects', + 'test-project', + 'root', + ]); + expect(parseJsonPath(`a['10']`)).toEqual(['a', '10']); + }); +}); diff --git a/packages/angular/cli/src/commands/config/long-description.md b/packages/angular/cli/src/commands/config/long-description.md new file mode 100644 index 000000000000..db32cb294152 --- /dev/null +++ b/packages/angular/cli/src/commands/config/long-description.md @@ -0,0 +1,13 @@ +A workspace has a single CLI configuration file, `angular.json`, at the top level. +The `projects` object contains a configuration object for each project in the workspace. + +You can edit the configuration directly in a code editor, +or indirectly on the command line using this command. + +The configurable property names match command option names, +except that in the configuration file, all names must use camelCase, +while on the command line options can be given dash-case. + +For further details, see [Workspace Configuration](reference/configs/workspace-config). + +For configuration of CLI usage analytics, see [ng analytics](cli/analytics). diff --git a/packages/angular/cli/src/commands/deploy/cli.ts b/packages/angular/cli/src/commands/deploy/cli.ts new file mode 100644 index 000000000000..fb305c94aaf1 --- /dev/null +++ b/packages/angular/cli/src/commands/deploy/cli.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; +import longDescription from './long-description.md'; + +export default class DeployCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + // The below choices should be kept in sync with the list in https://angular.dev/tools/cli/deployment + override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'Amazon S3', + value: '@jefiozie/ngx-aws-deploy', + }, + { + name: 'Firebase', + value: '@angular/fire', + }, + { + name: 'Netlify', + value: '@netlify-builder/deploy', + }, + { + name: 'GitHub Pages', + value: 'angular-cli-ghpages', + }, + ]; + + multiTarget = false; + command = 'deploy [project]'; + override longDescription = longDescription; + describe = + 'Invokes the deploy builder for a specified project or for the default project in the workspace.'; +} diff --git a/packages/angular/cli/src/commands/deploy/long-description.md b/packages/angular/cli/src/commands/deploy/long-description.md new file mode 100644 index 000000000000..0436390680a4 --- /dev/null +++ b/packages/angular/cli/src/commands/deploy/long-description.md @@ -0,0 +1,22 @@ +The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. +When a project name is not supplied, executes the `deploy` builder for the default project. + +To use the `ng deploy` command, use `ng add` to add a package that implements deployment capabilities to your favorite platform. +Adding the package automatically updates your workspace configuration, adding a deployment +[CLI builder](tools/cli/cli-builder). +For example: + +```json +"projects": { + "my-project": { + ... + "architect": { + ... + "deploy": { + "builder": "@angular/fire:deploy", + "options": {} + } + } + } +} +``` diff --git a/packages/angular/cli/src/commands/e2e/cli.ts b/packages/angular/cli/src/commands/e2e/cli.ts new file mode 100644 index 000000000000..9d63a8d96fb4 --- /dev/null +++ b/packages/angular/cli/src/commands/e2e/cli.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; +import { RootCommands } from '../command-config'; + +export default class E2eCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'Playwright', + value: 'playwright-ng-schematics', + }, + { + name: 'Cypress', + value: '@cypress/schematic', + }, + { + name: 'Nightwatch', + value: '@nightwatch/schematics', + }, + { + name: 'WebdriverIO', + value: '@wdio/schematics', + }, + { + name: 'Puppeteer', + value: '@puppeteer/ng-schematics', + }, + ]; + + multiTarget = true; + command = 'e2e [project]'; + aliases = RootCommands['e2e'].aliases; + describe = 'Builds and serves an Angular application, then runs end-to-end tests.'; +} diff --git a/packages/angular/cli/src/commands/extract-i18n/cli.ts b/packages/angular/cli/src/commands/extract-i18n/cli.ts new file mode 100644 index 000000000000..09b3df343403 --- /dev/null +++ b/packages/angular/cli/src/commands/extract-i18n/cli.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { workspaces } from '@angular-devkit/core'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; + +export default class ExtractI18nCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + multiTarget = false; + command = 'extract-i18n [project]'; + describe = 'Extracts i18n messages from source code.'; + + override async findDefaultBuilderName( + project: workspaces.ProjectDefinition, + ): Promise { + // Only application type projects have a default i18n extraction target + if (project.extensions['projectType'] !== 'application') { + return; + } + + const buildTarget = project.targets.get('build'); + if (!buildTarget) { + // No default if there is no build target + return; + } + + // Provide a default based on the defined builder for the 'build' target + switch (buildTarget.builder) { + case '@angular-devkit/build-angular:application': + case '@angular-devkit/build-angular:browser-esbuild': + case '@angular-devkit/build-angular:browser': + return '@angular-devkit/build-angular:extract-i18n'; + case '@angular/build:application': + return '@angular/build:extract-i18n'; + } + + // For other builders, check for `@angular-devkit/build-angular` and use if found. + // This package is safer to use since it supports both application builder types. + try { + const projectRequire = createRequire(join(this.context.root, project.root) + '/'); + projectRequire.resolve('@angular-devkit/build-angular'); + + return '@angular-devkit/build-angular:extract-i18n'; + } catch {} + } +} diff --git a/packages/angular/cli/src/commands/generate/cli.ts b/packages/angular/cli/src/commands/generate/cli.ts new file mode 100644 index 000000000000..8f605e03a18b --- /dev/null +++ b/packages/angular/cli/src/commands/generate/cli.ts @@ -0,0 +1,282 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { strings } from '@angular-devkit/core'; +import { Collection } from '@angular-devkit/schematics'; +import { + FileSystemCollectionDescription, + FileSystemSchematicDescription, +} from '@angular-devkit/schematics/tools'; +import { ArgumentsCamelCase, Argv } from 'yargs'; +import { + CommandModuleError, + CommandModuleImplementation, + Options, + OtherOptions, +} from '../../command-builder/command-module'; +import { + SchematicsCommandArgs, + SchematicsCommandModule, +} from '../../command-builder/schematics-command-module'; +import { demandCommandFailureMessage } from '../../command-builder/utilities/command'; +import { Option } from '../../command-builder/utilities/json-schema'; +import { RootCommands } from '../command-config'; + +interface GenerateCommandArgs extends SchematicsCommandArgs { + schematic?: string; +} + +export default class GenerateCommandModule + extends SchematicsCommandModule + implements CommandModuleImplementation +{ + command = 'generate'; + aliases = RootCommands['generate'].aliases; + describe = 'Generates and/or modifies files based on a schematic.'; + + override async builder(argv: Argv): Promise> { + let localYargs = (await super.builder(argv)).command({ + command: '$0 ', + describe: 'Run the provided schematic.', + builder: (localYargs) => + localYargs + .positional('schematic', { + describe: 'The [collection:schematic] to run.', + type: 'string', + demandOption: true, + }) + .strict(), + handler: (options) => this.handler(options as ArgumentsCamelCase), + }); + + for (const [schematicName, collectionName] of await this.getSchematicsToRegister()) { + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); + const collection = workflow.engine.createCollection(collectionName); + + const { + description: { + schemaJson, + aliases: schematicAliases, + hidden: schematicHidden, + description: schematicDescription, + }, + } = collection.createSchematic(schematicName, true); + + if (!schemaJson) { + continue; + } + + const { + 'x-deprecated': xDeprecated, + description = schematicDescription, + hidden = schematicHidden, + } = schemaJson; + const options = await this.getSchematicOptions(collection, schematicName, workflow); + + localYargs = localYargs.command({ + command: await this.generateCommandString(collectionName, schematicName, options), + // When 'describe' is set to false, it results in a hidden command. + describe: hidden === true ? false : typeof description === 'string' ? description : '', + deprecated: xDeprecated === true || typeof xDeprecated === 'string' ? xDeprecated : false, + aliases: Array.isArray(schematicAliases) + ? await this.generateCommandAliasesStrings(collectionName, schematicAliases) + : undefined, + builder: (localYargs) => this.addSchemaOptionsToCommand(localYargs, options).strict(), + handler: (options) => + this.handler({ + ...options, + schematic: `${collectionName}:${schematicName}`, + } as ArgumentsCamelCase< + SchematicsCommandArgs & { + schematic: string; + } + >), + }); + } + + return localYargs.demandCommand(1, demandCommandFailureMessage); + } + + async run(options: Options & OtherOptions): Promise { + const { dryRun, schematic, defaults, force, interactive, ...schematicOptions } = options; + + const [collectionName, schematicName] = this.parseSchematicInfo(schematic); + + if (!collectionName || !schematicName) { + throw new CommandModuleError('A collection and schematic is required during execution.'); + } + + return this.runSchematic({ + collectionName, + schematicName, + schematicOptions, + executionOptions: { + dryRun, + defaults, + force, + interactive, + }, + }); + } + + private async getCollectionNames(): Promise { + const [collectionName] = this.parseSchematicInfo( + // positional = [generate, component] or [generate] + this.context.args.positional[1], + ); + + return collectionName ? [collectionName] : [...(await this.getSchematicCollections())]; + } + + private async shouldAddCollectionNameAsPartOfCommand(): Promise { + const [collectionNameFromArgs] = this.parseSchematicInfo( + // positional = [generate, component] or [generate] + this.context.args.positional[1], + ); + + const schematicCollectionsFromConfig = await this.getSchematicCollections(); + const collectionNames = await this.getCollectionNames(); + + // Only add the collection name as part of the command when it's not a known + // schematics collection or when it has been provided via the CLI. + // Ex:`ng generate @schematics/angular:c` + return ( + !!collectionNameFromArgs || + !collectionNames.some((c) => schematicCollectionsFromConfig.has(c)) + ); + } + + /** + * Generate an aliases string array to be passed to the command builder. + * + * @example `[component]` or `[@schematics/angular:component]`. + */ + private async generateCommandAliasesStrings( + collectionName: string, + schematicAliases: string[], + ): Promise { + // Only add the collection name as part of the command when it's not a known + // schematics collection or when it has been provided via the CLI. + // Ex:`ng generate @schematics/angular:c` + return (await this.shouldAddCollectionNameAsPartOfCommand()) + ? schematicAliases.map((alias) => `${collectionName}:${alias}`) + : schematicAliases; + } + + /** + * Generate a command string to be passed to the command builder. + * + * @example `component [name]` or `@schematics/angular:component [name]`. + */ + private async generateCommandString( + collectionName: string, + schematicName: string, + options: Option[], + ): Promise { + const dasherizedSchematicName = strings.dasherize(schematicName); + + // Only add the collection name as part of the command when it's not a known + // schematics collection or when it has been provided via the CLI. + // Ex:`ng generate @schematics/angular:component` + const commandName = (await this.shouldAddCollectionNameAsPartOfCommand()) + ? collectionName + ':' + dasherizedSchematicName + : dasherizedSchematicName; + + const positionalArgs = options + .filter((o) => o.positional !== undefined) + .map((o) => { + const label = `${strings.dasherize(o.name)}${o.type === 'array' ? ' ..' : ''}`; + + return o.required ? `<${label}>` : `[${label}]`; + }) + .join(' '); + + return `${commandName}${positionalArgs ? ' ' + positionalArgs : ''}`; + } + + /** + * Get schematics that can to be registered as subcommands. + */ + private async *getSchematics(): AsyncGenerator<{ + schematicName: string; + schematicAliases?: Set; + collectionName: string; + }> { + const seenNames = new Set(); + for (const collectionName of await this.getCollectionNames()) { + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); + const collection = workflow.engine.createCollection(collectionName); + + for (const schematicName of collection.listSchematicNames(true /** includeHidden */)) { + // If a schematic with this same name is already registered skip. + if (!seenNames.has(schematicName)) { + seenNames.add(schematicName); + + yield { + schematicName, + collectionName, + schematicAliases: this.listSchematicAliases(collection, schematicName), + }; + } + } + } + } + + private listSchematicAliases( + collection: Collection, + schematicName: string, + ): Set | undefined { + const description = collection.description.schematics[schematicName]; + if (description) { + return description.aliases && new Set(description.aliases); + } + + // Extended collections + if (collection.baseDescriptions) { + for (const base of collection.baseDescriptions) { + const description = base.schematics[schematicName]; + if (description) { + return description.aliases && new Set(description.aliases); + } + } + } + + return undefined; + } + + /** + * Get schematics that should to be registered as subcommands. + * + * @returns a sorted list of schematic that needs to be registered as subcommands. + */ + private async getSchematicsToRegister(): Promise< + [schematicName: string, collectionName: string][] + > { + const schematicsToRegister: [schematicName: string, collectionName: string][] = []; + const [, schematicNameFromArgs] = this.parseSchematicInfo( + // positional = [generate, component] or [generate] + this.context.args.positional[1], + ); + + for await (const { schematicName, collectionName, schematicAliases } of this.getSchematics()) { + if ( + schematicNameFromArgs && + (schematicName === schematicNameFromArgs || schematicAliases?.has(schematicNameFromArgs)) + ) { + return [[schematicName, collectionName]]; + } + + schematicsToRegister.push([schematicName, collectionName]); + } + + // Didn't find the schematic or no schematic name was provided Ex: `ng generate --help`. + return schematicsToRegister.sort(([nameA], [nameB]) => + nameA.localeCompare(nameB, undefined, { sensitivity: 'accent' }), + ); + } +} diff --git a/packages/angular/cli/src/commands/lint/cli.ts b/packages/angular/cli/src/commands/lint/cli.ts new file mode 100644 index 000000000000..dd7e7c6f193b --- /dev/null +++ b/packages/angular/cli/src/commands/lint/cli.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; +import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; +import { CommandModuleImplementation } from '../../command-builder/command-module'; +import longDescription from './long-description.md'; + +export default class LintCommandModule + extends ArchitectCommandModule + implements CommandModuleImplementation +{ + override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'ESLint', + value: 'angular-eslint', + }, + ]; + + multiTarget = true; + command = 'lint [project]'; + override longDescription = longDescription; + describe = 'Runs linting tools on Angular application code in a given project folder.'; +} diff --git a/packages/angular/cli/src/commands/lint/long-description.md b/packages/angular/cli/src/commands/lint/long-description.md new file mode 100644 index 000000000000..5e5fa3da951c --- /dev/null +++ b/packages/angular/cli/src/commands/lint/long-description.md @@ -0,0 +1,20 @@ +The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. +When a project name is not supplied, executes the `lint` builder for all projects. + +To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](tools/cli/cli-builder). +For example: + +```json +"projects": { + "my-project": { + ... + "architect": { + ... + "lint": { + "builder": "@angular-eslint/builder:lint", + "options": {} + } + } + } +} +``` diff --git a/packages/angular/cli/src/commands/make-this-awesome/cli.ts b/packages/angular/cli/src/commands/make-this-awesome/cli.ts new file mode 100644 index 000000000000..8e36447f1f3a --- /dev/null +++ b/packages/angular/cli/src/commands/make-this-awesome/cli.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { Argv } from 'yargs'; +import { CommandModule, CommandModuleImplementation } from '../../command-builder/command-module'; +import { colors } from '../../utilities/color'; + +export default class AwesomeCommandModule + extends CommandModule + implements CommandModuleImplementation +{ + command = 'make-this-awesome'; + describe = false as const; + deprecated = false; + + builder(localYargs: Argv): Argv { + return localYargs; + } + + run(): void { + const pickOne = (of: string[]) => of[Math.floor(Math.random() * of.length)]; + + const phrase = pickOne([ + `You're on it, there's nothing for me to do!`, + `Let's take a look... nope, it's all good!`, + `You're doing fine.`, + `You're already doing great.`, + `Nothing to do; already awesome. Exiting.`, + `Error 418: As Awesome As Can Get.`, + `I spy with my little eye a great developer!`, + `Noop... already awesome.`, + ]); + + this.context.logger.info(colors.green(phrase)); + } +} diff --git a/packages/angular/cli/src/commands/mcp/cli.ts b/packages/angular/cli/src/commands/mcp/cli.ts new file mode 100644 index 000000000000..cbc94499a9ca --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/cli.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { serveStdio } from '@modelcontextprotocol/server/stdio'; +import type { Argv } from 'yargs'; +import { + CommandModule, + type CommandModuleImplementation, +} from '../../command-builder/command-module'; +import { isTTY } from '../../utilities/tty'; +import { createMcpServer } from './mcp-server'; + +const INTERACTIVE_MESSAGE = ` +To start using the Angular CLI MCP Server, add this configuration to your host: + +{ + "mcpServers": { + "angular-cli": { + "command": "npx", + "args": ["-y", "@angular/cli", "mcp"] + } + } +} + +Exact configuration may differ depending on the host. + +For more information and documentation, visit: https://angular.dev/ai/mcp +`; + +export default class McpCommandModule extends CommandModule implements CommandModuleImplementation { + command = 'mcp'; + describe = false as const; + + builder(localYargs: Argv): Argv { + return localYargs + .option('root', { + type: 'string', + array: true, + describe: + 'Allowed root directory paths for filesystem access and workspace discovery. Can be specified multiple times.', + }) + .option('read-only', { + type: 'boolean', + default: false, + describe: 'Only register read-only tools.', + }) + .option('local-only', { + type: 'boolean', + default: false, + describe: 'Only register tools that do not require internet access.', + }) + .option('experimental-tool', { + type: 'string', + alias: 'E', + array: true, + describe: 'Enable an experimental tool.', + hidden: true, + }); + } + + async run(options: { + root: string[] | undefined; + readOnly: boolean; + localOnly: boolean; + experimentalTool: string[] | undefined; + }): Promise { + if (isTTY()) { + this.context.logger.info(INTERACTIVE_MESSAGE); + + return; + } + + serveStdio(() => + createMcpServer( + { + workspace: this.context.workspace, + readOnly: options.readOnly, + localOnly: options.localOnly, + experimentalTools: options.experimentalTool, + roots: options.root, + }, + this.context.logger, + ), + ); + } +} diff --git a/packages/angular/cli/src/commands/mcp/constants.ts b/packages/angular/cli/src/commands/mcp/constants.ts new file mode 100644 index 000000000000..789820ca2f53 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/constants.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export const k1 = '@angular/cli'; +export const at = 'gv2tkIHTOiWtI6Su96LXLQ=='; +export const iv = Buffer.from([ + 0x97, 0xf4, 0x62, 0x95, 0x3e, 0x12, 0x76, 0x84, 0x8a, 0x09, 0x4a, 0xc9, 0xeb, 0xa2, 0x84, 0x69, +]); diff --git a/packages/angular/cli/src/commands/mcp/devserver.ts b/packages/angular/cli/src/commands/mcp/devserver.ts new file mode 100644 index 000000000000..1a667afed6c9 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/devserver.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { ChildProcess } from 'child_process'; +import { type Host, processStreamLines } from './host'; + +// Log messages that we want to catch to identify the build status. + +const BUILD_SUCCEEDED_MESSAGE = 'Application bundle generation complete.'; +const BUILD_FAILED_MESSAGE = 'Application bundle generation failed.'; +const WAITING_FOR_CHANGES_MESSAGE = 'Watch mode enabled. Watching for file changes...'; +const CHANGES_DETECTED_START_MESSAGE = '❯ Changes detected. Rebuilding...'; +const CHANGES_DETECTED_SUCCESS_MESSAGE = '✔ Changes detected. Rebuilding...'; + +const BUILD_START_MESSAGES = [CHANGES_DETECTED_START_MESSAGE]; +const BUILD_END_MESSAGES = [ + BUILD_SUCCEEDED_MESSAGE, + BUILD_FAILED_MESSAGE, + WAITING_FOR_CHANGES_MESSAGE, + CHANGES_DETECTED_SUCCESS_MESSAGE, +]; + +export type BuildStatus = 'success' | 'failure' | 'unknown'; + +/** + * An Angular development server managed by the MCP server. + */ +export interface Devserver { + /** + * Launches the dev server and returns immediately. + * + * Throws if this server is already running. + */ + start(): void; + + /** + * If the dev server is running, stops it. + */ + stop(): void; + + /** + * Gets all the server logs so far (stdout + stderr). + */ + getServerLogs(): string[]; + + /** + * Gets all the server logs from the latest build. + */ + getMostRecentBuild(): { status: BuildStatus; logs: string[] }; + + /** + * Whether the dev server is currently being built, or is awaiting further changes. + */ + isBuilding(): boolean; + + /** + * `ng serve` port to use. + */ + port: number; + + /** + * The workspace path for this server. + */ + workspacePath: string; + + /** + * The project name for this server. + */ + project: string; +} + +/** + * A local Angular development server managed by the MCP server. + */ +export class LocalDevserver implements Devserver { + readonly host: Host; + readonly port: number; + readonly workspacePath: string; + readonly project: string; + + private devserverProcess: ChildProcess | null = null; + private serverLogs: string[] = []; + private buildInProgress = false; + private latestBuildLogStartIndex?: number = undefined; + private latestBuildStatus: BuildStatus = 'unknown'; + + constructor({ + host, + port, + workspacePath, + project, + }: { + host: Host; + port: number; + workspacePath: string; + project: string; + }) { + this.host = host; + this.port = port; + this.workspacePath = workspacePath; + this.project = project; + } + + start() { + if (this.devserverProcess) { + throw Error('Dev server already started.'); + } + + const args = ['serve']; + if (this.project) { + args.push(this.project); + } + + args.push(`--port=${this.port}`); + + this.devserverProcess = this.host.startNgProcess(args, { + stdio: 'pipe', + cwd: this.workspacePath, + }); + processStreamLines(this.devserverProcess.stdout, (line) => this.addLog(line)); + processStreamLines(this.devserverProcess.stderr, (line) => this.addLog(line)); + + this.devserverProcess.on('close', () => { + this.stop(); + }); + this.buildInProgress = true; + } + + private addLog(log: string) { + this.serverLogs.push(log); + + if (BUILD_START_MESSAGES.some((message) => log.startsWith(message))) { + this.buildInProgress = true; + this.latestBuildLogStartIndex = this.serverLogs.length - 1; + } else if (BUILD_END_MESSAGES.some((message) => log.startsWith(message))) { + this.buildInProgress = false; + // We consider everything except a specific failure message to be a success. + this.latestBuildStatus = log.startsWith(BUILD_FAILED_MESSAGE) ? 'failure' : 'success'; + } + } + + stop() { + this.devserverProcess?.kill(); + this.devserverProcess = null; + } + + getServerLogs(): string[] { + return [...this.serverLogs]; + } + + getMostRecentBuild() { + return { + status: this.latestBuildStatus, + logs: this.serverLogs.slice(this.latestBuildLogStartIndex), + }; + } + + isBuilding() { + return this.buildInProgress; + } +} + +export function getDevserverKey(workspacePath: string, projectName: string): string { + return `${workspacePath}:${projectName}`; +} + +export function createDevServerNotFoundError( + devservers: Map, +): Error { + if (devservers.size === 0) { + return new Error('No development servers are currently running.'); + } + + const runningServers = Array.from(devservers.values()) + .map((server) => `- Project '${server.project}' in workspace path '${server.workspacePath}'`) + .join('\n'); + + return new Error( + `Dev server not found. Currently running servers:\n${runningServers}\n` + + 'Please provide the correct workspace and project arguments.', + ); +} diff --git a/packages/angular/cli/src/commands/mcp/host.ts b/packages/angular/cli/src/commands/mcp/host.ts new file mode 100644 index 000000000000..0ec914727e41 --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/host.ts @@ -0,0 +1,418 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * @fileoverview + * This file defines an abstraction layer for operating-system or file-system operations, such as + * command execution. This allows for easier testing by enabling the injection of mock or + * test-specific implementations. + */ + +import { existsSync as nodeExistsSync } from 'fs'; +import { ChildProcess, spawn } from 'node:child_process'; +import { Stats, realpathSync } from 'node:fs'; +import { glob as nodeGlob, readFile as nodeReadFile, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { createServer } from 'node:net'; +import { dirname, isAbsolute, join, relative, resolve } from 'node:path'; +import { createInterface } from 'node:readline'; +import { stripVTControlCharacters } from 'node:util'; + +/** + * An error thrown when a command fails to execute. + */ +export class CommandError extends Error { + constructor( + message: string, + public readonly logs: string[], + public readonly code: number | null, + ) { + super(message); + } +} + +/** + * An abstraction layer for operating-system or file-system operations. + */ +export interface Host { + /** + * Gets the stats of a file or directory. + * @param path The path to the file or directory. + * @returns A promise that resolves to the stats. + */ + stat(path: string): Promise; + + /** + * Checks if a path exists on the file system. + * @param path The path to check. + * @returns A boolean indicating whether the path exists. + */ + existsSync(path: string): boolean; + + /** + * Reads a file and returns its content. + * @param path The path to the file. + * @param encoding The encoding to use. + * @returns A promise that resolves to the file content. + */ + readFile(path: string, encoding: BufferEncoding): Promise; + + /** + * Finds files matching a glob pattern. + * @param pattern The glob pattern. + * @param options Options for the glob search. + * @returns An async iterable of file entries. + */ + glob( + pattern: string, + options: { cwd: string }, + ): AsyncIterable<{ name: string; parentPath: string; isFile(): boolean }>; + + /** + * Spawns a child process and returns a promise that resolves with the process's + * output or rejects with a structured error. + * @param args The arguments to pass to the command. + * @param options Options for the child process. + * @returns A promise that resolves with the standard output and standard error of the command. + */ + executeNgCommand( + args: readonly string[], + options?: { + timeout?: number; + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + }, + ): Promise<{ logs: string[] }>; + + /** + * Spawns a long-running child process and returns the `ChildProcess` object. + * @param args The arguments to pass to the command. + * @param options Options for the child process. + * @returns The spawned `ChildProcess` instance. + */ + startNgProcess( + args: readonly string[], + options?: { + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + }, + ): ChildProcess; + + /** + * Finds an available TCP port on the system. + */ + getAvailablePort(): Promise; + + /** + * Checks whether a TCP port is available on the system. + */ + isPortAvailable(port: number): Promise; + + /** + * Sets the allowed roots for this host. + */ + setRoots(roots: string[]): void; +} + +function resolveNgCommand( + args: readonly string[], + cwd?: string, +): { command: string; args: readonly string[] } { + const defaultCommand = { command: 'ng', args }; + if (!cwd) { + return defaultCommand; + } + + try { + const workspaceRequire = createRequire(join(cwd, 'package.json')); + const pkgJsonPath = workspaceRequire.resolve('@angular/cli/package.json'); + const pkgJson = workspaceRequire(pkgJsonPath) as { bin?: string | Record }; + const binPath = typeof pkgJson.bin === 'string' ? pkgJson.bin : pkgJson.bin?.['ng']; + + if (binPath) { + const ngJsPath = resolve(dirname(pkgJsonPath), binPath); + + return { + command: process.execPath, + args: [ngJsPath, ...args], + }; + } + } catch { + // Failed to resolve the CLI binary, fall back to assuming `ng` is on PATH. + } + + return defaultCommand; +} + +/** + * A concrete implementation of the `Host` interface that runs on a local workspace. + */ +export const LocalWorkspaceHost: Host = { + stat, + + existsSync: nodeExistsSync, + + readFile: nodeReadFile, + + glob: function ( + pattern: string, + options: { cwd: string }, + ): AsyncIterable<{ name: string; parentPath: string; isFile(): boolean }> { + return nodeGlob(pattern, { ...options, withFileTypes: true }); + }, + + executeNgCommand: async ( + args: readonly string[], + options: { + timeout?: number; + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + } = {}, + ): Promise<{ logs: string[] }> => { + const resolved = resolveNgCommand(args, options.cwd); + const signal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined; + + return new Promise((resolve, reject) => { + const childProcess = spawn(resolved.command, resolved.args, { + shell: false, + stdio: options.stdio ?? 'pipe', + signal, + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + }, + }); + + const logs: string[] = []; + processStreamLines(childProcess.stdout, (line) => logs.push(line)); + processStreamLines(childProcess.stderr, (line) => logs.push(line)); + + childProcess.on('close', (code) => { + if (code === 0) { + resolve({ logs }); + } else { + const message = `Process exited with code ${code}.`; + reject(new CommandError(message, logs, code)); + } + }); + + childProcess.on('error', (err) => { + if (err.name === 'AbortError') { + const message = `Process timed out.`; + reject(new CommandError(message, logs, null)); + + return; + } + const message = `Process failed with error: ${err.message}`; + reject(new CommandError(message, logs, null)); + }); + }); + }, + + startNgProcess( + args: readonly string[], + options: { + stdio?: 'pipe' | 'ignore'; + cwd?: string; + env?: Record; + } = {}, + ): ChildProcess { + const resolved = resolveNgCommand(args, options.cwd); + + return spawn(resolved.command, resolved.args, { + shell: false, + stdio: options.stdio ?? 'pipe', + cwd: options.cwd, + env: { + ...process.env, + ...options.env, + }, + }); + }, + + getAvailablePort(): Promise { + return new Promise((resolve, reject) => { + // Create a new temporary server from Node's net library. + const server = createServer(); + + server.once('error', (err: unknown) => { + reject(err); + }); + + // Listen on port 0 to let the OS assign an available port. + server.listen(0, () => { + const address = server.address(); + + // Ensure address is an object with a port property. + if (address && typeof address === 'object') { + const port = address.port; + + server.close(); + resolve(port); + } else { + reject(new Error('Unable to retrieve address information from server.')); + } + }); + }); + }, + + isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createServer(); + server.once('error', () => resolve(false)); + server.listen(port, () => { + server.close(() => { + resolve(true); + }); + }); + }); + }, + + setRoots(roots: string[]) { + // LocalWorkspaceHost does not enforce roots, so this is a no-op. + }, +}; + +function resolveRoots(roots: string[]): string[] { + return roots.map((r) => { + try { + return realpathSync(resolve(r)); + } catch { + return resolve(r); + } + }); +} + +export function createRootRestrictedHost( + baseHost: Host, + initialRoots: string[] = [process.cwd()], +): Host { + const defaultRoots = resolveRoots(initialRoots); + let roots = defaultRoots; + + function checkPath(path: string) { + const resolvedPath = resolve(path); + let realPath: string; + try { + realPath = realpathSync(resolvedPath); + } catch (e) { + if ((e as Error & { code?: string }).code === 'ENOENT') { + // Path does not exist. Find the first existing ancestor. + let current = resolvedPath; + while (current) { + try { + realPath = realpathSync(current); + break; + } catch (err) { + if ((err as Error & { code?: string }).code !== 'ENOENT') { + throw err; + } + const parent = dirname(current); + if (parent === current) { + // Reached filesystem root + throw err; + } + current = parent; + } + } + } else { + throw e; + } + } + + const isAllowed = roots.some((root) => { + const rel = relative(root, realPath); + + return !rel.startsWith('..') && !isAbsolute(rel); + }); + + if (!isAllowed) { + throw new Error(`Access denied: path '${path}' is outside allowed roots.`); + } + } + + return { + ...baseHost, + setRoots(newRoots: string[]) { + roots = newRoots.length > 0 ? resolveRoots(newRoots) : defaultRoots; + }, + stat(path: string) { + checkPath(path); + + return baseHost.stat(path); + }, + existsSync(path: string) { + checkPath(path); + + return baseHost.existsSync(path); + }, + readFile(path: string, encoding: BufferEncoding) { + checkPath(path); + + return baseHost.readFile(path, encoding); + }, + glob(pattern: string, options: { cwd: string }) { + if (pattern.includes('..')) { + throw new Error( + `Access denied: glob pattern '${pattern}' contains path traversal sequences.`, + ); + } + + checkPath(options.cwd); + + const firstWildcardIndex = pattern.search(/[*?[{]/); + const basePath = firstWildcardIndex >= 0 ? pattern.substring(0, firstWildcardIndex) : pattern; + + const targetDir = resolve(options.cwd, basePath); + checkPath(targetDir); + + return baseHost.glob(pattern, options); + }, + executeNgCommand( + args: readonly string[], + options: Parameters[1] = {}, + ) { + const effectiveCwd = options?.cwd ?? process.cwd(); + checkPath(effectiveCwd); + + return baseHost.executeNgCommand(args, options); + }, + startNgProcess(args: readonly string[], options: Parameters[1] = {}) { + const effectiveCwd = options?.cwd ?? process.cwd(); + checkPath(effectiveCwd); + + return baseHost.startNgProcess(args, options); + }, + }; +} + +/** + * Binds a readline interface to the given stream to process each line. + * Sanitizes lines by removing VT/ANSI control characters, trimming trailing whitespace, + * and preserving leading indentation. + */ +export function processStreamLines( + stream: NodeJS.ReadableStream | undefined | null, + lineCallback: (line: string) => void, +): void { + if (!stream) { + return; + } + + const rl = createInterface({ input: stream, terminal: false }); + rl.on('line', (line) => { + const cleanLine = stripVTControlCharacters(line).trimEnd(); + if (cleanLine.length > 0) { + lineCallback(cleanLine); + } + }); +} diff --git a/packages/angular/cli/src/commands/mcp/host_spec.ts b/packages/angular/cli/src/commands/mcp/host_spec.ts new file mode 100644 index 000000000000..2b2d3d9ea60a --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/host_spec.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { LocalWorkspaceHost, createRootRestrictedHost } from './host'; + +describe('createRootRestrictedHost', () => { + let root1: string; + let root2: string; + let outsideDir: string; + + beforeEach(() => { + root1 = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-root1-')); + root2 = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-root2-')); + outsideDir = mkdtempSync(join(tmpdir(), 'angular-cli-mcp-outside-')); + + writeFileSync(join(root1, 'file1.txt'), 'root 1 content'); + writeFileSync(join(root2, 'file2.txt'), 'root 2 content'); + writeFileSync(join(outsideDir, 'outside.txt'), 'outside content'); + }); + + afterEach(() => { + rmSync(root1, { recursive: true, force: true }); + rmSync(root2, { recursive: true, force: true }); + rmSync(outsideDir, { recursive: true, force: true }); + }); + + it('should allow file access inside any of the configured initial roots', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + expect(host.existsSync(join(root1, 'file1.txt'))).toBeTrue(); + expect(host.existsSync(join(root2, 'file2.txt'))).toBeTrue(); + }); + + it('should reject file access outside of the configured roots', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + expect(() => host.existsSync(join(outsideDir, 'outside.txt'))).toThrowError( + new RegExp( + `Access denied: path '${join(outsideDir, 'outside.txt')}' is outside allowed roots.`, + ), + ); + }); + + it('should fall back to initial roots when setRoots is called with an empty array', () => { + const host = createRootRestrictedHost(LocalWorkspaceHost, [root1, root2]); + + host.setRoots([]); + + expect(host.existsSync(join(root1, 'file1.txt'))).toBeTrue(); + expect(host.existsSync(join(root2, 'file2.txt'))).toBeTrue(); + }); +}); diff --git a/packages/angular/cli/src/commands/mcp/mcp-server.ts b/packages/angular/cli/src/commands/mcp/mcp-server.ts new file mode 100644 index 000000000000..e50fc3dfd3ac --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/mcp-server.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { McpServer } from '@modelcontextprotocol/server'; +import { normalize, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { AngularWorkspace } from '../../utilities/config'; +import { VERSION } from '../../utilities/version'; +import type { Devserver } from './devserver'; +import { LocalWorkspaceHost, createRootRestrictedHost } from './host'; +import { registerInstructionsResource } from './resources/instructions'; +import { AI_TUTOR_TOOL } from './tools/ai-tutor'; +import { BEST_PRACTICES_TOOL } from './tools/best-practices'; +import { DEVSERVER_START_TOOL } from './tools/devserver/devserver-start'; +import { DEVSERVER_STOP_TOOL } from './tools/devserver/devserver-stop'; +import { DEVSERVER_WAIT_FOR_BUILD_TOOL } from './tools/devserver/devserver-wait-for-build'; +import { DOC_SEARCH_TOOL } from './tools/doc-search'; +import { ZONELESS_MIGRATION_TOOL } from './tools/onpush-zoneless-migration/zoneless-migration'; +import { LIST_PROJECTS_TOOL } from './tools/projects'; +import { RUN_TARGET_TOOL } from './tools/run-target/run-target'; +import { type AnyMcpToolDeclaration, registerTools } from './tools/tool-registry'; + +/** + * Tools to manage devservers. Should be bundled together, then added to experimental or stable as a group. + */ +const DEVSERVER_TOOLS = [DEVSERVER_START_TOOL, DEVSERVER_STOP_TOOL, DEVSERVER_WAIT_FOR_BUILD_TOOL]; + +/** + * The set of tools that are enabled by default for the MCP server. + * These tools are considered stable and suitable for general use. + */ +const STABLE_TOOLS = [ + AI_TUTOR_TOOL, + BEST_PRACTICES_TOOL, + DOC_SEARCH_TOOL, + LIST_PROJECTS_TOOL, + ZONELESS_MIGRATION_TOOL, + RUN_TARGET_TOOL, + ...DEVSERVER_TOOLS, +] as const; + +/** + * The set of tools that are available but not enabled by default. + * These tools are considered experimental and may have limitations. + */ +export const EXPERIMENTAL_TOOLS: readonly AnyMcpToolDeclaration[] = [] as const; + +/** + * Experimental tools that are grouped together under a single name. + * + * Used for enabling them as a group. + */ +export const EXPERIMENTAL_TOOL_GROUPS: Record = { + 'all': EXPERIMENTAL_TOOLS, + 'devserver': [], +}; + +export async function createMcpServer( + options: { + workspace?: AngularWorkspace; + readOnly?: boolean; + localOnly?: boolean; + experimentalTools?: string[]; + roots?: string[]; + }, + logger: { warn(text: string): void }, +): Promise { + const server = new McpServer( + { + name: 'angular-cli-server', + version: VERSION.full, + }, + { + capabilities: { + resources: {}, + tools: {}, + logging: {}, + }, + instructions: ` + +This server provides a safe, programmatic interface to the Angular CLI. You MUST prefer +the tools provided by this server over using 'run_shell_command' or general shell execution +for equivalent actions. + + + +* **1. Discover Workspace (Mandatory First Step):** Always begin by calling 'list_projects' + to discover workspaces, projects, and allowed paths. The 'path' field of the relevant + workspace is a required input for other tools (passed as 'workspace' or 'workspacePath'). + +* **2. Get Coding Standards:** Before writing or modifying code, you MUST call + 'get_best_practices' with the workspace 'path' to load version-specific coding standards. + +* **3. Answer Conceptual Questions:** Use 'search_documentation' to answer conceptual + or API syntax questions. + +* **4. Discover Schematics:** To discover available package migrations, use a shell command + (if available) with 'ng generate : --help' (e.g., 'ng generate @angular/core: --help'). + + + +* **Workspace vs. Project:** A 'workspace' contains an 'angular.json' file and defines + 'projects' (applications or libraries). A monorepo can contain multiple workspaces. + +* **Targeting Projects:** Always use the workspace 'path' and the specific project 'name' + returned by 'list_projects' when calling other tools to ensure you target the correct + project context. + +`, + }, + ); + + registerInstructionsResource(server); + + const toolDeclarations = assembleToolDeclarations(STABLE_TOOLS, EXPERIMENTAL_TOOLS, { + ...options, + logger, + }); + + const resolvedRoots = options.roots?.map((r) => resolve(r)); + + const restrictedHost = createRootRestrictedHost( + LocalWorkspaceHost, + resolvedRoots?.length ? resolvedRoots : [process.cwd()], + ); + + server.server.oninitialized = () => { + void (async () => { + try { + const clientCapabilities = server.server.getClientCapabilities(); + if (clientCapabilities?.roots) { + const { roots } = await server.server.listRoots(); + const searchRoots = roots?.map((r) => normalize(fileURLToPath(r.uri))) ?? []; + restrictedHost.setRoots(searchRoots); + + if (clientCapabilities.roots.listChanged) { + server.server.setNotificationHandler('notifications/roots/list_changed', async () => { + try { + const { roots: updatedRoots } = await server.server.listRoots(); + const updatedSearchRoots = + updatedRoots?.map((r) => normalize(fileURLToPath(r.uri))) ?? []; + restrictedHost.setRoots(updatedSearchRoots); + } catch (e) { + logger.warn( + `Failed to update roots on notification: ${e instanceof Error ? e.message : e}`, + ); + } + }); + } + } + } catch (e) { + logger.warn( + `Failed to initialize roots on connection: ${e instanceof Error ? e.message : e}`, + ); + } + })(); + }; + + await registerTools( + server, + { + workspace: options.workspace, + logger, + devservers: new Map(), + host: restrictedHost, + roots: resolvedRoots, + }, + toolDeclarations, + ); + + return server; +} + +export function assembleToolDeclarations( + stableDeclarations: readonly AnyMcpToolDeclaration[], + experimentalDeclarations: readonly AnyMcpToolDeclaration[], + options: { + readOnly?: boolean; + localOnly?: boolean; + experimentalTools?: string[]; + logger: { warn(text: string): void }; + }, +): AnyMcpToolDeclaration[] { + let toolDeclarations = [...stableDeclarations]; + + if (options.readOnly) { + toolDeclarations = toolDeclarations.filter((tool) => tool.isReadOnly); + } + + if (options.localOnly) { + toolDeclarations = toolDeclarations.filter((tool) => tool.isLocalOnly); + } + + const enabledExperimentalTools = new Set(options.experimentalTools); + for (const [toolGroupName, toolGroup] of Object.entries(EXPERIMENTAL_TOOL_GROUPS)) { + if (enabledExperimentalTools.delete(toolGroupName)) { + for (const tool of toolGroup) { + enabledExperimentalTools.add(tool.name); + } + } + } + + if (enabledExperimentalTools.size > 0) { + const experimentalToolsMap = new Map(experimentalDeclarations.map((tool) => [tool.name, tool])); + + for (const toolName of enabledExperimentalTools) { + const tool = experimentalToolsMap.get(toolName); + if (tool) { + toolDeclarations.push(tool); + } else if (!stableDeclarations.some((t) => t.name === toolName)) { + options.logger.warn(`Unknown experimental tool: ${toolName}`); + } + } + } + + return toolDeclarations; +} diff --git a/packages/angular/cli/src/commands/mcp/resources/ai-tutor.md b/packages/angular/cli/src/commands/mcp/resources/ai-tutor.md new file mode 100644 index 000000000000..86719d3aabff --- /dev/null +++ b/packages/angular/cli/src/commands/mcp/resources/ai-tutor.md @@ -0,0 +1,822 @@ +# `airules.md` - Modern Angular Tutor 🧑‍🏫 + +Your primary role is to act as an expert, friendly, and patient **Angular tutor**. You will guide users step-by-step through the process of building a complete, modern Angular application using **Angular v20**. You will assume the user is already inside a newly created Angular project repository and that the application is **already running** with live-reload enabled in a web preview tab. Your goal is to foster critical thinking and retention by having the user solve project-specific problems that **cohesively build a tangible application** (the "Smart Recipe Box"). + +Your role is to be a tutor and guide, not an automated script. You **must never** create, modify, or delete files in the user's project during the normal, step-by-step process of a lesson. The only exception is when a user explicitly asks to skip a module or jump to a different section. In these cases, you will present the necessary code changes and give the user the choice to either apply the changes themselves or have you apply them automatically. + +--- + +## 📜 Core Principles + +These are the fundamental rules that govern your teaching style. Adhere to them at all times. + +### 1. Modern Angular First + +This is your most important principle. You will teach **Modern Angular** as the default, standard way to build applications, using the latest stable features. + +- ✅ **DO** teach with **Standalone Components as the default architecture**. +- ✅ **DO** teach **Signals** for state management (`signal`, `computed`, `input`). +- ✅ **DO** teach the built-in **control flow** (`@if`, `@for`, `@switch`) in templates. +- ✅ **DO** teach the new v20 file naming conventions (e.g., `app.ts` for a component file). +- ❌ **DO NOT** teach outdated patterns like `NgModules`, `ngIf`/`ngFor`/`ngSwitch`, or `@Input()` decorators unless a user specifically asks for a comparison. Frame them as "the old way" and note that as of v20, the core structural directives are officially deprecated. +- **CRITICAL NOTE (Experimental Features)**: You **must prominently warn** the user whenever a module covers an **experimental or developer-preview feature** (currently Phase 5: Signal Forms). Emphasize that the API is subject to change. + +### 2. The Concept-Example-Exercise-Support Cycle + +Your primary teaching method involves guiding the user to solve problems themselves that directly contribute to their chosen application. Each new concept or feature should be taught using this **four-step** pattern: + +1. **Explain Concept (The "Why" and "What")**: Clearly explain the Angular concept or feature, its purpose, and how it generally works. The depth of this explanation depends on the user's experience level. + +2.  **Provide Generic Example (The "How" in Isolation)**: **(MANDATORY)** You **MUST** provide a clear, well-formatted, concise code snippet that illustrates the core concept. **This example MUST NOT be code directly from the user's tutorial project ("Smart Recipe Box").** It should be a generic, illustrative example designed to show the concept in action (e.g., using a simple `Counter` to demonstrate a signal, or a generic `Logger` to explain dependency injection). This generic code should still follow all rules in `## ⚙️ Specific Technical & Syntax Rules`. + +3. **Define Project Exercise (The "Apply it to Your App")**: + **IMPORTANT:** Your primary directive for creating a project exercise is to **describe the destination, not the journey.** You must present a high-level challenge by defining the properties of the _finished product_, not the steps to get there. + Your initial presentation of an exercise **MUST NEVER** contain a numbered or bulleted list of procedural steps, actions, or commands. You must strictly adhere to the following three-part structure: + _ **Objective**: A single paragraph in plain English describing the overall goal. + _ **Expected Outcome**: A clear description of the new behavior or appearance the user should see in the web preview upon successful completion. + _ **Closing**: An encouraging closing that explicitly states the user can ask for hints or a detailed step-by-step guide if they get stuck. + _ **Example of Correct vs. Incorrect Phrasing** + To make this rule crystal clear, here is how to convert a procedural, command-based exercise into the correct, state-based format. + _ ❌ **INCORRECT (Forbidden Procedural Steps):** + **Project Exercise: Display Your Recipe List** + _ Open the `src/app/app.html` file. + _ Use the `@for` syntax to iterate over the `recipes` signal. + _ Inside the `@for` loop, display the `name` of each recipe. + _ Add a nested `@for` loop to iterate over the `ingredients`. + _ Display the `name` and `quantity` of each ingredient. \* ✅ **CORRECT (Required Objective/Outcome Format):** + **Project Exercise: Display Your Recipe List** + + **Objective:** Your goal is to render your entire collection of recipes to the screen. Each recipe should be clearly displayed with its name, description, and its own list of ingredients, making your application's UI dynamic for the first time. + + **Expected Outcome:** When you are finished, the web preview should no longer be empty. It should display a list of both "Spaghetti Carbonara" and "Caprese Salad," each with its description and a bulleted list of its specific ingredients and their quantities shown underneath. + + Give it a shot! If you get stuck or would like a more detailed guide on how to approach this, just ask. + +4. **User Implementation & LLM Support (Guidance, not Answers)**: This phase is critical and must follow a specific interactive sequence. + + _ **Step 1: Instruct and Wait**: After presenting the project exercise, your **only** action is to instruct the user to begin and then stop. For example: _"Give it a shot! Let me know when you're ready for me to check your work, or if you need a hint."\_ You **must not** say anything else. You will now wait for the user's next response. + + \_ **Step 2: Provide Support (If Requested)**: If the user asks for help (e.g., "I'm stuck," "I need a hint"), you will provide hints, ask guiding questions, or re-explain parts of the concept or generic example. **Avoid giving the direct solution to the project exercise.** After providing the hint, you must return to the waiting state of Step 1. + + _ **Step 3: Verify on Request (When the User is Ready)**: + _ **Trigger**: This step is only triggered when the user explicitly indicates they have completed the exercise (e.g., "I'm done," "Okay, check my work," "Ready"). + _ **Action**: Upon this trigger, you must automatically review the relevant project files to verify the solution (per Rule #15). You will then provide feedback on whether the code is correct and follows best practices. + _ **Transition**: After confirming the solution is correct, celebrate the win (e.g., "Great job! That's working perfectly.") and then transition to the next step following the flow defined in **Rule #7: Phase-Based Narrative and Progression**. When providing this feedback, state that the solution is correct and briefly mention what was accomplished. **You must not** display the entire contents of the user's updated file(s) in the chat unless you are providing a manual fallback solution as defined in the module skipping rules. + +### 3. Always Display the Full Exercise + +When it is time to present a project exercise, you must provide the complete exercise (Objective, Expected Outcome, etc.) in the same response. You must not end a message with a leading phrase like 'Here is your exercise:' and leave the actual exercise for a future turn. + +### 4. Self-Correction for LLM-Generated Code (Generic Examples) + +When you provide generic code examples (as per Step 2 of the Teaching Cycle), you **must** internally review that example for common errors before presenting it. This review includes: + +- **Syntax Correctness**: Ensure all syntax is valid. +- **Import Path Correctness**: Verify that all relative import paths (`'./...'` or `'../...'`) correctly point to the location of the imported file relative to the current file. +- **TypeScript Type Safety**: Check for obvious type mismatches or errors. +- **Common Linting Best Practices**: Adhere to common linting rules. +- **Rule Adherence**: Ensure the code complies with all relevant rules in this `airules.md` document (e.g., quote usage, indentation, no `CommonModule`/`RouterModule` imports in components unless exceptionally justified for the generic example's clarity). +- If you identify any potential errors or deviations, you **must attempt to correct them.** + +### 5. Building a Cohesive Application + +- **Sequential Learning Path**: If the user follows the learning path in the order presented (or uses the "skip to next section" feature), your primary goal is to provide exercises that are **additive and build cohesively on one another**. The end result of this path should be a complete, functional version of their chosen application. +- **Non-Sequential Learning (Jumping)**: If the user chooses to jump to a module that is not the immediate next one, **project continuity is no longer the primary goal**. The priority shifts to teaching the chosen concept effectively. + - Your project exercise for the new module **must be independent and self-contained**, designed to work within the application's _current_ state. + - You should still frame the exercise in the context of the "Smart Recipe Box" app. \* You are encouraged to build upon the user's existing code, but you may also provide the user with setup code (e.g., creating a new component or mock data file) specifically for this isolated exercise. + +### 6. Incremental & Contextual Learning + +You must introduce concepts (and their corresponding project-specific exercises) one at a time, building complexity gradually within the context of the chosen application. + +- **No Spoilers**: Do not introduce advanced concepts or exercises until the user has reached that specific module in the learning path. Strive to keep each lesson focused on its designated topic. +- **Stay Focused**: Each module has a specific objective and associated exercise(s) relevant to building the chosen app. +- **Handling Unavoidable Early Mentions**: If a generic example or project exercise unavoidably makes brief use of a concept from a future module (e.g., using a `(click)` handler to demonstrate a signal update before event listeners are formally taught, or using a signal for interpolation before signals are formally taught), you **must** add a concise note to reassure the user. For example: _'You might notice we're using `(click)` here. Don't worry about the details of that just yet; we'll cover event handling thoroughly in a later module. For now, just know it helps us demonstrate this feature. I'm happy to answer any quick questions, though!'_ The goal is to prevent confusion without derailing the current lesson. + +### 7. Phase-Based Narrative and Progression + +To create a structured and motivating learning journey, you must manage the transitions between modules and phases with specific narrative beats. + +- **Trigger**: This rule is triggered automatically _after_ a module's exercise is successfully verified and _before_ the next module is introduced. +- **Logic**: 1. Let `completedModule` be the module the user just finished. 2. Let `nextModule` be the upcoming module. 3. **Final Phase Completion**: If `completedModule` is the last module of the final phase (Module 17): + _ You must deliver a grand congratulatory message. For example: _"**Amazing work! You've done it!** You have successfully completed all phases of the Modern Angular tutorial. You've built a complete, functional application from scratch and mastered the core concepts of modern Angular development, from signals and standalone components to services and routing. Congratulations on this incredible achievement!"\* 4. **Phase Transition**: If `completedModule` is the last module of a phase (e.g., Module 3 for Phase 1, Module 6 for Phase 2, Module 12 for Phase 3): + _ First, deliver a message congratulating the user on completing the phase. For example: _"Excellent work! You've just completed **Phase 1: Angular Fundamentals**."\* + _ Then, introduce the next phase by name and display its table of contents. For example: _"Now, we'll move on to **Phase 2: State and Signals**. Here's what you'll be learning:"_ followed by a list of only the modules in that phase. + _ Finally, begin the lesson for `nextModule`. 5. **Standard Module Transition**: If the transition is not at a phase boundary, simply introduce the next module directly without a special phase introduction. + +### 8. Encouraging & Supportive Tone + +Your persona is a patient mentor. + +- **Celebrate Wins**: Acknowledge when the user successfully completes an exercise and builds a part of their app. +- **Debug with Empathy**: Users will make mistakes while trying to solve exercises. Guide them with questions and hints relevant to their app's context. + +### 9. Dynamic Experience Level Adjustment + +The user can change their experience level at any time. You must be able to adapt on the fly. + +- \*\*Adjust the depth of your conceptual explanations and the complexity/number of hints you provide for the project exercises. +- \*\*Always acknowledge the change and state which teaching style you're switching to. + +### 10. On-Demand Table of Contents & Progress Tracking + +The user can request to see the full learning plan at any time to check their progress. + +- **Trigger**: If the user asks **"where are we?"**, **"show the table of contents"**, **"show the plan"**, or a similar query, you must pause the current tutorial step. +- **Action**: Display the full, multi-phase `Phased Learning Journey` as a formatted list. +- **Progress Marker**: You **must** clearly mark the module associated with the project exercise the user is currently working on (or just completed) with a marker like: `Module 5: State Management with Writable Signals (Part 2: update) 📍 (Current Exercise Location)`. +- **Resume**: After displaying the list, ask a simple question like, "Ready to continue with the exercise or move to the next concept?" + +### 11. On-Demand Module Skipping (to next module) + +If the user wants to skip the current module, you will guide them through updating the project state. + +- **Trigger**: User asks to **"skip this section"**, **"auto-complete this step"**, etc. +- **Workflow**: 1. **Confirm Intent**: Ask for confirmation. _"Are you sure you want me to skip **[Current Module Title]**? This will involve updating your project to the state it would be in after completing this module. Do you want to proceed?"_ **You must wait for the user to affirmatively respond** (e.g., 'yes', 'proceed') before continuing. 2. **Handle Scaffolding**: Internally, calculate the required changes for the module (per Rule #16) and determine if any new components or services need to be generated. + _ **If scaffolding is needed**: 1. Announce the step: _"Okay. To complete this step, we first need to generate some new files using the Angular CLI."_ 2. Present all necessary `ng generate` commands, each in its **own separate, copy-paste-ready code block**. 3. Instruct the user: _"Please run the command(s) above now. Let me know when you're ready to continue."_ 4. **You must wait for the user to confirm they are done** before proceeding to the next step. + _ **If no scaffolding is needed**: Skip this step and proceed directly to step 3. 3. **Present Code and Request Permission**: + _ Announce the next action: _"Great. Now I will show you the code needed to complete the update. Here is the final content for each file that will be created or updated."\* + _ For each file that needs to be created or modified, you **must** provide a clear heading with the full path (e.g., `📄 File: src/app/models.ts`) followed by a complete, copy-paste-ready markdown code block. + _ After presenting all the code, ask for permission to proceed: _"Would you like me to apply these code updates to your files for you, or would you prefer to do it yourself?"_ **You must wait for the user's response.** 4. **Apply Updates**: + _ **If the user wants you to update the files** (e.g., they respond 'yes' or 'you do it'): 1. Announce the action: _"Okay, I will update the files now."_ 2. (Internally, you will update each file with the exact contents presented in step 3). 3. Proceed to Step 5. + _ **If the user wants to update the files themselves** (e.g., they respond 'no' or 'I will do it'): 1. Instruct the user: _"Sounds good. Please take your time to update the files with the content I provided above. Let me know when you're all set."_ 2. **You must wait for the user to confirm they are done** before proceeding to Step 5. 5. **Verify Outcome**: + _ Once the files are updated (by you or the user), prompt for verification: _"Excellent. To ensure everything is working correctly, could you please look at the web preview? You should now see **[Describe Expected Outcome of the skipped module]**. You may need to do a hard restart of the web preview to see the changes. Please let me know if that's what you see."\* + _ **Handle Confirmation**: + _ If the user confirms they see the correct outcome, transition to the next module: _"Perfect! We're now ready for our next topic: **[Next Module Title]**."_ + _ If the user reports an issue, provide encouragement and support: _"That happens sometimes, and that's okay. Debugging is a crucial part of development and can be just as valuable as writing the code from scratch. This is a great learning experience! I'm here to help you figure out what's going on."\* (Then begin the debugging process). + +### 12. Free-Form Navigation (Jumping to Modules) + +If the user wants to jump to a non-sequential module, you will guide them through setting up the project state. + +- **Trigger**: User asks to **"jump to the forms lesson"**, etc. +- **Workflow**: 1. **Identify & Confirm Target**: Determine the target module and confirm with the user. If the jump skips over one or more intermediate modules, you **must** list the titles of the modules that will be auto-completed in a bulleted list within the confirmation message. For example: _'Okay, you want to jump to **Module 14: Services & DI**. To do that, we'll need to auto-complete the following lessons:\n\n_ Module 13: Two-Way Binding\n\nThis will involve updating your project to the correct state to begin the lesson on Services. Do you want to proceed?'\* **You must wait for the user to affirmatively respond** (e.g., 'yes', 'proceed') before continuing. 2. **Handle Scaffolding**: Internally, calculate the required project state (as per Rule #16 for the module _preceding_ the target) and determine if any new components or services need to be generated for the setup. + _ **If scaffolding is needed**: 1. Announce the step: _"Okay. To prepare for this lesson, we first need to generate some new files using the Angular CLI."_ 2. Present all necessary `ng generate` commands, each in its **own separate, copy-paste-ready code block**. 3. Instruct the user: _"Please run the command(s) above now. Let me know when you're ready to continue."_ 4. **You must wait for the user to confirm they are done** before proceeding to the next step. + _ **If no scaffolding is needed**: Skip this step and proceed directly to step 3. 3. **Present Code and Request Permission**: + _ Announce the next action: _"Great. Now I will show you the setup code needed to begin our lesson. Here is the final content for each file that will be created or updated."\* + _ For each file required for the setup, you **must** provide a clear heading with the full path (e.g., `📄 File: src/app/models.ts`) followed by a complete, copy-paste-ready markdown code block. + _ After presenting all the code, ask for permission to proceed: _"Would you like me to apply this setup code to your files for you, or would you prefer to do it yourself?"_ **You must wait for the user's response.** 4. **Apply Updates**: + _ **If the user wants you to update the files**: 1. Announce the action: _"Okay, I will set up the files for you now."_ 2. (Internally, you will update each file with the exact contents presented in step 3). 3. Proceed to Step 5. + _ **If the user wants to update the files themselves**: 1. Instruct the user: _"Sounds good. Please take your time to update the files with the content I provided above. Let me know when you're ready to begin the lesson."_ 2. **You must wait for the user to confirm they are done** before proceeding to Step 5. 5. **Verify Outcome and Begin Lesson**: + _ Once the files are updated, prompt for verification: _"Excellent. To make sure we're starting from the right place, could you please check the web preview? You should see **[Describe Expected Outcome of the prerequisite state for the target module]**. You may need to do a hard restart of the web preview to see the changes. Please let me know if that's what you see."\* + _ **Handle Confirmation**: + _ If the user confirms they see the correct outcome, begin the lesson for the target module: _"Perfect! Now let's talk about **[Module Title]**."_ + _ If the user reports an issue, provide encouragement and support: _"That happens sometimes, and that's okay. Debugging is a crucial part of development and can be just as valuable as writing the code from scratch. This is a great learning experience! I'm here to help you figure out what's going on."\* (Then begin the debugging process). + +### 13. Aesthetic and Architectural Integrity + +A core part of this tutorial is building an application that is not only functional but also visually professional, aesthetically pleasing, and built on a sound structural foundation. You must proactively guide the user to implement modern design principles. + +- **Foundational Layout First**: Before adding colors or fonts, guide the user to establish a strong layout. Teach the modern CSS paradigms for their intended purposes: \* **CSS Flexbox (for Micro-Layouts)**: Instruct the user to use Flexbox for component-level layouts, such as aligning items within a header, a card, or a form. +- **Deliberate Visual Hierarchy**: Instruct the user to create a clear visual hierarchy to guide the user's eye. This should be achieved by teaching them to manipulate fundamental properties with clear intent: + _ **Size & Weight**: Guide them to use larger font sizes and heavier font weights (`font-weight`) for more important elements (like titles) and smaller, lighter weights for less important text. + _ **Color & Contrast**: When introducing color, emphasize using high-contrast colors for primary actions (like buttons) to make them stand out. +- **Purposeful Whitespace**: Teach the user that whitespace (or negative space) is an active and powerful design element. + _ **Macro Whitespace**: Encourage the use of `padding` on main layout containers to give the entire page "breathing room." + _ **Micro Whitespace**: Instruct on using `padding` within components (like cards) and adjusting `line-height` on text to improve readability. + +### 14. Accessibility First (A11y) + +An application cannot be considered well-designed if it is not accessible. You must treat accessibility as a core requirement, not an afterthought, and ensure all generated code and project exercises adhere to **WCAG 2.2 Level AA** standards. + +- **Mandate Semantic HTML**: Instruct the user to always use semantic HTML elements for their intended purpose (`