mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
Compare commits
228 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 938d7a65f6 | |||
| 8ffa85fb5d | |||
| 7d579b9db0 | |||
| a6f3508c24 | |||
| 3342961a38 | |||
| 5a0a5f939b | |||
| d811f10276 | |||
| 7c6bc328a2 | |||
| 4783b74171 | |||
| 6892388685 | |||
| 461dacf2eb | |||
| 0ea0705588 | |||
| d7e6866d62 | |||
| fe491824c2 | |||
| 5296841b7b | |||
| 87f3d07f1b | |||
| db0f653e1a | |||
| 262f1738e2 | |||
| cc722ea1f5 | |||
| 21299d95f5 | |||
| e891cacf12 | |||
| d0f95c1948 | |||
| f7216174b7 | |||
| 07969d1dc7 | |||
| 03f9c9305b | |||
| 5043064997 | |||
| de0d3d704c | |||
| d0865db749 | |||
| 2a471204a8 | |||
| 7cfc36567f | |||
| bfc6e482ac | |||
| 916916d835 | |||
| c4267decab | |||
| de85276016 | |||
| c7f429841c | |||
| e0946b5189 | |||
| 47e51f61d4 | |||
| 859fbc4c7f | |||
| 528609e34c | |||
| 5eec8233b1 | |||
| 46afa8a91f | |||
| 8c8e7210f4 | |||
| 42ef97c824 | |||
| 324b77d63d | |||
| e3543ee08e | |||
| fe36e3d0b9 | |||
| e1d5220d4d | |||
| 4176f59670 | |||
| c815969f37 | |||
| 7df464812d | |||
| 5a5ecf78bd | |||
| 20c85f1ffb | |||
| d2b0677688 | |||
| 89f9a0d73d | |||
| ed1900eca5 | |||
| 3da03cacae | |||
| 73cab7b16e | |||
| dea8dc0378 | |||
| 9258f3b0ee | |||
| 4c7339af1f | |||
| 78663e6a7d | |||
| 9f451e7288 | |||
| 3bef91b204 | |||
| f96e337063 | |||
| ae2e9b7bce | |||
| 189965863d | |||
| 9f914a88b0 | |||
| cfdb2ef8d8 | |||
| fcfe20d360 | |||
| 6952c3a117 | |||
| 7463818110 | |||
| e41245b344 | |||
| d0a9f029d9 | |||
| e170c10567 | |||
| 92a03c4b63 | |||
| cb741a201e | |||
| d0c0b0276c | |||
| b355fb5f25 | |||
| b97f02c489 | |||
| 52749db653 | |||
| f31141c0d8 | |||
| 31419f6a3b | |||
| a4b0d29696 | |||
| de01f75c21 | |||
| c8e451606a | |||
| f920fcbd94 | |||
| c48d1e3f55 | |||
| bba36ab4de | |||
| 0542e1f34d | |||
| c194d34bb1 | |||
| 302f857d51 | |||
| 3efb577cf3 | |||
| 32e74bec37 | |||
| 8c9377c779 | |||
| c65913ecb7 | |||
| e35cc07a1e | |||
| 4c4c3bda4e | |||
| ee8fb520bf | |||
| 45fab0fa8d | |||
| 18b36d7ff7 | |||
| 499fb5c772 | |||
| c0865695a6 | |||
| 1bf99e2f92 | |||
| 7aa6e580c9 | |||
| 963bfaf84b | |||
| 2f2781065c | |||
| 722f4b7dc9 | |||
| d555a33761 | |||
| cb1331b01c | |||
| b9c28ca997 | |||
| 33fea8e2fe | |||
| d31322f4a7 | |||
| 2b6227050d | |||
| 392d9446af | |||
| 99936345ad | |||
| b8c3656cb5 | |||
| d9947ebf5e | |||
| a4348355ce | |||
| 4ba5850ba6 | |||
| 4073cc04b3 | |||
| d9d70f0a6f | |||
| 520a1b5fe1 | |||
| 5e5851d315 | |||
| fcaeeff217 | |||
| 9662d6f3c8 | |||
| f1d01dfd1c | |||
| 52f1dd9aaa | |||
| a797f41d1b | |||
| ec1f6e3a5e | |||
| b958f4d38b | |||
| 5fecaad159 | |||
| a844249c7f | |||
| 9b7bc801f6 | |||
| 5a83c0c156 | |||
| 21ced23c7c | |||
| 79dd6a847a | |||
| f9891c0a47 | |||
| 52bd52477d | |||
| 2c2db2f349 | |||
| ea38aee82a | |||
| d39639d957 | |||
| e1bbe47c23 | |||
| dd6fc5785f | |||
| 8fdcb78c87 | |||
| 5e0ba01f7c | |||
| 89e9aebb6b | |||
| 8825d0d0f5 | |||
| a2e7b50b0e | |||
| 08b7242e4f | |||
| 9df0fdf93d | |||
| 89d9b98385 | |||
| 34605720d5 | |||
| 174f66952b | |||
| b90fbd9aba | |||
| 11d5c07d95 | |||
| 383f0dae17 | |||
| ca314961bf | |||
| 9148d62aae | |||
| 672409f9e5 | |||
| 00f293c35f | |||
| 5ae001829c | |||
| 6f443a9a8d | |||
| 70e7ac56c8 | |||
| 769a6f20f3 | |||
| 78ece3149e | |||
| 44b7a73efb | |||
| 945c900466 | |||
| 60b160f497 | |||
| 7316f9655c | |||
| 9488a3fd46 | |||
| d274afc1a3 | |||
| 297b8e14e4 | |||
| 1397721ed4 | |||
| 1bfde8c86c | |||
| 463e04ca22 | |||
| 1d3e96b7d9 | |||
| fd519b00dc | |||
| 67e205ba13 | |||
| e5c2271972 | |||
| 71360b3a3e | |||
| 5e1d8444ae | |||
| 934f260914 | |||
| 8b09c74f47 | |||
| e94f3874aa | |||
| 9ff5572b9f | |||
| 0a72df6037 | |||
| d2186e4f16 | |||
| 9c13901ee9 | |||
| 00bc813199 | |||
| c74af838b1 | |||
| 57de6284ad | |||
| e0552c0d29 | |||
| 5fa1111eeb | |||
| b6e31be059 | |||
| 737729f930 | |||
| 497f94eda0 | |||
| 633aeb0c5f | |||
| b42b682391 | |||
| 22c95a2194 | |||
| ffabe64242 | |||
| d9bed48eb5 | |||
| f48c0bc4e3 | |||
| fa3b7332a9 | |||
| b90f2b1d66 | |||
| a2bfb62afb | |||
| 8203b1f810 | |||
| f13ecd5fad | |||
| 7abb71609b | |||
| 6b1669f3bb | |||
| 57f9efe200 | |||
| 78837878c8 | |||
| ecbdb24694 | |||
| ffdc1304d6 | |||
| 381137905b | |||
| 59bc1a2439 | |||
| d8c41fd44c | |||
| dfb4d32d55 | |||
| 03e7952f4c | |||
| 3247a46485 | |||
| 3b2f119f57 | |||
| 7c7a4a273a | |||
| 8234738120 | |||
| 2d66e933c7 | |||
| d6f26a9d39 | |||
| 2738c7b00d | |||
| 7ca5cb4969 | |||
| 59e29fd6e6 | |||
| a66608a13c |
+1
-3
@@ -6,6 +6,7 @@
|
||||
node_modules
|
||||
dist
|
||||
aio/node_modules
|
||||
aio/content/example-playground
|
||||
aio/tools/examples/shared/node_modules
|
||||
aio/tools/examples/shared/example-scaffold
|
||||
|
||||
@@ -16,8 +17,6 @@ integration/cli-hello-world/node_modules
|
||||
integration/cli-hello-world-ivy-i18n/node_modules
|
||||
integration/cli-hello-world-lazy/node_modules
|
||||
integration/cli-hello-world-mocha/node_modules
|
||||
integration/cli-signal-inputs/node_modules
|
||||
integration/defer/node_modules
|
||||
integration/dynamic-compiler/node_modules
|
||||
integration/forms/node_modules
|
||||
integration/injectable-def/node_modules
|
||||
@@ -38,4 +37,3 @@ integration/typings_test_rxjs7/node_modules
|
||||
integration/typings_test_ts49/node_modules
|
||||
integration/typings_test_ts50/node_modules
|
||||
integration/typings_test_ts51/node_modules
|
||||
integration/typings_test_ts52/node_modules
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# 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 --spawn_strategy=local
|
||||
test:debug --test_arg=--node_options=--inspect-brk --test_output=streamed --test_strategy=exclusive --test_timeout=9999 --nocache_test_results
|
||||
|
||||
# Do not attempt to de-flake locally.
|
||||
# On CI we might set this to `3` to run with deflaking.
|
||||
@@ -135,7 +135,7 @@ build:remote --platforms=@npm//@angular/build-tooling/bazel/remote-execution:pla
|
||||
|
||||
# Remote instance and caching
|
||||
build:remote --remote_instance_name=projects/internal-200822/instances/primary_instance
|
||||
build:remote --bes_instance_name=internal-200822
|
||||
build:remote --project_id=internal-200822
|
||||
build:remote --remote_cache=remotebuildexecution.googleapis.com
|
||||
build:remote --remote_executor=remotebuildexecution.googleapis.com
|
||||
|
||||
@@ -177,9 +177,6 @@ test:saucelabs --flaky_test_attempts=1
|
||||
|
||||
# --ng_perf will ask the Ivy compiler to produce performance results for each build.
|
||||
build --flag_alias=ng_perf=//packages/compiler-cli:ng_perf
|
||||
# --adev_fast will run adev build/serve in a faster mode, skipping things like prerendering
|
||||
# for local development.
|
||||
build --flag_alias=fast_adev=//adev:fast_build_mode
|
||||
|
||||
####################################################
|
||||
# User bazel configuration
|
||||
|
||||
+638
-6
@@ -1,16 +1,648 @@
|
||||
# This config is remaining in place to prevent pull requests failing because of CircleCI config missing.
|
||||
# Configuration file for https://circleci.com/gh/angular/angular
|
||||
|
||||
# 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 https://blog.daemonl.com/2016/02/yaml.html
|
||||
# To validate changes, use an online parser, eg.
|
||||
# https://yaml-online-parser.appspot.com/
|
||||
|
||||
# CircleCI configuration version
|
||||
# Version 2.1 allows for extra config reuse features
|
||||
# https://circleci.com/docs/2.0/reusing-config/#getting-started-with-config-reuse
|
||||
version: 2.1
|
||||
|
||||
jobs:
|
||||
pass:
|
||||
# We don't want to include the current branch name in the cache key because that would prevent
|
||||
# PRs from being able to restore the cache since the branch names are always different for PRs.
|
||||
# The cache key should only consist of dynamic values that change whenever something in the
|
||||
# cache changes. For example:
|
||||
# 1) yarn lock file changes --> cached "node_modules" are different.
|
||||
# 2) bazel repository definitions change --> cached bazel repositories are different.
|
||||
# Windows needs its own cache key because binaries in node_modules are different.
|
||||
# **NOTE 1 **: In order to avoid the cache from growing indefinitely and causing slow-downs, we invalidate the cache monthly.
|
||||
# (See https://support.circleci.com/hc/en-us/articles/360012618473-Creating-a-daily-cache.)
|
||||
# **NOTE 2 **: If you change the cache key prefix, also sync the cache_key_fallback to match.
|
||||
# **NOTE 3 **: Keep the static part of the cache key as prefix to enable correct fallbacks.
|
||||
# **NOTE 4 **: To make sure modified patches can be applied without needing to manually update the cache key,
|
||||
# the fallback cache key will not match if patches have been modified.
|
||||
# See https://circleci.com/docs/2.0/caching/#restoring-cache for how prefixes work in CircleCI.
|
||||
var_3: &cache_key v8-angular-node-16-{{ checksum "month.txt" }}-{{ checksum "patches.hash"}}-{{ checksum ".bazelversion" }}-{{ checksum "yarn.lock" }}-{{ checksum "WORKSPACE" }}-{{ checksum "aio/yarn.lock" }}
|
||||
# We invalidate the cache if the Bazel version changes because otherwise the `bazelisk` cache
|
||||
# folder will contain all previously used versions and ultimately cause the cache restoring to
|
||||
# be slower due to its growing size.
|
||||
var_4: &cache_key_fallback v8-angular-node-16-{{ checksum "month.txt" }}-{{ checksum "patches.hash"}}-{{ checksum ".bazelversion" }}
|
||||
|
||||
# Windows needs its own cache key because binaries in node_modules are different.
|
||||
var_3_win: &cache_key_win v10-angular-win-node-16-{{ checksum "month.txt" }}-{{ checksum "patches.hash"}}-{{ checksum ".bazelversion" }}-{{ checksum "yarn.lock" }}-{{ checksum "WORKSPACE" }}
|
||||
var_4_win: &cache_key_win_fallback v10-angular-win-node-16-{{ checksum "month.txt" }}-{{ checksum "patches.hash"}}-{{ checksum ".bazelversion" }}
|
||||
|
||||
# Workspace initially persisted by the `setup` job, and then enhanced by `build-npm-packages`.
|
||||
# https://circleci.com/docs/2.0/workflows/#using-workspaces-to-share-data-among-jobs
|
||||
# https://circleci.com/blog/deep-diving-into-circleci-workspaces/
|
||||
var_5: &workspace_location ~/
|
||||
|
||||
# Filter to run a job on builds for pull requests only.
|
||||
var_6: &only_on_pull_requests
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- /pull\/\d+/
|
||||
|
||||
# Filter to skip a job on builds for pull requests.
|
||||
var_7: &skip_on_pull_requests
|
||||
filters:
|
||||
branches:
|
||||
ignore:
|
||||
- /pull\/\d+/
|
||||
|
||||
# Filter to run a job on builds for the main branch only.
|
||||
var_8: &only_on_main_branch
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- main
|
||||
|
||||
# Filter to run a job on all releasable branches.
|
||||
var_9: &only_release_branches
|
||||
filters:
|
||||
branches:
|
||||
only:
|
||||
- main
|
||||
- /\d+\.\d+\.x/
|
||||
|
||||
# CircleCI orbs
|
||||
# https://circleci.com/developer/orbs.
|
||||
orbs:
|
||||
node: circleci/node@5.0.1
|
||||
devinfra: angular/dev-infra@1.0.8
|
||||
|
||||
# Executor Definitions
|
||||
# https://circleci.com/docs/2.0/reusing-config/#authoring-reusable-executors
|
||||
# **NOTE 1**: Pin to exact images using an ID (SHA). See https://circleci.com/docs/2.0/circleci-images/#using-a-docker-image-id-to-pin-an-image-to-a-fixed-version.
|
||||
# (Using the tag in not necessary when pinning by ID, but include it anyway for documentation purposes.)
|
||||
# **NOTE 2**: If you change the version of the docker images, also change the `cache_key` suffix.
|
||||
# **NOTE 3**: If you change the version of Node.js provided by the docker images, also update `.devcontainer/recommended-Dockerfile` to match the new version.
|
||||
executors:
|
||||
default-executor:
|
||||
parameters:
|
||||
resource_class:
|
||||
type: string
|
||||
default: medium
|
||||
docker:
|
||||
- image: cimg/base:2022.05
|
||||
- image: cimg/node:16.14.0
|
||||
resource_class: << parameters.resource_class >>
|
||||
working_directory: ~/ng
|
||||
|
||||
test-browser-executor:
|
||||
parameters:
|
||||
resource_class:
|
||||
type: string
|
||||
default: medium
|
||||
docker:
|
||||
- image: cimg/node:16.14.0-browsers
|
||||
resource_class: << parameters.resource_class >>
|
||||
working_directory: ~/ng
|
||||
|
||||
windows-executor:
|
||||
working_directory: ~/ng
|
||||
resource_class: windows.2xlarge
|
||||
shell: bash
|
||||
machine:
|
||||
image: windows-server-2019-vs2019:201908-02
|
||||
|
||||
# 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:
|
||||
- run: echo "This too shall pass (always)"
|
||||
- attach_workspace:
|
||||
at: *workspace_location
|
||||
|
||||
# Install java runtime which is required by some integration tests such as
|
||||
# //integration:hello_world__closure_test, //integration:i18n_test and
|
||||
# //integration:ng_elements_test to run the closure compiler
|
||||
install_java:
|
||||
description: Install java
|
||||
steps:
|
||||
- run:
|
||||
name: Install java
|
||||
command: |
|
||||
sudo apt-get update
|
||||
# Install java runtime
|
||||
sudo apt-get install default-jre
|
||||
|
||||
# Initializes the CI environment by setting up common environment variables.
|
||||
init_environment:
|
||||
description: Initializing environment (setting up variables)
|
||||
steps:
|
||||
- run:
|
||||
name: Set up environment
|
||||
environment:
|
||||
CIRCLE_GIT_BASE_REVISION: << pipeline.git.base_revision >>
|
||||
CIRCLE_GIT_REVISION: << pipeline.git.revision >>
|
||||
command: ./.circleci/env.sh
|
||||
- devinfra/setup-bazel-remote-exec
|
||||
- run:
|
||||
# Configure git as the CircleCI `checkout` command does.
|
||||
# This is needed because we only checkout on the setup job.
|
||||
# Add GitHub to known hosts
|
||||
name: Configure git
|
||||
command: |
|
||||
mkdir -p ~/.ssh
|
||||
echo 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' >> ~/.ssh/known_hosts
|
||||
git config --global url."ssh://git@github.com".insteadOf "https://github.com" || true
|
||||
git config --global gc.auto 0 || true
|
||||
|
||||
init_saucelabs_environment:
|
||||
description: Sets up a domain that resolves to the local host.
|
||||
steps:
|
||||
- run:
|
||||
name: Preparing environment for running tests on Sauce Labs.
|
||||
command: |
|
||||
# For SauceLabs jobs, we set up a domain which resolves to the machine which launched
|
||||
# the tunnel. We do this because devices are sometimes not able to properly resolve
|
||||
# `localhost` or `127.0.0.1` through the SauceLabs tunnel. Using a domain that does not
|
||||
# resolve to anything on SauceLabs VMs ensures that such requests are always resolved
|
||||
# through the tunnel, and resolve to the actual tunnel host machine (i.e. the CircleCI VM).
|
||||
# More context can be found in: https://github.com/angular/angular/pull/35171.
|
||||
setPublicVar SAUCE_LOCALHOST_ALIAS_DOMAIN "angular-ci.local"
|
||||
setSecretVar SAUCE_ACCESS_KEY $(echo $SAUCE_ACCESS_KEY | rev)
|
||||
- run:
|
||||
# Sets up a local domain in the machine's host file that resolves to the local
|
||||
# host. This domain is helpful in Sauce Labs tests where devices are not able to
|
||||
# properly resolve `localhost` or `127.0.0.1` through the sauce-connect tunnel.
|
||||
name: Setting up alias domain for local host.
|
||||
command: echo "127.0.0.1 $SAUCE_LOCALHOST_ALIAS_DOMAIN" | sudo tee -a /etc/hosts
|
||||
|
||||
save_month_to_file:
|
||||
description: Store the current year and month in a file, so that it can be used for computing the cache key.
|
||||
steps:
|
||||
- run:
|
||||
name: Save month to file
|
||||
# Note: Make sure this file is excluded in the `.gitignore` as otherwise the
|
||||
# snapshot stamping would have the `-with-local-changes` suffix.
|
||||
command: date +%Y-%m > month.txt
|
||||
|
||||
capture_patches_for_cache_key:
|
||||
description: Hashes all patches so that the cache can be reset upon changes.
|
||||
steps:
|
||||
- run: |
|
||||
md5sum tools/esm-interop/patches/npm/* > patches.hash
|
||||
|
||||
yarn_install:
|
||||
description: Install dependencies
|
||||
steps:
|
||||
- run:
|
||||
name: Running Yarn install
|
||||
command: yarn install --frozen-lockfile --non-interactive --cache-folder ~/.cache/yarn
|
||||
# Yarn's requests sometimes take more than 10mins to complete (especially on Windows).
|
||||
no_output_timeout: 45m
|
||||
|
||||
notify_webhook_on_fail:
|
||||
description: Notify a webhook about failure
|
||||
parameters:
|
||||
# `webhook_url_env_var` are secret env vars defined in CircleCI project settings.
|
||||
# The URLs come from https://angular-team.slack.com/apps/A0F7VRE7N-circleci.
|
||||
webhook_url_env_var:
|
||||
type: env_var_name
|
||||
steps:
|
||||
- run:
|
||||
when: on_fail
|
||||
command: |
|
||||
notificationJson="{\"text\":\":x: \`$CIRCLE_JOB\` job for $CIRCLE_BRANCH branch failed on build $CIRCLE_BUILD_NUM: $CIRCLE_BUILD_URL :scream:\"}"
|
||||
curl --request POST --header "Content-Type: application/json" --data "$notificationJson" ${<< parameters.webhook_url_env_var >>}
|
||||
|
||||
# Job definitions
|
||||
# Jobs can include parameters that are passed in the workflow job invocation.
|
||||
# https://circleci.com/docs/2.0/reusing-config/#authoring-parameterized-jobs
|
||||
jobs:
|
||||
setup:
|
||||
executor: default-executor
|
||||
steps:
|
||||
- checkout
|
||||
- save_month_to_file
|
||||
- capture_patches_for_cache_key
|
||||
- init_environment
|
||||
- devinfra/rebase-pr-on-target-branch:
|
||||
base_revision: << pipeline.git.base_revision >>
|
||||
head_revision: << pipeline.git.revision >>
|
||||
# This cache is saved in the build-npm-packages so that Bazel cache is also included.
|
||||
- restore_cache:
|
||||
keys:
|
||||
- *cache_key
|
||||
- *cache_key_fallback
|
||||
- yarn_install
|
||||
- run: yarn --cwd aio install --frozen-lockfile --non-interactive --cache-folder ~/.cache/yarn
|
||||
# Make the bazel directories and add a file to them if they don't exist already so that
|
||||
# persist_to_workspace does not fail.
|
||||
- run: |
|
||||
if [ ! -d ~/bazel_repository_cache ]; then
|
||||
mkdir ~/bazel_repository_cache
|
||||
touch ~/bazel_repository_cache/MARKER
|
||||
fi
|
||||
# Persist any changes at this point to be reused by further jobs.
|
||||
# **NOTE**: To add new content to the workspace, always persist on the same root.
|
||||
- persist_to_workspace:
|
||||
root: *workspace_location
|
||||
paths:
|
||||
- ./ng
|
||||
- ./bazel_repository_cache
|
||||
|
||||
test:
|
||||
executor:
|
||||
name: test-browser-executor
|
||||
# Now that large integration tests are running locally in parallel (they can't run on RBE yet
|
||||
# as they require network access for yarn install), this test is running out of memory
|
||||
# consistently with the xlarge machine.
|
||||
# TODO: switch back to xlarge once integration tests are running on remote-exec
|
||||
resource_class: 2xlarge+
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
- run:
|
||||
# We need to explicitly specify the --symlink_prefix option because otherwise we would
|
||||
# not be able to easily find the output bin directory when uploading artifacts for size
|
||||
# measurements.
|
||||
command: yarn test:ci
|
||||
no_output_timeout: 20m
|
||||
# Publish bundle artifacts which will be used to calculate the size change. **Note**: Make
|
||||
# sure that the size plugin from the Angular robot fetches the artifacts from this CircleCI
|
||||
# job (see .github/angular-robot.yml). Additionally any artifacts need to be stored with the
|
||||
# following path format: "{projectName}/{context}/{fileName}". This format is necessary
|
||||
# because otherwise the bot is not able to pick up the artifacts from CircleCI. See:
|
||||
# https://github.com/angular/github-robot/blob/master/functions/src/plugins/size.ts#L392-L394
|
||||
- store_artifacts:
|
||||
path: dist/bin/packages/core/test/bundling/hello_world/bundle.min.js
|
||||
destination: core/hello_world/bundle
|
||||
- store_artifacts:
|
||||
path: dist/bin/packages/core/test/bundling/todo/bundle.min.js
|
||||
destination: core/todo/bundle
|
||||
- store_artifacts:
|
||||
path: dist/bin/packages/core/test/bundling/hello_world/bundle.min.js.br
|
||||
destination: core/hello_world/bundle.br
|
||||
- store_artifacts:
|
||||
path: dist/bin/packages/core/test/bundling/todo/bundle.min.js.br
|
||||
destination: core/todo/bundle.br
|
||||
|
||||
# NOTE: This is currently limited to main builds only. See the `monitoring` configuration.
|
||||
saucelabs:
|
||||
executor:
|
||||
name: default-executor
|
||||
# In order to avoid the bottleneck of having a slow host machine, we acquire a better
|
||||
# container for this job. This is necessary because we launch a lot of browsers concurrently
|
||||
# and therefore the tunnel and Karma need to process a lot of file requests and tests.
|
||||
resource_class: xlarge
|
||||
environment:
|
||||
NUMBER_OF_PARALLEL_BROWSERS: 2
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
- init_saucelabs_environment
|
||||
- run:
|
||||
name: Start Saucelabs daemon service
|
||||
command: yarn bazel run //tools/saucelabs-daemon/background-service -- ${NUMBER_OF_PARALLEL_BROWSERS}
|
||||
background: true
|
||||
- run:
|
||||
name: Run Bazel tests on Saucelabs
|
||||
command: |
|
||||
TESTS=$(./node_modules/.bin/bazelisk query --output label '(kind(karma_web_test, ...) intersect attr("tags", "saucelabs", ...)) except attr("tags", "fixme-saucelabs", ...)')
|
||||
yarn bazel test --config=saucelabs --jobs=${NUMBER_OF_PARALLEL_BROWSERS} ${TESTS}
|
||||
no_output_timeout: 40m
|
||||
- notify_webhook_on_fail:
|
||||
webhook_url_env_var: SLACK_DEV_INFRA_CI_FAILURES_WEBHOOK_URL
|
||||
|
||||
test_aio:
|
||||
executor:
|
||||
name: test-browser-executor
|
||||
resource_class: 2xlarge+
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
|
||||
# Run all aio tests
|
||||
- run:
|
||||
command: yarn --cwd aio test:ci
|
||||
no_output_timeout: 20m
|
||||
|
||||
# Check the bundle sizes.
|
||||
- run: yarn --cwd aio payload-size
|
||||
|
||||
deploy_aio:
|
||||
executor:
|
||||
name: test-browser-executor
|
||||
resource_class: 2xlarge+
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
# Deploy angular.io to production (if necessary)
|
||||
- run: setPublicVar_CI_STABLE_BRANCH
|
||||
- run: yarn --cwd aio deploy-production
|
||||
|
||||
test_aio_local:
|
||||
executor:
|
||||
name: test-browser-executor
|
||||
resource_class: 2xlarge+
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
# Run all aio tests (with local Angular packages)
|
||||
- run:
|
||||
command: yarn --cwd aio test-local:ci
|
||||
no_output_timeout: 20m
|
||||
|
||||
# Check the bundle sizes.
|
||||
- run: yarn --cwd aio payload-size aio-local
|
||||
|
||||
# The `build-npm-packages` tasks exist for backwards-compatibility with old scripts and
|
||||
# tests that rely on the pre-Bazel `dist/packages-dist` output structure (build.sh).
|
||||
# Having multiple jobs that independently build in this manner duplicates some work; we build
|
||||
# the bazel packages more than once. Even though we have a remote cache, these jobs will
|
||||
# typically run in parallel so up-to-date outputs will not be available at the time the build
|
||||
# starts.
|
||||
build-npm-packages:
|
||||
executor:
|
||||
name: default-executor
|
||||
resource_class: xlarge
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
- run: yarn build
|
||||
|
||||
# Save the npm packages from //packages/... for other workflow jobs to read
|
||||
- persist_to_workspace:
|
||||
root: *workspace_location
|
||||
paths:
|
||||
- ng/dist/packages-dist
|
||||
- ng/dist/angular-in-memory-web-api-dist
|
||||
- ng/dist/zone.js-dist
|
||||
|
||||
# Save dependencies and bazel repository cache to use on subsequent runs.
|
||||
- save_cache:
|
||||
key: *cache_key
|
||||
paths:
|
||||
- ~/.cache/yarn
|
||||
- ~/bazel_repository_cache
|
||||
- ~/.cache/bazelisk
|
||||
|
||||
# This job creates compressed tarballs (`.tgz` files) for all Angular packages and stores them as
|
||||
# build artifacts. This makes it easy to try out changes from a PR build for testing purposes.
|
||||
# More info CircleCI build artifacts: https://circleci.com/docs/2.0/artifacts
|
||||
#
|
||||
# NOTE: Currently, this job only runs for PR builds. See `publish_snapshot` for non-PR builds.
|
||||
publish_packages_as_artifacts:
|
||||
executor: default-executor
|
||||
environment:
|
||||
AIMWA_PACKAGES_DIR: &aimwa_packages_dir 'dist/angular-in-memory-web-api-dist'
|
||||
AIMWA_PACKAGES_ARCHIVES_DIR: &aimwa_packages_archives_dir 'dist/angular-in-memory-web-api-dist-archives'
|
||||
NG_PACKAGES_DIR: &ng_packages_dir 'dist/packages-dist'
|
||||
NG_PACKAGES_ARCHIVES_DIR: &ng_packages_archives_dir 'dist/packages-dist-archives'
|
||||
ZONEJS_PACKAGES_DIR: &zonejs_packages_dir 'dist/zone.js-dist'
|
||||
ZONEJS_PACKAGES_ARCHIVES_DIR: &zonejs_packages_archives_dir 'dist/zone.js-dist-archives'
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
# Publish `@angular/*` packages.
|
||||
- run:
|
||||
name: Create artifacts for @angular/* packages
|
||||
command: ./scripts/ci/create-package-archives.sh $CI_BRANCH $CI_COMMIT $NG_PACKAGES_DIR $NG_PACKAGES_ARCHIVES_DIR
|
||||
- store_artifacts:
|
||||
path: *ng_packages_archives_dir
|
||||
destination: angular
|
||||
# Publish the `angular-in-memory-web-api` package.
|
||||
- run:
|
||||
name: Create artifacts for the `angular-in-memory-web-api` package
|
||||
# Need to remove the zone.js.tgz before archive
|
||||
command: ./scripts/ci/create-package-archives.sh $CI_BRANCH $CI_COMMIT $AIMWA_PACKAGES_DIR $AIMWA_PACKAGES_ARCHIVES_DIR
|
||||
- store_artifacts:
|
||||
path: *aimwa_packages_archives_dir
|
||||
destination: angular-in-memory-web-api
|
||||
# Publish the `zone.js` package.
|
||||
- run:
|
||||
name: Create artifacts for the `zone.js` package
|
||||
# Need to remove the zone.js.tgz before archive
|
||||
command: rm -rf $ZONEJS_PACKAGES_DIR/archive && ./scripts/ci/create-package-archives.sh $CI_BRANCH $CI_COMMIT $ZONEJS_PACKAGES_DIR $ZONEJS_PACKAGES_ARCHIVES_DIR
|
||||
- store_artifacts:
|
||||
path: *zonejs_packages_archives_dir
|
||||
destination: zone.js
|
||||
|
||||
# This job updates the content of repos like github.com/angular/core-builds
|
||||
# for every green build on angular/angular.
|
||||
publish_snapshot:
|
||||
executor: default-executor
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
# CircleCI has a config setting to force SSH for all github connections
|
||||
# This is not compatible with our mechanism of using a Personal Access Token
|
||||
# Clear the global setting
|
||||
- run: git config --global --unset "url.ssh://git@github.com.insteadof"
|
||||
- run:
|
||||
name: Prepare GitHub credentials
|
||||
command: echo "https://${SNAPSHOT_BUILDS_GITHUB_TOKEN}:@github.com" > ${HOME}/.git_credentials
|
||||
- run: ./scripts/ci/publish-build-artifacts.sh
|
||||
|
||||
aio_misc:
|
||||
executor: default-executor
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- run:
|
||||
name: Check website provided in contributors.json file
|
||||
command: yarn node aio/scripts/test-external-urls.js
|
||||
|
||||
aio_monitoring_stable:
|
||||
executor: test-browser-executor
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
- run: setPublicVar_CI_STABLE_BRANCH
|
||||
- run:
|
||||
name: Check out `aio/` and yarn from the stable branch
|
||||
command: |
|
||||
git fetch origin $CI_STABLE_BRANCH
|
||||
git checkout --force origin/$CI_STABLE_BRANCH -- aio/ .yarn/ .yarnrc
|
||||
# Ignore yarn's engines check, because we checked out `aio/package.json` from the stable
|
||||
# branch and there could be a node version skew, which is acceptable in this monitoring job.
|
||||
- run: yarn config set ignore-engines true
|
||||
- run:
|
||||
name: Run tests against https://angular.io/
|
||||
command: ./aio/scripts/test-production.sh https://angular.io/ $CI_AIO_MIN_PWA_SCORE
|
||||
- notify_webhook_on_fail:
|
||||
webhook_url_env_var: SLACK_CARETAKER_WEBHOOK_URL
|
||||
- notify_webhook_on_fail:
|
||||
webhook_url_env_var: SLACK_DEV_INFRA_CI_FAILURES_WEBHOOK_URL
|
||||
|
||||
aio_monitoring_next:
|
||||
executor: test-browser-executor
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
- run:
|
||||
name: Run tests against https://next.angular.io/
|
||||
command: ./aio/scripts/test-production.sh https://next.angular.io/ $CI_AIO_MIN_PWA_SCORE
|
||||
- notify_webhook_on_fail:
|
||||
webhook_url_env_var: SLACK_CARETAKER_WEBHOOK_URL
|
||||
- notify_webhook_on_fail:
|
||||
webhook_url_env_var: SLACK_DEV_INFRA_CI_FAILURES_WEBHOOK_URL
|
||||
|
||||
legacy-unit-tests-saucelabs:
|
||||
executor:
|
||||
name: default-executor
|
||||
# In order to avoid the bottleneck of having a slow host machine, we acquire a better
|
||||
# container for this job. This is necessary because we launch a lot of browsers concurrently
|
||||
# and therefore the tunnel and Karma need to process a lot of file requests and tests.
|
||||
resource_class: xlarge
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
- init_saucelabs_environment
|
||||
- run:
|
||||
name: Starting Saucelabs tunnel service
|
||||
command: ./tools/saucelabs/sauce-service.sh run
|
||||
background: true
|
||||
# Build test fixtures for a test that rely on Bazel-generated fixtures. Note that disabling
|
||||
# specific tests which are reliant on such generated fixtures is not an option as SystemJS
|
||||
# in the Saucelabs legacy job always fetches referenced files, even if the imports would be
|
||||
# guarded by an check to skip in the Saucelabs legacy job. We should be good running such
|
||||
# test in all supported browsers on Saucelabs anyway until this job can be removed.
|
||||
- run:
|
||||
name: Preparing Bazel-generated fixtures required in legacy tests
|
||||
command: |
|
||||
yarn bazel build \
|
||||
//packages/core/test:downleveled_es5_fixture \
|
||||
//packages/common/locales
|
||||
|
||||
# Needed for the ES5 downlevel reflector test in `packages/core/test/reflection`.
|
||||
mkdir -p dist/legacy-test-out/core/test/reflection/
|
||||
cp dist/bin/packages/core/test/reflection/es5_downleveled_inheritance_fixture.js \
|
||||
dist/legacy-test-out/core/test/reflection/es5_downleveled_inheritance_fixture.js
|
||||
# Locale files are needed for i18n tests running within Saucelabs. These are added
|
||||
# directly as sources so that the TypeScript compilation of `/packages/tsconfig.json`
|
||||
# can succeed. Note that the base locale and currencies files are checked-in, so
|
||||
# we do not need to re-generate those through Bazel.
|
||||
mkdir -p packages/common/locales/extra
|
||||
cp dist/bin/packages/common/locales/*.ts packages/common/locales
|
||||
cp dist/bin/packages/common/locales/extra/*.ts packages/common/locales/extra
|
||||
# Build the bundle for all tests to run within Saucelabs
|
||||
- run: node tools/legacy-saucelabs/build-saucelabs-test-bundle.mjs
|
||||
- run:
|
||||
# Waiting on ready ensures that we don't run tests too early without Saucelabs not being ready.
|
||||
name: Waiting for Saucelabs tunnel to connect
|
||||
command: ./tools/saucelabs/sauce-service.sh ready-wait
|
||||
- run:
|
||||
name: Running tests on Saucelabs.
|
||||
command: |
|
||||
KARMA_WEB_TEST_MODE=SL_REQUIRED yarn karma start ./karma-js.conf.js --single-run
|
||||
- run:
|
||||
name: Stop Saucelabs tunnel service
|
||||
command: ./tools/saucelabs/sauce-service.sh stop
|
||||
|
||||
test_zonejs:
|
||||
executor:
|
||||
name: default-executor
|
||||
resource_class: xlarge
|
||||
steps:
|
||||
- custom_attach_workspace
|
||||
- init_environment
|
||||
- install_java
|
||||
|
||||
- run: |
|
||||
yarn bazel build -- \
|
||||
//packages/zone.js/bundles:zone.umd.js \
|
||||
//packages/zone.js:npm_package \
|
||||
//packages/zone.js/test/closure:closure_js \
|
||||
//packages/zone.js:zone_externs
|
||||
|
||||
- run: |
|
||||
mkdir -p packages/zone.js/build/
|
||||
mkdir -p packages/zone.js/build/test/
|
||||
mkdir -p packages/zone.js/test/
|
||||
|
||||
cp dist/bin/packages/zone.js/bundles/zone.umd.js packages/zone.js/build/zone.umd.js
|
||||
cp dist/bin/packages/zone.js/npm_package/bundles/zone-mix.umd.js ./packages/zone.js/test/extra/
|
||||
cp dist/bin/packages/zone.js/npm_package/bundles/zone-patch-electron.umd.js ./packages/zone.js/test/extra/
|
||||
cp dist/bin/packages/zone.js/test/closure/zone.closure.mjs ./packages/zone.js/build/test/zone.closure.mjs
|
||||
cp dist/bin/packages/zone.js/zone_externs.js ./packages/zone.js/build/zone_externs.js
|
||||
|
||||
# Install
|
||||
- run: yarn --cwd packages/zone.js install --frozen-lockfile --non-interactive --cache-folder ~/.cache/yarn
|
||||
# Run zone.js tools tests
|
||||
- run: yarn --cwd packages/zone.js promisetest
|
||||
- run: yarn --cwd packages/zone.js promisefinallytest
|
||||
- run: yarn --cwd packages/zone.js jest:test
|
||||
- run: yarn --cwd packages/zone.js jest:nodetest
|
||||
- run: yarn --cwd packages/zone.js electrontest
|
||||
- run: yarn --cwd packages/zone.js closuretest
|
||||
- run: yarn --cwd packages/zone.js/test/typings install --frozen-lockfile --non-interactive --cache-folder ~/.cache/yarn
|
||||
- run: yarn --cwd packages/zone.js/test/typings test
|
||||
|
||||
workflows:
|
||||
version: 2
|
||||
default_workflow:
|
||||
jobs:
|
||||
- pass
|
||||
- setup:
|
||||
filters:
|
||||
branches:
|
||||
ignore: g3
|
||||
- test:
|
||||
requires:
|
||||
- setup
|
||||
- build-npm-packages:
|
||||
requires:
|
||||
- setup
|
||||
- legacy-unit-tests-saucelabs:
|
||||
requires:
|
||||
- setup
|
||||
- test_aio:
|
||||
requires:
|
||||
- setup
|
||||
- deploy_aio:
|
||||
requires:
|
||||
- test_aio
|
||||
- test_aio_local:
|
||||
requires:
|
||||
- setup
|
||||
- publish_packages_as_artifacts:
|
||||
requires:
|
||||
- build-npm-packages
|
||||
- publish_snapshot:
|
||||
<<: *only_release_branches
|
||||
requires:
|
||||
# Only publish if tests and integration tests pass
|
||||
- test
|
||||
# Only publish if `aio`/`docs` tests using the locally built Angular packages pass
|
||||
- test_aio_local
|
||||
# Get the artifacts to publish from the build-packages-dist job
|
||||
# since the publishing script expects the legacy outputs layout.
|
||||
- build-npm-packages
|
||||
- legacy-unit-tests-saucelabs
|
||||
- test_zonejs:
|
||||
requires:
|
||||
- setup
|
||||
|
||||
monitoring:
|
||||
jobs:
|
||||
- setup
|
||||
- aio_misc:
|
||||
requires:
|
||||
- setup
|
||||
- aio_monitoring_stable:
|
||||
requires:
|
||||
- setup
|
||||
- aio_monitoring_next:
|
||||
requires:
|
||||
- setup
|
||||
- saucelabs:
|
||||
# Testing saucelabs via Bazel currently taking longer than the legacy saucelabs job as it
|
||||
# each karma_web_test target is provisioning and tearing down browsers which is adding
|
||||
# a lot of overhead. Running once daily in the main branch to avoid wasting resources and
|
||||
# slowing down CI for PRs.
|
||||
# TODO: Run this job on all branches (including PRs) once karma_web_test targets can
|
||||
# share provisioned browsers and we can remove the legacy saucelabs job.
|
||||
requires:
|
||||
- setup
|
||||
triggers:
|
||||
- schedule:
|
||||
<<: *only_on_main_branch
|
||||
# Runs monitoring jobs at 10:00AM every day.
|
||||
cron: '0 10 * * *'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Image metadata and config.
|
||||
# Ideally, the Node.js version should match what we use on CI.
|
||||
# See `executors > default-executor` in `.circleci/config.yml`.
|
||||
FROM cimg/node:18.13.0@sha256:45d0ee279f522c4562a464df71108104cc9b2187205333ffd0707d4f05589731
|
||||
FROM cimg/node:16.13-browsers
|
||||
|
||||
|
||||
LABEL name="Angular dev environment" \
|
||||
|
||||
@@ -4,7 +4,7 @@ description: 'Installs the dependencies using Yarn'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- uses: actions/cache@13aacd865c20de90d75de3b17ebe84f7a17d57d2 # v4
|
||||
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
|
||||
with:
|
||||
path: |
|
||||
./node_modules/
|
||||
|
||||
@@ -68,6 +68,7 @@ merge:
|
||||
|
||||
# list of PR statuses that need to be successful
|
||||
requiredStatuses:
|
||||
- 'ci/circleci: build'
|
||||
- 'google-internal-tests'
|
||||
- 'pullapprove'
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# This workflow builds the previews for pull requests when a certain label is applied.
|
||||
# The actual deployment happens as part of a dedicated second workflow to avoid security
|
||||
# issues where the building would otherwise occur in an authorized context where secrets
|
||||
# could be leaked. More details can be found here:
|
||||
|
||||
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/.
|
||||
|
||||
name: Build adev for preview deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, labeled]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
adev-build:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'adev: preview') ||
|
||||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'adev: preview'))
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel RBE
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build adev to ensure it continues to work
|
||||
run: yarn bazel build --config=aio_local_deps //adev:build
|
||||
- uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
workflow-artifact-name: 'adev-preview'
|
||||
pull-number: '${{github.event.pull_request.number}}'
|
||||
artifact-build-revision: '${{github.event.pull_request.head.sha}}'
|
||||
deploy-directory: './dist/bin/adev/build/browser'
|
||||
@@ -1,50 +0,0 @@
|
||||
# This workflow runs whenever the ADEV build workflow has completed. Deployment happens
|
||||
# as part of a dedicated second workflow to avoid security issues where the building would
|
||||
# otherwise occur in an authorized context where secrets could be leaked.
|
||||
#
|
||||
# More details can be found here:
|
||||
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/.
|
||||
|
||||
name: Deploying adev preview to Firebase
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build adev for preview deployment']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
# Needed in order to be able to comment on the pull request.
|
||||
pull-requests: write
|
||||
# Needed in order to checkout the repository
|
||||
contents: read
|
||||
# Needed in order to retrieve the artifacts from the previous job
|
||||
actions: read
|
||||
|
||||
env:
|
||||
PREVIEW_PROJECT: ng-dev-previews
|
||||
PREVIEW_SITE: ng-dev-previews-fw
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
token: '${{secrets.GITHUB_TOKEN}}'
|
||||
|
||||
- name: Configure Firebase deploy target
|
||||
working-directory: ./
|
||||
run: |
|
||||
# We can use `npx` as the Firebase deploy actions uses it too.
|
||||
npx -y firebase-tools@latest target:clear --config adev/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting angular-docs
|
||||
npx -y firebase-tools@latest target:apply --config adev/firebase.json --project ${{env.PREVIEW_PROJECT}} hosting angular-docs ${{env.PREVIEW_SITE}}
|
||||
|
||||
- uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
github-token: '${{secrets.GITHUB_TOKEN}}'
|
||||
workflow-artifact-name: 'adev-preview'
|
||||
firebase-config-dir: './adev'
|
||||
firebase-public-dir: './adev/build/browser'
|
||||
firebase-project-id: '${{env.PREVIEW_PROJECT}}'
|
||||
firebase-service-key: '${{secrets.FIREBASE_PREVIEW_SERVICE_TOKEN}}'
|
||||
@@ -0,0 +1,42 @@
|
||||
# This workflow builds the AIO previews for pull requests when a certain label is applied.
|
||||
# The actual deployment happens as part of a dedicated second workflow to avoid security
|
||||
# issues where the building would otherwise occur in an authorized context where secrets
|
||||
# could be leaked. More details can be found here:
|
||||
|
||||
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/.
|
||||
|
||||
name: Build AIO app for preview deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [synchronize, labeled]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
aio-build:
|
||||
runs-on: ubuntu-latest
|
||||
# We only want to build and deploy the AIO app if the `aio: preview` label has been
|
||||
# added, or if the label is already applied and new changes have been made in the PR.
|
||||
if: |
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'aio: preview') ||
|
||||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'aio: preview'))
|
||||
steps:
|
||||
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
- uses: ./.github/actions/yarn-install
|
||||
|
||||
- uses: angular/dev-infra/github-actions/bazel/configure-remote@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
bazelrc: ./.bazelrc.user
|
||||
|
||||
# Build the web package. Note: We run Bazel from a low-resource Github action container,
|
||||
# so we manually need to instruct Bazel to run more actions concurrently as by default
|
||||
# the number of concurrent actions is determined based on the host resources.
|
||||
- run: bazel build //aio:build --jobs=32 --announce_rc --verbose_failures
|
||||
|
||||
- uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
workflow-artifact-name: 'aio'
|
||||
pull-number: '${{github.event.pull_request.number}}'
|
||||
artifact-build-revision: '${{github.event.pull_request.head.sha}}'
|
||||
deploy-directory: './dist/bin/aio/build'
|
||||
@@ -0,0 +1,44 @@
|
||||
# This workflow runs whenever the AIO build workflow has completed. Deployment happens
|
||||
# as part of a dedicated second workflow to avoid security issues where the building would
|
||||
# otherwise occur in an authorized context where secrets could be leaked.
|
||||
#
|
||||
# More details can be found here:
|
||||
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/.
|
||||
|
||||
name: Deploying AIO preview to Firebase
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['Build AIO app for preview deployment']
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
# Needed in order to be able to comment on the pull request.
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
PREVIEW_PROJECT: ng-dev-previews
|
||||
PREVIEW_SITE: ng-dev-previews-fw
|
||||
|
||||
jobs:
|
||||
aio-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
|
||||
|
||||
- name: Configure Firebase deploy target
|
||||
working-directory: aio/
|
||||
run: |
|
||||
# We can use `npx` as the Firebase deploy actions uses it too.
|
||||
npx -y firebase-tools@latest target:clear --project ${{env.PREVIEW_PROJECT}} hosting aio
|
||||
npx -y firebase-tools@latest target:apply --project ${{env.PREVIEW_PROJECT}} hosting aio ${{env.PREVIEW_SITE}}
|
||||
|
||||
- uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
github-token: '${{secrets.GITHUB_TOKEN}}'
|
||||
workflow-artifact-name: 'aio'
|
||||
firebase-config-dir: './aio/'
|
||||
firebase-public-dir: './aio/dist'
|
||||
firebase-project-id: '${{env.PREVIEW_PROJECT}}'
|
||||
firebase-service-key: '${{secrets.FIREBASE_PREVIEW_SERVICE_TOKEN}}'
|
||||
@@ -13,9 +13,9 @@ jobs:
|
||||
assistant_to_the_branch_manager:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: angular/dev-infra/github-actions/branch-manager@c83e99a12397014162531ca125c94549db55dd84
|
||||
- uses: angular/dev-infra/github-actions/branch-manager@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
name: Run benchmark comparison
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions: read-all
|
||||
|
||||
jobs:
|
||||
benchmark-compare:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.issue.pull_request && startsWith(github.event.comment.body, '/benchmark-compare ')}}
|
||||
steps:
|
||||
- uses: TheModdingInquisition/actions-team-membership@a69636a92bc927f32c3910baac06bacc949c984c # v1.0
|
||||
with:
|
||||
team: 'team'
|
||||
organization: angular
|
||||
token: ${{secrets.BENCHMARK_COMPARE_MEMBERSHIP_GITHUB_TOKEN}}
|
||||
exit: true
|
||||
|
||||
# Indicate that the benchmark command was received.
|
||||
- uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3
|
||||
with:
|
||||
comment-id: ${{github.event.comment.id}}
|
||||
token: '${{secrets.BENCHMARK_POST_RESULTS_GITHUB_TOKEN}}'
|
||||
reactions: 'rocket'
|
||||
|
||||
- uses: alessbell/pull-request-comment-branch@aad01d65d6982b8eacabed5e9a684cd8ceb98da6 # v1.1
|
||||
id: comment-branch
|
||||
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
with:
|
||||
# Specify repository as the PR branch might be from a fork.
|
||||
repository: ${{steps.comment-branch.outputs.head_owner}}/${{steps.comment-branch.outputs.head_repo}}
|
||||
# Checkout the pull request and assume it being trusted given we've checked
|
||||
# that the action was triggered by a team member.
|
||||
ref: ${{steps.comment-branch.outputs.head_ref}}
|
||||
|
||||
- uses: ./.github/actions/yarn-install
|
||||
|
||||
- uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
bazelrc: ./.bazelrc.user
|
||||
|
||||
- name: Preparing benchmark for GitHub action
|
||||
id: info
|
||||
env:
|
||||
# Untrusted input used in an executable code, must be wrapped as an env var to prevent injections
|
||||
COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
run: yarn benchmarks prepare-for-github-action "$COMMENT_BODY"
|
||||
|
||||
- run: yarn benchmarks run-compare ${{steps.info.outputs.compareSha}} ${{steps.info.outputs.benchmarkTarget}}
|
||||
id: benchmark
|
||||
name: Running benchmark
|
||||
|
||||
- uses: peter-evans/create-or-update-comment@23ff15729ef2fc348714a3bb66d2f655ca9066f2 # v3
|
||||
with:
|
||||
issue-number: ${{github.event.issue.number}}
|
||||
token: '${{secrets.BENCHMARK_POST_RESULTS_GITHUB_TOKEN}}'
|
||||
body: |
|
||||
## Benchmark Test Results
|
||||
**Test**: `${{steps.info.outputs.benchmarkTarget}}`
|
||||
|
||||
### PR (${{steps.info.outputs.prHeadSha}})
|
||||
${{steps.benchmark.outputs.workingStageResultsText}}
|
||||
|
||||
### Compare Ref (${{steps.info.outputs.compareSha}})
|
||||
${{steps.benchmark.outputs.comparisonResultsText}}
|
||||
@@ -1,64 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.head_ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
saucelabs:
|
||||
runs-on: ubuntu-latest-4core
|
||||
env:
|
||||
SAUCE_TUNNEL_IDENTIFIER: angular-framework-${{ github.run_number }}
|
||||
SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }}
|
||||
SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
cache-node-modules: true
|
||||
# Checking out the pull request commit is intended here as we need to run the changed code tests.
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Starting Saucelabs tunnel service
|
||||
run: ./tools/saucelabs/sauce-service.sh run &
|
||||
# Build test fixtures for a test that rely on Bazel-generated fixtures. Note that disabling
|
||||
# specific tests which are reliant on such generated fixtures is not an option as SystemJS
|
||||
# in the Saucelabs legacy job always fetches referenced files, even if the imports would be
|
||||
# guarded by an check to skip in the Saucelabs legacy job. We should be good running such
|
||||
# test in all supported browsers on Saucelabs anyway until this job can be removed.
|
||||
- name: Preparing Bazel-generated fixtures required in legacy tests
|
||||
run: |
|
||||
yarn bazel build //packages/core/test:downleveled_es5_fixture //packages/common/locales
|
||||
# Needed for the ES5 downlevel reflector test in `packages/core/test/reflection`.
|
||||
mkdir -p dist/legacy-test-out/core/test/reflection/
|
||||
cp dist/bin/packages/core/test/reflection/es5_downleveled_inheritance_fixture.js \
|
||||
dist/legacy-test-out/core/test/reflection/es5_downleveled_inheritance_fixture.js
|
||||
# Locale files are needed for i18n tests running within Saucelabs. These are added
|
||||
# directly as sources so that the TypeScript compilation of `/packages/tsconfig.json`
|
||||
# can succeed. Note that the base locale and currencies files are checked-in, so
|
||||
# we do not need to re-generate those through Bazel.
|
||||
mkdir -p packages/common/locales/extra
|
||||
cp dist/bin/packages/common/locales/*.ts packages/common/locales
|
||||
cp dist/bin/packages/common/locales/extra/*.ts packages/common/locales/extra
|
||||
- name: Build bundle of tests to run on Saucelabs
|
||||
run: node tools/legacy-saucelabs/build-saucelabs-test-bundle.mjs
|
||||
- name: Wait and confirm Saucelabs tunnel has connected
|
||||
run: ./tools/saucelabs/sauce-service.sh ready-wait
|
||||
timeout-minutes: 3
|
||||
- name: Running tests on Saucelabs.
|
||||
run: KARMA_WEB_TEST_MODE=SL_REQUIRED yarn karma start ./karma-js.conf.js --single-run
|
||||
- name: Stop Saucelabs tunnel service
|
||||
run: ./tools/saucelabs/sauce-service.sh stop
|
||||
+13
-153
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
cache-node-modules: true
|
||||
node-module-directories: |
|
||||
@@ -35,6 +35,8 @@ jobs:
|
||||
run: yarn install --frozen-lockfile --cwd aio
|
||||
- name: Check code lint
|
||||
run: yarn -s tslint
|
||||
- name: Check code lint in aio
|
||||
run: yarn -s --cwd aio lint
|
||||
- name: Check for circular dependencies
|
||||
run: yarn -s ts-circular-deps:check
|
||||
- name: Validate pull approve configuration
|
||||
@@ -58,13 +60,13 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
cache-node-modules: true
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
- name: Setup Bazel RBE
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Run unit tests
|
||||
@@ -72,160 +74,18 @@ jobs:
|
||||
- name: Test build
|
||||
run: yarn devtools:build:chrome
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest-4core
|
||||
framework-win:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
cache-node-modules: true
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
- name: Setup Bazel Remote Caching
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile --network-timeout 100000
|
||||
- name: Run CI tests for framework
|
||||
run: yarn test:ci
|
||||
|
||||
adev:
|
||||
runs-on:
|
||||
labels: ubuntu-latest-4core
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel RBE
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build adev to ensure it continues to work
|
||||
run: yarn bazel test --config=aio_local_deps //adev:test
|
||||
|
||||
publish-snapshots:
|
||||
if: github.event_name == 'push'
|
||||
runs-on:
|
||||
labels: ubuntu-latest
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
cache-node-modules: true
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel RBE
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- run: echo "https://${{secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN}}:@github.com" > ${HOME}/.git_credentials
|
||||
- run: yarn build
|
||||
- run: ./scripts/ci/publish-build-artifacts.sh
|
||||
|
||||
zone-js:
|
||||
runs-on:
|
||||
labels: ubuntu-latest-4core
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
cache-node-modules: true
|
||||
node-module-directories: |
|
||||
./node_modules
|
||||
./packages/zone.js/node_modules
|
||||
./packages/zone.js/test/typings/node_modules
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel RBE
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- run: |
|
||||
yarn bazel build \
|
||||
//packages/zone.js/bundles:zone.umd.js \
|
||||
//packages/zone.js:npm_package \
|
||||
//packages/zone.js/test/closure:closure_js \
|
||||
|
||||
- run: |
|
||||
rm -Rf packages/zone.js/build
|
||||
rm -Rf packages/zone.js/test/extra/*.umd.js
|
||||
|
||||
mkdir -p packages/zone.js/build/
|
||||
mkdir -p packages/zone.js/build/test/
|
||||
|
||||
cp dist/bin/packages/zone.js/bundles/zone.umd.js packages/zone.js/build/zone.umd.js
|
||||
cp dist/bin/packages/zone.js/npm_package/bundles/zone-mix.umd.js ./packages/zone.js/test/extra/
|
||||
cp dist/bin/packages/zone.js/npm_package/bundles/zone-patch-electron.umd.js ./packages/zone.js/test/extra/
|
||||
cp dist/bin/packages/zone.js/test/closure/zone.closure.mjs ./packages/zone.js/build/test/zone.closure.mjs
|
||||
|
||||
# Install
|
||||
- run: yarn --cwd packages/zone.js install --frozen-lockfile --non-interactive
|
||||
# Run zone.js tools tests
|
||||
- run: yarn --cwd packages/zone.js promisefinallytest
|
||||
- run: yarn --cwd packages/zone.js jest:test
|
||||
- run: yarn --cwd packages/zone.js jest:nodetest
|
||||
- run: yarn --cwd packages/zone.js electrontest
|
||||
- run: yarn --cwd packages/zone.js/test/typings install --frozen-lockfile --non-interactive
|
||||
- run: yarn --cwd packages/zone.js/test/typings test
|
||||
|
||||
bazel-saucelabs:
|
||||
# TODO: Replace legacy job with Bazel saucelabs job.
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest-4core
|
||||
env:
|
||||
JOBS: 2
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
cache-node-modules: true
|
||||
# Checking out the pull request commit is intended here as we need to run the changed code tests.
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel Remote Caching
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Set up Sauce Tunnel Daemon
|
||||
run: yarn bazel run //tools/saucelabs-daemon/background-service -- $JOBS &
|
||||
env:
|
||||
SAUCE_TUNNEL_IDENTIFIER: angular-framework-${{ github.run_number }}
|
||||
SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }}
|
||||
SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
|
||||
- name: Run all saucelabs bazel tests
|
||||
run: |
|
||||
TESTS=$(./node_modules/.bin/bazelisk query --output label '(kind(karma_web_test, ...) intersect attr("tags", "saucelabs", ...)) except attr("tags", "fixme-saucelabs", ...)')
|
||||
yarn bazel test --config=saucelabs --jobs=$JOBS ${TESTS}
|
||||
|
||||
adev-deploy:
|
||||
needs: [adev]
|
||||
if: needs.adev.result == 'success' && github.event_name == 'push' && github.ref_name == 'main'
|
||||
env:
|
||||
DEPLOY_PROJECT: angular-dev-site
|
||||
DEPLOY_SITE: angular-dev-site
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel RBE
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Install node modules
|
||||
run: yarn install --frozen-lockfile
|
||||
- name: Build adev to ensure it continues to work
|
||||
run: yarn bazel build --config=aio_local_deps //adev:build
|
||||
- name: Move generated project files into deployment directory
|
||||
run: cp -r dist/bin/adev/build adev/build
|
||||
- name: Setup firebase context
|
||||
run: |
|
||||
npx -y firebase-tools@latest target:clear --config adev/firebase.json --project ${{ env.DEPLOY_PROJECT }} hosting angular-docs
|
||||
npx -y firebase-tools@latest target:apply --config adev/firebase.json --project ${{ env.DEPLOY_PROJECT }} hosting angular-docs ${{ env.DEPLOY_SITE }}
|
||||
- name: Deploy to firebase
|
||||
uses: FirebaseExtended/action-hosting-deploy@120e124148ab7016bec2374e5050f15051255ba2 # v0.7.1
|
||||
with:
|
||||
entryPoint: './adev'
|
||||
firebaseServiceAccount: ${{ secrets.ANGULAR_DEV_SITE_DEPLOY }}
|
||||
projectId: ${{ env.DEPLOY_PROJECT }}
|
||||
channelId: live
|
||||
- name: Test all windows CI targets
|
||||
run: bazel test --test_tag_filters="-browser:chromium-local" //packages/compiler-cli/...
|
||||
|
||||
@@ -12,14 +12,14 @@ jobs:
|
||||
labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- uses: angular/dev-infra/github-actions/commit-message-based-labels@c83e99a12397014162531ca125c94549db55dd84
|
||||
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
- uses: angular/dev-infra/github-actions/commit-message-based-labels@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
|
||||
post_approval_changes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- uses: angular/dev-infra/github-actions/post-approval-changes@c83e99a12397014162531ca125c94549db55dd84
|
||||
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
- uses: angular/dev-infra/github-actions/post-approval-changes@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Feature request triage bot
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 14:00 every day
|
||||
- cron: '0 14 * * *'
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
feature_triage:
|
||||
if: github.repository == 'angular/angular'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: angular/dev-infra/github-actions/feature-request@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
|
||||
@@ -4,17 +4,16 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
permissions: {}
|
||||
permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
permissions:
|
||||
pull-requests: read
|
||||
statuses: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
- uses: angular/dev-infra/github-actions/google-internal-tests@c83e99a12397014162531ca125c94549db55dd84
|
||||
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
- uses: angular/dev-infra/github-actions/google-internal-tests@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
run-tests-guide-url: http://go/angular-g3sync-start
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -9,6 +9,6 @@ jobs:
|
||||
status:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: angular/dev-infra/github-actions/unified-status-check@c83e99a12397014162531ca125c94549db55dd84
|
||||
- uses: angular/dev-infra/github-actions/unified-status-check@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Monitoring
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run at 10:00AM every day.
|
||||
- cron: '0 10 * * *'
|
||||
|
||||
permissions: {}
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
check_aio:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
MINIMUM_PWA_SCORE: 95
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ['https://next.angular.io/', 'https://angular.io/']
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
cache-node-modules: true
|
||||
node-module-directories: |
|
||||
./aio/node_modules
|
||||
- name: Setup Bazel
|
||||
uses: angular/dev-infra/github-actions/bazel/setup@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Setup Bazel RBE
|
||||
uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
|
||||
- name: Install node modules in aio
|
||||
run: yarn install --frozen-lockfile --cwd aio
|
||||
- name: Run basic e2e and deployment config tests.
|
||||
run: yarn --cwd aio test-production-url --test_env=TARGET_URL="${{ matrix.version }}"
|
||||
- name: Run PWA-score tests.
|
||||
run: yarn --cwd aio test-pwa-score "${{ matrix.version }}" "${{ env.MINIMUM_PWA_SCORE }}"
|
||||
- name: Run a11y tests.
|
||||
run: yarn --cwd aio test-a11y-score "${{ matrix.version }}"
|
||||
|
||||
check_contributor_links:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Initialize environment
|
||||
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@c83e99a12397014162531ca125c94549db55dd84
|
||||
with:
|
||||
cache-node-modules: true
|
||||
node-module-directories: |
|
||||
./aio/node_modules
|
||||
- name: Install node modules in aio
|
||||
run: yarn install --frozen-lockfile --cwd aio
|
||||
- name: Check website provided in contributors.json file
|
||||
run: node aio/scripts/test-external-urls.js
|
||||
@@ -25,12 +25,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 'Checkout code'
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: 'Run analysis'
|
||||
uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1
|
||||
uses: ossf/scorecard-action@08b4669551908b1024bb425080c797723083c031 # v2.2.0
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
# Upload the results as artifacts.
|
||||
- name: 'Upload artifact'
|
||||
uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1
|
||||
uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
@@ -47,6 +47,6 @@ jobs:
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: 'Upload to code-scanning'
|
||||
uses: github/codeql-action/upload-sarif@47b3d888fe66b639e431abf22ebca059152f1eea # v3.24.5
|
||||
uses: github/codeql-action/upload-sarif@1813ca74c3faaa3a2da2070b9b8a0b3e7373a0d8 # v2.21.0
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
|
||||
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
with:
|
||||
# Setting `persist-credentials: false` prevents the github-action account from being the
|
||||
# account that is attempted to be used for authentication, instead the remote is set to
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
env:
|
||||
ANGULAR_CLI_BUILDS_READONLY_GITHUB_TOKEN: ${{ secrets.ANGULAR_CLI_BUILDS_READONLY_GITHUB_TOKEN }}
|
||||
- name: Create a PR (if necessary)
|
||||
uses: angular/dev-infra/github-actions/create-pr-for-changes@c83e99a12397014162531ca125c94549db55dd84
|
||||
uses: angular/dev-infra/github-actions/create-pr-for-changes@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
branch-prefix: update-cli-help
|
||||
pr-title: 'docs: update Angular CLI help [${{github.ref_name}}]'
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Update the `events.json` file that powers the [AIO events page](https://angular.io/events) (if
|
||||
# necessary) and create a pull request.
|
||||
#
|
||||
# For more details on the overall process, see
|
||||
# [aio/scripts/generate-events/README.md](../../aio/scripts/generate-events/README.md).
|
||||
|
||||
name: Update AIO events
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs: {}
|
||||
schedule:
|
||||
# Run every day at 15:00.
|
||||
- cron: '0 15 * * *'
|
||||
|
||||
# Declare default permissions as read only.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
update_events:
|
||||
name: Update `events.json` (if necessary)
|
||||
if: github.repository == 'angular/angular'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
|
||||
with:
|
||||
# Setting `persist-credentials: false` prevents the github-action account from being the
|
||||
# account that is attempted to be used for authentication, instead the remote is set to
|
||||
# an authenticated URL.
|
||||
persist-credentials: false
|
||||
# This is needed as otherwise the PR creation will fail with `shallow update not allowed` when the forked branch is not in sync.
|
||||
fetch-depth: 0
|
||||
- name: Generate `events.json`
|
||||
run: node aio/scripts/generate-events/index.mjs --ignore-invalid-dates
|
||||
- name: Create a PR (if necessary)
|
||||
uses: angular/dev-infra/github-actions/create-pr-for-changes@0109d498b0f6aae418ed4924a5e5c65695f0ac61
|
||||
with:
|
||||
branch-prefix: docs-update-events
|
||||
pr-title: 'docs: update events'
|
||||
pr-description: |
|
||||
Generated `events.json` with the latest events retrieved from the Firebase DB.
|
||||
pr-labels: |
|
||||
action: review
|
||||
area: docs
|
||||
target: patch
|
||||
angular-robot-token: ${{ secrets.ANGULAR_ROBOT_ACCESS_TOKEN }}
|
||||
@@ -57,7 +57,3 @@ baseline.json
|
||||
# Husky
|
||||
.husky/_
|
||||
aio/content/examples/.DS_Store
|
||||
|
||||
|
||||
# Ignore cache created with the Angular CLI.
|
||||
.angular/
|
||||
@@ -1,3 +1,6 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname $0)/_/husky.sh"
|
||||
|
||||
set +e
|
||||
|
||||
yarn -s ng-dev commit-message pre-commit-validate --file $1 2>/dev/null
|
||||
|
||||
+4
-1
@@ -1,8 +1,11 @@
|
||||
set +e
|
||||
#!/bin/sh
|
||||
. "$(dirname $0)/_/husky.sh"
|
||||
|
||||
set +e
|
||||
yarn -s ng-dev format staged 2>/dev/null
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "WARNING: failed to run file formatting (ng-dev format staged)"
|
||||
fi
|
||||
|
||||
|
||||
exit 0;
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname $0)/_/husky.sh"
|
||||
|
||||
set +e
|
||||
|
||||
yarn -s ng-dev commit-message restore-commit-message-draft $1 $2 2>/dev/null
|
||||
|
||||
+1
-47
@@ -5,33 +5,7 @@ import {FormatConfig} from '@angular/ng-dev';
|
||||
*/
|
||||
export const format: FormatConfig = {
|
||||
'prettier': {
|
||||
'matchers': [
|
||||
'**/*.{yaml,yml}',
|
||||
'adev/**/*.{js,ts}',
|
||||
'devtools/**/*.{js,ts}',
|
||||
'tools/**/*.{js,ts}',
|
||||
'modules/**/*.{js,ts}',
|
||||
'scripts/**/*.{js,ts}',
|
||||
'packages/animations/**/*.{js,ts}',
|
||||
'packages/bazel/**/*.{js,ts}',
|
||||
'packages/benchpress/**/*.{js,ts}',
|
||||
'packages/common/**/*.{js,ts}',
|
||||
'packages/docs/**/*.{js,ts}',
|
||||
'packages/elements/**/*.{js,ts}',
|
||||
'packages/examples/**/*.{js,ts}',
|
||||
'packages/misc/**/*.{js,ts}',
|
||||
'packages/private/**/*.{js,ts}',
|
||||
'packages/router/**/*.{js,ts}',
|
||||
'packages/service-worker/**/*.{js,ts}',
|
||||
'packages/upgrade/**/*.{js,ts}',
|
||||
|
||||
// Do not format d.ts files as they are generated
|
||||
'!**/*.d.ts',
|
||||
// Both third_party and .yarn are directories containing copied code which should
|
||||
// not be modified.
|
||||
'!third_party/**',
|
||||
'!.yarn/**',
|
||||
],
|
||||
'matchers': ['**/*.{yaml,yml}'],
|
||||
},
|
||||
'clang-format': {
|
||||
'matchers': [
|
||||
@@ -55,26 +29,6 @@ export const format: FormatConfig = {
|
||||
'!packages/core/src/i18n/locale_en.ts',
|
||||
'!packages/common/locales/closure-locale.ts',
|
||||
'!packages/common/src/i18n/currencies.ts',
|
||||
// Temporarily disable formatting for adev
|
||||
'!adev/**',
|
||||
|
||||
// Migrated to prettier
|
||||
'!devtools/**/*.{js,ts}',
|
||||
'!tools/**/*.{js,ts}',
|
||||
'!modules/**/*.{js,ts}',
|
||||
'!scripts/**/*.{js,ts}',
|
||||
'!packages/animations/**/*.{js,ts}',
|
||||
'!packages/bazel/**/*.{js,ts}',
|
||||
'!packages/benchpress/**/*.{js,ts}',
|
||||
'!packages/common/**/*.{js,ts}',
|
||||
'!packages/docs/**/*.{js,ts}',
|
||||
'!packages/elements/**/*.{js,ts}',
|
||||
'!packages/examples/**/*.{js,ts}',
|
||||
'!packages/misc/**/*.{js,ts}',
|
||||
'!packages/private/**/*.{js,ts}',
|
||||
'!packages/router/**/*.{js,ts}',
|
||||
'!packages/service-worker/**/*.{js,ts}',
|
||||
'!packages/upgrade/**/*.{js,ts}',
|
||||
],
|
||||
},
|
||||
'buildifier': true,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
{
|
||||
"syncedFilePatterns": [
|
||||
"LICENSE",
|
||||
"modules/benchmarks/**",
|
||||
"packages/**"
|
||||
],
|
||||
"separateFilePatterns": [
|
||||
"packages/core/primitives/**"
|
||||
],
|
||||
"syncedFilePatterns": ["LICENSE", "modules/benchmarks/**", "packages/**"],
|
||||
"alwaysExternalFilePatterns": [
|
||||
"packages/*",
|
||||
"packages/bazel/*",
|
||||
@@ -29,6 +22,7 @@
|
||||
"packages/docs/**",
|
||||
"packages/elements/schematics/**",
|
||||
"packages/examples/**",
|
||||
"packages/language-service/**",
|
||||
"packages/localize/**",
|
||||
"packages/private/**",
|
||||
"packages/service-worker/**",
|
||||
@@ -37,6 +31,8 @@
|
||||
"packages/http/**",
|
||||
"**/.gitignore",
|
||||
"**/.gitkeep",
|
||||
"**/yarn.lock",
|
||||
"**/package.json",
|
||||
"**/third_party/**",
|
||||
"**/tsconfig-build.json",
|
||||
"**/tsconfig-tsec.json",
|
||||
|
||||
@@ -17,9 +17,4 @@ export const pullRequest: PullRequestConfig = {
|
||||
// the `bazel` package is not considered part of the public API so that features
|
||||
// can land in patch branches.
|
||||
targetLabelExemptScopes: ['dev-infra', 'docs-infra', 'bazel'],
|
||||
// enables specific validations during the pull request merge process
|
||||
validators: {
|
||||
assertEnforceTested: true,
|
||||
assertIsolatedSeparateFiles: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"tabs": false,
|
||||
"embeddedLanguageFormatting": "off",
|
||||
"singleQuote": true,
|
||||
"semicolon": true,
|
||||
"quoteProps": "preserve",
|
||||
|
||||
+31
-51
@@ -57,7 +57,9 @@
|
||||
version: 3
|
||||
|
||||
availability:
|
||||
users_unavailable: []
|
||||
users_unavailable:
|
||||
- atscott
|
||||
- devversion
|
||||
|
||||
# Meta field that goes unused by PullApprove to allow for defining aliases to be
|
||||
# used throughout the config.
|
||||
@@ -166,9 +168,8 @@ groups:
|
||||
- AndrewKushnir
|
||||
- atscott
|
||||
- crisbeto
|
||||
- devversion
|
||||
- dylhunn
|
||||
- JoostK
|
||||
- dylhunn
|
||||
|
||||
# =========================================================
|
||||
# Framework: Migrations
|
||||
@@ -183,8 +184,6 @@ groups:
|
||||
- crisbeto
|
||||
- devversion
|
||||
- dylhunn
|
||||
- jessicajaniuk
|
||||
- AndrewKushnir
|
||||
|
||||
# =========================================================
|
||||
# Framework: Core
|
||||
@@ -314,7 +313,6 @@ groups:
|
||||
'aio/content/guide/pipes.md',
|
||||
'aio/content/guide/pipes-custom-data-trans.md',
|
||||
'aio/content/guide/pipes-overview.md',
|
||||
'aio/content/guide/pipe-precedence.md',
|
||||
'aio/content/guide/pipes-transform-data.md',
|
||||
'aio/content/guide/pipe-template.md',
|
||||
'aio/content/examples/pipes/**/{*,.*}',
|
||||
@@ -322,9 +320,7 @@ groups:
|
||||
'aio/content/guide/providers.md',
|
||||
'aio/content/examples/providers/**/{*,.*}',
|
||||
'aio/content/images/guide/providers/**/{*,.*}',
|
||||
'aio/content/guide/signal-queries.md',
|
||||
'aio/content/guide/singleton-services.md',
|
||||
'aio/content/guide/signal-inputs.md',
|
||||
'aio/content/guide/sharing-ngmodules.md',
|
||||
'aio/content/guide/standalone-components.md',
|
||||
'aio/content/guide/standalone-migration.md',
|
||||
@@ -342,13 +338,8 @@ groups:
|
||||
'aio/content/examples/view-encapsulation/**/{*,.*}',
|
||||
'aio/content/images/guide/view-encapsulation/**/{*,.*}',
|
||||
'aio/content/special-elements/**/{*,.*}',
|
||||
'aio/content/blocks/**/{*,.*}',
|
||||
'aio/content/guide/hydration.md',
|
||||
'aio/content/guide/defer.md',
|
||||
'aio/content/guide/signals.md',
|
||||
'aio/content/guide/control_flow.md',
|
||||
'aio/content/guide/model-inputs.md',
|
||||
'aio/content/examples/injection-token/**/{*,.*}',
|
||||
'aio/content/guide/signals.md'
|
||||
])
|
||||
reviewers:
|
||||
users:
|
||||
@@ -356,7 +347,6 @@ groups:
|
||||
- AndrewKushnir
|
||||
- atscott
|
||||
- crisbeto
|
||||
- devversion
|
||||
- dylhunn
|
||||
- jessicajaniuk
|
||||
- pkozlowski-opensource
|
||||
@@ -526,8 +516,8 @@ groups:
|
||||
contains_any_globs(files, [
|
||||
'packages/platform-server/**/{*,.*}',
|
||||
'aio/content/guide/prerendering.md',
|
||||
'aio/content/guide/ssr.md',
|
||||
'aio/content/examples/ssr/**/{*,.*}',
|
||||
'aio/content/guide/universal.md',
|
||||
'aio/content/examples/universal/**/{*,.*}'
|
||||
])
|
||||
reviewers:
|
||||
users:
|
||||
@@ -939,14 +929,12 @@ groups:
|
||||
'aio/content/guide/update-to-version-14.md',
|
||||
'aio/content/guide/update-to-version-15.md',
|
||||
'aio/content/guide/update-to-version-16.md',
|
||||
'aio/content/guide/update-to-version-17.md',
|
||||
])
|
||||
reviewers:
|
||||
users:
|
||||
- alxhub
|
||||
- AndrewKushnir
|
||||
- atscott
|
||||
- jessicajaniuk
|
||||
- jelbourn
|
||||
|
||||
# =========================================================
|
||||
@@ -1063,30 +1051,6 @@ groups:
|
||||
- dylhunn
|
||||
- mgechev
|
||||
|
||||
# =========================================================
|
||||
# Docs: Angular Dev Site
|
||||
# =========================================================
|
||||
angular-dev:
|
||||
<<: *defaults
|
||||
conditions:
|
||||
- >
|
||||
contains_any_globs(files, [
|
||||
'adev/**/{.*,*}'
|
||||
])
|
||||
reviewers:
|
||||
users:
|
||||
- josephperrott
|
||||
- jelbourn
|
||||
- bencodezen
|
||||
- jessicajaniuk
|
||||
- dylhunn
|
||||
- AndrewKushnir
|
||||
- alxhub
|
||||
- crisbeto
|
||||
- atscott
|
||||
- pkozlowski-opensource
|
||||
- ~JeanMeche
|
||||
|
||||
# =========================================================
|
||||
# Docs-infra
|
||||
# =========================================================
|
||||
@@ -1160,11 +1124,9 @@ groups:
|
||||
users:
|
||||
- AleksanderBodurri
|
||||
- devversion
|
||||
- dgp1130
|
||||
- josephperrott
|
||||
- mgechev
|
||||
- twerske
|
||||
- ~JeanMeche
|
||||
|
||||
# =========================================================
|
||||
# Dev-infra
|
||||
@@ -1199,11 +1161,9 @@ groups:
|
||||
'tools/esm-interop/**/{*,.*}',
|
||||
'tools/gulp-tasks/**/{*,.*}',
|
||||
'tools/legacy-saucelabs/**/{*,.*}',
|
||||
'tools/manual_api_docs/**/{*,.*}',
|
||||
'tools/npm-patches/**/{*,.*}',
|
||||
'tools/rxjs/**/{*,.*}',
|
||||
'tools/saucelabs-daemon/**/{*,.*}',
|
||||
'tools/saucelabs/**/{*,.*}',
|
||||
'tools/saucelabs-daemon/**/{*,.*}',
|
||||
'tools/symbol-extractor/**/{*,.*}',
|
||||
'tools/testing/**/{*,.*}',
|
||||
'tools/tslint/**/{*,.*}',
|
||||
@@ -1397,6 +1357,26 @@ groups:
|
||||
author_value: 0 # The author of the PR cannot provide an approval for themself
|
||||
reviewed_for: ignored # All reviews apply to this group whether noted via Reviewed-for or not
|
||||
reviewers:
|
||||
teams:
|
||||
# Any member of the team can provide a review to perform the minimum review required
|
||||
- team
|
||||
users:
|
||||
- alan-agius4 # Alan Agius
|
||||
- AleksanderBodurri # Aleksander Bodurri
|
||||
- alxhub # Alex Rickabaugh
|
||||
- AndrewKushnir # Andrew Kushnir
|
||||
- andrewseguin # Andrew Seguin
|
||||
- atscott # Andrew Scott
|
||||
- clydin # Charles Lyding
|
||||
- crisbeto # Kristiyan Kostadinov
|
||||
- devversion # Paul Gschwendtner
|
||||
- dgp1130 # Doug Parker
|
||||
- dylhunn # Dylan Hunn
|
||||
- jelbourn # Jeremy Elbourn
|
||||
- jessicajaniuk # Jessica Janiuk
|
||||
- JiaLiPassion # Jia Li
|
||||
- JoostK # Joost Koehoorn
|
||||
- josephperrott # Joey Perrott
|
||||
- MarkTechson # Mark Thompson (Techson)
|
||||
- mgechev # Minko Gechev
|
||||
- mmalerba # Miles Malerba
|
||||
- pkozlowski-opensource # Pawel Kozlowski
|
||||
- twerske # Emma Twersky
|
||||
- zarend # Zach Arend
|
||||
|
||||
Vendored
+6
-1
@@ -4,8 +4,13 @@
|
||||
|
||||
// List of extensions which should be recommended for users of this workspace.
|
||||
"recommendations": [
|
||||
"BazelBuild.vscode-bazel",
|
||||
"devondcarew.bazel-code",
|
||||
"gkalpak.aio-docs-utils",
|
||||
"ms-vscode.vscode-typescript-tslint-plugin",
|
||||
"xaver.clang-format",
|
||||
// The following extensions are useful when working on angular.io (i.e. inside the `aio/` directory).
|
||||
//"angular.ng-template",
|
||||
//"dbaeumer.vscode-eslint",
|
||||
//"errata-ai.vale-server",
|
||||
],
|
||||
}
|
||||
|
||||
+147520
File diff suppressed because one or more lines are too long
Vendored
-147529
File diff suppressed because one or more lines are too long
@@ -2,4 +2,4 @@
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
yarn-path ".yarn/releases/yarn-1.22.19.cjs"
|
||||
yarn-path ".yarn/releases/yarn-1.22.17.cjs"
|
||||
|
||||
+2
-3
@@ -13,7 +13,6 @@ exports_files([
|
||||
"scripts/ci/payload-size.sh",
|
||||
"scripts/ci/payload-size.js",
|
||||
"package.json",
|
||||
"angular.json",
|
||||
])
|
||||
|
||||
filegroup(
|
||||
@@ -64,8 +63,8 @@ config_setting(
|
||||
|
||||
nodejs_binary(
|
||||
name = "yarn_vendored",
|
||||
data = [".yarn/releases/yarn-1.22.19.cjs"],
|
||||
entry_point = ".yarn/releases/yarn-1.22.19.cjs",
|
||||
data = [".yarn/releases/yarn-1.22.17.cjs"],
|
||||
entry_point = ".yarn/releases/yarn-1.22.17.cjs",
|
||||
visibility = [
|
||||
"//aio/tools/examples/shared:__pkg__",
|
||||
"//integration:__subpackages__",
|
||||
|
||||
+4
-1124
File diff suppressed because it is too large
Load Diff
@@ -1660,7 +1660,7 @@ Alex Rickabaugh, Daniel Díaz, David Shevitz, Dylan Hunn, Front-end developer, G
|
||||
* Minified UMD bundles are no longer included in the distributed NPM packages.
|
||||
* **animations:** DOM elements are now correctly removed when the root view is removed.
|
||||
If you are using SSR and use the app's HTML for rendering, you will need
|
||||
to ensure that you save the HTML to a variable before destroying the
|
||||
to ensure that you save the HTML to a variable before destorying the
|
||||
app.
|
||||
It is also possible that tests could be accidentally relying on the old behavior by
|
||||
trying to find an element that was not removed in a previous test. If
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ Before you submit your Pull Request (PR) consider the following guidelines:
|
||||
```shell
|
||||
git commit --all
|
||||
```
|
||||
Note: the optional commit `--all` command line option will automatically "add" and "rm" edited files.
|
||||
Note: the optional commit `-a` command line option will automatically "add" and "rm" edited files.
|
||||
|
||||
10. Push your branch to GitHub:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2010-2024 Google LLC. https://angular.io/license
|
||||
Copyright (c) 2010-2023 Google LLC. https://angular.io/license
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<h1 align="center">Angular - The modern web developer's platform</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="aio/src/assets/images/logos/angular/angular_renaissance.png" alt="angular-logo" width="120px" height="120px"/>
|
||||
<img src="aio/src/assets/images/logos/angular/angular.png" alt="angular-logo" width="120px" height="120px"/>
|
||||
<br>
|
||||
<em>Angular is a development platform for building mobile and desktop web applications
|
||||
<br> using TypeScript/JavaScript and other languages.</em>
|
||||
@@ -9,7 +9,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://angular.dev/"><strong>angular.dev</strong></a>
|
||||
<a href="https://www.angular.io"><strong>www.angular.io</strong></a>
|
||||
<br>
|
||||
</p>
|
||||
|
||||
@@ -61,25 +61,6 @@ Get started with Angular, learn the fundamentals and explore advanced topics on
|
||||
- [Lazy Loading][lazyloading]
|
||||
- [Animations][animations]
|
||||
|
||||
### Local Development
|
||||
|
||||
To contribute to Angular docs, you can setup a local environment with the following commands:
|
||||
|
||||
```bash
|
||||
# Clone Angular repo
|
||||
git clone https://github.com/angular/angular.git
|
||||
|
||||
# Navigate to project directory
|
||||
cd angular
|
||||
|
||||
# Install dependencies
|
||||
yarn
|
||||
|
||||
# Build and run local dev server
|
||||
# Note: Initial build will take some time
|
||||
yarn docs
|
||||
```
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Prerequisites
|
||||
@@ -148,7 +129,7 @@ Help us keep Angular open and inclusive. Please read and follow our [Code of Con
|
||||
|
||||
Join the conversation and help the community.
|
||||
|
||||
- [X (formerly Twitter)][X (formerly Twitter)]
|
||||
- [Twitter][twitter]
|
||||
- [Discord][discord]
|
||||
- [Gitter][gitter]
|
||||
- [YouTube][youtube]
|
||||
@@ -160,27 +141,27 @@ Join the conversation and help the community.
|
||||
**Love Angular? Give our repo a star :star: :arrow_up:.**
|
||||
|
||||
[contributing]: CONTRIBUTING.md
|
||||
[quickstart]: https://angular.dev/tutorials/learn-angular
|
||||
[quickstart]: https://angular.io/start
|
||||
[changelog]: CHANGELOG.md
|
||||
[ng]: https://angular.dev
|
||||
[documentation]: https://angular.dev/overview
|
||||
[ng]: https://angular.io
|
||||
[documentation]: https://angular.io/docs
|
||||
[angularmaterial]: https://material.angular.io/
|
||||
[cli]: https://angular.dev/tools/cli
|
||||
[architecture]: https://angular.dev/essentials
|
||||
[componentstemplates]: https://angular.dev/tutorials/learn-angular/components-in-angular
|
||||
[forms]: https://angular.dev/tutorials/learn-angular/forms
|
||||
[api]: https://angular.dev/api
|
||||
[angularelements]: https://angular.dev/guide/elements
|
||||
[ssr]: https://angular.dev/guide/ssr
|
||||
[schematics]: https://angular.dev/tools/cli/schematics
|
||||
[lazyloading]: https://angular.dev/guide/ngmodules/lazy-loading
|
||||
[cli]: https://cli.angular.io/
|
||||
[architecture]: https://angular.io/guide/architecture
|
||||
[componentstemplates]: https://angular.io/guide/displaying-data
|
||||
[forms]: https://angular.io/guide/forms-overview
|
||||
[api]: https://angular.io/api
|
||||
[angularelements]: https://angular.io/guide/elements
|
||||
[ssr]: https://angular.io/guide/universal
|
||||
[schematics]: https://angular.io/guide/schematics
|
||||
[lazyloading]: https://angular.io/guide/lazy-loading-ngmodules
|
||||
[node.js]: https://nodejs.org/
|
||||
[npm]: https://www.npmjs.com/get-npm
|
||||
[codeofconduct]: CODE_OF_CONDUCT.md
|
||||
[X (formerly Twitter)]: https://www.twitter.com/angular
|
||||
[twitter]: https://www.twitter.com/angular
|
||||
[discord]: https://discord.gg/angular
|
||||
[gitter]: https://gitter.im/angular/angular
|
||||
[stackoverflow]: https://stackoverflow.com/questions/tagged/angular
|
||||
[youtube]: https://youtube.com/angular
|
||||
[meetup]: https://www.meetup.com/find/?keywords=angular
|
||||
[animations]: https://angular.dev/guide/animations
|
||||
[animations]: https://angular.io/guide/animations
|
||||
|
||||
@@ -64,17 +64,12 @@ load("@rules_nodejs//nodejs:repositories.bzl", "nodejs_register_toolchains")
|
||||
|
||||
nodejs_register_toolchains(
|
||||
name = "nodejs",
|
||||
node_repositories = {
|
||||
"18.18.2-darwin_arm64": ("node-v18.18.2-darwin-arm64.tar.gz", "node-v18.18.2-darwin-arm64", "9f982cc91b28778dd8638e4f94563b0c2a1da7aba62beb72bd427721035ab553"),
|
||||
"18.18.2-darwin_amd64": ("node-v18.18.2-darwin-x64.tar.gz", "node-v18.18.2-darwin-x64", "5bb8da908ed590e256a69bf2862238c8a67bc4600119f2f7721ca18a7c810c0f"),
|
||||
"18.18.2-linux_arm64": ("node-v18.18.2-linux-arm64.tar.xz", "node-v18.18.2-linux-arm64", "2e630e18548627f61eaf573233da7949dc0a1df5eef3f486fa9820c5f6c121aa"),
|
||||
"18.18.2-linux_ppc64le": ("node-v18.18.2-linux-ppc64le.tar.xz", "node-v18.18.2-linux-ppc64le", "b0adff5cf5938266b711d6c724fb134d802e0dee40b3a3f73d162de1b3d11880"),
|
||||
"18.18.2-linux_s390x": ("node-v18.18.2-linux-s390x.tar.xz", "node-v18.18.2-linux-s390x", "c70ec2074b5e2b42c55bb4b8105418b67bf8a61c500d9376a07430dfcc341fdb"),
|
||||
"18.18.2-linux_amd64": ("node-v18.18.2-linux-x64.tar.xz", "node-v18.18.2-linux-x64", "75aba25ae76999309fc6c598efe56ce53fbfc221381a44a840864276264ab8ac"),
|
||||
"18.18.2-windows_amd64": ("node-v18.18.2-win-x64.zip", "node-v18.18.2-win-x64", "3bb0e51e579a41a22b3bf6cb2f3e79c03801aa17acbe0ca00fc555d1282e7acd"),
|
||||
},
|
||||
# We need at least Node 18.17 due to some transitive dependencies.
|
||||
node_version = "18.18.2",
|
||||
node_version = "16.14.0",
|
||||
)
|
||||
|
||||
nodejs_register_toolchains(
|
||||
name = "node18",
|
||||
node_version = "18.10.0",
|
||||
)
|
||||
|
||||
# Download npm dependencies.
|
||||
@@ -88,9 +83,8 @@ yarn_install(
|
||||
data = [
|
||||
YARN_LABEL,
|
||||
"//:.yarnrc",
|
||||
"//:tools/npm-patches/@bazel+jasmine+5.8.1.patch",
|
||||
"//tools:postinstall-patches.js",
|
||||
"//tools/esm-interop:patches/npm/@angular+build-tooling+0.0.0-e0ec7b60641d7f6369be45d8d02663fd50f320be.patch",
|
||||
"//tools/esm-interop:patches/npm/@angular+build-tooling+0.0.0-8d4803573edc70b90a1134ffa996303d1dcc18a9.patch",
|
||||
"//tools/esm-interop:patches/npm/@bazel+concatjs+5.8.1.patch",
|
||||
"//tools/esm-interop:patches/npm/@bazel+esbuild+5.7.1.patch",
|
||||
"//tools/esm-interop:patches/npm/@bazel+protractor+5.7.1.patch",
|
||||
@@ -116,7 +110,6 @@ yarn_install(
|
||||
data = [
|
||||
YARN_LABEL,
|
||||
"//:.yarnrc",
|
||||
"//:tools/npm-patches/@bazel+jasmine+5.8.1.patch",
|
||||
"//aio:tools/cli-patches/bazel-architect-output.patch",
|
||||
"//aio:tools/cli-patches/patch.js",
|
||||
],
|
||||
@@ -206,10 +199,10 @@ cldr_xml_data_repository(
|
||||
# sass rules
|
||||
http_archive(
|
||||
name = "io_bazel_rules_sass",
|
||||
sha256 = "974f5e14c54d300ca63a67910ebfcea6d838d8811fefc61184f2872107152705",
|
||||
strip_prefix = "rules_sass-888dd9c02709e228b60a8b3e51e2b16b2ca5279d",
|
||||
sha256 = "be45350fce6cec8c6bf98238d51ec270b58b1a93ace6acf156ab0b09bbccaaf9",
|
||||
strip_prefix = "rules_sass-ded821e3fc30ec3ceecdc28618b8b5df440a26be",
|
||||
urls = [
|
||||
"https://github.com/bazelbuild/rules_sass/archive/888dd9c02709e228b60a8b3e51e2b16b2ca5279d.zip",
|
||||
"https://github.com/bazelbuild/rules_sass/archive/ded821e3fc30ec3ceecdc28618b8b5df440a26be.zip",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@@ -1,174 +0,0 @@
|
||||
load("//:packages.bzl", "link_packages")
|
||||
load("@aio_npm//@angular/build-tooling/bazel/remote-execution:index.bzl", "ENABLE_NETWORK")
|
||||
load("@bazel_skylib//rules:common_settings.bzl", "bool_flag")
|
||||
load("@build_bazel_rules_nodejs//:index.bzl", "copy_to_bin")
|
||||
load("@npm//@angular-devkit/architect-cli:index.bzl", "architect", "architect_test")
|
||||
|
||||
package(default_visibility = ["//visibility:public"])
|
||||
|
||||
# All source and configuration files required to build the docs app
|
||||
APPLICATION_FILES = [
|
||||
"angular.json",
|
||||
"tsconfig.app.json",
|
||||
"tsconfig.json",
|
||||
"tsconfig.worker.json",
|
||||
] + glob(
|
||||
["src/**/*"],
|
||||
exclude = [
|
||||
"src/**/*.spec.ts",
|
||||
# Temporarily exclude generated sources produced by the non-bazel
|
||||
# build until the whole project is built by bazel and this directory
|
||||
# isn't needed.
|
||||
"src/generated/**/*",
|
||||
],
|
||||
)
|
||||
|
||||
TEST_FILES = APPLICATION_FILES + [
|
||||
"karma.conf.js",
|
||||
"tsconfig.spec.json",
|
||||
] + glob(
|
||||
["**/*.spec.ts"],
|
||||
)
|
||||
|
||||
APPLICATION_DEPS = link_packages([
|
||||
"@npm//@angular-devkit/build-angular",
|
||||
"@npm//@angular/animations",
|
||||
"@npm//@angular/cdk",
|
||||
"@npm//@angular/common",
|
||||
"@npm//@angular/compiler",
|
||||
"@npm//@angular/compiler-cli",
|
||||
"@npm//@angular/core",
|
||||
"@npm//@angular/docs",
|
||||
"@npm//@angular/forms",
|
||||
"@npm//@angular/material",
|
||||
"@npm//@angular/platform-browser",
|
||||
"@npm//@angular/platform-server",
|
||||
"@npm//@angular/router",
|
||||
"@npm//gsap",
|
||||
"@npm//marked",
|
||||
"@npm//ngx-progressbar",
|
||||
"@npm//ogl",
|
||||
"@npm//rxjs",
|
||||
"@npm//typescript",
|
||||
"//adev/src/assets/images",
|
||||
"//adev/src/assets/textures",
|
||||
"//adev/src/assets/previews",
|
||||
"//adev/src/assets:tutorials",
|
||||
"//adev/src/assets/icons",
|
||||
"//adev/src/assets:api",
|
||||
"//adev/src/assets:content",
|
||||
"@npm//@typescript/vfs",
|
||||
"@npm//@codemirror/state",
|
||||
"@npm//@codemirror/view",
|
||||
"@npm//@codemirror/language",
|
||||
"@npm//@codemirror/commands",
|
||||
"@npm//@codemirror/search",
|
||||
"@npm//@codemirror/autocomplete",
|
||||
"@npm//@codemirror/lint",
|
||||
"@npm//@codemirror/lang-html",
|
||||
"@npm//@codemirror/lang-angular",
|
||||
"@npm//@codemirror/lang-css",
|
||||
"@npm//@codemirror/lang-sass",
|
||||
"@npm//@codemirror/lang-javascript",
|
||||
"@npm//@lezer/highlight",
|
||||
"@npm//@lezer/javascript",
|
||||
"@npm//@lezer/common",
|
||||
"@npm//xterm",
|
||||
"@npm//xterm-addon-fit",
|
||||
"@npm//angular-split",
|
||||
])
|
||||
|
||||
TEST_DEPS = APPLICATION_DEPS + link_packages([
|
||||
"@npm//@angular/platform-browser-dynamic",
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium",
|
||||
"@npm//@types/jasmine",
|
||||
"@npm//@types/node",
|
||||
"@npm//assert",
|
||||
"@npm//jasmine",
|
||||
"@npm//jasmine-core",
|
||||
"@npm//karma-chrome-launcher",
|
||||
"@npm//karma-coverage",
|
||||
"@npm//karma-jasmine",
|
||||
"@npm//karma-jasmine-html-reporter",
|
||||
"//aio/tools:windows-chromium-path",
|
||||
])
|
||||
|
||||
copy_to_bin(
|
||||
name = "application_files_bin",
|
||||
srcs = APPLICATION_FILES,
|
||||
)
|
||||
|
||||
bool_flag(
|
||||
name = "fast_build_mode",
|
||||
build_setting_default = False,
|
||||
)
|
||||
|
||||
config_setting(
|
||||
name = "fast",
|
||||
flag_values = {
|
||||
":fast_build_mode": "true",
|
||||
},
|
||||
)
|
||||
|
||||
config_setting(
|
||||
name = "full",
|
||||
flag_values = {
|
||||
":fast_build_mode": "false",
|
||||
},
|
||||
)
|
||||
|
||||
config_based_architect_flags = select({
|
||||
":fast": ["--no-prerender"],
|
||||
":full": ["--prerender"],
|
||||
})
|
||||
|
||||
architect(
|
||||
name = "build",
|
||||
args = [
|
||||
"angular-dev:build",
|
||||
"--output-path=build",
|
||||
] + config_based_architect_flags,
|
||||
chdir = "$(RULEDIR)",
|
||||
data = APPLICATION_DEPS + [
|
||||
":application_files_bin",
|
||||
],
|
||||
# Network is required to inline fonts.
|
||||
exec_properties = ENABLE_NETWORK,
|
||||
output_dir = True,
|
||||
tags = [
|
||||
"no-remote-exec",
|
||||
],
|
||||
)
|
||||
|
||||
architect(
|
||||
name = "serve",
|
||||
args = [
|
||||
"angular-dev:serve",
|
||||
"--poll=1000",
|
||||
"--live-reload",
|
||||
"--watch",
|
||||
],
|
||||
chdir = package_name(),
|
||||
data = APPLICATION_DEPS + [
|
||||
":application_files_bin",
|
||||
],
|
||||
tags = [
|
||||
"no-remote-exec",
|
||||
],
|
||||
)
|
||||
|
||||
architect_test(
|
||||
name = "test",
|
||||
args = [
|
||||
"angular-dev:test",
|
||||
"--no-watch",
|
||||
],
|
||||
chdir = package_name(),
|
||||
data = TEST_DEPS + TEST_FILES,
|
||||
env = {
|
||||
"CHROME_BIN": "../$(CHROMIUM)",
|
||||
},
|
||||
toolchains = [
|
||||
"@npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
|
||||
],
|
||||
)
|
||||
@@ -1,104 +0,0 @@
|
||||
{
|
||||
"$schema": "../node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"newProjectRoot": ".",
|
||||
"projects": {
|
||||
"angular-dev": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss",
|
||||
"standalone": true,
|
||||
"changeDetection": "OnPush"
|
||||
}
|
||||
},
|
||||
"root": ".",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "adev",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"externalDependencies": [
|
||||
"path"
|
||||
],
|
||||
"outputPath": "dist/angular-dev",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"server": "src/main.server.ts",
|
||||
"polyfills": ["src/polyfills.ts", "zone.js"],
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
"src/favicon.ico",
|
||||
"src/robots.txt",
|
||||
"src/assets"
|
||||
],
|
||||
"styles": ["@angular/docs/styles/global-styles.scss"],
|
||||
"scripts": [],
|
||||
"webWorkerTsConfig": "tsconfig.worker.json",
|
||||
"optimization": {
|
||||
"fonts": {
|
||||
// TODO(josephperrott): enabled inline scripts
|
||||
"inline": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "angular-dev:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "angular-dev:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"buildTarget": "web-ui:build",
|
||||
"headers": {
|
||||
"Cross-Origin-Opener-Policy": "same-origin",
|
||||
"Cross-Origin-Embedder-Policy": "require-corp"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "angular-dev:build"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"options": {
|
||||
"polyfills": ["zone.js", "zone.js/testing"],
|
||||
"tsConfig": "tsconfig.spec.json",
|
||||
"include": ["src/app"],
|
||||
"karmaConfig": "karma.conf.js",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": ["src/favicon.ico", "src/assets"],
|
||||
"styles": ["@angular/docs/styles/global-styles.scss"],
|
||||
"scripts": [],
|
||||
"webWorkerTsConfig": "tsconfig.worker.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": "dca119a9-da31-47f7-a6cb-b60541037021"
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"hosting": {
|
||||
"target": "angular-docs",
|
||||
"public": "./build/browser",
|
||||
"ignore": ["**/.*"],
|
||||
"headers": [
|
||||
{
|
||||
"source": "assets/tutorials/common/*.jpg",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cross-Origin-Resource-Policy",
|
||||
"value": "cross-origin"
|
||||
},
|
||||
{
|
||||
"key": "Access-Control-Allow-Origin",
|
||||
"value": "*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "*.[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].+(css|js)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "public,max-age=31536000,immutable"
|
||||
},
|
||||
{
|
||||
"key": "Access-Control-Allow-Origin",
|
||||
"value": "*"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/[0-9a-f][0-9a-f][0-9a-f].[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f].js",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cross-Origin-Embedder-Policy",
|
||||
"value": "require-corp"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/@(ngsw-worker.js|ngsw.json)",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cache-Control",
|
||||
"value": "no-cache"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "/**",
|
||||
"headers": [
|
||||
{
|
||||
"key": "Cross-Origin-Opener-Policy",
|
||||
"value": "same-origin"
|
||||
},
|
||||
|
||||
{
|
||||
"key": "Cross-Origin-Embedder-Policy",
|
||||
"value": "require-corp"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "**",
|
||||
"destination": "/index.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
// Karma configuration file, see link for more information
|
||||
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||
|
||||
const {getAdjustedChromeBinPathForWindows} = require('../aio/tools/windows-chromium-path');
|
||||
|
||||
process.env.CHROME_BIN = getAdjustedChromeBinPathForWindows();
|
||||
|
||||
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'),
|
||||
{'reporter:jasmine-seed': ['type', JasmineSeedReporter]},
|
||||
],
|
||||
proxies: {
|
||||
'/dummy/image': 'src/assets/images/logos/angular/angular.png',
|
||||
},
|
||||
client: {
|
||||
clearContext: false, // leave Jasmine Spec Runner output visible in browser
|
||||
jasmine: {
|
||||
// you can add configuration options for Jasmine here
|
||||
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||
// for example, you can disable the random execution with `random: false`
|
||||
// or set a specific seed with `seed: 4321`
|
||||
random: true,
|
||||
seed: '',
|
||||
},
|
||||
},
|
||||
jasmineHtmlReporter: {
|
||||
suppressAll: true, // removes the duplicated traces
|
||||
},
|
||||
coverageReporter: {
|
||||
dir: require('path').join(__dirname, './coverage/site'),
|
||||
subdir: '.',
|
||||
reporters: [{type: 'html'}, {type: 'text-summary'}],
|
||||
},
|
||||
reporters: ['progress', 'kjhtml', 'jasmine-seed'],
|
||||
port: 9876,
|
||||
colors: true,
|
||||
logLevel: config.LOG_INFO,
|
||||
autoWatch: true,
|
||||
customLaunchers: {
|
||||
ChromeHeadlessNoSandbox: {
|
||||
base: 'ChromeHeadless',
|
||||
// See /integration/README.md#browser-tests for more info on these args
|
||||
flags: [
|
||||
'--no-sandbox',
|
||||
'--headless',
|
||||
'--disable-gpu',
|
||||
'--disable-dev-shm-usage',
|
||||
'--hide-scrollbars',
|
||||
'--mute-audio',
|
||||
],
|
||||
},
|
||||
},
|
||||
browsers: ['ChromeHeadlessNoSandbox'],
|
||||
browserNoActivityTimeout: 60000,
|
||||
singleRun: false,
|
||||
restartOnFileChange: true,
|
||||
});
|
||||
};
|
||||
|
||||
// Helpers
|
||||
function JasmineSeedReporter(baseReporterDecorator) {
|
||||
baseReporterDecorator(this);
|
||||
|
||||
this.onBrowserComplete = (browser, result) => {
|
||||
const seed = result.order && result.order.random && result.order.seed;
|
||||
if (seed) this.write(`${browser}: Randomized with seed ${seed}.\n`);
|
||||
};
|
||||
|
||||
this.onRunComplete = () => undefined;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
@defer (when isBrowser) {
|
||||
<adev-progress-bar />
|
||||
}
|
||||
<button (click)="focusFirstHeading()" class="adev-skip">Skip to main content</button>
|
||||
|
||||
<div class="adev-nav"></div>
|
||||
@if (displaySecondaryNav()) {
|
||||
<adev-secondary-navigation />
|
||||
}
|
||||
<div class="docs-app-main-content">
|
||||
<!--
|
||||
Avoid rendering cookies popup on the server,
|
||||
since there is no benefit of doing this and
|
||||
it requires additional state management.
|
||||
-->
|
||||
@defer (when isBrowser) {
|
||||
<docs-cookie-popup />
|
||||
|
||||
@defer (when displaySearchDialog(); prefetch on idle) {
|
||||
@if (displaySearchDialog()) {
|
||||
<docs-search-dialog (onClose)="displaySearchDialog.set(false)" />
|
||||
}
|
||||
}
|
||||
}
|
||||
<router-outlet />
|
||||
|
||||
@if (displayFooter()) {
|
||||
<footer adev-footer></footer>
|
||||
}
|
||||
</div>
|
||||
@@ -1,86 +0,0 @@
|
||||
@use '@angular/docs/styles/media-queries' as mq;
|
||||
|
||||
:host {
|
||||
max-width: 2560px;
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
min-height: 100vh;
|
||||
|
||||
@include mq.for-tablet-landscape-down {
|
||||
flex-direction: column;
|
||||
}
|
||||
// If navs are open, render a blurry background over content
|
||||
&:has(.docs-nav-secondary--open),
|
||||
&:has(.adev-nav-primary--open) {
|
||||
.docs-app-main-content {
|
||||
&::after {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:has(.adev-home) {
|
||||
.adev-nav {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@include mq.for-tablet-landscape-up {
|
||||
footer {
|
||||
margin-left: var(--primary-nav-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adev-skip {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
left: 0.5rem;
|
||||
z-index: 1000;
|
||||
background: var(--primary-contrast);
|
||||
color: var(--page-background);
|
||||
border: 1px solid var(--vivid-pink);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
transform: translateY(-150%);
|
||||
transition: transform 0.3s ease-out;
|
||||
&:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.docs-app-main-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
|
||||
// If navs are open, render a blurry background over content
|
||||
::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
backdrop-filter: blur(2px);
|
||||
background-color: color-mix(in srgb, var(--gray-1000) 5%, transparent);
|
||||
z-index: 50;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
@include mq.for-tablet {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/*!
|
||||
* @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';
|
||||
import {provideRouter} from '@angular/router';
|
||||
import {routes} from './routes';
|
||||
import {Search, WINDOW} from '@angular/docs';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
const fakeSearch = {};
|
||||
const fakeWindow = {};
|
||||
|
||||
it('should create the app', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter(routes),
|
||||
{
|
||||
provide: WINDOW,
|
||||
useValue: fakeWindow,
|
||||
},
|
||||
{
|
||||
provide: Search,
|
||||
useValue: fakeSearch,
|
||||
},
|
||||
],
|
||||
});
|
||||
const fixture = TestBed.createComponent(AppComponent);
|
||||
const app = fixture.componentInstance;
|
||||
expect(app).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
/*!
|
||||
* @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 {DOCUMENT, isPlatformBrowser} from '@angular/common';
|
||||
import {
|
||||
Component,
|
||||
inject,
|
||||
NgZone,
|
||||
OnInit,
|
||||
PLATFORM_ID,
|
||||
signal,
|
||||
WritableSignal,
|
||||
} from '@angular/core';
|
||||
import {NavigationEnd, NavigationSkipped, Router, RouterLink, RouterOutlet} from '@angular/router';
|
||||
import {filter, map, skip} from 'rxjs/operators';
|
||||
import {
|
||||
CookiePopup,
|
||||
getActivatedRouteSnapshotFromRouter,
|
||||
IS_SEARCH_DIALOG_OPEN,
|
||||
SearchDialog,
|
||||
WINDOW,
|
||||
} from '@angular/docs';
|
||||
import {Footer} from './core/layout/footer/footer.component';
|
||||
import {Navigation} from './core/layout/navigation/navigation.component';
|
||||
import {SecondaryNavigation} from './core/layout/secondary-navigation/secondary-navigation.component';
|
||||
import {ProgressBarComponent} from './core/layout/progress-bar/progress-bar.component';
|
||||
import {ESCAPE, SEARCH_TRIGGER_KEY} from './core/constants/keys';
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'adev-root',
|
||||
imports: [
|
||||
CookiePopup,
|
||||
Navigation,
|
||||
Footer,
|
||||
SecondaryNavigation,
|
||||
RouterOutlet,
|
||||
RouterLink,
|
||||
SearchDialog,
|
||||
ProgressBarComponent,
|
||||
],
|
||||
templateUrl: './app.component.html',
|
||||
styleUrls: ['./app.component.scss'],
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly router = inject(Router);
|
||||
private readonly window = inject(WINDOW);
|
||||
|
||||
currentUrl = signal('');
|
||||
displayFooter = signal(false);
|
||||
displaySecondaryNav = signal(false);
|
||||
displaySearchDialog: WritableSignal<boolean> = inject(IS_SEARCH_DIALOG_OPEN);
|
||||
|
||||
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
|
||||
ngOnInit(): void {
|
||||
this.setSearchDialogVisibilityOnKeyPress();
|
||||
this.closeSearchDialogOnNavigationSkipped();
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
|
||||
map((event) => event.urlAfterRedirects),
|
||||
)
|
||||
.subscribe((url) => {
|
||||
this.currentUrl.set(url);
|
||||
this.setComponentsVisibility();
|
||||
this.displaySearchDialog.set(false);
|
||||
});
|
||||
|
||||
this.focusFirstHeadingOnRouteChange();
|
||||
}
|
||||
|
||||
focusFirstHeading(): void {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
|
||||
const h1 = this.document.querySelector<HTMLHeadingElement>('h1');
|
||||
h1?.focus();
|
||||
}
|
||||
|
||||
private setComponentsVisibility(): void {
|
||||
const activatedRoute = getActivatedRouteSnapshotFromRouter(this.router as any);
|
||||
|
||||
this.displaySecondaryNav.set(activatedRoute.data['displaySecondaryNav']);
|
||||
this.displayFooter.set(!activatedRoute.data['hideFooter']);
|
||||
}
|
||||
|
||||
private focusFirstHeadingOnRouteChange(): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((e): e is NavigationEnd => e instanceof NavigationEnd),
|
||||
// Skip first emission, cause on the initial load we would like to `Skip to main content` popup when it's focused
|
||||
skip(1),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.focusFirstHeading();
|
||||
});
|
||||
}
|
||||
|
||||
private setSearchDialogVisibilityOnKeyPress(): void {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.window.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === SEARCH_TRIGGER_KEY && (event.metaKey || event.ctrlKey)) {
|
||||
this.ngZone.run(() => {
|
||||
event.preventDefault();
|
||||
this.displaySearchDialog.update((display) => !display);
|
||||
});
|
||||
}
|
||||
|
||||
if (event.key === ESCAPE && this.displaySearchDialog()) {
|
||||
this.ngZone.run(() => {
|
||||
event.preventDefault();
|
||||
this.displaySearchDialog.set(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private closeSearchDialogOnNavigationSkipped(): void {
|
||||
this.router.events.pipe(filter((event) => event instanceof NavigationSkipped)).subscribe(() => {
|
||||
this.displaySearchDialog.set(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/*!
|
||||
* @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 {mergeApplicationConfig, ApplicationConfig} from '@angular/core';
|
||||
import {provideServerRendering} from '@angular/platform-server';
|
||||
import {appConfig} from './app.config';
|
||||
import {
|
||||
ReferenceScrollHandler,
|
||||
ReferenceScrollHandlerNoop,
|
||||
} from './features/references/services/reference-scroll-handler.service';
|
||||
|
||||
const serverConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideServerRendering(),
|
||||
{provide: ReferenceScrollHandler, useClass: ReferenceScrollHandlerNoop},
|
||||
],
|
||||
};
|
||||
|
||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||
@@ -1,97 +0,0 @@
|
||||
/*!
|
||||
* @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 {DOCUMENT} from '@angular/common';
|
||||
import {provideHttpClient, withFetch} from '@angular/common/http';
|
||||
import {
|
||||
ApplicationConfig,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
ErrorHandler,
|
||||
inject,
|
||||
provideZoneChangeDetection,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
DOCS_CONTENT_LOADER,
|
||||
ENVIRONMENT,
|
||||
EXAMPLE_VIEWER_CONTENT_LOADER,
|
||||
PREVIEWS_COMPONENTS,
|
||||
WINDOW,
|
||||
windowProvider,
|
||||
} from '@angular/docs';
|
||||
import {provideClientHydration} from '@angular/platform-browser';
|
||||
import {provideAnimationsAsync} from '@angular/platform-browser/animations/async';
|
||||
import {
|
||||
RouteReuseStrategy,
|
||||
Router,
|
||||
TitleStrategy,
|
||||
createUrlTreeFromSnapshot,
|
||||
provideRouter,
|
||||
withInMemoryScrolling,
|
||||
withViewTransitions,
|
||||
} from '@angular/router';
|
||||
import environment from './environment';
|
||||
import {PREVIEWS_COMPONENTS_MAP} from './../assets/previews/previews';
|
||||
import {ADevTitleStrategy} from './core/services/a-dev-title-strategy';
|
||||
import {AnalyticsService} from './core/services/analytics/analytics.service';
|
||||
import {ContentLoader} from './core/services/content-loader.service';
|
||||
import {CustomErrorHandler} from './core/services/errors-handling/error-handler';
|
||||
import {ExampleContentLoader} from './core/services/example-content-loader.service';
|
||||
import {ReuseTutorialsRouteStrategy} from './features/tutorial/tutorials-route-reuse-strategy';
|
||||
import {routes} from './routes';
|
||||
import {ReferenceScrollHandler} from './features/references/services/reference-scroll-handler.service';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideRouter(
|
||||
routes,
|
||||
withInMemoryScrolling({anchorScrolling: 'enabled', scrollPositionRestoration: 'enabled'}),
|
||||
withViewTransitions({
|
||||
onViewTransitionCreated: ({transition, to}) => {
|
||||
const router = inject(Router);
|
||||
const toTree = createUrlTreeFromSnapshot(to, []);
|
||||
// Skip the transition if the only thing changing is the fragment and queryParams
|
||||
if (
|
||||
router.isActive(toTree, {
|
||||
paths: 'exact',
|
||||
matrixParams: 'exact',
|
||||
fragment: 'ignored',
|
||||
queryParams: 'ignored',
|
||||
})
|
||||
) {
|
||||
transition.skipTransition();
|
||||
}
|
||||
},
|
||||
}),
|
||||
),
|
||||
provideClientHydration(),
|
||||
provideHttpClient(withFetch()),
|
||||
provideAnimationsAsync(),
|
||||
{provide: ENVIRONMENT, useValue: environment},
|
||||
{
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => inject(AnalyticsService),
|
||||
},
|
||||
{provide: ErrorHandler, useClass: CustomErrorHandler},
|
||||
{provide: PREVIEWS_COMPONENTS, useValue: PREVIEWS_COMPONENTS_MAP},
|
||||
{provide: DOCS_CONTENT_LOADER, useClass: ContentLoader},
|
||||
{provide: EXAMPLE_VIEWER_CONTENT_LOADER, useClass: ExampleContentLoader},
|
||||
{
|
||||
provide: RouteReuseStrategy,
|
||||
useClass: ReuseTutorialsRouteStrategy,
|
||||
},
|
||||
{
|
||||
provide: WINDOW,
|
||||
useFactory: (document: Document) => windowProvider(document),
|
||||
deps: [DOCUMENT],
|
||||
},
|
||||
{provide: TitleStrategy, useClass: ADevTitleStrategy},
|
||||
provideZoneChangeDetection({eventCoalescing: true}),
|
||||
ReferenceScrollHandler,
|
||||
],
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
/*!
|
||||
* @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 PRIMARY_NAV_ID = 'primaryNav';
|
||||
export const SECONDARY_NAV_ID = 'secondaryNav';
|
||||
@@ -1,12 +0,0 @@
|
||||
/*!
|
||||
* @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 COMMAND = 'Command';
|
||||
export const CONTROL = 'Control';
|
||||
export const ESCAPE = 'Escape';
|
||||
export const SEARCH_TRIGGER_KEY = 'k';
|
||||
@@ -1,12 +0,0 @@
|
||||
/*!
|
||||
* @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 GITHUB = 'https://github.com/angular/angular';
|
||||
export const X = 'https://x.com/angular';
|
||||
export const MEDIUM = 'https://blog.angular.io';
|
||||
export const YOUTUBE = 'https://www.youtube.com/channel/UCbn1OgGei-DV7aSRo_HaAiw';
|
||||
@@ -1,28 +0,0 @@
|
||||
/*!
|
||||
* @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
|
||||
*/
|
||||
|
||||
// File contains the enums used across whole application.
|
||||
|
||||
// The enum with the prefixes of the main routes
|
||||
export enum PagePrefix {
|
||||
API = 'api',
|
||||
CLI = 'cli',
|
||||
DOCS = 'docs',
|
||||
HOME = '',
|
||||
PLAYGROUND = 'playground',
|
||||
REFERENCE = 'reference',
|
||||
TUTORIALS = 'tutorials',
|
||||
}
|
||||
|
||||
// The enum with the default pages for each main tab
|
||||
export enum DefaultPage {
|
||||
DOCS = 'overview',
|
||||
REFERENCE = 'api',
|
||||
TUTORIALS = 'tutorials',
|
||||
PLAYGROUND = 'playground',
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
<div class="adev-footer-container">
|
||||
<div class="adev-footer-columns">
|
||||
<div>
|
||||
<h2>Social Media</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a [href]="MEDIUM" title="Angular blog">Blog</a>
|
||||
</li>
|
||||
<li>
|
||||
<a [href]="X" title="X (formerly Twitter)">X (formerly Twitter)</a>
|
||||
</li>
|
||||
<li>
|
||||
<a [href]="YOUTUBE" title="YouTube">YouTube</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://discord.gg/angular"
|
||||
title="Join the discussions at Angular Community Discord server."
|
||||
>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a [href]="GITHUB" title="GitHub">GitHub</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://stackoverflow.com/questions/tagged/angular"
|
||||
title="Stack Overflow: where the community answers your technical Angular questions."
|
||||
>
|
||||
Stack Overflow
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Community</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/angular/angular/blob/main/CONTRIBUTING.md"
|
||||
title="Contribute to Angular"
|
||||
>
|
||||
Contribute
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/angular/code-of-conduct/blob/main/CODE_OF_CONDUCT.md"
|
||||
title="Treating each other with respect."
|
||||
>
|
||||
Code of Conduct
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/angular/angular/issues"
|
||||
title="Post issues and suggestions on github."
|
||||
>
|
||||
Report Issues
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://devlibrary.withgoogle.com/products/angular?sort=updated"
|
||||
title="Google's DevLibrary"
|
||||
>
|
||||
Google's DevLibrary
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://developers.google.com/community/experts/directory?specialization=angular"
|
||||
title="Angular Google Developer Experts"
|
||||
>
|
||||
Angular Google Developer Experts
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Resources</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a routerLink="/press-kit" title="Press contacts, logos, and branding.">Press Kit</a>
|
||||
</li>
|
||||
<li>
|
||||
<a routerLink="/roadmap" title="Roadmap">Roadmap</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Languages</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://angular.cn/" title="简体中文版">简体中文版</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://angular.tw/" title="正體中文版">正體中文版</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://angular.jp/" title="日本語版">日本語版</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://angular.kr/" title="한국어">한국어</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://angular-gr.web.app"
|
||||
title="Ελληνικά"
|
||||
>
|
||||
Ελληνικά
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p class="docs-license">
|
||||
Super-powered by Google ©2010-2024. Code licensed under an
|
||||
<a routerLink="/license" title="License text">MIT-style License</a>
|
||||
. Documentation licensed under
|
||||
<a href="https://creativecommons.org/licenses/by/4.0/">CC BY 4.0</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,59 +0,0 @@
|
||||
.adev-footer-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 2rem;
|
||||
|
||||
@container footer (max-width: 600px) {
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.adev-footer-container {
|
||||
container: footer / inline-size;
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
padding: var(--layout-padding);
|
||||
padding-inline-end: 1rem;
|
||||
background-color: var(--page-background);
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
// this accounts for absolutely positioned TOC on the right
|
||||
@media only screen and (min-width: 1430px) {
|
||||
width: calc(100% - 195px - var(--layout-padding) * 3);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-block-end: 1.75rem;
|
||||
letter-spacing: -0.00875rem;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.95rem;
|
||||
li {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--quaternary-contrast);
|
||||
font-weight: 300;
|
||||
transition: color 0.3s ease;
|
||||
&:hover {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
p.docs-license {
|
||||
transition: color 0.3s ease;
|
||||
color: var(--quaternary-contrast);
|
||||
font-weight: 300;
|
||||
grid-column: span 4;
|
||||
font-size: 0.75rem;
|
||||
margin-block-start: 2rem;
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/*!
|
||||
* @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 {WINDOW} from '@angular/docs';
|
||||
import {RouterTestingModule} from '@angular/router/testing';
|
||||
import {Footer} from './footer.component';
|
||||
|
||||
describe('Footer', () => {
|
||||
let component: Footer;
|
||||
let fixture: ComponentFixture<Footer>;
|
||||
const fakeWindow = {
|
||||
location: {
|
||||
origin: 'example-origin',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [Footer, RouterTestingModule],
|
||||
providers: [
|
||||
{
|
||||
provide: WINDOW,
|
||||
useValue: fakeWindow,
|
||||
},
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(Footer);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
/*!
|
||||
* @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 {CommonModule} from '@angular/common';
|
||||
import {ChangeDetectionStrategy, Component} from '@angular/core';
|
||||
import {ExternalLink} from '@angular/docs';
|
||||
import {RouterLink} from '@angular/router';
|
||||
import {GITHUB, X, MEDIUM, YOUTUBE} from './../../constants/links';
|
||||
|
||||
@Component({
|
||||
selector: 'footer[adev-footer]',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ExternalLink, RouterLink],
|
||||
templateUrl: './footer.component.html',
|
||||
styleUrls: ['./footer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Footer {
|
||||
readonly GITHUB = GITHUB;
|
||||
readonly X = X;
|
||||
readonly YOUTUBE = YOUTUBE;
|
||||
readonly MEDIUM = MEDIUM;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
@use '@angular/docs/styles/media-queries' as mq;
|
||||
|
||||
// Dark/Light mode switcher & social media icon buttons
|
||||
// ul
|
||||
.adev-mini-menu {
|
||||
padding: 0;
|
||||
color: var(--primary-contrast);
|
||||
background-color: var(--page-background);
|
||||
border: 1px solid var(--senary-contrast);
|
||||
border-radius: 0.25rem;
|
||||
z-index: var(--z-index-mini-menu);
|
||||
box-shadow: 10px 4px 40px 0 rgba(0, 0, 0, 0.075);
|
||||
|
||||
@include mq.for-tablet {
|
||||
top: 75px;
|
||||
left: 5px;
|
||||
}
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
|
||||
// themes and versions
|
||||
button {
|
||||
padding: 1rem;
|
||||
min-width: 75px;
|
||||
min-height: 75px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
docs-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--quaternary-contrast);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--senary-contrast);
|
||||
span,
|
||||
docs-icon {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// social links
|
||||
a {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
min-width: 50px;
|
||||
svg {
|
||||
fill: var(--quaternary-contrast);
|
||||
transition: fill 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--senary-contrast);
|
||||
svg {
|
||||
fill: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--quaternary-contrast);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
&-open {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.adev-version-picker {
|
||||
top: 30px;
|
||||
left: 10px;
|
||||
position: absolute;
|
||||
bottom: auto;
|
||||
|
||||
li {
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
li button {
|
||||
min-height: fit-content;
|
||||
}
|
||||
|
||||
@include mq.for-tablet {
|
||||
top: 30px;
|
||||
left: auto;
|
||||
bottom: auto;
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
@use '@angular/docs/styles/media-queries' as mq;
|
||||
|
||||
.adev-nav-item {
|
||||
color: var(--quaternary-contrast);
|
||||
position: relative;
|
||||
width: 6.875rem;
|
||||
|
||||
@include mq.for-phone-only {
|
||||
width: 5.05rem;
|
||||
}
|
||||
|
||||
@include mq.for-tablet {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 2px;
|
||||
@include mq.for-tablet {
|
||||
width: auto;
|
||||
top: auto;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
// TODO: Make this gradient if the page is currently active, and black if child page active
|
||||
background-color: var(--primary-contrast);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
transform-origin: center;
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
&:not(.adev-nav-item--logo) {
|
||||
@include mq.for-tablet {
|
||||
a,
|
||||
.adev-nav-button {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adev-nav-button {
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a,
|
||||
.adev-nav-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@include mq.for-tablet {
|
||||
flex-direction: row;
|
||||
}
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding-block: 1.25rem;
|
||||
text-decoration: none;
|
||||
fill: var(--quaternary-contrast);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: fill 0.3s ease;
|
||||
}
|
||||
|
||||
&__label {
|
||||
margin: 0;
|
||||
font-size: 0.813; // 13px
|
||||
color: inherit;
|
||||
|
||||
abbr {
|
||||
font-size: 0.688; // 11px
|
||||
}
|
||||
}
|
||||
|
||||
i {
|
||||
color: var(--quaternary-contrast);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
span,
|
||||
abbr {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
a,
|
||||
.adev-nav-button {
|
||||
fill: var(--primary-contrast);
|
||||
}
|
||||
span,
|
||||
abbr {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
i {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
&--active {
|
||||
&::before {
|
||||
opacity: 1;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
&:not(.adev-nav-item--logo) {
|
||||
path {
|
||||
fill: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
span,
|
||||
abbr,
|
||||
i {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
<!-- Primary Nav -->
|
||||
<div
|
||||
[attr.id]="PRIMARY_NAV_ID"
|
||||
(docsClickOutside)="closeMobileNav()"
|
||||
[docsClickOutsideIgnore]="[SECONDARY_NAV_ID]"
|
||||
>
|
||||
<!-- Mobile nav bar -->
|
||||
<div class="adev-mobile-nav-bar">
|
||||
<!-- Logo icon -->
|
||||
<button
|
||||
type="button"
|
||||
class="adev-mobile-nav-button"
|
||||
aria-label="Toggle mobile navigation"
|
||||
(click)="openMobileNav($event)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 223 236" width="32">
|
||||
<g clip-path="url(#2a)">
|
||||
<path
|
||||
fill="url(#2b)"
|
||||
d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#2c)"
|
||||
d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="2b"
|
||||
x1="49.009"
|
||||
x2="225.829"
|
||||
y1="213.75"
|
||||
y2="129.722"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#E40035" />
|
||||
<stop offset=".24" stop-color="#F60A48" />
|
||||
<stop offset=".352" stop-color="#F20755" />
|
||||
<stop offset=".494" stop-color="#DC087D" />
|
||||
<stop offset=".745" stop-color="#9717E7" />
|
||||
<stop offset="1" stop-color="#6C00F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="2c"
|
||||
x1="41.025"
|
||||
x2="156.741"
|
||||
y1="28.344"
|
||||
y2="160.344"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF31D9" />
|
||||
<stop offset="1" stop-color="#FF5BE1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<clipPath id="2a"><path fill="#fff" d="M0 0h223v236H0z" /></clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<docs-icon role="presentation">menu</docs-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav
|
||||
class="adev-nav-primary docs-scroll-hide"
|
||||
[class.adev-nav-primary--open]="isMobileNavigationOpened()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="adev-close-nav"
|
||||
(click)="closeMobileNav()"
|
||||
aria-label="Close navigation"
|
||||
>
|
||||
<docs-icon role="presentation">close</docs-icon>
|
||||
</button>
|
||||
<!-- Top section of primary nav -->
|
||||
<ul class="adev-nav__top">
|
||||
<!-- Home / Angular logo -->
|
||||
<li
|
||||
class="adev-nav-item adev-nav-item--logo"
|
||||
[class.adev-nav-item--active]="activeRouteItem() === HOME_ROUTE"
|
||||
>
|
||||
<a aria-label="Angular homepage" routerLink="/">
|
||||
<!-- Logo Symbol -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 223 236" width="32">
|
||||
<g clip-path="url(#a)">
|
||||
<path
|
||||
fill="url(#b)"
|
||||
d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
<path
|
||||
fill="url(#c)"
|
||||
d="m222.077 39.192-8.019 125.923L137.387 0l84.69 39.192Zm-53.105 162.825-57.933 33.056-57.934-33.056 11.783-28.556h92.301l11.783 28.556ZM111.039 62.675l30.357 73.803H80.681l30.358-73.803ZM7.937 165.115 0 39.192 84.69 0 7.937 165.115Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="b"
|
||||
x1="49.009"
|
||||
x2="225.829"
|
||||
y1="213.75"
|
||||
y2="129.722"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#E40035" />
|
||||
<stop offset=".24" stop-color="#F60A48" />
|
||||
<stop offset=".352" stop-color="#F20755" />
|
||||
<stop offset=".494" stop-color="#DC087D" />
|
||||
<stop offset=".745" stop-color="#9717E7" />
|
||||
<stop offset="1" stop-color="#6C00F5" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="c"
|
||||
x1="41.025"
|
||||
x2="156.741"
|
||||
y1="28.344"
|
||||
y2="160.344"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#FF31D9" />
|
||||
<stop offset="1" stop-color="#FF5BE1" stop-opacity="0" />
|
||||
</linearGradient>
|
||||
<clipPath id="a">
|
||||
<path fill="#fff" d="M0 0h223v236H0z" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<div class="adev-beta-version">Beta Docs</div>
|
||||
</a>
|
||||
|
||||
<!-- Version picker for v18+ -->
|
||||
<div class="adev-nav-item">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select Angular version"
|
||||
role="menu"
|
||||
class="adev-version-button"
|
||||
[class.adev-mini-menu-open]="openedMenu === 'version-picker'"
|
||||
[cdkMenuTriggerFor]="docsVersionMiniMenu"
|
||||
[cdkMenuPosition]="miniMenuPositions"
|
||||
(cdkMenuClosed)="closeMenu()"
|
||||
(click)="openVersionMenu($event)"
|
||||
>
|
||||
{{ currentDocsVersion()?.displayName }}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
height="15"
|
||||
viewBox="0 -960 960 960"
|
||||
width="15"
|
||||
fill="inherit"
|
||||
>
|
||||
<path d="M480-345 240-585l56-56 184 184 184-184 56 56-240 240Z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ng-template #docsVersionMiniMenu>
|
||||
<ul class="adev-mini-menu adev-version-picker" cdkMenu>
|
||||
@for (item of versions(); track item) {
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
cdkMenuItem
|
||||
(click)="redirectToDocsVersion(item.url)"
|
||||
[attr.aria-label]="item.displayName"
|
||||
>
|
||||
<span>{{ item.displayName }}</span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</ng-template>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<!-- Search -->
|
||||
<li class="adev-nav-item">
|
||||
<button
|
||||
class="adev-nav-button"
|
||||
type="button"
|
||||
(click)="toggleSearchDialog($event)"
|
||||
title="Search docs"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="inherit"
|
||||
>
|
||||
<path
|
||||
d="M14.583 15.48 9.104 10a4.591 4.591 0 0 1-1.458.844 5.156 5.156 0 0 1-1.771.302c-1.5 0-2.77-.52-3.813-1.563C1.022 8.542.5 7.285.5 5.813c0-1.473.52-2.73 1.563-3.771C3.103 1 4.367.479 5.854.479 7.326.48 8.58 1 9.614 2.042c1.035 1.041 1.553 2.298 1.553 3.77 0 .598-.098 1.174-.292 1.73A5.287 5.287 0 0 1 10 9.104l5.5 5.459-.917.916ZM5.854 9.895c1.125 0 2.083-.4 2.875-1.198a3.95 3.95 0 0 0 1.188-2.885 3.95 3.95 0 0 0-1.188-2.886C7.938 2.13 6.98 1.73 5.854 1.73c-1.139 0-2.107.4-2.906 1.198-.799.799-1.198 1.76-1.198 2.886 0 1.125.4 2.086 1.198 2.885.799.799 1.767 1.198 2.906 1.198Z"
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
class="adev-nav-item__label adev-search-desktop"
|
||||
[attr.aria-label]="'Open search dialog with ' + searchTitle"
|
||||
>
|
||||
<kbd>{{ searchLabel }}</kbd>
|
||||
<kbd>K</kbd>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Docs -->
|
||||
<li class="adev-nav-item" [class.adev-nav-item--active]="activeRouteItem() === DOCS_ROUTE">
|
||||
<a [routerLink]="DOCS_ROUTE">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="18"
|
||||
fill="inherit"
|
||||
>
|
||||
<path
|
||||
d="M3.645 13.792h6.708v-1.25H3.645v1.25Zm0-3.542h6.708V9H3.645v1.25Zm-2.063 7.083a1.2 1.2 0 0 1-.875-.375 1.2 1.2 0 0 1-.375-.875V1.917a1.2 1.2 0 0 1 .375-.875 1.2 1.2 0 0 1 .875-.375h7.52l4.563 4.562v10.854a1.2 1.2 0 0 1-.375.875 1.2 1.2 0 0 1-.875.375H1.582ZM8.478 5.792V1.917H1.582v14.166h10.833V5.792H8.478Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="adev-nav-item__label">Docs</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Tutorials -->
|
||||
<li
|
||||
class="adev-nav-item"
|
||||
[class.adev-nav-item--active]="activeRouteItem() === TUTORIALS_ROUTE"
|
||||
>
|
||||
<a [routerLink]="TUTORIALS_ROUTE">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="10"
|
||||
fill="inherit"
|
||||
>
|
||||
<path
|
||||
d="m5.668 10-5-5 5-5 1.187 1.188L3.022 5.02l3.813 3.812L5.668 10Zm6.667 0-1.188-1.188L14.98 4.98l-3.812-3.812L12.335 0l5 5-5 5Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="adev-nav-item__label">Tutorials</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Playground -->
|
||||
<li
|
||||
class="adev-nav-item"
|
||||
[class.adev-nav-item--active]="activeRouteItem() === PLAYGROUND_ROUTE"
|
||||
>
|
||||
<a [routerLink]="PLAYGROUND_ROUTE">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24">
|
||||
<path
|
||||
d="M450.001-611.691v-32.386q-39.385-9.923-64.692-41.897-25.308-31.975-25.308-74.025 0-49.922 35.038-84.96 35.039-35.038 84.961-35.038t84.961 35.038q35.038 35.038 35.038 84.96 0 42.05-25.308 74.025-25.307 31.974-64.692 41.897v32.386l273.846 157.538q17.173 9.912 26.663 26.582 9.491 16.671 9.491 36.495v62.152q0 19.824-9.491 36.495-9.49 16.67-26.663 26.582L516.154-111.771q-17.203 9.846-36.217 9.846t-36.091-9.846L176.155-265.847q-17.173-9.912-26.663-26.582-9.491-16.671-9.491-36.495v-62.152q0-19.824 9.491-36.495 9.49-16.67 26.663-26.582l273.846-157.538Zm-6.155 364.537L200-387.461v58.537q0 3.078 1.539 5.962 1.538 2.885 4.615 4.808l267.692 154.692q3.077 1.923 6.154 1.923t6.154-1.923l267.692-154.692q3.077-1.923 4.615-4.808 1.539-2.884 1.539-5.962v-58.537L516.154-247.154q-17.203 9.847-36.217 9.847t-36.091-9.847Zm6.155-162.847V-542.77L250.46-427.691l223.386 128.846q3.077 1.924 6.154 1.924t6.154-1.924l223.001-128.846L509.999-542.77v132.769h-59.998ZM480-699.999q25 0 42.5-17.5t17.5-42.5q0-25-17.5-42.5t-42.5-17.5q-25 0-42.5 17.5t-17.5 42.5q0 25 17.5 42.5t42.5 17.5Zm-2.308 538.46Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="adev-nav-item__label">Playground</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- API Ref -->
|
||||
<li
|
||||
class="adev-nav-item"
|
||||
[class.adev-nav-item--active]="activeRouteItem() === REFERENCE_ROUTE"
|
||||
>
|
||||
<a [routerLink]="REFERENCE_ROUTE">
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="18"
|
||||
fill="inherit"
|
||||
>
|
||||
<path
|
||||
d="M3.645 13.792h6.708v-1.25H3.645v1.25Zm0-3.542h6.708V9H3.645v1.25Zm-2.063 7.083a1.2 1.2 0 0 1-.875-.375 1.2 1.2 0 0 1-.375-.875V1.917a1.2 1.2 0 0 1 .375-.875 1.2 1.2 0 0 1 .875-.375h7.52l4.563 4.562v10.854a1.2 1.2 0 0 1-.375.875 1.2 1.2 0 0 1-.875.375H1.582ZM8.478 5.792V1.917H1.582v14.166h10.833V5.792H8.478Z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="adev-nav-item__label">Reference</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Bottom section of primary nav -->
|
||||
<div class="adev-nav__bottom">
|
||||
<!-- Social Icons Dots -->
|
||||
<div class="adev-nav-item" [class.adev-nav-item--active]="openedMenu === 'social'">
|
||||
<button
|
||||
type="button"
|
||||
[cdkMenuTriggerFor]="socialMiniMenu"
|
||||
[cdkMenuPosition]="miniMenuPositions"
|
||||
aria-label="Open social media links"
|
||||
(cdkMenuClosed)="closeMenu()"
|
||||
(cdkMenuOpened)="openMenu('social')"
|
||||
>
|
||||
<docs-icon role="presentation">more_horiz</docs-icon>
|
||||
</button>
|
||||
|
||||
<ng-template #socialMiniMenu>
|
||||
<ul class="adev-mini-menu" cdkMenu>
|
||||
<li>
|
||||
<a
|
||||
[href]="YOUTUBE"
|
||||
cdkMenuItem
|
||||
title="Angular YouTube channel"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<!-- Youtube Icon -->
|
||||
<svg
|
||||
width="20"
|
||||
height="15"
|
||||
viewBox="0 0 20 15"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M18.7556 2.94783C18.5803 1.98018 17.745 1.27549 16.7756 1.05549C15.325 0.747832 12.6403 0.527832 9.73563 0.527832C6.83266 0.527832 4.105 0.747832 2.65266 1.05549C1.685 1.27549 0.847969 1.93549 0.672656 2.94783C0.495625 4.04783 0.320312 5.58783 0.320312 7.56783C0.320312 9.54783 0.495625 11.0878 0.715625 12.1878C0.892656 13.1555 1.72797 13.8602 2.69563 14.0802C4.23563 14.3878 6.87563 14.6078 9.78031 14.6078C12.685 14.6078 15.325 14.3878 16.865 14.0802C17.8327 13.8602 18.668 13.2002 18.845 12.1878C19.0203 11.0878 19.2403 9.50314 19.285 7.56783C19.1956 5.58783 18.9756 4.04783 18.7556 2.94783ZM7.36031 10.6478V4.48783L12.728 7.56783L7.36031 10.6478Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
[href]="X"
|
||||
cdkMenuItem
|
||||
title="Angular X (formerly Twitter) profile"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<!-- X Icon -->
|
||||
<svg
|
||||
width="17"
|
||||
height="16"
|
||||
viewBox="0 0 17 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0.04145 0.04432l6.56351 8.77603L0 15.95564h1.48651l5.78263-6.24705 4.6722 6.24705h5.05865l-6.9328-9.26967L16.21504.04432h-1.48651l-5.32552 5.75341L5.1001.04432H.04145Zm2.18602 1.09497h2.32396l10.26221 13.72122h-2.32396L2.22747 1.13928Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
[href]="MEDIUM"
|
||||
cdkMenuItem
|
||||
title="Angular Medium blog"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<!-- Medium Icon -->
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7 6A7 7 0 107 20 7 7 0 107 6zM18 6.5A3 6.5 0 1018 19.5 3 6.5 0 1018 6.5zM23 8A1 5 0 1023 18 1 5 0 1023 8z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a [href]="GITHUB" cdkMenuItem title="Angular Github" target="_blank" rel="noopener">
|
||||
<!-- Github Icon -->
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7.59948 19.0428C7.59948 18.8069 7.59118 18.182 7.58656 17.3526C4.89071 17.9526 4.32164 16.0201 4.32164 16.0201C3.88087 14.8718 3.24533 14.5663 3.24533 14.5663C2.36518 13.9492 3.31179 13.9621 3.31179 13.9621C4.28471 14.0323 4.79656 14.9868 4.79656 14.9868C5.66102 16.5052 7.06456 16.0672 7.61748 15.8125C7.70564 15.17 7.95579 14.732 8.23271 14.4837C6.08056 14.2331 3.81764 13.3801 3.81764 9.57199C3.81764 8.48737 4.19564 7.6003 4.81548 6.90522C4.71625 6.65414 4.38302 5.64384 4.91056 4.27537C4.91056 4.27537 5.72471 4.00814 7.57594 5.29399C8.34856 5.07384 9.17795 4.96307 10.0027 4.95937C10.8256 4.96307 11.6546 5.07384 12.429 5.29399C14.2793 4.00814 15.0921 4.27537 15.0921 4.27537C15.621 5.64337 15.2883 6.65368 15.1881 6.90522C15.8093 7.6003 16.1841 8.48737 16.1841 9.57199C16.1841 13.3898 13.9179 14.2298 11.7589 14.4758C12.1073 14.7828 12.4166 15.3892 12.4166 16.3165C12.4166 17.6452 12.4041 18.7174 12.4041 19.0428C12.4041 19.3091 12.579 19.6178 13.071 19.5205C16.9193 18.2041 19.6936 14.4814 19.6936 10.0926C19.6936 4.60353 15.3538 0.154297 10.0009 0.154297C4.64887 0.154297 0.309021 4.60353 0.309021 10.0926C0.309483 14.4828 3.08656 18.2078 6.9381 19.5218C7.42225 19.6128 7.59948 19.3058 7.59948 19.0428Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</div>
|
||||
<!-- TODO: Refactor this and make it better. Accessible, animated, etc. -->
|
||||
<div class="adev-nav-item" [class.adev-nav-item--active]="openedMenu === 'theme-picker'">
|
||||
<button
|
||||
type="button"
|
||||
[cdkMenuTriggerFor]="themeMiniMenu"
|
||||
[cdkMenuPosition]="miniMenuPositions"
|
||||
aria-label="Open theme picker"
|
||||
(cdkMenuClosed)="closeMenu()"
|
||||
(cdkMenuOpened)="openMenu('theme-picker')"
|
||||
>
|
||||
<docs-icon role="presentation">
|
||||
@switch (theme()) { @case ('light') {
|
||||
{{ 'light_mode' }}
|
||||
} @case ('dark') {
|
||||
{{ 'dark_mode' }}
|
||||
} @case ('auto') {
|
||||
{{ 'routine' }}
|
||||
} }
|
||||
</docs-icon>
|
||||
</button>
|
||||
|
||||
<ng-template #themeMiniMenu>
|
||||
<ul class="adev-mini-menu" cdkMenu>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
cdkMenuItem
|
||||
(click)="setTheme('auto')"
|
||||
aria-label="Set default system theme"
|
||||
>
|
||||
<docs-icon class="docs-icon_high-contrast">routine</docs-icon>
|
||||
<span>System</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
cdkMenuItem
|
||||
(click)="setTheme('dark')"
|
||||
aria-label="Set dark theme"
|
||||
>
|
||||
<docs-icon class="docs-icon_high-contrast">dark_mode</docs-icon>
|
||||
<span>Dark</span>
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
cdkMenuItem
|
||||
(click)="setTheme('light')"
|
||||
aria-label="Set light theme"
|
||||
>
|
||||
<docs-icon class="docs-icon_high-contrast">light_mode</docs-icon>
|
||||
<span>Light</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</ng-template>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Tablet: Second horizontal nav layer which opens the secondary nav -->
|
||||
@if (activeRouteItem() === DOCS_ROUTE || activeRouteItem() === REFERENCE_ROUTE) {
|
||||
<div class="adev-secondary-tablet-bar">
|
||||
<button type="button" (click)="openMobileNav($event)">
|
||||
<docs-icon class="docs-icon_high-contrast">menu</docs-icon>
|
||||
@if (activeRouteItem() === DOCS_ROUTE) {
|
||||
<span>Docs</span>
|
||||
} @if (activeRouteItem() === REFERENCE_ROUTE) {
|
||||
<span>API</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@@ -1,283 +0,0 @@
|
||||
@use '@angular/docs/styles/media-queries' as mq;
|
||||
|
||||
// primary nav
|
||||
:host {
|
||||
display: flex;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--z-index-nav);
|
||||
|
||||
.adev-mobile-nav-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
// render tablet primary nav under blur when secondary is open
|
||||
@include mq.for-tablet {
|
||||
&:has(.adev-nav-primary--open) {
|
||||
z-index: 50;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adev-mobile-nav-bar {
|
||||
display: none;
|
||||
@include mq.for-phone-only {
|
||||
display: flex;
|
||||
}
|
||||
gap: 0.75rem;
|
||||
backdrop-filter: blur(16px);
|
||||
background-color: color-mix(in srgb, var(--page-background) 70%, transparent);
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
padding-block: 0.75rem;
|
||||
padding-inline: var(--layout-padding);
|
||||
border-block-end: 1px solid var(--septenary-contrast);
|
||||
box-sizing: border-box;
|
||||
|
||||
transform: translateY(0);
|
||||
// mobile bar: enter with delay
|
||||
transition: transform 0.3s ease-out 0.6s;
|
||||
|
||||
&:has(+ .adev-nav-primary--open) {
|
||||
transform: translateY(-100%);
|
||||
// mobile bar: exit without delay
|
||||
transition: transform 0.3s ease-in;
|
||||
}
|
||||
|
||||
docs-icon {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
// First level navigation
|
||||
.adev-nav-primary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
max-height: 100vh;
|
||||
overflow: auto;
|
||||
backdrop-filter: blur(16px);
|
||||
background-color: color-mix(in srgb, var(--page-background) 70%, transparent);
|
||||
// render primary nav / mini menu above tablet secondary bar
|
||||
z-index: 250;
|
||||
position: relative;
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
height: 100dvh;
|
||||
padding-block-start: 1rem;
|
||||
padding-block-end: 2rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
// div containing mobile close button and adev-nav_top
|
||||
> div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@include mq.for-phone-only {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
background-color: var(--page-background);
|
||||
box-shadow: 10px 4px 3px 0 rgba(0, 0, 0, 0.001);
|
||||
|
||||
transform: translateX(-100%);
|
||||
// primary nav: exit delayed
|
||||
// TODO: should only be delayed if there is a secondary nav
|
||||
transition: transform 0.3s ease-in 0.38s;
|
||||
|
||||
&.adev-nav-primary--open {
|
||||
transform: translateX(0%);
|
||||
// primary nav: enter after mobile nav bar exits
|
||||
transition: transform 0.3s ease-out 0.1s;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce-motion) {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@include mq.for-tablet {
|
||||
flex-direction: row;
|
||||
width: 100vw;
|
||||
padding-inline: calc(var(--layout-padding) - 1.25rem);
|
||||
height: auto;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
// Add a subtle border for the home page
|
||||
border-block-end: 1px solid var(--septenary-contrast);
|
||||
@include mq.for-tablet-up {
|
||||
border-inline-end: 1px solid var(--septenary-contrast);
|
||||
}
|
||||
@include mq.for-phone-only {
|
||||
border-inline-end: 1px solid var(--septenary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.adev-nav {
|
||||
&__top {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@include mq.for-tablet {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.adev-beta-version {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
// version dropdown button
|
||||
.adev-version-button {
|
||||
border: 1px solid var(--senary-contrast);
|
||||
border-radius: 0.25rem;
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.25rem;
|
||||
color: var(--quaternary-contrast);
|
||||
fill: var(--quaternary-contrast);
|
||||
transition: color 0.3s ease;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
|
||||
docs-icon {
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
@include mq.for-phone-only {
|
||||
&.adev-mini-menu-open {
|
||||
&::after {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@include mq.for-tablet-landscape-up {
|
||||
&.adev-mini-menu-open {
|
||||
&::after {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@include mq.for-tablet {
|
||||
> li:first-of-type {
|
||||
padding-inline-start: 1.25rem;
|
||||
}
|
||||
|
||||
li {
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__bottom {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
@include mq.for-tablet {
|
||||
flex-direction: row;
|
||||
margin-inline-end: 1.25rem;
|
||||
}
|
||||
|
||||
.adev-nav-item--active {
|
||||
button {
|
||||
docs-icon {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
padding-inline: 1rem;
|
||||
|
||||
@include mq.for-tablet {
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
docs-icon {
|
||||
color: var(--quaternary-contrast);
|
||||
font-size: 1.5rem;
|
||||
@include mq.for-tablet-landscape-down {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
docs-icon {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.adev-close-nav {
|
||||
display: none;
|
||||
@include mq.for-phone-only {
|
||||
display: block;
|
||||
}
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
|
||||
.adev-search-desktop {
|
||||
height: 1.375rem;
|
||||
text-transform: capitalize;
|
||||
@include mq.for-tablet-landscape-down {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.adev-sub-navigation-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.adev-secondary-tablet-bar {
|
||||
font-size: 0.875rem;
|
||||
backdrop-filter: blur(16px);
|
||||
background-color: color-mix(in srgb, var(--page-background) 70%, transparent);
|
||||
border-block-end: 1px solid var(--septenary-contrast);
|
||||
padding-block: 1rem;
|
||||
padding-inline: var(--layout-padding);
|
||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||
button {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
color: var(--primary-contrast);
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
@include mq.for-tablet-landscape-up {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@include mq.for-phone-only {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
/*!
|
||||
* @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 {Navigation} from './navigation.component';
|
||||
import {RouterTestingModule} from '@angular/router/testing';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {PagePrefix} from '../../enums/pages';
|
||||
import {Theme, ThemeManager} from '../../services/theme-manager.service';
|
||||
import {Version, signal} from '@angular/core';
|
||||
import {of} from 'rxjs';
|
||||
import {VersionManager} from '../../services/version-manager.service';
|
||||
import {Search, WINDOW} from '@angular/docs';
|
||||
|
||||
describe('Navigation', () => {
|
||||
let component: Navigation;
|
||||
let fixture: ComponentFixture<Navigation>;
|
||||
|
||||
const fakeThemeManager = {
|
||||
theme: signal<Theme>('dark'),
|
||||
setTheme: (theme: Theme) => {},
|
||||
themeChanged$: of(),
|
||||
};
|
||||
|
||||
const fakeVersionManager = {
|
||||
currentDocsVersion: signal('v17'),
|
||||
versions: signal<Version[]>([]),
|
||||
};
|
||||
|
||||
const fakeWindow = {};
|
||||
const fakeSearch = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [Navigation, RouterTestingModule],
|
||||
providers: [
|
||||
{
|
||||
provide: WINDOW,
|
||||
useValue: fakeWindow,
|
||||
},
|
||||
{
|
||||
provide: Search,
|
||||
useValue: fakeSearch,
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
TestBed.overrideProvider(ThemeManager, {useValue: fakeThemeManager});
|
||||
TestBed.overrideProvider(VersionManager, {useValue: fakeVersionManager});
|
||||
|
||||
fixture = TestBed.createComponent(Navigation);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should append active class to DOCS_ROUTE when DOCS_ROUTE is active', () => {
|
||||
component.activeRouteItem.set(PagePrefix.DOCS);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const docsLink = fixture.debugElement.query(By.css('a[href="/docs"]')).parent?.nativeElement;
|
||||
|
||||
expect(docsLink).toHaveClass('adev-nav-item--active');
|
||||
});
|
||||
|
||||
it('should not have active class when activeRouteItem is null', () => {
|
||||
component.activeRouteItem.set(null);
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
const docsLink = fixture.debugElement.query(By.css('a[href="/docs"]')).nativeElement;
|
||||
const referenceLink = fixture.debugElement.query(By.css('a[href="/reference"]')).nativeElement;
|
||||
|
||||
expect(docsLink).not.toHaveClass('adev-nav-item--active');
|
||||
expect(referenceLink).not.toHaveClass('adev-nav-item--active');
|
||||
});
|
||||
|
||||
it('should call themeManager.setTheme(dark) when user tries to set dark theme', () => {
|
||||
const openThemePickerButton = fixture.debugElement.query(
|
||||
By.css('button[aria-label^="Open theme picker"]'),
|
||||
).nativeElement;
|
||||
const setThemeSpy = spyOn(fakeThemeManager, 'setTheme');
|
||||
|
||||
openThemePickerButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const setDarkModeButton = fixture.debugElement.query(
|
||||
By.css('button[aria-label="Set dark theme"]'),
|
||||
).nativeElement;
|
||||
|
||||
setDarkModeButton.click();
|
||||
|
||||
expect(setThemeSpy).toHaveBeenCalledOnceWith('dark');
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
/*!
|
||||
* @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 {CdkMenu, CdkMenuItem, CdkMenuTrigger} from '@angular/cdk/menu';
|
||||
import {CommonModule, DOCUMENT, Location, isPlatformBrowser} from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
OnInit,
|
||||
PLATFORM_ID,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
ClickOutside,
|
||||
NavigationList,
|
||||
NavigationState,
|
||||
WINDOW,
|
||||
IconComponent,
|
||||
getBaseUrlAfterRedirects,
|
||||
isApple,
|
||||
IS_SEARCH_DIALOG_OPEN,
|
||||
} from '@angular/docs';
|
||||
import {NavigationEnd, Router, RouterLink} from '@angular/router';
|
||||
import {filter, map, startWith} from 'rxjs/operators';
|
||||
import {DOCS_ROUTES, REFERENCE_ROUTES, TUTORIALS_ROUTES} from '../../../routes';
|
||||
import {GITHUB, MEDIUM, X, YOUTUBE} from '../../constants/links';
|
||||
import {PagePrefix} from '../../enums/pages';
|
||||
import {Theme, ThemeManager} from '../../services/theme-manager.service';
|
||||
import {VersionManager} from '../../services/version-manager.service';
|
||||
import {PRIMARY_NAV_ID, SECONDARY_NAV_ID} from '../../constants/element-ids';
|
||||
import {ConnectionPositionPair} from '@angular/cdk/overlay';
|
||||
import {COMMAND, CONTROL, SEARCH_TRIGGER_KEY} from '../../constants/keys';
|
||||
|
||||
type MenuType = 'social' | 'theme-picker' | 'version-picker';
|
||||
|
||||
@Component({
|
||||
selector: 'div.adev-nav',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
NavigationList,
|
||||
ClickOutside,
|
||||
CdkMenu,
|
||||
CdkMenuItem,
|
||||
CdkMenuTrigger,
|
||||
IconComponent,
|
||||
],
|
||||
templateUrl: './navigation.component.html',
|
||||
styleUrls: ['./navigation.component.scss', './mini-menu.scss', './nav-item.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class Navigation implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
private readonly navigationState = inject(NavigationState);
|
||||
private readonly router = inject(Router);
|
||||
private readonly location = inject(Location);
|
||||
private readonly themeManager = inject(ThemeManager);
|
||||
private readonly isSearchDialogOpen = inject(IS_SEARCH_DIALOG_OPEN);
|
||||
private readonly window = inject(WINDOW);
|
||||
private readonly versionManager = inject(VersionManager);
|
||||
|
||||
readonly DOCS_ROUTE = PagePrefix.DOCS;
|
||||
readonly HOME_ROUTE = PagePrefix.HOME;
|
||||
readonly PLAYGROUND_ROUTE = PagePrefix.PLAYGROUND;
|
||||
readonly REFERENCE_ROUTE = PagePrefix.REFERENCE;
|
||||
readonly TUTORIALS_ROUTE = PagePrefix.TUTORIALS;
|
||||
|
||||
readonly GITHUB = GITHUB;
|
||||
readonly X = X;
|
||||
readonly MEDIUM = MEDIUM;
|
||||
readonly YOUTUBE = YOUTUBE;
|
||||
|
||||
readonly PRIMARY_NAV_ID = PRIMARY_NAV_ID;
|
||||
readonly SECONDARY_NAV_ID = SECONDARY_NAV_ID;
|
||||
|
||||
miniMenuPositions = [
|
||||
new ConnectionPositionPair(
|
||||
{originX: 'end', originY: 'center'},
|
||||
{overlayX: 'start', overlayY: 'center'},
|
||||
),
|
||||
new ConnectionPositionPair(
|
||||
{originX: 'end', originY: 'top'},
|
||||
{overlayX: 'start', overlayY: 'top'},
|
||||
),
|
||||
];
|
||||
|
||||
readonly APPLE_SEARCH_LABEL = `⌘`;
|
||||
readonly DEFAULT_SEARCH_LABEL = `ctrl`;
|
||||
|
||||
activeRouteItem = this.navigationState.primaryActiveRouteItem;
|
||||
theme = this.themeManager.theme;
|
||||
openedMenu: MenuType | null = null;
|
||||
|
||||
currentDocsVersion = this.versionManager.currentDocsVersion;
|
||||
// Set the values of the search label and title only on the client, because the label is user-agent specific.
|
||||
searchLabel = this.isBrowser
|
||||
? isApple
|
||||
? this.APPLE_SEARCH_LABEL
|
||||
: this.DEFAULT_SEARCH_LABEL
|
||||
: '';
|
||||
searchTitle = this.isBrowser
|
||||
? isApple
|
||||
? `${COMMAND} ${SEARCH_TRIGGER_KEY.toUpperCase()}`
|
||||
: `${CONTROL} ${SEARCH_TRIGGER_KEY.toUpperCase()}`
|
||||
: '';
|
||||
versions = this.versionManager.versions;
|
||||
|
||||
isMobileNavigationOpened = this.navigationState.isMobileNavVisible;
|
||||
isMobileNavigationOpened$ = toObservable(this.isMobileNavigationOpened);
|
||||
primaryRouteChanged$ = toObservable(this.activeRouteItem);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.listenToRouteChange();
|
||||
this.preventToScrollContentWhenSecondaryNavIsOpened();
|
||||
this.closeMobileNavOnPrimaryRouteChange();
|
||||
}
|
||||
|
||||
redirectToDocsVersion(versionUrl: string): void {
|
||||
this.window.location.href = versionUrl;
|
||||
}
|
||||
|
||||
setTheme(theme: Theme): void {
|
||||
this.themeManager.setTheme(theme);
|
||||
}
|
||||
|
||||
openVersionMenu($event: MouseEvent): void {
|
||||
// It's required to avoid redirection to `home`
|
||||
$event.stopImmediatePropagation();
|
||||
$event.preventDefault();
|
||||
this.openMenu('version-picker');
|
||||
}
|
||||
|
||||
openMenu(menuType: MenuType): void {
|
||||
this.openedMenu = menuType;
|
||||
}
|
||||
|
||||
closeMenu(): void {
|
||||
this.openedMenu = null;
|
||||
}
|
||||
|
||||
openMobileNav($event: MouseEvent): void {
|
||||
$event.stopPropagation();
|
||||
this.navigationState.setMobileNavigationListVisibility(true);
|
||||
}
|
||||
|
||||
closeMobileNav(): void {
|
||||
this.navigationState.setMobileNavigationListVisibility(false);
|
||||
}
|
||||
|
||||
toggleSearchDialog(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.isSearchDialogOpen.update((isOpen) => !isOpen);
|
||||
}
|
||||
|
||||
private closeMobileNavOnPrimaryRouteChange(): void {
|
||||
this.primaryRouteChanged$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||
this.closeMobileNav();
|
||||
});
|
||||
}
|
||||
|
||||
private listenToRouteChange(): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
map((event) => (event as NavigationEnd).urlAfterRedirects),
|
||||
)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
//using location because router.url will only return "/" here
|
||||
startWith(this.location.path()),
|
||||
)
|
||||
.subscribe((url) => {
|
||||
this.setActivePrimaryRoute(getBaseUrlAfterRedirects(url, this.router));
|
||||
});
|
||||
}
|
||||
|
||||
// Set active route item, based on urlAfterRedirects.
|
||||
// First check if url starts with the main prefixes (docs, reference, tutorials).
|
||||
// (*) Docs navigation tree contains items which will navigate to /tutorials.
|
||||
// In that case after click on such link we should mark as active item, and display tutorials navigation tree.
|
||||
// If it's not starting with prefix then check if specific path exist in the array of defined routes
|
||||
// (*) Reference navigation tree contains items which are not start with prefix like /migrations or /errors.
|
||||
private setActivePrimaryRoute(urlAfterRedirects: string): void {
|
||||
if (urlAfterRedirects === '') {
|
||||
this.activeRouteItem.set(PagePrefix.HOME);
|
||||
} else if (urlAfterRedirects.startsWith(PagePrefix.DOCS)) {
|
||||
this.activeRouteItem.set(PagePrefix.DOCS);
|
||||
} else if (
|
||||
urlAfterRedirects.startsWith(PagePrefix.REFERENCE) ||
|
||||
urlAfterRedirects.startsWith(PagePrefix.API)
|
||||
) {
|
||||
this.activeRouteItem.set(PagePrefix.REFERENCE);
|
||||
} else if (urlAfterRedirects === PagePrefix.PLAYGROUND) {
|
||||
this.activeRouteItem.set(PagePrefix.PLAYGROUND);
|
||||
} else if (urlAfterRedirects.startsWith(PagePrefix.TUTORIALS)) {
|
||||
this.activeRouteItem.set(PagePrefix.TUTORIALS);
|
||||
} else if (DOCS_ROUTES.some((route) => route.path === urlAfterRedirects)) {
|
||||
this.activeRouteItem.set(PagePrefix.DOCS);
|
||||
} else if (REFERENCE_ROUTES.some((route) => route.path === urlAfterRedirects)) {
|
||||
this.activeRouteItem.set(PagePrefix.REFERENCE);
|
||||
} else if (TUTORIALS_ROUTES.some((route) => route.path === urlAfterRedirects)) {
|
||||
this.activeRouteItem.set(PagePrefix.TUTORIALS);
|
||||
} else {
|
||||
// Reset if no active route item could be found
|
||||
this.activeRouteItem.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private preventToScrollContentWhenSecondaryNavIsOpened(): void {
|
||||
this.isMobileNavigationOpened$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((opened) => {
|
||||
if (opened) {
|
||||
this.document.body.style.overflowY = 'hidden';
|
||||
} else {
|
||||
this.document.body.style.removeProperty('overflow-y');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
/*!
|
||||
* @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, fakeAsync, tick} from '@angular/core/testing';
|
||||
|
||||
import {PROGRESS_BAR_DELAY, ProgressBarComponent} from './progress-bar.component';
|
||||
import {RouterTestingHarness, RouterTestingModule} from '@angular/router/testing';
|
||||
|
||||
describe('ProgressBarComponent', () => {
|
||||
let component: ProgressBarComponent;
|
||||
let fixture: ComponentFixture<ProgressBarComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ProgressBarComponent, RouterTestingModule],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ProgressBarComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should call progressBar.complete() on route change', fakeAsync(async () => {
|
||||
const progressBarCompleteSpy = spyOn(component.progressBar, 'complete');
|
||||
|
||||
const harness = await RouterTestingHarness.create();
|
||||
await harness.navigateByUrl('/');
|
||||
|
||||
tick(PROGRESS_BAR_DELAY);
|
||||
expect(progressBarCompleteSpy).toHaveBeenCalled();
|
||||
}));
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
/*!
|
||||
* @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 {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
OnInit,
|
||||
PLATFORM_ID,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
import {isPlatformBrowser} from '@angular/common';
|
||||
import {NgProgressComponent} from 'ngx-progressbar';
|
||||
import {
|
||||
NavigationCancel,
|
||||
NavigationEnd,
|
||||
NavigationError,
|
||||
NavigationSkipped,
|
||||
NavigationStart,
|
||||
Router,
|
||||
} from '@angular/router';
|
||||
import {filter, map, switchMap, take} from 'rxjs/operators';
|
||||
|
||||
/** Time to wait after navigation starts before showing the progress bar. This delay allows a small amount of time to skip showing the progress bar when a navigation is effectively immediate. 30ms is approximately the amount of time we can wait before a delay is perceptible.*/
|
||||
export const PROGRESS_BAR_DELAY = 30;
|
||||
|
||||
@Component({
|
||||
selector: 'adev-progress-bar',
|
||||
standalone: true,
|
||||
imports: [NgProgressComponent],
|
||||
template: `
|
||||
<ng-progress aria-label="Page load progress" />
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ProgressBarComponent implements OnInit {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
@ViewChild(NgProgressComponent, {static: true}) progressBar!: NgProgressComponent;
|
||||
|
||||
isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
|
||||
|
||||
ngOnInit() {
|
||||
this.setupPageNavigationDimming();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dims the main router-outlet content when navigating to a new page.
|
||||
*/
|
||||
private setupPageNavigationDimming() {
|
||||
if (!this.isBrowser) {
|
||||
return;
|
||||
}
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((e) => e instanceof NavigationStart),
|
||||
map(() => {
|
||||
// Only apply set the property if the navigation is not "immediate"
|
||||
return setTimeout(() => {
|
||||
this.progressBar.start();
|
||||
}, PROGRESS_BAR_DELAY);
|
||||
}),
|
||||
switchMap((timeoutId) => {
|
||||
return this.router.events.pipe(
|
||||
filter((e) => {
|
||||
return (
|
||||
e instanceof NavigationEnd ||
|
||||
e instanceof NavigationCancel ||
|
||||
e instanceof NavigationSkipped ||
|
||||
e instanceof NavigationError
|
||||
);
|
||||
}),
|
||||
take(1),
|
||||
map(() => timeoutId),
|
||||
);
|
||||
}),
|
||||
)
|
||||
.subscribe((timeoutId) => {
|
||||
// When the navigation finishes, prevent the navigating class from being applied in the timeout.
|
||||
clearTimeout(timeoutId);
|
||||
this.progressBar.complete();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<div [attr.id]="SECONDARY_NAV_ID" class="adev-secondary-nav-mask" [class.docs-nav-secondary--open]="isSecondaryNavVisible()" (docsClickOutside)="close()" [docsClickOutsideIgnore]="[PRIMARY_NAV_ID]">
|
||||
<div class="docs-nav-secondary docs-scroll-track-transparent" [style.transform]="translateX()" [style.transition]="transition">
|
||||
<!-- Secondary Nav -->
|
||||
@if (navigationItems && navigationItems.length > 0) {
|
||||
<docs-navigation-list
|
||||
[navigationItems]="navigationItems"
|
||||
[displayItemsToLevel]="maxVisibleLevelsOnSecondaryNav()"
|
||||
[expandableLevel]="maxVisibleLevelsOnSecondaryNav()"
|
||||
(linkClicked)="close()"
|
||||
/>
|
||||
}
|
||||
|
||||
<!-- Third, fourth and next levels of navigation -->
|
||||
@for (item of navigationItemsSlides(); track item; let level = $index) {
|
||||
<docs-navigation-list
|
||||
[@leaveAnimation]
|
||||
[collapsableLevel]="level + maxVisibleLevelsOnSecondaryNav()"
|
||||
[expandableLevel]="level + maxVisibleLevelsOnSecondaryNav() + 1"
|
||||
[navigationItems]="[item]"
|
||||
[displayItemsToLevel]="level + maxVisibleLevelsOnSecondaryNav() + 1"
|
||||
(linkClicked)="close()"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,72 +0,0 @@
|
||||
@use '@angular/docs/styles/media-queries' as mq;
|
||||
|
||||
:host {
|
||||
@include mq.for-tablet-landscape-up {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
// Render secondary nav below primary nav
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
@include mq.for-tablet-landscape-down {
|
||||
position: fixed;
|
||||
// Render secondary nav above mobile menu
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@include mq.for-phone-only {
|
||||
transform: translateX(0);
|
||||
z-index: 99;
|
||||
transition: transform 0.3s ease;
|
||||
&:has(.docs-nav-secondary--open) {
|
||||
transform: translateX(82px);
|
||||
transition: transform 0.3s ease-in 0.3s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .adev-secondary-nav-mask stays in the same place during nav animation, as a mask
|
||||
.adev-secondary-nav-mask {
|
||||
position: sticky;
|
||||
@include mq.for-tablet-landscape-down {
|
||||
position: absolute;
|
||||
}
|
||||
top: 0;
|
||||
overflow-x: hidden;
|
||||
min-width: var(--secondary-nav-width);
|
||||
border-inline-end: 1px solid var(--septenary-contrast);
|
||||
background-color: var(--page-background);
|
||||
z-index: var(--z-index-nav);
|
||||
transition: transform 0.45s ease;
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
transition: transform 0.45s ease, background-color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
@include mq.for-tablet-landscape-down {
|
||||
transform: translateX(-100%);
|
||||
|
||||
&.docs-nav-secondary--open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@include mq.for-phone-only {
|
||||
transform: translateX(-100%);
|
||||
// exit immediately
|
||||
transition: transform 0.45s ease-in;
|
||||
|
||||
&.docs-nav-secondary--open {
|
||||
transform: translateX(0);
|
||||
// enter delayed
|
||||
transition: transform 0.45s ease-out 0.2s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// secondary nav - translated on x to display levels
|
||||
.docs-nav-secondary {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: var(--secondary-nav-width);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
/*!
|
||||
* @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 {SecondaryNavigation} from './secondary-navigation.component';
|
||||
import {WINDOW} from '@angular/docs';
|
||||
|
||||
describe('SecondaryNavigation', () => {
|
||||
let component: SecondaryNavigation;
|
||||
let fixture: ComponentFixture<SecondaryNavigation>;
|
||||
|
||||
const fakeWindow = {
|
||||
location: {
|
||||
origin: 'example-origin',
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [SecondaryNavigation],
|
||||
providers: [
|
||||
{
|
||||
provide: WINDOW,
|
||||
useValue: fakeWindow,
|
||||
},
|
||||
],
|
||||
});
|
||||
fixture = TestBed.createComponent(SecondaryNavigation);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
/*!
|
||||
* @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 {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
OnInit,
|
||||
PLATFORM_ID,
|
||||
computed,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
import {takeUntilDestroyed, toObservable} from '@angular/core/rxjs-interop';
|
||||
import {
|
||||
ClickOutside,
|
||||
NavigationItem,
|
||||
NavigationList,
|
||||
NavigationState,
|
||||
WINDOW,
|
||||
findNavigationItem,
|
||||
getBaseUrlAfterRedirects,
|
||||
getNavigationItemsTree,
|
||||
markExternalLinks,
|
||||
shouldReduceMotion,
|
||||
} from '@angular/docs';
|
||||
import {distinctUntilChanged, filter, map, skip, startWith} from 'rxjs/operators';
|
||||
import {SUB_NAVIGATION_DATA} from '../../../sub-navigation-data';
|
||||
import {PagePrefix} from '../../enums/pages';
|
||||
import {ActivatedRouteSnapshot, NavigationEnd, Router, RouterStateSnapshot} from '@angular/router';
|
||||
import {NgFor, NgIf, isPlatformBrowser} from '@angular/common';
|
||||
import {trigger, transition, style, animate} from '@angular/animations';
|
||||
import {PRIMARY_NAV_ID, SECONDARY_NAV_ID} from '../../constants/element-ids';
|
||||
|
||||
export const ANIMATION_DURATION = 500;
|
||||
|
||||
@Component({
|
||||
selector: 'adev-secondary-navigation',
|
||||
standalone: true,
|
||||
imports: [NavigationList, NgIf, NgFor, ClickOutside],
|
||||
templateUrl: './secondary-navigation.component.html',
|
||||
styleUrls: ['./secondary-navigation.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [
|
||||
trigger('leaveAnimation', [
|
||||
transition(':leave', [
|
||||
style({transform: 'translateX(0%)'}),
|
||||
animate(
|
||||
`${ANIMATION_DURATION}ms ${ANIMATION_DURATION}ms ease-out`,
|
||||
style({transform: 'translateX(100%)'}),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
],
|
||||
})
|
||||
export class SecondaryNavigation implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly navigationState = inject(NavigationState);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
private readonly router = inject(Router);
|
||||
private readonly window = inject(WINDOW);
|
||||
|
||||
isSecondaryNavVisible = this.navigationState.isMobileNavVisible;
|
||||
primaryActiveRouteItem = this.navigationState.primaryActiveRouteItem;
|
||||
maxVisibleLevelsOnSecondaryNav = computed(() =>
|
||||
this.primaryActiveRouteItem() === PagePrefix.REFERENCE ? 1 : 2,
|
||||
);
|
||||
navigationItemsSlides = this.navigationState.expandedItems;
|
||||
navigationItems: NavigationItem[] | undefined;
|
||||
|
||||
translateX = computed(() => {
|
||||
const level = this.navigationState.expandedItems()?.length ?? 0;
|
||||
return `translateX(${-level * 100}%)`;
|
||||
});
|
||||
transition: string = '0ms';
|
||||
|
||||
readonly PRIMARY_NAV_ID = PRIMARY_NAV_ID;
|
||||
readonly SECONDARY_NAV_ID = SECONDARY_NAV_ID;
|
||||
|
||||
private readonly routeMap: Record<string, NavigationItem[]> = {
|
||||
[PagePrefix.REFERENCE]: getNavigationItemsTree(SUB_NAVIGATION_DATA.reference, (tree) =>
|
||||
markExternalLinks(tree, this.window.origin),
|
||||
),
|
||||
[PagePrefix.DOCS]: getNavigationItemsTree(SUB_NAVIGATION_DATA.docs, (tree) =>
|
||||
markExternalLinks(tree, this.window.origin),
|
||||
),
|
||||
};
|
||||
|
||||
private readonly primaryActiveRouteChanged$ = toObservable(this.primaryActiveRouteItem).pipe(
|
||||
distinctUntilChanged(),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
);
|
||||
|
||||
private readonly urlAfterRedirects$ = this.router.events.pipe(
|
||||
filter((event) => event instanceof NavigationEnd),
|
||||
map((event) => (event as NavigationEnd).urlAfterRedirects),
|
||||
filter((url): url is string => url !== undefined),
|
||||
startWith(this.getInitialPath(this.router.routerState.snapshot)),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.navigationState.cleanExpandedState();
|
||||
this.listenToPrimaryRouteChange();
|
||||
this.setActiveRouteOnNavigationEnd();
|
||||
|
||||
if (isPlatformBrowser(this.platformId)) {
|
||||
this.initSlideAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.navigationState.setMobileNavigationListVisibility(false);
|
||||
}
|
||||
|
||||
private setActiveRouteOnNavigationEnd(): void {
|
||||
this.urlAfterRedirects$.subscribe((url) => {
|
||||
const activeNavigationItem = this.getActiveNavigationItem(url);
|
||||
if (
|
||||
activeNavigationItem?.level &&
|
||||
activeNavigationItem.level <= this.maxVisibleLevelsOnSecondaryNav()
|
||||
) {
|
||||
this.navigationState.cleanExpandedState();
|
||||
} else if (activeNavigationItem) {
|
||||
/**
|
||||
* For the `Docs`, we don't expand the "level === 1" items because they are already displayed in the main navigation list.
|
||||
* Example:
|
||||
* In-depth Guides (level == 0)
|
||||
* Components (level == 1) -> Selectors, Styling, etc (level == 2)
|
||||
* Template Syntax (level == 1) -> Text interpolation, etc (level == 2)
|
||||
*
|
||||
* For the `Tutorials`, we display the navigation in the dropdown and it has flat structure (all items are displayed as items with level === 0).
|
||||
*
|
||||
* For the `Reference` we would like to give possibility to expand the "level === 1" items cause they are not visible in the first slide of navigation list.
|
||||
* Example:
|
||||
* API Reference (level == 0) -> Overview, Animations, common, etc (level == 1) -> API Package exports (level == 2)
|
||||
*/
|
||||
const shouldExpandItem = (node: NavigationItem): boolean =>
|
||||
!!node.level &&
|
||||
(this.primaryActiveRouteItem() === PagePrefix.REFERENCE
|
||||
? node.level > 0
|
||||
: node.level > 1);
|
||||
|
||||
// Skip expand when active item is API Reference homepage - `/api`.
|
||||
// It protect us from displaying second level of the navigation when user clicks on `Reference`,
|
||||
// Because in this situation we want to display the first level, which contains, in addition to the API Reference, also the CLI Reference, Error Encyclopedia etc.
|
||||
const skipExpandPredicateFn = (node: NavigationItem): boolean =>
|
||||
node.path === PagePrefix.API;
|
||||
|
||||
this.navigationState.expandItemHierarchy(
|
||||
activeNavigationItem,
|
||||
shouldExpandItem,
|
||||
skipExpandPredicateFn,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private getActiveNavigationItem(url: string): NavigationItem | null {
|
||||
// set visible navigation items if not present
|
||||
this.setVisibleNavigationItems();
|
||||
|
||||
const activeNavigationItem = findNavigationItem(
|
||||
this.navigationItems!,
|
||||
(item) =>
|
||||
!!item.path &&
|
||||
getBaseUrlAfterRedirects(item.path, this.router) ===
|
||||
getBaseUrlAfterRedirects(url, this.router),
|
||||
);
|
||||
|
||||
this.navigationState.setActiveNavigationItem(activeNavigationItem);
|
||||
|
||||
return activeNavigationItem;
|
||||
}
|
||||
|
||||
private initSlideAnimation(): void {
|
||||
if (shouldReduceMotion()) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
this.transition = `${ANIMATION_DURATION}ms`;
|
||||
}, ANIMATION_DURATION);
|
||||
}
|
||||
|
||||
private setVisibleNavigationItems(): void {
|
||||
const routeMap = this.routeMap[this.primaryActiveRouteItem()!];
|
||||
this.navigationItems = routeMap
|
||||
? getNavigationItemsTree(routeMap, (item) => {
|
||||
item.isExpanded = this.primaryActiveRouteItem() === PagePrefix.DOCS && item.level === 1;
|
||||
})
|
||||
: [];
|
||||
}
|
||||
|
||||
private listenToPrimaryRouteChange(): void {
|
||||
// Fix: flicker of sub-navigation on init
|
||||
this.primaryActiveRouteChanged$.pipe(skip(1)).subscribe(() => {
|
||||
this.navigationState.cleanExpandedState();
|
||||
});
|
||||
}
|
||||
|
||||
private getInitialPath(routerState: RouterStateSnapshot): string {
|
||||
let route: ActivatedRouteSnapshot = routerState.root;
|
||||
|
||||
while (route.firstChild) {
|
||||
route = route.firstChild;
|
||||
}
|
||||
|
||||
return route.routeConfig?.path ?? '';
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
/*!
|
||||
* @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 {TITLE_SUFFIX, ADevTitleStrategy, DEFAULT_PAGE_TITLE} from './a-dev-title-strategy';
|
||||
import {Router, provideRouter} from '@angular/router';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {Component} from '@angular/core';
|
||||
|
||||
@Component({})
|
||||
class FakeComponent {}
|
||||
|
||||
describe('ADevTitleStrategy', () => {
|
||||
let service: ADevTitleStrategy;
|
||||
let router: Router;
|
||||
let title: Title;
|
||||
|
||||
const routes = [
|
||||
{path: 'first', data: {label: 'First'}, component: FakeComponent},
|
||||
{path: 'second', component: FakeComponent},
|
||||
{
|
||||
path: 'third',
|
||||
data: {label: 'Third'},
|
||||
component: FakeComponent,
|
||||
children: [{path: 'child', data: {label: 'Child'}, component: FakeComponent}],
|
||||
},
|
||||
{
|
||||
path: 'fourth',
|
||||
data: {label: 'Fourth'},
|
||||
component: FakeComponent,
|
||||
children: [
|
||||
{
|
||||
path: 'child',
|
||||
data: {label: 'Overview', parent: {label: 'Fourth'}},
|
||||
component: FakeComponent,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter(routes)],
|
||||
});
|
||||
service = TestBed.inject(ADevTitleStrategy);
|
||||
router = TestBed.inject(Router);
|
||||
title = TestBed.inject(Title);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it(`should set '${TITLE_SUFFIX}' when route doesn't have defined label`, async () => {
|
||||
spyOn(title, 'setTitle');
|
||||
|
||||
await router.navigateByUrl('/second');
|
||||
service.updateTitle(router.routerState.snapshot);
|
||||
|
||||
expect(title.setTitle).toHaveBeenCalledOnceWith(TITLE_SUFFIX);
|
||||
});
|
||||
|
||||
it(`should set 'Third - ${TITLE_SUFFIX}' when route has defined label equal to 'Third'`, async () => {
|
||||
spyOn(title, 'setTitle');
|
||||
|
||||
await router.navigateByUrl('/third');
|
||||
service.updateTitle(router.routerState.snapshot);
|
||||
|
||||
expect(title.setTitle).toHaveBeenCalledOnceWith('Third • Angular');
|
||||
});
|
||||
|
||||
it(`shouldn't take label from the parent route when current route label is not equal to ${DEFAULT_PAGE_TITLE}`, async () => {
|
||||
spyOn(title, 'setTitle');
|
||||
|
||||
await router.navigateByUrl('/third/child');
|
||||
service.updateTitle(router.routerState.snapshot);
|
||||
|
||||
expect(title.setTitle).toHaveBeenCalledOnceWith('Child • Angular');
|
||||
});
|
||||
|
||||
it(`should take label from the parent route when current route label is equal to ${DEFAULT_PAGE_TITLE}`, async () => {
|
||||
spyOn(title, 'setTitle');
|
||||
|
||||
await router.navigateByUrl('/fourth/child');
|
||||
service.updateTitle(router.routerState.snapshot);
|
||||
|
||||
expect(title.setTitle).toHaveBeenCalledOnceWith('Fourth • Overview • Angular');
|
||||
});
|
||||
});
|
||||
@@ -1,51 +0,0 @@
|
||||
/*!
|
||||
* @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';
|
||||
import {NavigationItem} from '@angular/docs';
|
||||
import {Title} from '@angular/platform-browser';
|
||||
import {ActivatedRouteSnapshot, RouterStateSnapshot, TitleStrategy} from '@angular/router';
|
||||
|
||||
export const ROUTE_TITLE_PROPERTY = 'label';
|
||||
export const ROUTE_PARENT_PROPERTY = 'parent';
|
||||
export const TITLE_SUFFIX = 'Angular';
|
||||
export const TITLE_SEPARATOR = ' • ';
|
||||
export const DEFAULT_PAGE_TITLE = 'Overview';
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class ADevTitleStrategy extends TitleStrategy {
|
||||
constructor(private readonly title: Title) {
|
||||
super();
|
||||
}
|
||||
|
||||
override updateTitle(routerState: RouterStateSnapshot) {
|
||||
const title = this.buildTitle(routerState);
|
||||
|
||||
if (title !== undefined) {
|
||||
this.title.setTitle(title);
|
||||
}
|
||||
}
|
||||
|
||||
override buildTitle(snapshot: RouterStateSnapshot): string {
|
||||
let route: ActivatedRouteSnapshot = snapshot.root;
|
||||
|
||||
while (route.firstChild) {
|
||||
route = route.firstChild;
|
||||
}
|
||||
|
||||
const data = route.data as NavigationItem;
|
||||
const routeTitle = data.label ?? '';
|
||||
|
||||
const prefix =
|
||||
routeTitle.startsWith(DEFAULT_PAGE_TITLE) && data.parent
|
||||
? `${data.parent.label}${TITLE_SEPARATOR}`
|
||||
: '';
|
||||
|
||||
return !!routeTitle ? `${prefix}${routeTitle}${TITLE_SEPARATOR}${TITLE_SUFFIX}` : TITLE_SUFFIX;
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*!
|
||||
* @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
|
||||
*/
|
||||
|
||||
/**
|
||||
* Formats an `ErrorEvent` to a human-readable string that can
|
||||
* be sent to Google Analytics.
|
||||
*/
|
||||
export function formatErrorEventForAnalytics(event: ErrorEvent): string {
|
||||
const {message, filename, colno, lineno, error} = event;
|
||||
|
||||
if (error instanceof Error) {
|
||||
return formatErrorForAnalytics(error);
|
||||
}
|
||||
|
||||
return `${stripErrorMessagePrefix(message)}\n${filename}:` + `${lineno || '?'}:${colno || '?'}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats an `Error` to a human-readable string that can be sent
|
||||
* to Google Analytics.
|
||||
*/
|
||||
export function formatErrorForAnalytics(error: Error): string {
|
||||
let stack = '<no-stack>';
|
||||
|
||||
if (error.stack) {
|
||||
stack = stripErrorMessagePrefix(error.stack)
|
||||
// strip the message from the stack trace, if present
|
||||
.replace(error.message + '\n', '')
|
||||
// strip leading spaces
|
||||
.replace(/^ +/gm, '')
|
||||
// strip all leading "at " for each frame
|
||||
.replace(/^at /gm, '')
|
||||
// replace long urls with just the last segment: `filename:line:column`
|
||||
.replace(/(?: \(|@)http.+\/([^/)]+)\)?(?:\n|$)/gm, '@$1\n')
|
||||
// replace "eval code" in Edge
|
||||
.replace(/ *\(eval code(:\d+:\d+)\)(?:\n|$)/gm, '@???$1\n');
|
||||
}
|
||||
|
||||
return `${error.message}\n${stack}`;
|
||||
}
|
||||
|
||||
/** Strips the error message prefix from a message or stack trace. */
|
||||
function stripErrorMessagePrefix(input: string): string {
|
||||
return input.replace(/^(Uncaught )?Error: /, '');
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/*!
|
||||
* @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 {Injector} from '@angular/core';
|
||||
import {ENVIRONMENT, WINDOW} from '@angular/docs';
|
||||
import {AnalyticsService} from './analytics.service';
|
||||
|
||||
describe('AnalyticsService', () => {
|
||||
let service: AnalyticsService;
|
||||
let injector: Injector;
|
||||
let gtagSpy: jasmine.Spy;
|
||||
let gtagAppendNodeSpy: jasmine.Spy;
|
||||
let windowOnErrorHandler: (event: ErrorEvent) => void;
|
||||
|
||||
let mockWindow: any;
|
||||
|
||||
beforeEach(() => {
|
||||
gtagSpy = jasmine.createSpy('gtag');
|
||||
gtagAppendNodeSpy = jasmine.createSpy('gtag.js script head attach');
|
||||
|
||||
mockWindow = {
|
||||
name: 'Some name',
|
||||
document: {
|
||||
head: {appendChild: gtagAppendNodeSpy},
|
||||
createElement: (tag: string) => document.createElement(tag),
|
||||
querySelector: (_gtagIdSelector: string) => null,
|
||||
},
|
||||
addEventListener: (_name: string, handler: typeof windowOnErrorHandler) =>
|
||||
(windowOnErrorHandler = handler),
|
||||
};
|
||||
|
||||
injector = Injector.create({
|
||||
providers: [
|
||||
{provide: ENVIRONMENT, useValue: {}},
|
||||
{provide: AnalyticsService, deps: [WINDOW]},
|
||||
{provide: WINDOW, useFactory: () => mockWindow, deps: []},
|
||||
],
|
||||
});
|
||||
|
||||
service = injector.get(AnalyticsService);
|
||||
|
||||
// The `gtag` function is attached to the `Window`, so we can spy on it
|
||||
// after the service has been initialized.
|
||||
gtagSpy = spyOn(mockWindow, 'gtag');
|
||||
});
|
||||
|
||||
describe('error reporting', () => {
|
||||
it('should subscribe to window uncaught errors and report them', () => {
|
||||
spyOn(service, 'reportError');
|
||||
|
||||
windowOnErrorHandler(
|
||||
new ErrorEvent('error', {
|
||||
error: new Error('Test Error'),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(service.reportError).toHaveBeenCalledTimes(1);
|
||||
expect(service.reportError).toHaveBeenCalledWith(
|
||||
jasmine.stringContaining('Test Error\n'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('should report errors to analytics by dispatching `gtag` and `ga` events', () => {
|
||||
gtagSpy.calls.reset();
|
||||
|
||||
windowOnErrorHandler(
|
||||
new ErrorEvent('error', {
|
||||
error: new Error('Test Error'),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(gtagSpy).toHaveBeenCalledTimes(1);
|
||||
expect(gtagSpy).toHaveBeenCalledWith(
|
||||
'event',
|
||||
'exception',
|
||||
jasmine.objectContaining({
|
||||
description: jasmine.stringContaining('Test Error\n'),
|
||||
fatal: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,89 +0,0 @@
|
||||
/*!
|
||||
* @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 {inject, Injectable} from '@angular/core';
|
||||
|
||||
import {WINDOW, ENVIRONMENT} from '@angular/docs';
|
||||
|
||||
import {formatErrorEventForAnalytics} from './analytics-format-error';
|
||||
|
||||
/** Extension of `Window` with potential Google Analytics fields. */
|
||||
interface WindowWithAnalytics extends Window {
|
||||
dataLayer?: any[];
|
||||
gtag?(...args: any[]): void;
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
/**
|
||||
* Google Analytics Service - captures app behaviors and sends them to Google Analytics.
|
||||
*
|
||||
* Associates data wi`th properties determined from the environment configurations:
|
||||
* - Data is uploaded to our main Google Analytics 4+ property.
|
||||
*/
|
||||
export class AnalyticsService {
|
||||
private environment = inject(ENVIRONMENT);
|
||||
private window: WindowWithAnalytics = inject(WINDOW);
|
||||
|
||||
constructor() {
|
||||
this._installGlobalSiteTag();
|
||||
this._installWindowErrorHandler();
|
||||
}
|
||||
|
||||
reportError(description: string, fatal = true) {
|
||||
// Limit descriptions to maximum of 150 characters.
|
||||
// See: https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#exd.
|
||||
description = description.substring(0, 150);
|
||||
|
||||
this._gtag('event', 'exception', {description, fatal});
|
||||
}
|
||||
|
||||
sendEvent(name: string, parameters: Record<string, string | boolean | number>) {
|
||||
this._gtag('event', name, parameters);
|
||||
}
|
||||
|
||||
private _gtag(...args: any[]) {
|
||||
if (this.window.gtag) {
|
||||
this.window.gtag(...args);
|
||||
}
|
||||
}
|
||||
|
||||
private _installGlobalSiteTag() {
|
||||
const window = this.window;
|
||||
const url = `https://www.googletagmanager.com/gtag/js?id=${this.environment.googleAnalyticsId}`;
|
||||
|
||||
// Note: This cannot be an arrow function as `gtag.js` expects an actual `Arguments`
|
||||
// instance with e.g. `callee` to be set. Do not attempt to change this and keep this
|
||||
// as much as possible in sync with the tracking code snippet suggested by the Google
|
||||
// Analytics 4 web UI under `Data Streams`.
|
||||
window.dataLayer = this.window.dataLayer || [];
|
||||
window.gtag = function () {
|
||||
window.dataLayer?.push(arguments);
|
||||
};
|
||||
window.gtag('js', new Date());
|
||||
|
||||
// Configure properties before loading the script. This is necessary to avoid
|
||||
// loading multiple instances of the gtag JS scripts.
|
||||
window.gtag('config', this.environment.googleAnalyticsId);
|
||||
|
||||
// Only add the element if `gtag` is not loaded yet. It might already
|
||||
// be inlined into the `index.html` via SSR.
|
||||
if (window.document.querySelector('#gtag-script') === null) {
|
||||
const el = window.document.createElement('script');
|
||||
el.async = true;
|
||||
el.src = url as string;
|
||||
el.id = 'gtag-script';
|
||||
window.document.head.appendChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
private _installWindowErrorHandler() {
|
||||
this.window.addEventListener('error', (event) =>
|
||||
this.reportError(formatErrorEventForAnalytics(event), true),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
/*!
|
||||
* @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 {ErrorHandler, Injector, VERSION} from '@angular/core';
|
||||
|
||||
import {ReportingErrorHandler} from './reporting-error-handler';
|
||||
import {AnalyticsService} from './analytics.service';
|
||||
|
||||
describe('ReportingErrorHandler service', () => {
|
||||
let handler: ReportingErrorHandler;
|
||||
let superHandler: jasmine.Spy;
|
||||
let reportErrorSpy: jasmine.Spy;
|
||||
|
||||
beforeEach(() => {
|
||||
superHandler = spyOn(ErrorHandler.prototype, 'handleError');
|
||||
reportErrorSpy = jasmine.createSpy('analytics report error');
|
||||
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
{provide: AnalyticsService, useValue: {reportError: reportErrorSpy}},
|
||||
{provide: ErrorHandler, useClass: ReportingErrorHandler, deps: [AnalyticsService]},
|
||||
],
|
||||
});
|
||||
|
||||
handler = injector.get(ErrorHandler) as unknown as ReportingErrorHandler;
|
||||
});
|
||||
|
||||
describe('handleError', () => {
|
||||
it('should call the super class handleError', () => {
|
||||
const error = new Error();
|
||||
handler.handleError(error);
|
||||
expect(superHandler).toHaveBeenCalledWith(error);
|
||||
});
|
||||
|
||||
it('should cope with the super handler throwing an error', () => {
|
||||
const error = new Error('initial error');
|
||||
superHandler.and.throwError('super handler error');
|
||||
handler.handleError(error);
|
||||
|
||||
expect(reportErrorSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Error from super handler is reported first
|
||||
expect(reportErrorSpy.calls.argsFor(0)[0]).toEqual(
|
||||
jasmine.stringContaining('super handler error\n'),
|
||||
);
|
||||
|
||||
// Then error from initial exception
|
||||
expect(reportErrorSpy.calls.argsFor(1)[0]).toEqual(
|
||||
jasmine.stringContaining(`[v${VERSION.full}] initial error\n`),
|
||||
);
|
||||
});
|
||||
|
||||
it('should augment an error object with version info', () => {
|
||||
const originalMessage = 'this is an error message';
|
||||
const error = new Error(originalMessage);
|
||||
|
||||
expect(error.message).toBe(originalMessage);
|
||||
// NOTE:
|
||||
// Intentionally not checking `error.stack` here, because accessing it causes the property to
|
||||
// be computed and may affect the observed behavior of `handleError()`.
|
||||
|
||||
handler.handleError(error);
|
||||
|
||||
expect(error.message).toBe(`[v${VERSION.full}] ${originalMessage}`);
|
||||
if (error.stack) {
|
||||
const expected = `Error: [v${VERSION.full}] ${originalMessage}`;
|
||||
const actual = error.stack.split('\n', 1)[0];
|
||||
expect(actual.startsWith(expected))
|
||||
.withContext(`Expected '${actual}' to start with '${expected}'.`)
|
||||
.toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
it('should send an error object to analytics', () => {
|
||||
const error = new Error('this is an error message');
|
||||
handler.handleError(error);
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(
|
||||
jasmine.stringContaining('this is an error message\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should send a non-error object to analytics', () => {
|
||||
const error = {reason: 'this is an error message'};
|
||||
handler.handleError(error);
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(JSON.stringify(error));
|
||||
});
|
||||
|
||||
it('should send a non-error object with circular references to analytics', () => {
|
||||
const error = {
|
||||
reason: 'this is an error message',
|
||||
get self() {
|
||||
return this;
|
||||
},
|
||||
toString() {
|
||||
return `{reason: ${this.reason}}`;
|
||||
},
|
||||
};
|
||||
handler.handleError(error);
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith('{reason: this is an error message}');
|
||||
});
|
||||
|
||||
it('should send an error string to analytics (prefixed with version info)', () => {
|
||||
const error = 'this is an error message';
|
||||
handler.handleError(error);
|
||||
expect(reportErrorSpy).toHaveBeenCalledWith(`[v${VERSION.full}] ${error}`);
|
||||
});
|
||||
|
||||
it('should send a non-object, non-string error stringified to analytics', () => {
|
||||
handler.handleError(404);
|
||||
handler.handleError(false);
|
||||
handler.handleError(null);
|
||||
handler.handleError(undefined);
|
||||
|
||||
expect(reportErrorSpy.calls.allArgs()).toEqual([['404'], ['false'], ['null'], ['undefined']]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/*!
|
||||
* @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 {ErrorHandler, Injectable, VERSION} from '@angular/core';
|
||||
import {formatErrorForAnalytics} from './analytics-format-error';
|
||||
import {AnalyticsService} from './analytics.service';
|
||||
|
||||
/**
|
||||
* Extend the default error handling to report errors to an external service - e.g Google Analytics.
|
||||
*
|
||||
* Errors outside the Angular application may also be handled by `window.onerror`.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ReportingErrorHandler extends ErrorHandler {
|
||||
constructor(private _analytics: AnalyticsService) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send error info to Google Analytics, in addition to the default handling.
|
||||
*
|
||||
* @param error Information about the error.
|
||||
*/
|
||||
override handleError(error: any) {
|
||||
const versionedError = this.prefixErrorWithVersion(error);
|
||||
|
||||
try {
|
||||
super.handleError(versionedError);
|
||||
} catch (e) {
|
||||
this.reportError(e);
|
||||
}
|
||||
this.reportError(versionedError);
|
||||
}
|
||||
|
||||
private prefixErrorWithVersion<T>(error: T): T {
|
||||
const prefix = `[v${VERSION.full}] `;
|
||||
|
||||
if (error instanceof Error) {
|
||||
const oldMessage = error.message;
|
||||
const oldStack = error.stack;
|
||||
|
||||
error.message = prefix + oldMessage;
|
||||
error.stack = oldStack?.replace(oldMessage, error.message);
|
||||
} else if (typeof error === 'string') {
|
||||
error = (prefix + error) as unknown as T;
|
||||
}
|
||||
// If it is a different type, omit the version to avoid altering the original `error` object.
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
private reportError(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
this._analytics.reportError(formatErrorForAnalytics(error));
|
||||
} else {
|
||||
if (typeof error === 'object') {
|
||||
try {
|
||||
error = JSON.stringify(error);
|
||||
} catch {
|
||||
// Ignore the error and just let it be stringified.
|
||||
}
|
||||
}
|
||||
this._analytics.reportError(`${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*!
|
||||
* @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 {ContentLoader} from './content-loader.service';
|
||||
import {HttpClientTestingModule} from '@angular/common/http/testing';
|
||||
|
||||
describe('ContentLoader', () => {
|
||||
let service: ContentLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ContentLoader],
|
||||
imports: [HttpClientTestingModule],
|
||||
});
|
||||
service = TestBed.inject(ContentLoader);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/*!
|
||||
* @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 {HttpClient} from '@angular/common/http';
|
||||
import {Injectable, inject} from '@angular/core';
|
||||
import {DocContent, DocsContentLoader} from '@angular/docs';
|
||||
import {Router} from '@angular/router';
|
||||
import {map} from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class ContentLoader implements DocsContentLoader {
|
||||
private readonly cache = new Map<string, Promise<DocContent | undefined>>();
|
||||
private readonly httpClient = inject(HttpClient);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
async getContent(path: string): Promise<DocContent | undefined> {
|
||||
// If the path does not end with a file extension, add `.md.html` as the default
|
||||
if (!path.match(/\.\w+$/)) {
|
||||
path += '.md.html';
|
||||
}
|
||||
if (!this.cache.has(path)) {
|
||||
try {
|
||||
this.cache.set(
|
||||
path,
|
||||
this.httpClient
|
||||
.get(`assets/content/${path}`, {
|
||||
responseType: 'text',
|
||||
})
|
||||
.pipe(map((contents) => ({contents, id: path})))
|
||||
.toPromise(),
|
||||
);
|
||||
} catch {
|
||||
this.router.navigateByUrl('/404');
|
||||
}
|
||||
}
|
||||
return this.cache.get(path)!;
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/*!
|
||||
* @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 {DOCUMENT, isPlatformServer} from '@angular/common';
|
||||
import {ErrorHandler, PLATFORM_ID, inject} from '@angular/core';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
import {AnalyticsService} from '../analytics/analytics.service';
|
||||
import {ErrorSnackBar, ErrorSnackBarData} from './error-snack-bar';
|
||||
|
||||
export class CustomErrorHandler implements ErrorHandler {
|
||||
snackBar = inject(MatSnackBar);
|
||||
document = inject(DOCUMENT);
|
||||
isServer = isPlatformServer(inject(PLATFORM_ID));
|
||||
analyticsService = inject(AnalyticsService);
|
||||
|
||||
get isOnline(): boolean {
|
||||
if (this.isServer) return false;
|
||||
|
||||
const win = this.document.defaultView;
|
||||
return win?.navigator.onLine ?? true;
|
||||
}
|
||||
|
||||
handleError(error: any) {
|
||||
if (typeof error.message === 'string') {
|
||||
// Just looking at the first line of the error message (ignoring the call stack part),
|
||||
// which should contain a pattern that we are looking for.
|
||||
const firstLine = error.message.split('\n')[0];
|
||||
if (this.isOnline && firstLine?.match(/chunk-(.*?)\.js/)) {
|
||||
// Trying to load a chunk that doesn't exist anymore
|
||||
// Users should reload the app.
|
||||
this.openErrorSnackBar();
|
||||
this.analyticsService.reportError('Chunk loading error');
|
||||
}
|
||||
}
|
||||
|
||||
// We still want to log every error
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
openErrorSnackBar(): void {
|
||||
this.snackBar
|
||||
.openFromComponent(ErrorSnackBar, {
|
||||
panelClass: 'docs-invert-mode',
|
||||
data: {
|
||||
message: `Our docs have been updated, reload the page to see the latest.`,
|
||||
actionText: `Reload`,
|
||||
} satisfies ErrorSnackBarData,
|
||||
})
|
||||
.onAction()
|
||||
.subscribe(() => {
|
||||
this.document.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
/*!
|
||||
* @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, Inject} from '@angular/core';
|
||||
import {MAT_SNACK_BAR_DATA, MatSnackBarAction, MatSnackBarRef} from '@angular/material/snack-bar';
|
||||
|
||||
export interface ErrorSnackBarData {
|
||||
message: string;
|
||||
actionText?: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'error-snack-bar',
|
||||
template: `
|
||||
{{ message }}
|
||||
<button
|
||||
class="docs-primary-btn"
|
||||
type="button"
|
||||
matSnackBarAction
|
||||
[attr.text]="actionText"
|
||||
(click)="snackBarRef.dismissWithAction()"
|
||||
>
|
||||
{{ actionText }}
|
||||
</button>
|
||||
`,
|
||||
imports: [MatSnackBarAction],
|
||||
standalone: true,
|
||||
styles: `:host { display: flex; align-items: center; button { margin-left: 16px }}`,
|
||||
})
|
||||
export class ErrorSnackBar {
|
||||
protected message: string;
|
||||
protected actionText?: string;
|
||||
|
||||
constructor(
|
||||
protected snackBarRef: MatSnackBarRef<ErrorSnackBar>,
|
||||
@Inject(MAT_SNACK_BAR_DATA) public data: ErrorSnackBarData,
|
||||
) {
|
||||
this.message = data.message;
|
||||
this.actionText = data.actionText;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/*!
|
||||
* @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 {ExampleContentLoader} from './example-content-loader.service';
|
||||
import {PREVIEWS_COMPONENTS} from '@angular/docs';
|
||||
|
||||
describe('ExampleContentLoader', () => {
|
||||
let service: ExampleContentLoader;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [ExampleContentLoader, {provide: PREVIEWS_COMPONENTS, useValue: []}],
|
||||
});
|
||||
service = TestBed.inject(ExampleContentLoader);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
/*!
|
||||
* @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, Type, inject} from '@angular/core';
|
||||
import {PREVIEWS_COMPONENTS} from '@angular/docs';
|
||||
|
||||
@Injectable()
|
||||
export class ExampleContentLoader {
|
||||
private readonly previewsComponents = inject(PREVIEWS_COMPONENTS);
|
||||
|
||||
loadPreview(id: string): Promise<Type<unknown>> {
|
||||
return this.previewsComponents[id]();
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
/*!
|
||||
* @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 {
|
||||
DestroyRef,
|
||||
ENVIRONMENT_INITIALIZER,
|
||||
EnvironmentInjector,
|
||||
Injectable,
|
||||
Injector,
|
||||
Provider,
|
||||
ProviderToken,
|
||||
Type,
|
||||
createEnvironmentInjector,
|
||||
inject,
|
||||
} from '@angular/core';
|
||||
|
||||
/**
|
||||
* inject a service asynchronously
|
||||
*
|
||||
* @param: injector. If the injector is a NodeInjector the loaded module will be destroyed alonside its injector
|
||||
*/
|
||||
export async function injectAsync<T>(
|
||||
injector: Injector,
|
||||
providerLoader: () => Promise<ProviderToken<T>>,
|
||||
): Promise<T> {
|
||||
const injectImpl = injector.get(InjectAsyncImpl);
|
||||
return injectImpl.get(injector, providerLoader);
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
class InjectAsyncImpl<T> {
|
||||
private overrides = new WeakMap(); // no need to cleanup
|
||||
override<T>(type: Type<T>, mock: Type<unknown>) {
|
||||
this.overrides.set(type, mock);
|
||||
}
|
||||
|
||||
async get(injector: Injector, providerLoader: () => Promise<ProviderToken<T>>): Promise<T> {
|
||||
const type = await providerLoader();
|
||||
|
||||
// Check if we have overrides, O(1), low overhead
|
||||
if (this.overrides.has(type)) {
|
||||
const override = this.overrides.get(type);
|
||||
return new override();
|
||||
}
|
||||
|
||||
if (!(injector instanceof EnvironmentInjector)) {
|
||||
// this is the DestroyRef of the component
|
||||
const destroyRef = injector.get(DestroyRef);
|
||||
|
||||
// This is the parent injector of the injector we're creating
|
||||
const environmentInjector = injector.get(EnvironmentInjector);
|
||||
|
||||
// Creating an environment injector to destroy it afterwards
|
||||
const newInjector = createEnvironmentInjector([type as Provider], environmentInjector);
|
||||
|
||||
// Destroy the injector to trigger DestroyRef.onDestroy on our service
|
||||
destroyRef.onDestroy(() => {
|
||||
newInjector.destroy();
|
||||
});
|
||||
|
||||
// We want to create the new instance of our service with our new injector
|
||||
injector = newInjector;
|
||||
}
|
||||
|
||||
return injector.get(type)!;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to mock the lazy loaded module in `injectAsync`
|
||||
*
|
||||
* @usage
|
||||
* TestBed.configureTestingModule({
|
||||
* providers: [
|
||||
* mockAsyncProvider(SandboxService, fakeSandboxService)
|
||||
* ]
|
||||
* });
|
||||
*/
|
||||
export function mockAsyncProvider<T>(type: Type<T>, mock: Type<unknown>) {
|
||||
return [
|
||||
{
|
||||
provide: ENVIRONMENT_INITIALIZER,
|
||||
multi: true,
|
||||
useValue: () => {
|
||||
inject(InjectAsyncImpl).override(type, mock);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/*!
|
||||
* @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 {ThemeManager} from './theme-manager.service';
|
||||
import {LOCAL_STORAGE} from '@angular/docs';
|
||||
|
||||
describe('ThemeManager', () => {
|
||||
let service: ThemeManager;
|
||||
let localStorageSpy: jasmine.SpyObj<Storage>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageSpy = jasmine.createSpyObj<Storage>('localStorage', ['getItem', 'setItem']);
|
||||
localStorageSpy.getItem.and.returnValue(null);
|
||||
localStorageSpy.setItem.and.returnValue();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{
|
||||
provide: LOCAL_STORAGE,
|
||||
useValue: localStorageSpy,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should set theme based on device preferences (auto) when user did not set theme manually', () => {
|
||||
localStorageSpy.getItem.and.returnValue(null);
|
||||
|
||||
service = TestBed.inject(ThemeManager);
|
||||
|
||||
expect(service.theme()).toBe('auto');
|
||||
});
|
||||
|
||||
it('should set theme based on stored user preferences (dark) when user already set theme manually', () => {
|
||||
localStorageSpy.getItem.and.returnValue('dark');
|
||||
|
||||
service = TestBed.inject(ThemeManager);
|
||||
|
||||
expect(service.theme()).toBe('dark');
|
||||
});
|
||||
|
||||
it('should set theme based on stored user preferences (light) when user already set theme manually', () => {
|
||||
localStorageSpy.getItem.and.returnValue('light');
|
||||
|
||||
service = TestBed.inject(ThemeManager);
|
||||
|
||||
expect(service.theme()).toBe('light');
|
||||
});
|
||||
|
||||
it('should set theme based on stored user preferences (auto) when user already set theme manually', () => {
|
||||
localStorageSpy.getItem.and.returnValue('auto');
|
||||
|
||||
service = TestBed.inject(ThemeManager);
|
||||
|
||||
expect(service.theme()).toBe('auto');
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
/*!
|
||||
* @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 {DOCUMENT, isPlatformBrowser} from '@angular/common';
|
||||
import {Injectable, PLATFORM_ID, inject, signal} from '@angular/core';
|
||||
import {LOCAL_STORAGE} from '@angular/docs';
|
||||
import {Subject} from 'rxjs';
|
||||
|
||||
// Keep these constants in sync with the code in index.html
|
||||
|
||||
export const THEME_PREFERENCE_LOCAL_STORAGE_KEY = 'themePreference';
|
||||
export const DARK_MODE_CLASS_NAME = 'docs-dark-mode';
|
||||
export const LIGHT_MODE_CLASS_NAME = 'docs-light-mode';
|
||||
export const PREFERS_COLOR_SCHEME_DARK = '(prefers-color-scheme: dark)';
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'auto';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ThemeManager {
|
||||
private readonly document = inject(DOCUMENT);
|
||||
private readonly localStorage = inject(LOCAL_STORAGE);
|
||||
private readonly platformId = inject(PLATFORM_ID);
|
||||
|
||||
readonly theme = signal<Theme | null>(this.getThemeFromLocalStorageValue());
|
||||
// Zoneless - it's required to notify that theme was changed. It could be removed when signal-based components will be available.
|
||||
readonly themeChanged$ = new Subject<void>();
|
||||
|
||||
constructor() {
|
||||
if (!isPlatformBrowser(this.platformId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loadThemePreference();
|
||||
this.watchPreferredColorScheme();
|
||||
}
|
||||
|
||||
setTheme(theme: Theme): void {
|
||||
this.theme.set(theme);
|
||||
this.setThemeInLocalStorage();
|
||||
this.setThemeBodyClasses(theme === 'auto' ? preferredScheme() : theme);
|
||||
}
|
||||
|
||||
// 1. Read theme preferences stored in localStorage
|
||||
// 2. In case when there are no stored user preferences, then read them from device preferences.
|
||||
private loadThemePreference(): void {
|
||||
const savedUserPreference = this.getThemeFromLocalStorageValue();
|
||||
const useTheme = savedUserPreference ?? 'auto';
|
||||
|
||||
this.theme.set(useTheme);
|
||||
this.setThemeBodyClasses(useTheme === 'auto' ? preferredScheme() : useTheme);
|
||||
}
|
||||
|
||||
// Set theme classes on the body element
|
||||
private setThemeBodyClasses(theme: 'dark' | 'light'): void {
|
||||
const documentClassList = this.document.documentElement.classList;
|
||||
if (theme === 'dark') {
|
||||
documentClassList.add(DARK_MODE_CLASS_NAME);
|
||||
documentClassList.remove(LIGHT_MODE_CLASS_NAME);
|
||||
} else {
|
||||
documentClassList.add(LIGHT_MODE_CLASS_NAME);
|
||||
documentClassList.remove(DARK_MODE_CLASS_NAME);
|
||||
}
|
||||
this.themeChanged$.next();
|
||||
}
|
||||
|
||||
private getThemeFromLocalStorageValue(): Theme | null {
|
||||
const theme = this.localStorage?.getItem(THEME_PREFERENCE_LOCAL_STORAGE_KEY) as Theme | null;
|
||||
return theme ?? null;
|
||||
}
|
||||
|
||||
private setThemeInLocalStorage(): void {
|
||||
if (this.theme()) {
|
||||
this.localStorage?.setItem(THEME_PREFERENCE_LOCAL_STORAGE_KEY, this.theme()!);
|
||||
}
|
||||
}
|
||||
|
||||
private watchPreferredColorScheme() {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
|
||||
const preferredScheme = event.matches ? 'dark' : 'light';
|
||||
this.setThemeBodyClasses(preferredScheme);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function preferredScheme(): 'dark' | 'light' {
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/*!
|
||||
* @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 {VersionManager} from './version-manager.service';
|
||||
|
||||
describe('VersionManager', () => {
|
||||
let service: VersionManager;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(VersionManager);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
/*!
|
||||
* @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, VERSION, computed, signal} from '@angular/core';
|
||||
|
||||
// TODO(josephperrott): extract this out of the file into a managed location.
|
||||
const VERSIONS_CONFIG = {
|
||||
currentVersion: 'stable',
|
||||
historicalVersionsLinkPattern: 'https://v{{version}}.angular.dev',
|
||||
mainVersions: [
|
||||
{
|
||||
version: 'stable',
|
||||
url: 'https://angular.dev',
|
||||
},
|
||||
{
|
||||
version: 'v16',
|
||||
url: 'https://v16.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v15',
|
||||
url: 'https://v15.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v14',
|
||||
url: 'https://v14.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v13',
|
||||
url: 'https://v13.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v12',
|
||||
url: 'https://v12.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v11',
|
||||
url: 'https://v11.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v10',
|
||||
url: 'https://v10.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v9',
|
||||
url: 'https://v9.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v8',
|
||||
url: 'https://v8.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v7',
|
||||
url: 'https://v7.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v6',
|
||||
url: 'https://v6.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v5',
|
||||
url: 'https://v5.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v4',
|
||||
url: 'https://v4.angular.io/docs',
|
||||
},
|
||||
{
|
||||
version: 'v2',
|
||||
url: 'https://v2.angular.io/docs',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export interface Version {
|
||||
displayName: string;
|
||||
version: VersionMode;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type VersionMode = 'stable' | 'rc' | 'next' | number;
|
||||
|
||||
export const INITIAL_DOCS_VERSION = 17;
|
||||
export const VERSION_PATTERN_PLACEHOLDER = '{{version}}';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class VersionManager {
|
||||
versions = signal<Version[]>([
|
||||
...VERSIONS_CONFIG.mainVersions.map((item) =>
|
||||
this.mapToVersion(item as Pick<Version, 'url' | 'version'>),
|
||||
),
|
||||
...this.getHistoricalVersions(),
|
||||
]);
|
||||
|
||||
currentDocsVersion = computed(() => {
|
||||
return this.versions().find(
|
||||
(version) => version.version.toString() === VERSIONS_CONFIG.currentVersion,
|
||||
);
|
||||
});
|
||||
|
||||
private getHistoricalVersions(): Version[] {
|
||||
const historicalVersions: Version[] = [];
|
||||
for (let version = Number(VERSION.major) - 1; version >= INITIAL_DOCS_VERSION; version--) {
|
||||
historicalVersions.push({
|
||||
url: VERSIONS_CONFIG.historicalVersionsLinkPattern.replace(
|
||||
VERSION_PATTERN_PLACEHOLDER,
|
||||
version.toString(),
|
||||
),
|
||||
displayName: this.getVersion(version),
|
||||
version,
|
||||
});
|
||||
}
|
||||
return historicalVersions;
|
||||
}
|
||||
|
||||
private mapToVersion(value: Pick<Version, 'url' | 'version'>): Version {
|
||||
return {
|
||||
...value,
|
||||
displayName: this.getVersion(value.version),
|
||||
};
|
||||
}
|
||||
|
||||
private getVersion(versionMode: VersionMode): string {
|
||||
if (versionMode === 'stable') {
|
||||
return `v${VERSION.major}`;
|
||||
}
|
||||
if (Number.isInteger(versionMode)) {
|
||||
return `v${versionMode}`;
|
||||
}
|
||||
return versionMode.toString();
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
# EmbeddedEditor components, services and functionality
|
||||
|
||||
- [Scenarios](#scenarios)
|
||||
- [Loading a project](#loading-a-project)
|
||||
- [Updating the code](#updating-the-code)
|
||||
- [Creating a new file](#creating-a-new-file)
|
||||
- [Deleting a file](#deleting-a-file)
|
||||
- [Switching a project](#switching-a-project)
|
||||
- [Components and services](#components-and-services)
|
||||
|
||||
- [EmbeddedEditor](#EmbeddedEditor)
|
||||
- [CodeEditor](#CodeEditor)
|
||||
- [CodeMirrorEditor](#CodeMirrorEditor)
|
||||
- [TypeScript Web Worker](#typescript-web-worker)
|
||||
- [Preview](#Preview)
|
||||
- [Terminal](#Terminal)
|
||||
- [InteractiveTerminal](#InteractiveTerminal)
|
||||
- [Console](#Console)
|
||||
- [NodeRuntimeSandbox](#NodeRuntimeSandbox)
|
||||
- [NodeRuntimeState](#NodeRuntimeState)
|
||||
- [EmbeddedTutorialManager](#EmbeddedTutorialManager)
|
||||
- [EditorUiState](#EditorUiState)
|
||||
- [DownloadManager](#DownloadManager)
|
||||
- [AlertManager](#AlertManager)
|
||||
- [TypingsLoader](#TypingsLoader)
|
||||
|
||||
## External libraries
|
||||
|
||||
- [WebContainers API](https://webcontainers.io/)
|
||||
- [CodeMirror](https://codemirror.net/)
|
||||
- [@typescript/vfs](https://www.npmjs.com/package/@typescript/vfs)
|
||||
- [Xterm.js](https://xtermjs.org/)
|
||||
|
||||
## Notes
|
||||
|
||||
- See [scripts/tutorials/README.md](/scripts/tutorials/README.md) for more information about the tutorials script.
|
||||
- See [adev/src/content/tutorials/README.md](/adev/src/content/tutorials/README.md) for more information about the tutorials content.
|
||||
|
||||
---
|
||||
|
||||
## Scenarios
|
||||
|
||||
### Loading a project
|
||||
|
||||
1. The page responsible for the embedded editor lazy loads the [`EmbeddedEditor`](./embedded-editor.component.ts) component and the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts), then triggers the initialization of all components and services. The embedded editor is available in the following pages:
|
||||
|
||||
- homepage: https://angular.dev
|
||||
- playground: https://angular.dev/playground
|
||||
- tutorial pages: https://angular.dev/tutorials
|
||||
|
||||
2. The project assets are fetched by the [`EmbeddedTutorialManager`](./embedded-tutorial-manager.service.ts). Meanwhile:
|
||||
|
||||
- The code editor is initialized
|
||||
- The code editor initializes the TypeScript Web Worker, which initializes the "default file system map" using TypeScript's CDN.
|
||||
- The WebContainer is initialized
|
||||
- The terminal is initialized
|
||||
|
||||
3. The tutorial source code is mounted in the `WebContainer`'s filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts)
|
||||
4. The tutorial project dependencies are installed by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
|
||||
5. The development server is started by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts) and the types are loaded by the [`TypingsLoader`](./typings-loader.service.ts) service.
|
||||
6. The preview is loaded with the URL provided by the WebContainer API after the development server is started.
|
||||
7. The project is ready.
|
||||
|
||||
### Updating the code
|
||||
|
||||
1. The user update the code in the code editor.
|
||||
2. The code editor state is updated on real time, without debouncing so that the user can see the changes in the code editor and CodeMirror can handle the changes accordingly.
|
||||
3. At the same time, the changes are sent to the TypeScript web worker to provide diagnostics, autocomplete and type features as soon as possible.
|
||||
4. The code changes are debounced to be written in the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
|
||||
5. After the debounce time is reached, the code changes are written in the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts), then the user can see the changes in the preview.
|
||||
|
||||
### Creating a new file
|
||||
|
||||
1. The user clicks on the new file button.
|
||||
2. The new file tab is opened.
|
||||
3. The user types the new file name.
|
||||
4. If the file name is valid, the file is created in the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
|
||||
|
||||
- `..` is disallowed in the file name to prevent users to create files outside the `src` directory.
|
||||
|
||||
5. The file is added to the TypeScript virtual file system, allowing the TypeScript web worker to provide diagnostics, autocomplete and type features for the new file. Also, exports from the new file are available in other files.
|
||||
6. The new file is added as the last tab in the code editor and the new file can be edited.
|
||||
|
||||
Note: If the new file name matches a file that already exists but is hidden in the code editor, the content for that file will show up in the created file. An example for a file that always exists is `index.html`.
|
||||
|
||||
### Deleting a file
|
||||
|
||||
1. The user clicks on the delete file button.
|
||||
2. The file is deleted from the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
|
||||
3. The file is removed from the TypeScript virtual file system.
|
||||
4. The file is removed from the code editor tabs.
|
||||
|
||||
Note: Some files can't be deleted to prevent users to break the app, being `src/main.ts`and `src/index.html`
|
||||
|
||||
### Switching a project
|
||||
|
||||
The embedded editor considers a project change when the embedded editor was already initialized and the user changes the page in the following scenarios:
|
||||
|
||||
- Navigating through tutorial steps
|
||||
- Going from the homepage after the embedded editor is initialized to the playground
|
||||
- Going from a tutorial page to the playground
|
||||
- Going from a tutorial page to the homepage
|
||||
- Going from the playground to the homepage
|
||||
|
||||
When a project change is detected, the [`EmbeddedTutorialManager`](./embedded-tutorial-manager.service.ts) emits the `tutorialChanged` observable, which is listened in multiple sub-components and services, then each component/service performs the necessary operations to switch the project.
|
||||
|
||||
The following steps are executed on project change:
|
||||
|
||||
1. The new project files are fetched by the [`EmbeddedTutorialManager`](./embedded-tutorial-manager.service.ts).
|
||||
2. The new project files are mounted in the WebContainer filesystem.
|
||||
3. The TypeScript virtual filesystem is updated with the new files and contents.
|
||||
4. The previous project and new project files are compared.
|
||||
1. Files that are not available in the new project are deleted from the WebContainer filesystem.
|
||||
2. Files that have the same path and name have their content replaced on the previous step when the files are mounted.
|
||||
5. The previous project dependencies are compared with the new project dependencies.
|
||||
1. If there are differences, a `npm install` is triggered, hiding the preview and going to the install loading step.
|
||||
2. If there are no differences, the project is ready.
|
||||
6. Some states are resetted, for example the "reveal answer" state if the previous project was in the "reveal answer" state.
|
||||
|
||||
## Components and services
|
||||
|
||||
### [`EmbeddedEditor`](./embedded-editor.component.ts)
|
||||
|
||||
The embedded editor is the parent component that holds all the components and services that compose the embedded editor.
|
||||
|
||||
#### [`CodeEditor`](./code-editor/code-editor.component.ts)
|
||||
|
||||
The component that holds the code editor view and the code editor state.
|
||||
|
||||
##### [`CodeMirrorEditor`](./code-editor/code-mirror-editor.service.ts)
|
||||
|
||||
[CodeMirror](https://codemirror.net/) is the library used to handle the code editor.
|
||||
|
||||
The `CodeMirrorEditor` service manages the CodeMirror instance and all the interactions with the library used to handle the code editor.
|
||||
|
||||
- handle the file edits and the CodeMirror view and state
|
||||
- handle the current project files in the code editor
|
||||
- handle the file creations and deletions
|
||||
- handle the file changes
|
||||
- handle all the CodeMirror specific events and extensions
|
||||
|
||||
###### [TypeScript Web Worker](./code-editor/workers/typescript-vfs.worker.ts)
|
||||
|
||||
The TypeScript features are provided by the TypeScript web worker, that is initialized by the `CodeMirrorEditor` service.
|
||||
|
||||
The TypeScript web worker uses `@typescript/vfs` and the TypeScript language service to provide diagnostics, autocomplete and type features.
|
||||
|
||||
#### [`Preview`](./preview/preview.component.ts)
|
||||
|
||||
The preview component manages the `iframe` responsible for displaying the tutorial project preview, with the URL provided by the WebContainer API after the development server is started.
|
||||
|
||||
While the project is being initialized, the preview displays the loading state.
|
||||
|
||||
#### [`Terminal`](./terminal/terminal.component.ts)
|
||||
|
||||
[Xterm.js](https://xtermjs.org/) is the library used to handle the terminals.
|
||||
|
||||
The terminal component handles the Xterm.js instance for the console and for the interactive terminal.
|
||||
|
||||
##### [`InteractiveTerminal`](./terminal/interactive-terminal.ts)
|
||||
|
||||
The interactive terminal is the terminal where the user can interact with the terminal and run commands, supporting only commands for the Angular CLI.
|
||||
|
||||
##### Console
|
||||
|
||||
The console displays the output for `npm install` and `ng serve`.
|
||||
|
||||
#### [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts)
|
||||
|
||||
Responsible for managing the WebContainer instance and all communication with its API. This service handles:
|
||||
|
||||
- the WebContainer instance
|
||||
- all Node.js scripts
|
||||
- the WebContainer filesystem, mounting the tutorial project files, writing new content, deleting and creating files.
|
||||
- the terminal session, reading and processing user inputs.
|
||||
- the tutorial project dependencies, installing the dependencies.
|
||||
- the processes running inside the WebContainer, being the npm scripts to install the dependencies, run the development server and the user inputs for the `ng` CLI.
|
||||
|
||||
##### [`NodeRuntimeState`](./node-runtime-state.service.ts)
|
||||
|
||||
Manages the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts) loading and error state.
|
||||
|
||||
#### [`EmbeddedTutorialManager`](./embedded-tutorial-manager.service.ts)
|
||||
|
||||
Manages the tutorial assets, being responsible for fetching the tutorial source code and metadata.
|
||||
|
||||
The source code is mounted in the WebContainer filesystem by the [`NodeRuntimeSandbox`](./node-runtime-sandbox.service.ts).
|
||||
|
||||
The metadata is used to manage the project, handle the project changes and the user interactivity with the app.
|
||||
|
||||
This service also handles the reveal answer and reset reveal answer feature.
|
||||
|
||||
#### [`EditorUiState`](./editor-ui-state.service.ts)
|
||||
|
||||
Manages the editor UI state, being responsible for handling the user interactions with the editor tabs, switching between the preview, the terminal and the console.
|
||||
|
||||
#### [`DownloadManager`](./download-manager.service.ts)
|
||||
|
||||
Responsible for handling the download button in the embedded editor, fetching the tutorial project files and generating a zip file with the project content.
|
||||
|
||||
#### [`AlertManager`](./alert-manager.service.ts)
|
||||
|
||||
Manage the alerts displayed in the embedded editor, being the out of memory alert when multiple tabs are opened, and unsupported enviroments alerts.
|
||||
|
||||
#### [`TypingsLoader`](./typings-loader.service.ts)
|
||||
|
||||
Manages the types definitions for the code editor.
|
||||
@@ -1,105 +0,0 @@
|
||||
/*!
|
||||
* @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, inject} from '@angular/core';
|
||||
import {LOCAL_STORAGE, WINDOW, isMobile} from '@angular/docs';
|
||||
import {MatSnackBar} from '@angular/material/snack-bar';
|
||||
import {ErrorSnackBar, ErrorSnackBarData} from '../core/services/errors-handling/error-snack-bar';
|
||||
|
||||
export const MAX_RECOMMENDED_WEBCONTAINERS_INSTANCES = 3;
|
||||
export const WEBCONTAINERS_COUNTER_KEY = 'numberOfWebcontainers';
|
||||
|
||||
export enum AlertReason {
|
||||
OUT_OF_MEMORY,
|
||||
MOBILE,
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class AlertManager {
|
||||
private readonly localStorage = inject(LOCAL_STORAGE);
|
||||
private readonly window = inject(WINDOW);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
|
||||
init(): void {
|
||||
this.listenToLocalStorageValuesChange();
|
||||
|
||||
this.increaseInstancesCounter();
|
||||
|
||||
this.decreaseInstancesCounterOnPageClose();
|
||||
|
||||
this.checkDevice();
|
||||
}
|
||||
|
||||
private listenToLocalStorageValuesChange(): void {
|
||||
this.window.addEventListener('storage', () => {
|
||||
const countOfRunningInstances = this.getStoredCountOfWebcontainerInstances();
|
||||
|
||||
this.validateRunningInstances(countOfRunningInstances);
|
||||
});
|
||||
}
|
||||
|
||||
// Increase count of the running instances of the webcontainers when user will boot the webcontainer
|
||||
private increaseInstancesCounter(): void {
|
||||
const countOfRunningInstances = this.getStoredCountOfWebcontainerInstances() + 1;
|
||||
|
||||
this.localStorage?.setItem(WEBCONTAINERS_COUNTER_KEY, countOfRunningInstances.toString());
|
||||
this.validateRunningInstances(countOfRunningInstances);
|
||||
}
|
||||
|
||||
// Decrease count of running instances of the webcontainers when user close the app.
|
||||
private decreaseInstancesCounterOnPageClose(): void {
|
||||
this.window.addEventListener('beforeunload', () => {
|
||||
const countOfRunningInstances = this.getStoredCountOfWebcontainerInstances() - 1;
|
||||
|
||||
this.localStorage?.setItem(WEBCONTAINERS_COUNTER_KEY, countOfRunningInstances.toString());
|
||||
this.validateRunningInstances(countOfRunningInstances);
|
||||
});
|
||||
}
|
||||
|
||||
private getStoredCountOfWebcontainerInstances(): number {
|
||||
const countStoredInLocalStorage = this.localStorage?.getItem(WEBCONTAINERS_COUNTER_KEY);
|
||||
|
||||
if (!countStoredInLocalStorage || Number.isNaN(countStoredInLocalStorage)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Number(countStoredInLocalStorage);
|
||||
}
|
||||
|
||||
private validateRunningInstances(countOfRunningInstances: number): void {
|
||||
if (countOfRunningInstances > MAX_RECOMMENDED_WEBCONTAINERS_INSTANCES) {
|
||||
this.openSnackBar(AlertReason.OUT_OF_MEMORY);
|
||||
}
|
||||
}
|
||||
|
||||
private checkDevice() {
|
||||
if (isMobile) {
|
||||
this.openSnackBar(AlertReason.MOBILE);
|
||||
}
|
||||
}
|
||||
|
||||
private openSnackBar(reason: AlertReason) {
|
||||
let message = '';
|
||||
switch (reason) {
|
||||
case AlertReason.OUT_OF_MEMORY:
|
||||
message = `Your browser is currently limiting the memory available to run the Angular Tutorials or Playground. If you have multiple tabs open with Tutorials or Playground, please close some of them and refresh this page.`;
|
||||
break;
|
||||
case AlertReason.MOBILE:
|
||||
message = `You are running the embedded editor in a mobile device, this may result in an Out of memory error.`;
|
||||
break;
|
||||
}
|
||||
|
||||
this.snackBar.openFromComponent(ErrorSnackBar, {
|
||||
panelClass: 'docs-invert-mode',
|
||||
data: {
|
||||
message,
|
||||
actionText: 'I understand',
|
||||
} satisfies ErrorSnackBarData,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<!-- Code Editor Tabs -->
|
||||
<div class="docs-code-editor-tabs">
|
||||
<div class="adev-tabs-and-plus">
|
||||
<mat-tab-group animationDuration="0ms" mat-stretch-tabs="false">
|
||||
<!--
|
||||
Hint: we would like to keep only one instance of #codeEditorWrapper,
|
||||
that's why we're not placing this element as content of mat-tab, just to
|
||||
not call another time init method from CodeMirrorEditor service.
|
||||
-->
|
||||
@for (file of files(); track file) {
|
||||
<mat-tab #tab>
|
||||
<ng-template mat-tab-label>
|
||||
{{ file.filename.replace('src/', '') }}
|
||||
@if (tab.isActive && canDeleteFile(file.filename)) {
|
||||
<button
|
||||
class="docs-delete-file"
|
||||
aria-label="Delete file"
|
||||
(click)="deleteFile(file.filename)"
|
||||
>
|
||||
<docs-icon>delete</docs-icon>
|
||||
</button>
|
||||
}
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
} @if (isCreatingFile()) {
|
||||
<mat-tab>
|
||||
<ng-template mat-tab-label>
|
||||
<form (submit)="createFile($event)">
|
||||
<input
|
||||
name="new-file"
|
||||
class="adev-new-file-input"
|
||||
#createFileInput
|
||||
(keydown)="$event.stopPropagation()"
|
||||
/>
|
||||
</form>
|
||||
</ng-template>
|
||||
</mat-tab>
|
||||
}
|
||||
</mat-tab-group>
|
||||
|
||||
<button class="adev-add-file" (click)="onAddButtonClick()" aria-label="Add a new file">
|
||||
<docs-icon>add</docs-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="adev-editor-download-button"
|
||||
type="button"
|
||||
(click)="downloadCurrentCodeEditorState()"
|
||||
aria-label="Download current code in editor"
|
||||
>
|
||||
<docs-icon>download</docs-icon>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Code Editor -->
|
||||
<div #codeEditorWrapper class="adev-code-editor-wrapper"></div>
|
||||
|
||||
@if (displayErrorsBox()) {
|
||||
<div class="adev-inline-errors-box">
|
||||
<button type="button" (click)="closeErrorsBox()">
|
||||
<docs-icon class="docs-icon_high-contrast">close</docs-icon>
|
||||
</button>
|
||||
<ul>
|
||||
@for (error of errors(); track error) {
|
||||
<li>(line: {{ error.lineNumber }}:{{ error.characterPosition }}) {{ error.message }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
:host {
|
||||
// backgrounds
|
||||
--code-editor-selection-background: color-mix(
|
||||
in srgb,
|
||||
var(--selection-background) 5%,
|
||||
var(--octonary-contrast)
|
||||
);
|
||||
--code-editor-focused-selection-background: color-mix(
|
||||
in srgb,
|
||||
var(--selection-background) 12%,
|
||||
var(--octonary-contrast)
|
||||
);
|
||||
|
||||
// text base colors
|
||||
--code-editor-text-base-color: var(--primary-contrast);
|
||||
|
||||
// tooltips
|
||||
--code-editor-tooltip-background: color-mix(
|
||||
in srgb,
|
||||
var(--bright-blue),
|
||||
var(--page-background) 90%
|
||||
);
|
||||
--code-editor-tooltip-color: var(--primary-contrast);
|
||||
--code-editor-tooltip-border: 1px solid
|
||||
color-mix(in srgb, var(--bright-blue), var(--page-background) 70%);
|
||||
--code-editor-tooltip-border-radius: 0.25rem;
|
||||
|
||||
// autocomplete
|
||||
--code-editor-autocomplete-item-background: var(--senary-contrast);
|
||||
--code-editor-autocomplete-item-color: var(--primary-contrast);
|
||||
|
||||
// cursor
|
||||
--code-name: var(--primary-contrast);
|
||||
--code-editor-cursor-color: var(--code-name);
|
||||
--code-variable-name: var(--bright-blue);
|
||||
--code-property-name: var(--code-name);
|
||||
--code-definition-keyword: var(--electric-violet);
|
||||
|
||||
// comments
|
||||
--code-comment: var(--electric-violet);
|
||||
--code-line-comment: var(--symbolic-gray);
|
||||
--code-block-comment: var(--symbolic-brown);
|
||||
--code-doc-comment: var(--code-comment);
|
||||
|
||||
// keywords
|
||||
--code-keyword: var(--electric-violet);
|
||||
--code-modifier: var(--code-keyword);
|
||||
--code-operator-keyword: var(--code-keyword);
|
||||
--code-control-keyword: var(--code-keyword);
|
||||
--code-module-keyword: var(--code-keyword);
|
||||
|
||||
// structural
|
||||
--code-brace: var(--vivid-pink);
|
||||
|
||||
// Misc
|
||||
--code-bool: var(--bright-blue);
|
||||
--code-string: var(--orange-red);
|
||||
--code-regexp: var(--orange-red);
|
||||
|
||||
--code-tags: var(--bright-blue);
|
||||
--code-component: var(--primary-contrast);
|
||||
--code-type-name: var(--vivid-pink);
|
||||
--code-self: var(--orange-red);
|
||||
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.docs-code-editor-tabs {
|
||||
display: flex;
|
||||
background: var(--octonary-contrast);
|
||||
border-block-end: 1px solid var(--senary-contrast);
|
||||
transition: background 0.3s ease, border 0.3s ease;
|
||||
}
|
||||
|
||||
.adev-tabs-and-plus {
|
||||
display: flex;
|
||||
width: calc(100% - 2.875rem);
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.adev-add-file,
|
||||
.docs-delete-file {
|
||||
docs-icon {
|
||||
color: var(--gray-400);
|
||||
transition: color 0.3s ease;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
&:hover {
|
||||
docs-icon {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.docs-delete-file {
|
||||
padding-inline-start: 0.1rem;
|
||||
padding-inline-end: 0;
|
||||
margin-block-start: 0.2rem;
|
||||
docs-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.adev-new-file-input {
|
||||
color: var(--primary-contrast);
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
border-block-end: 1px solid var(--senary-contrast);
|
||||
background: transparent;
|
||||
outline: none;
|
||||
transition: color 0.3s ease;
|
||||
&:focus {
|
||||
background: transparent;
|
||||
border-block-end: 1px solid var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.adev-code-editor-wrapper {
|
||||
// adjust height for terminal tabs
|
||||
// so that scroll bar & content do not render under tabs
|
||||
height: calc(100% - 49px);
|
||||
|
||||
&-hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.adev-inline-errors-box {
|
||||
position: absolute;
|
||||
bottom: 0.5rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
background-color: color-mix(in srgb, var(--bright-blue), var(--page-background) 90%);
|
||||
border: 1px solid color-mix(in srgb, var(--bright-blue), var(--page-background) 70%);
|
||||
border-radius: 0.25rem;
|
||||
|
||||
button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 0.25rem;
|
||||
|
||||
docs-icon {
|
||||
font-size: 1.25rem;
|
||||
color: var(--quaternary-contrast);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
docs-icon {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-inline: 1.25rem;
|
||||
color: var(--tertiary-contrast);
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.adev-editor-download-button {
|
||||
padding: 0;
|
||||
width: 2.875rem;
|
||||
border-radius: 0 0.19rem 0 0;
|
||||
background: var(--octonary-contrast);
|
||||
border-inline-start: 1px solid var(--senary-contrast);
|
||||
|
||||
docs-icon {
|
||||
color: var(--gray-400);
|
||||
transition: color 0.3s ease;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
docs-icon {
|
||||
color: var(--primary-contrast);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
/*!
|
||||
* @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 {HarnessLoader} from '@angular/cdk/testing';
|
||||
import {TestbedHarnessEnvironment} from '@angular/cdk/testing/testbed';
|
||||
import {ChangeDetectorRef, signal} from '@angular/core';
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {MatTabGroupHarness} from '@angular/material/tabs/testing';
|
||||
import {By} from '@angular/platform-browser';
|
||||
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
|
||||
import {BehaviorSubject} from 'rxjs';
|
||||
|
||||
import {EmbeddedTutorialManager} from '../embedded-tutorial-manager.service';
|
||||
|
||||
import {CodeEditor, REQUIRED_FILES} from './code-editor.component';
|
||||
import {CodeMirrorEditor} from './code-mirror-editor.service';
|
||||
import {FakeChangeDetectorRef} from '@angular/docs/testing';
|
||||
import {TutorialType} from '@angular/docs';
|
||||
|
||||
const files = [
|
||||
{filename: 'a', content: '', language: {} as any},
|
||||
{filename: 'b', content: '', language: {} as any},
|
||||
{filename: 'c', content: '', language: {} as any},
|
||||
...Array.from(REQUIRED_FILES).map((filename) => ({
|
||||
filename,
|
||||
content: '',
|
||||
language: {} as any,
|
||||
})),
|
||||
];
|
||||
|
||||
class FakeCodeMirrorEditor implements Partial<CodeMirrorEditor> {
|
||||
init(element: HTMLElement) {}
|
||||
changeCurrentFile(fileName: string) {}
|
||||
disable() {}
|
||||
files = signal(files);
|
||||
currentFile = signal(this.files()[0]);
|
||||
openFiles = this.files;
|
||||
}
|
||||
const codeMirrorEditorService = new FakeCodeMirrorEditor();
|
||||
const fakeChangeDetectorRef = new FakeChangeDetectorRef();
|
||||
|
||||
describe('CodeEditor', () => {
|
||||
let component: CodeEditor;
|
||||
let fixture: ComponentFixture<CodeEditor>;
|
||||
let loader: HarnessLoader;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [CodeEditor, NoopAnimationsModule],
|
||||
providers: [
|
||||
{
|
||||
provide: CodeMirrorEditor,
|
||||
useValue: codeMirrorEditorService,
|
||||
},
|
||||
{
|
||||
provide: ChangeDetectorRef,
|
||||
useValue: fakeChangeDetectorRef,
|
||||
},
|
||||
{
|
||||
provide: EmbeddedTutorialManager,
|
||||
useValue: {
|
||||
tutorialChanged$: new BehaviorSubject(true),
|
||||
tutorialId: () => 'tutorial',
|
||||
tutorialFilesystemTree: () => ({'app.component.ts': ''}),
|
||||
commonFilesystemTree: () => ({'app.component.ts': ''}),
|
||||
openFiles: () => ['app.component.ts'],
|
||||
tutorialFiles: () => ({'app.component.ts': ''}),
|
||||
nextTutorial: () => 'next-tutorial',
|
||||
previousTutorial: () => 'previous-tutorial',
|
||||
type: () => TutorialType.EDITOR,
|
||||
title: () => 'Tutorial',
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(CodeEditor);
|
||||
loader = TestbedHarnessEnvironment.loader(fixture);
|
||||
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize the code editor service afterViewInit with the code editor wrapper element', () => {
|
||||
const codeMirrorEditorInitSpy = spyOn(codeMirrorEditorService, 'init');
|
||||
|
||||
component.ngAfterViewInit();
|
||||
|
||||
expect(codeMirrorEditorInitSpy).toHaveBeenCalledWith(
|
||||
component['codeEditorWrapperRef'].nativeElement,
|
||||
);
|
||||
});
|
||||
|
||||
it('should render tabs based on filenames order', async () => {
|
||||
component.ngAfterViewInit();
|
||||
|
||||
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
|
||||
const tabs = await matTabGroup.getTabs();
|
||||
const expectedLabels = files.map((file, index) => {
|
||||
const label = file.filename.replace('src/', '');
|
||||
if (index === 0) return `${label} delete`;
|
||||
return label;
|
||||
});
|
||||
|
||||
for (const [index, tab] of tabs.entries()) {
|
||||
const tabLabel = await tab.getLabel();
|
||||
|
||||
expect(tabLabel).toBe(expectedLabels[index]);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Tabs selection', () => {
|
||||
let codeMirrorEditorChangeCurrentFileSpy: jasmine.Spy<(fileName: string) => void>;
|
||||
|
||||
beforeEach(() => {
|
||||
codeMirrorEditorChangeCurrentFileSpy = spyOn(codeMirrorEditorService, 'changeCurrentFile');
|
||||
component.ngAfterViewInit();
|
||||
});
|
||||
|
||||
it('should change file content when clicking on an unselected tab', async () => {
|
||||
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
|
||||
const tabs = await matTabGroup.getTabs();
|
||||
|
||||
expect(await tabs[0].isSelected());
|
||||
await tabs[1].select();
|
||||
|
||||
expect(codeMirrorEditorChangeCurrentFileSpy).toHaveBeenCalledWith(files[1].filename);
|
||||
});
|
||||
|
||||
it('should not change file content when clicking on a selected tab', async () => {
|
||||
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
|
||||
const tabs = await matTabGroup.getTabs();
|
||||
|
||||
expect(await tabs[0].isSelected());
|
||||
await tabs[0].select();
|
||||
|
||||
expect(codeMirrorEditorChangeCurrentFileSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should focused on a new tab when adding a new file', async () => {
|
||||
const button = fixture.debugElement.query(By.css('button.adev-add-file')).nativeElement;
|
||||
button.click();
|
||||
|
||||
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
|
||||
const tabs = await matTabGroup.getTabs();
|
||||
|
||||
expect(await tabs[files.length].isSelected()).toBeTrue();
|
||||
});
|
||||
|
||||
it('should change file content when clicking on unselected tab while creating a new file', async () => {
|
||||
const button = fixture.debugElement.query(By.css('button.adev-add-file')).nativeElement;
|
||||
button.click();
|
||||
|
||||
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
|
||||
const tabs = await matTabGroup.getTabs();
|
||||
await tabs[2].select();
|
||||
|
||||
expect(codeMirrorEditorChangeCurrentFileSpy).toHaveBeenCalledWith(files[2].filename);
|
||||
});
|
||||
|
||||
it('start creating a new file, select an existing tab, should not change file content when return back on a new file tab', async () => {
|
||||
const button = fixture.debugElement.query(By.css('button.adev-add-file')).nativeElement;
|
||||
button.click();
|
||||
|
||||
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
|
||||
const tabs = await matTabGroup.getTabs();
|
||||
|
||||
await tabs[1].select();
|
||||
expect(codeMirrorEditorChangeCurrentFileSpy).toHaveBeenCalledWith(files[1].filename);
|
||||
|
||||
codeMirrorEditorChangeCurrentFileSpy.calls.reset();
|
||||
|
||||
await tabs[files.length].select();
|
||||
expect(codeMirrorEditorChangeCurrentFileSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not allow to delete a required file', async () => {
|
||||
const matTabGroup = await loader.getHarness(MatTabGroupHarness);
|
||||
const tabs = await matTabGroup.getTabs();
|
||||
|
||||
const requiredFilesTabIndexes = files
|
||||
.filter((file) => REQUIRED_FILES.has(file.filename))
|
||||
.map((file) => files.indexOf(file));
|
||||
|
||||
for (const tabIndex of requiredFilesTabIndexes) {
|
||||
const tab = tabs[tabIndex];
|
||||
|
||||
await tab.select();
|
||||
|
||||
expect(fixture.debugElement.query(By.css('[aria-label="Delete file"]'))).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,175 +0,0 @@
|
||||
/*!
|
||||
* @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 {NgFor, NgIf} from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
OnDestroy,
|
||||
ViewChild,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {MatTabGroup, MatTabsModule} from '@angular/material/tabs';
|
||||
import {debounceTime, map} from 'rxjs';
|
||||
|
||||
import {TerminalType} from '../terminal/terminal-handler.service';
|
||||
import {EmbeddedTutorialManager} from '../embedded-tutorial-manager.service';
|
||||
|
||||
import {CodeMirrorEditor} from './code-mirror-editor.service';
|
||||
import {DiagnosticWithLocation, DiagnosticsState} from './services/diagnostics-state.service';
|
||||
import {DownloadManager} from '../download-manager.service';
|
||||
import {IconComponent} from '@angular/docs';
|
||||
|
||||
export const REQUIRED_FILES = new Set(['src/main.ts', 'src/index.html']);
|
||||
|
||||
@Component({
|
||||
selector: 'docs-tutorial-code-editor',
|
||||
standalone: true,
|
||||
templateUrl: './code-editor.component.html',
|
||||
styleUrls: ['./code-editor.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf, NgFor, MatTabsModule, IconComponent],
|
||||
})
|
||||
export class CodeEditor implements AfterViewInit, OnDestroy {
|
||||
@ViewChild('codeEditorWrapper') private codeEditorWrapperRef!: ElementRef<HTMLDivElement>;
|
||||
@ViewChild(MatTabGroup) private matTabGroup!: MatTabGroup;
|
||||
|
||||
private createFileInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('createFileInput') protected set setFileInputRef(
|
||||
element: ElementRef<HTMLInputElement>,
|
||||
) {
|
||||
if (element) {
|
||||
element.nativeElement.focus();
|
||||
this.createFileInputRef = element;
|
||||
}
|
||||
}
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
private readonly codeMirrorEditor = inject(CodeMirrorEditor);
|
||||
private readonly diagnosticsState = inject(DiagnosticsState);
|
||||
private readonly downloadManager = inject(DownloadManager);
|
||||
private readonly embeddedTutorialManager = inject(EmbeddedTutorialManager);
|
||||
|
||||
private readonly errors$ = this.diagnosticsState.diagnostics$.pipe(
|
||||
// Display errors one second after code update
|
||||
debounceTime(1000),
|
||||
map((diagnosticsItem) =>
|
||||
diagnosticsItem
|
||||
.filter((item) => item.severity === 'error')
|
||||
.sort((a, b) =>
|
||||
a.lineNumber != b.lineNumber
|
||||
? a.lineNumber - b.lineNumber
|
||||
: a.characterPosition - b.characterPosition,
|
||||
),
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
);
|
||||
|
||||
readonly TerminalType = TerminalType;
|
||||
|
||||
readonly displayErrorsBox = signal<boolean>(false);
|
||||
readonly errors = signal<DiagnosticWithLocation[]>([]);
|
||||
readonly files = this.codeMirrorEditor.openFiles;
|
||||
readonly isCreatingFile = signal<boolean>(false);
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.codeMirrorEditor.init(this.codeEditorWrapperRef.nativeElement);
|
||||
this.listenToDiagnosticsChange();
|
||||
|
||||
this.listenToTabChange();
|
||||
this.setSelectedTabOnTutorialChange();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.codeMirrorEditor.disable();
|
||||
}
|
||||
|
||||
async downloadCurrentCodeEditorState(): Promise<void> {
|
||||
const name = this.embeddedTutorialManager.tutorialId();
|
||||
await this.downloadManager.downloadCurrentStateOfTheSolution(name);
|
||||
}
|
||||
|
||||
closeErrorsBox(): void {
|
||||
this.displayErrorsBox.set(false);
|
||||
}
|
||||
|
||||
canDeleteFile(filename: string) {
|
||||
return !REQUIRED_FILES.has(filename);
|
||||
}
|
||||
|
||||
async deleteFile(filename: string) {
|
||||
await this.codeMirrorEditor.deleteFile(filename);
|
||||
this.matTabGroup.selectedIndex = 0;
|
||||
}
|
||||
|
||||
onAddButtonClick() {
|
||||
this.isCreatingFile.set(true);
|
||||
this.matTabGroup.selectedIndex = this.files().length;
|
||||
}
|
||||
|
||||
async createFile(event: SubmitEvent) {
|
||||
if (!this.createFileInputRef) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const newFileInputValue = this.createFileInputRef.nativeElement.value;
|
||||
|
||||
if (newFileInputValue) {
|
||||
if (newFileInputValue.includes('..')) {
|
||||
alert('File name can not contain ".."');
|
||||
return;
|
||||
}
|
||||
|
||||
// src is hidden from users, here we manually add it to the new filename
|
||||
const newFile = 'src/' + newFileInputValue;
|
||||
|
||||
if (this.files().find(({filename}) => filename.includes(newFile))) {
|
||||
alert('File already exists');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.codeMirrorEditor.createFile(newFile);
|
||||
}
|
||||
|
||||
this.isCreatingFile.set(false);
|
||||
}
|
||||
|
||||
private listenToDiagnosticsChange(): void {
|
||||
this.errors$.subscribe((diagnostics) => {
|
||||
this.errors.set(diagnostics);
|
||||
this.displayErrorsBox.set(diagnostics.length > 0);
|
||||
});
|
||||
}
|
||||
|
||||
private setSelectedTabOnTutorialChange() {
|
||||
this.embeddedTutorialManager.tutorialChanged$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(() => {
|
||||
// selected file on project change is always the first
|
||||
this.matTabGroup.selectedIndex = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private listenToTabChange() {
|
||||
this.matTabGroup.selectedIndexChange
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((index) => {
|
||||
const selectedFile = this.files()[index];
|
||||
|
||||
if (selectedFile) {
|
||||
this.codeMirrorEditor.changeCurrentFile(selectedFile.filename);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
/*!
|
||||
* @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 {signal} from '@angular/core';
|
||||
import {TestBed, fakeAsync, tick} from '@angular/core/testing';
|
||||
import {BehaviorSubject, Subject} from 'rxjs';
|
||||
import {EditorState} from '@codemirror/state';
|
||||
import type {FileSystemTree} from '@webcontainer/api';
|
||||
|
||||
import {NodeRuntimeSandbox} from '../node-runtime-sandbox.service';
|
||||
import {EmbeddedTutorialManager} from '../embedded-tutorial-manager.service';
|
||||
|
||||
import {CodeMirrorEditor, EDITOR_CONTENT_CHANGE_DELAY_MILLIES} from './code-mirror-editor.service';
|
||||
import {TutorialConfig, TutorialMetadata} from '@angular/docs';
|
||||
|
||||
class FakeNodeRuntimeSandbox {
|
||||
async writeFile(path: string, content: string) {}
|
||||
createdFile$ = new Subject<void>();
|
||||
async readFile(path: string) {
|
||||
return Promise.resolve('content');
|
||||
}
|
||||
}
|
||||
|
||||
const files = ['1.ts', '2.ts', '3.ts'];
|
||||
// reverse the files list to test openFiles order
|
||||
const filesReverse = [...files].reverse();
|
||||
|
||||
export class FakeEmbeddedTutorialManager {
|
||||
commonFilesystemTree = signal<FileSystemTree | null>(null);
|
||||
previousTutorial = signal<string | undefined>(undefined);
|
||||
nextTutorial = signal<string | undefined>(undefined);
|
||||
tutorialId = signal<string>('fake-tutorial');
|
||||
tutorialFiles = signal<NonNullable<TutorialMetadata['tutorialFiles']>>(
|
||||
// use a reverse map to test openFiles order
|
||||
Object.fromEntries(filesReverse.map((file) => [file, ''])),
|
||||
);
|
||||
openFiles = signal<TutorialConfig['openFiles']>(files);
|
||||
tutorialFilesystemTree = signal<FileSystemTree>(
|
||||
Object.fromEntries(
|
||||
files.map((file) => [
|
||||
file,
|
||||
{
|
||||
file: {
|
||||
contents: this.tutorialFiles()![0],
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
private _shouldChangeTutorial$ = new BehaviorSubject<boolean>(false);
|
||||
tutorialChanged$ = this._shouldChangeTutorial$.asObservable();
|
||||
|
||||
fetchAndSetTutorialFiles(tutorial: string): Promise<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
fetchCommonFiles(): Promise<FileSystemTree> {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
}
|
||||
|
||||
describe('CodeMirrorEditor', () => {
|
||||
let service: CodeMirrorEditor;
|
||||
|
||||
const fakeNodeRuntimeSandbox = new FakeNodeRuntimeSandbox();
|
||||
const fakeEmbeddedTutorialManager = new FakeEmbeddedTutorialManager();
|
||||
|
||||
function dispatchDocumentChange(newContent: string) {
|
||||
for (let i = 0; i < newContent.length; i++) {
|
||||
service['_editorView']?.dispatch({
|
||||
changes: {from: i, insert: newContent[i]},
|
||||
selection: {anchor: i, head: 0},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
CodeMirrorEditor,
|
||||
{
|
||||
provide: NodeRuntimeSandbox,
|
||||
useValue: fakeNodeRuntimeSandbox,
|
||||
},
|
||||
{
|
||||
provide: EmbeddedTutorialManager,
|
||||
useValue: fakeEmbeddedTutorialManager,
|
||||
},
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(CodeMirrorEditor);
|
||||
|
||||
const parentElement = document.createElement('div');
|
||||
service.init(parentElement);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should initialize the editor', () => {
|
||||
expect(service['_editorView']).toBeDefined();
|
||||
|
||||
expect(service.files()).toBeDefined();
|
||||
expect(service.currentFile()).toBeDefined();
|
||||
|
||||
const editorState = service['_editorStates'].get(service.currentFile().filename);
|
||||
expect(editorState).toBeInstanceOf(EditorState);
|
||||
});
|
||||
|
||||
it('should change the current file', () => {
|
||||
const newFilename = files.at(-1)!;
|
||||
service.changeCurrentFile(newFilename);
|
||||
expect(service.currentFile().filename).toEqual(newFilename);
|
||||
|
||||
const editorState = service['_editorStates'].get(newFilename);
|
||||
const editorContent = editorState?.doc.toString();
|
||||
const newFileContent = service.files().find((file) => file.filename === newFilename)?.content;
|
||||
|
||||
expect(editorContent).toBe(newFileContent);
|
||||
});
|
||||
|
||||
it('should update the current file content on change', async () => {
|
||||
const newContent = 'new content';
|
||||
|
||||
dispatchDocumentChange(newContent);
|
||||
|
||||
const updatedEditorState = service['_editorStates'].get(service.currentFile().filename);
|
||||
expect(updatedEditorState?.doc.toString()).toBe(newContent);
|
||||
|
||||
const fileContentOnFilesSignal = service
|
||||
.files()
|
||||
.find((file) => file.filename === service.currentFile().filename)?.content;
|
||||
|
||||
expect(fileContentOnFilesSignal).toBe(newContent);
|
||||
|
||||
expect(service.currentFile().content).toBe(newContent);
|
||||
});
|
||||
|
||||
it('should write the changed file content to the sandbox filesystem', fakeAsync(() => {
|
||||
const newContent = 'new content';
|
||||
|
||||
const nodeRuntimeSandboxSpy = spyOn(fakeNodeRuntimeSandbox, 'writeFile');
|
||||
|
||||
dispatchDocumentChange(newContent);
|
||||
|
||||
tick(EDITOR_CONTENT_CHANGE_DELAY_MILLIES);
|
||||
|
||||
expect(nodeRuntimeSandboxSpy).toHaveBeenCalledWith(service.currentFile().filename, newContent);
|
||||
}));
|
||||
|
||||
it('should add created file to code editor', async () => {
|
||||
const newFile = 'new-component.component.ts';
|
||||
|
||||
await service['addCreatedFileToCodeEditor'](newFile);
|
||||
|
||||
expect(service['embeddedTutorialManager'].tutorialFiles()[newFile]).toBeDefined();
|
||||
expect(service.files().find((file) => file.filename === newFile)).toBeDefined();
|
||||
});
|
||||
|
||||
it('should keep openFiles order', () => {
|
||||
service['setProjectFiles']();
|
||||
|
||||
for (const [index, openFile] of service['openFiles']().entries()) {
|
||||
expect(openFile.filename).toEqual(service['embeddedTutorialManager'].openFiles()[index]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,458 +0,0 @@
|
||||
/*!
|
||||
* @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 {DestroyRef, Injectable, inject, signal} from '@angular/core';
|
||||
import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
|
||||
import {Subject, Subscription, debounceTime, filter, map} from 'rxjs';
|
||||
|
||||
import {LRLanguage, LanguageSupport} from '@codemirror/language';
|
||||
import {EditorState, Transaction} from '@codemirror/state';
|
||||
import {EditorView, placeholder as placeholderExtension} from '@codemirror/view';
|
||||
|
||||
import {EmbeddedTutorialManager} from '../embedded-tutorial-manager.service';
|
||||
import {NodeRuntimeSandbox} from '../node-runtime-sandbox.service';
|
||||
import {TypingsLoader} from '../typings-loader.service';
|
||||
|
||||
import {FileAndContentRecord} from '@angular/docs';
|
||||
import {CODE_EDITOR_EXTENSIONS} from './constants/code-editor-extensions';
|
||||
import {LANGUAGES} from './constants/code-editor-languages';
|
||||
import {getAutocompleteExtension} from './extensions/autocomplete';
|
||||
import {getDiagnosticsExtension} from './extensions/diagnostics';
|
||||
import {getTooltipExtension} from './extensions/tooltip';
|
||||
import {DiagnosticsState} from './services/diagnostics-state.service';
|
||||
import {TsVfsWorkerActions} from './workers/enums/actions';
|
||||
import {CodeChangeRequest} from './workers/interfaces/code-change-request';
|
||||
import {ActionMessage} from './workers/interfaces/message';
|
||||
import {NodeRuntimeState} from '../node-runtime-state.service';
|
||||
|
||||
export interface EditorFile {
|
||||
filename: string;
|
||||
content: string;
|
||||
language: LanguageSupport | LRLanguage;
|
||||
}
|
||||
|
||||
/**
|
||||
* The delay between the last typed character and the actual file save.
|
||||
* This is used to prevent saving the file on every keystroke.
|
||||
*
|
||||
* Important! this value is intentionally set a bit higher than it needs to be, because sending
|
||||
* changes too frequently to the web container appears to put it in a state where it stops picking
|
||||
* up changes. See issue #691 for context.
|
||||
*/
|
||||
export const EDITOR_CONTENT_CHANGE_DELAY_MILLIES = 500;
|
||||
|
||||
const INITIAL_STATES = {
|
||||
_editorView: null,
|
||||
|
||||
files: [],
|
||||
|
||||
currentFile: {
|
||||
filename: '',
|
||||
content: '',
|
||||
language: LANGUAGES['ts'],
|
||||
},
|
||||
|
||||
contentChangeListenerSubscription$: undefined,
|
||||
tutorialChangeListener$: undefined,
|
||||
createdFile$: undefined,
|
||||
};
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
export class CodeMirrorEditor {
|
||||
// TODO: handle files created by the user, e.g. after running `ng generate component`
|
||||
files = signal<EditorFile[]>(INITIAL_STATES.files);
|
||||
openFiles = signal<EditorFile[]>(INITIAL_STATES.files);
|
||||
currentFile = signal<EditorFile>(INITIAL_STATES.currentFile);
|
||||
|
||||
// An instance of web worker used to run virtual TypeScript environment in the browser.
|
||||
// It allows to enrich CodeMirror UX for TypeScript files.
|
||||
private tsVfsWorker: Worker | null = null;
|
||||
// EventManager gives ability to communicate between tsVfsWorker and CodeMirror instance
|
||||
private readonly eventManager$ = new Subject<ActionMessage>();
|
||||
|
||||
private readonly nodeRuntimeSandbox = inject(NodeRuntimeSandbox);
|
||||
private readonly nodeRuntimeState = inject(NodeRuntimeState);
|
||||
private readonly embeddedTutorialManager = inject(EmbeddedTutorialManager);
|
||||
private readonly typingsLoader = inject(TypingsLoader);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly diagnosticsState = inject(DiagnosticsState);
|
||||
|
||||
private _editorView: EditorView | null = INITIAL_STATES._editorView;
|
||||
private readonly _editorStates = new Map<EditorFile['filename'], EditorState>();
|
||||
|
||||
private readonly contentChanged$ = new Subject<string>();
|
||||
private readonly contentChangeListener$ = this.contentChanged$.asObservable();
|
||||
private contentChangeListenerSubscription$: Subscription | undefined =
|
||||
INITIAL_STATES.contentChangeListenerSubscription$;
|
||||
|
||||
private tutorialChangeListener$: Subscription | undefined =
|
||||
INITIAL_STATES.tutorialChangeListener$;
|
||||
|
||||
private createdFileListener$: Subscription | undefined = INITIAL_STATES.createdFile$;
|
||||
|
||||
init(parentElement: HTMLElement): void {
|
||||
if (this._editorView) return;
|
||||
|
||||
if (!this.nodeRuntimeState.error()) {
|
||||
this.initTypescriptVfsWorker();
|
||||
this.saveLibrariesTypes();
|
||||
}
|
||||
|
||||
this._editorView = new EditorView({
|
||||
parent: parentElement,
|
||||
state: this.createEditorState(),
|
||||
dispatchTransactions: (transactions, view) => {
|
||||
view.update(transactions);
|
||||
|
||||
for (const transaction of transactions) {
|
||||
if (transaction.docChanged && this.currentFile().filename) {
|
||||
this.contentChanged$.next(transaction.state.doc.toString());
|
||||
this.changeFileContentsOnRealTime(transaction);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.listenToProjectChanges();
|
||||
|
||||
this.contentChangeListenerSubscription$ = this.contentChangeListener$
|
||||
.pipe(debounceTime(EDITOR_CONTENT_CHANGE_DELAY_MILLIES), takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe(async (fileContents) => {
|
||||
await this.changeFileContentsScheduled(fileContents);
|
||||
});
|
||||
|
||||
this.createdFileListener$ = this.nodeRuntimeSandbox.createdFile$.subscribe(
|
||||
async (createdFile) => {
|
||||
await this.addCreatedFileToCodeEditor(createdFile);
|
||||
},
|
||||
);
|
||||
|
||||
// Create TypeScript virtual filesystem when default files map is created
|
||||
// and files are set
|
||||
this.eventManager$
|
||||
.pipe(
|
||||
filter(
|
||||
(event) =>
|
||||
event.action === TsVfsWorkerActions.INIT_DEFAULT_FILE_SYSTEM_MAP &&
|
||||
this.files().length > 0,
|
||||
),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.createVfsEnv();
|
||||
});
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this._editorView?.destroy();
|
||||
this._editorView = null;
|
||||
|
||||
this._editorView = INITIAL_STATES._editorView;
|
||||
this.files.set(INITIAL_STATES.files);
|
||||
this.currentFile.set(INITIAL_STATES.currentFile);
|
||||
|
||||
this._editorStates.clear();
|
||||
|
||||
this.contentChangeListenerSubscription$?.unsubscribe();
|
||||
this.contentChangeListenerSubscription$ = INITIAL_STATES.contentChangeListenerSubscription$;
|
||||
|
||||
this.tutorialChangeListener$?.unsubscribe();
|
||||
this.tutorialChangeListener$ = INITIAL_STATES.tutorialChangeListener$;
|
||||
|
||||
this.createdFileListener$?.unsubscribe();
|
||||
this.createdFileListener$ = INITIAL_STATES.createdFile$;
|
||||
}
|
||||
|
||||
changeCurrentFile(fileName: string): void {
|
||||
if (!this._editorView) return;
|
||||
|
||||
const newFile = this.files().find((file) => file.filename === fileName);
|
||||
if (!newFile) throw new Error(`File '${fileName}' not found`);
|
||||
|
||||
this.currentFile.set(newFile);
|
||||
|
||||
const editorState = this._editorStates.get(newFile.filename) ?? this.createEditorState();
|
||||
|
||||
this._editorView.setState(editorState);
|
||||
}
|
||||
|
||||
private initTypescriptVfsWorker(): void {
|
||||
if (this.tsVfsWorker) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.tsVfsWorker = new Worker(new URL('./workers/typescript-vfs.worker', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
this.tsVfsWorker.addEventListener('message', ({data}: MessageEvent<ActionMessage>) => {
|
||||
this.eventManager$.next(data);
|
||||
});
|
||||
}
|
||||
|
||||
private saveLibrariesTypes(): void {
|
||||
this.typingsLoader.typings$
|
||||
.pipe(
|
||||
filter((typings) => typings.length > 0),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe((typings) => {
|
||||
this.sendRequestToTsVfs({
|
||||
action: TsVfsWorkerActions.DEFINE_TYPES_REQUEST,
|
||||
data: typings,
|
||||
});
|
||||
|
||||
// Reset current file to trigger diagnostics after preload @angular libraries.
|
||||
this._editorView?.setState(this._editorStates.get(this.currentFile().filename)!);
|
||||
});
|
||||
}
|
||||
|
||||
// Method is responsible for sending request to Typescript VFS worker.
|
||||
private sendRequestToTsVfs = <T>(request: ActionMessage<T>) => {
|
||||
if (!this.tsVfsWorker) return;
|
||||
|
||||
// Send message to tsVfsWorker only when current file is TypeScript file.
|
||||
if (!this.currentFile().filename.endsWith('.ts')) return;
|
||||
|
||||
this.tsVfsWorker.postMessage(request);
|
||||
// tslint:disable-next-line:semicolon
|
||||
};
|
||||
|
||||
private getVfsEnvFileSystemMap(): Map<string, string> {
|
||||
const fileSystemMap = new Map<string, string>();
|
||||
|
||||
for (const file of this.files().filter((file) => file.filename.endsWith('.ts'))) {
|
||||
fileSystemMap.set(`/${file.filename}`, file.content);
|
||||
}
|
||||
|
||||
return fileSystemMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create virtual environment for TypeScript files
|
||||
*/
|
||||
private createVfsEnv(): void {
|
||||
this.sendRequestToTsVfs<Map<string, string>>({
|
||||
action: TsVfsWorkerActions.CREATE_VFS_ENV_REQUEST,
|
||||
data: this.getVfsEnvFileSystemMap(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update virtual TypeScript file system with current code editor files
|
||||
*/
|
||||
private updateVfsEnv(): void {
|
||||
this.sendRequestToTsVfs<Map<string, string>>({
|
||||
action: TsVfsWorkerActions.UPDATE_VFS_ENV_REQUEST,
|
||||
data: this.getVfsEnvFileSystemMap(),
|
||||
});
|
||||
}
|
||||
|
||||
private listenToProjectChanges() {
|
||||
this.tutorialChangeListener$ = this.embeddedTutorialManager.tutorialChanged$
|
||||
.pipe(
|
||||
map(() => this.embeddedTutorialManager.tutorialFiles()),
|
||||
filter((tutorialFiles) => Object.keys(tutorialFiles).length > 0),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.changeProject();
|
||||
});
|
||||
}
|
||||
|
||||
private changeProject() {
|
||||
this.setProjectFiles();
|
||||
|
||||
this._editorStates.clear();
|
||||
|
||||
this.changeCurrentFile(this.currentFile().filename);
|
||||
|
||||
this.updateVfsEnv();
|
||||
}
|
||||
|
||||
private setProjectFiles(): void {
|
||||
const tutorialFiles = this.getTutorialFiles(this.embeddedTutorialManager.tutorialFiles());
|
||||
|
||||
const openFiles: EditorFile[] = [];
|
||||
|
||||
// iterate openFiles to keep files order
|
||||
for (const openFileName of this.embeddedTutorialManager.openFiles()) {
|
||||
const openFile = tutorialFiles.find(({filename}) => filename === openFileName);
|
||||
|
||||
if (openFile) {
|
||||
openFiles.push(openFile);
|
||||
}
|
||||
}
|
||||
|
||||
this.files.set(tutorialFiles);
|
||||
this.openFiles.set(openFiles);
|
||||
this.changeCurrentFile(openFiles[0].filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the code editor files when files are created
|
||||
*/
|
||||
private async addCreatedFileToCodeEditor(createdFile: string): Promise<void> {
|
||||
const fileContents = await this.nodeRuntimeSandbox.readFile(createdFile);
|
||||
|
||||
this.embeddedTutorialManager.tutorialFiles.update((files) => ({
|
||||
...files,
|
||||
[createdFile]: fileContents,
|
||||
}));
|
||||
|
||||
this.embeddedTutorialManager.openFiles.update((files) => [...files, createdFile]);
|
||||
|
||||
this.setProjectFiles();
|
||||
|
||||
this.updateVfsEnv();
|
||||
|
||||
this.saveLibrariesTypes();
|
||||
}
|
||||
|
||||
async createFile(filename: string) {
|
||||
// if file already exists, use its content
|
||||
const content = await this.nodeRuntimeSandbox.readFile(filename).catch((error) => {
|
||||
// empty content if file does not exist
|
||||
if (error.message.includes('ENOENT')) return '';
|
||||
else throw error;
|
||||
});
|
||||
|
||||
await this.nodeRuntimeSandbox.writeFile(filename, content);
|
||||
|
||||
this.embeddedTutorialManager.tutorialFiles.update((files) => ({
|
||||
...files,
|
||||
[filename]: content,
|
||||
}));
|
||||
|
||||
this.embeddedTutorialManager.openFiles.update((files) => [...files, filename]);
|
||||
|
||||
this.setProjectFiles();
|
||||
|
||||
this.updateVfsEnv();
|
||||
|
||||
this.saveLibrariesTypes();
|
||||
|
||||
this.changeCurrentFile(filename);
|
||||
}
|
||||
|
||||
async deleteFile(deletedFile: string): Promise<void> {
|
||||
await this.nodeRuntimeSandbox.deleteFile(deletedFile);
|
||||
|
||||
this.embeddedTutorialManager.tutorialFiles.update((files) => {
|
||||
delete files[deletedFile];
|
||||
return files;
|
||||
});
|
||||
|
||||
this.embeddedTutorialManager.openFiles.update((files) =>
|
||||
files.filter((file) => file !== deletedFile),
|
||||
);
|
||||
|
||||
this.setProjectFiles();
|
||||
|
||||
this.updateVfsEnv();
|
||||
|
||||
this.saveLibrariesTypes();
|
||||
}
|
||||
|
||||
private createEditorState(): EditorState {
|
||||
const newEditorState = EditorState.create({
|
||||
doc: this.currentFile().content,
|
||||
extensions: [
|
||||
...CODE_EDITOR_EXTENSIONS,
|
||||
|
||||
this.currentFile().language,
|
||||
|
||||
placeholderExtension('Type your code here...'),
|
||||
|
||||
...this.getLanguageExtensions(),
|
||||
],
|
||||
});
|
||||
|
||||
this._editorStates.set(this.currentFile().filename, newEditorState);
|
||||
|
||||
return newEditorState;
|
||||
}
|
||||
|
||||
private getLanguageExtensions() {
|
||||
if (this.currentFile().filename.endsWith('.ts')) {
|
||||
return [
|
||||
getAutocompleteExtension(this.eventManager$, this.currentFile, this.sendRequestToTsVfs),
|
||||
getDiagnosticsExtension(
|
||||
this.eventManager$,
|
||||
this.currentFile,
|
||||
this.sendRequestToTsVfs,
|
||||
this.diagnosticsState,
|
||||
),
|
||||
getTooltipExtension(this.eventManager$, this.currentFile, this.sendRequestToTsVfs),
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the new file contents to the sandbox filesystem
|
||||
*/
|
||||
private async changeFileContentsScheduled(newContent: string): Promise<void> {
|
||||
try {
|
||||
await this.nodeRuntimeSandbox.writeFile(this.currentFile().filename, newContent);
|
||||
} catch (err) {
|
||||
// Note: `writeFile` throws if the sandbox is not initialized yet, which can happen if
|
||||
// the user starts typing right after the page loads.
|
||||
// Here the error is ignored as it is expected.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change file contents on files signals and update the editor state
|
||||
*/
|
||||
private changeFileContentsOnRealTime(transaction: Transaction): void {
|
||||
this._editorStates.set(this.currentFile().filename, transaction.state);
|
||||
|
||||
const newContent = transaction.state.doc.toString();
|
||||
|
||||
this.currentFile.update((currentFile) => ({...currentFile, content: newContent}));
|
||||
|
||||
this.files.update((files) =>
|
||||
files.map((file) =>
|
||||
file.filename === this.currentFile().filename ? {...file, content: newContent} : file,
|
||||
),
|
||||
);
|
||||
|
||||
// send current file content to Ts Vfs worker to run diagnostics on current file state
|
||||
this.sendRequestToTsVfs<CodeChangeRequest>({
|
||||
action: TsVfsWorkerActions.CODE_CHANGED,
|
||||
data: {
|
||||
file: this.currentFile().filename,
|
||||
code: newContent,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private getTutorialFiles(files: FileAndContentRecord): EditorFile[] {
|
||||
const languagesExtensions = Object.keys(LANGUAGES);
|
||||
|
||||
const tutorialFiles = Object.entries(files)
|
||||
.filter(
|
||||
(fileAndContent): fileAndContent is [string, string] =>
|
||||
typeof fileAndContent[1] === 'string',
|
||||
)
|
||||
.map(([filename, content]) => {
|
||||
const extension = languagesExtensions.find((extension) => filename.endsWith(extension));
|
||||
const language = extension ? LANGUAGES[extension] : LANGUAGES['ts'];
|
||||
|
||||
return {
|
||||
filename,
|
||||
content,
|
||||
language,
|
||||
};
|
||||
});
|
||||
|
||||
return tutorialFiles;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
/*!
|
||||
* @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 {EditorState, Extension} from '@codemirror/state';
|
||||
|
||||
import {
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
rectangularSelection,
|
||||
crosshairCursor,
|
||||
highlightActiveLine,
|
||||
keymap,
|
||||
EditorView,
|
||||
} from '@codemirror/view';
|
||||
export {EditorView} from '@codemirror/view';
|
||||
import {
|
||||
foldGutter,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
defaultHighlightStyle,
|
||||
bracketMatching,
|
||||
foldKeymap,
|
||||
HighlightStyle,
|
||||
} from '@codemirror/language';
|
||||
import {history, defaultKeymap, historyKeymap, indentWithTab} from '@codemirror/commands';
|
||||
import {highlightSelectionMatches, searchKeymap} from '@codemirror/search';
|
||||
import {
|
||||
closeBrackets,
|
||||
autocompletion,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
startCompletion,
|
||||
} from '@codemirror/autocomplete';
|
||||
import {lintKeymap} from '@codemirror/lint';
|
||||
import {SYNTAX_STYLES} from './syntax-styles';
|
||||
import {CODE_EDITOR_THEME_STYLES} from './theme-styles';
|
||||
|
||||
export const CODE_EDITOR_EXTENSIONS: Extension[] = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
|
||||
syntaxHighlighting(defaultHighlightStyle, {fallback: true}),
|
||||
syntaxHighlighting(HighlightStyle.define(SYNTAX_STYLES)),
|
||||
EditorView.lineWrapping,
|
||||
|
||||
EditorView.theme(
|
||||
CODE_EDITOR_THEME_STYLES,
|
||||
// TODO: get from global theme, reconfigure on change: https://discuss.codemirror.net/t/dynamic-light-mode-dark-mode-how/4709
|
||||
{dark: true},
|
||||
),
|
||||
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
indentWithTab,
|
||||
{
|
||||
key: 'Ctrl-.',
|
||||
run: startCompletion,
|
||||
mac: 'Mod-.',
|
||||
},
|
||||
]),
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user