Compare commits

..

88 Commits

Author SHA1 Message Date
Paul Gschwendtner c4b6d474f8 WIP STYLING: temp change to fix styling template pipeline emit 2023-07-14 15:52:38 +00:00
Paul Gschwendtner 78efc406ba WIP STYLING: improve comment correctness styling 2023-07-14 15:30:48 +00:00
Paul Gschwendtner c277e88b22 WIP STYLING: styling findings/reversing 2023-07-14 15:30:45 +00:00
Paul Gschwendtner 574b1c276d test: setup signal acceptance tests to also run with AOT compilation
This allows us to test the template pipeline TS emit (where we already
caught a bug using this), and also allows us to test the proper
recognition of e.g. `input`, `output`, `viewChild` etc- while also
testing type-checking.
2023-07-14 13:12:41 +00:00
Paul Gschwendtner 40eb5e3207 fix(compiler): do not use constants for colocated shallow references
Currently the TS generated output (not JIT), breaks at runtime because
we emit all variables using the `Final` modifier. i.e. constants.

This breaks shallow references when we run our acceptance signal
tests using AOT.
2023-07-14 13:12:41 +00:00
Paul Gschwendtner 8ef145fa1b feat(compiler): support public aliases for signal output API 2023-07-14 13:12:41 +00:00
Paul Gschwendtner 94f2daaa1c feat(compiler): support signal-based output API
* Detect and recognize outputs for type-checking and definition
  generation
* Add compliance tests & fake core infrastructure
* Add playground example
2023-07-14 13:12:41 +00:00
Paul Gschwendtner ffc61749ff test: rework and enable outputs test to verify functionality 2023-07-14 13:12:41 +00:00
Paul Gschwendtner f70d80c8c7 refactor(compiler): properly sanitize identifier names in template pipeline
Currently, a listener on an element containing a dash, will result in
runtime errors because the function name will be generated using a dash.

e.g.

```
function MyApp_Template_some-comp_bla_0() {}
```

throwing with a syntax error due to the dash. We fix this by re-using
the sanitize identifier function from the current template definition
builder.
2023-07-14 13:12:41 +00:00
Pawel Kozlowski a4cb2e671c feat(core): add runtime support for outputs in signal components
Runtime implementation is very simple - it adds the output function that
acts as an alias to the existing EventEmitter class constructor.
2023-07-14 13:12:41 +00:00
Paul Gschwendtner dc7882cf97 test: update partial output golden for content queries 2023-07-14 13:12:41 +00:00
Paul Gschwendtner 5f9bd59b14 feat(compiler): support signal based content queries
Compiler portion for supporting content queries in signal components.
This is based on the preparation work, for detecting such queries. See
previous commits.
2023-07-14 13:12:40 +00:00
Pawel Kozlowski cfd466dfbb feat(core): reactive content queries
Runtime part of the reactive content queries.
2023-07-14 13:12:40 +00:00
Pawel Kozlowski 61ce057a09 test(core): add test for QueryList.onDirty callback
Add a test verifying that the onDirty callback:
- does not fire initially;
- fires once when a query is marked dirty (even if there are
multiple setDirty notifications).
2023-07-14 13:12:40 +00:00
Pawel Kozlowski 8dc6c9fe22 fix(core): adjust reactive query types
Improvements to the types signature in viewChild / viewChildren.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner e71e900708 feat(compiler): support generating queries for signal components 2023-07-14 13:12:40 +00:00
Pawel Kozlowski 1168fd585f fixup! refactor(core): rename queryCreate to viewQueryCreate 2023-07-14 13:12:40 +00:00
Pawel Kozlowski 18ea007390 refactor(core): rename queryCreate to viewQueryCreate
In the end we need to distinguish between view and content query
creation and need separate names for those instructions.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner bd23af0639 test: update api golden for core for new query APIs 2023-07-14 13:12:40 +00:00
Pawel Kozlowski 6b0e1c7703 feat(core): reactive queries
Initial runtime implementation of the reactive view queries
(in preparation for the compiler work).
2023-07-14 13:12:40 +00:00
Pawel Kozlowski 6b89ce9e27 fix(core): fix stringify logic for interpolated values
Make sure that null, undefined and NaN interpolated values
are rendered consistently with the current ivy approach:
- null and undefined are rendered as empty strings;
- NaN is rendered as-is.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner 0bf724f4d4 feat: support property interpolation in signal components 2023-07-14 13:12:40 +00:00
Pawel Kozlowski 7acf163f51 test: fix signal tests after property binding changes
Property bindings are wrapped into computed now.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner 6808375e50 ci: send test job failures to reactivity channel 2023-07-14 13:12:40 +00:00
Pawel Kozlowski 97d6781be7 refactor(core): fix lint issues
Fixing lint issues that I've just introduced...
2023-07-14 13:12:40 +00:00
Pawel Kozlowski f12e8e3ea2 feat(core): introduce property interpolation and make DOM property bindings reactive
DOM bindings are wrapped into computed now so their values
are memoized. As the result expression in DOM bindings are
re-evaluated when a component is change-detected.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner f5638c25e6 fix: variable optimization preserving unused nextContext calls due to listeners
With the recent changes for local refs, we now also generate all
variables in the create block.

This works overall, but we end up with incorrect variable optimization
because calls of `nextContext` in listener functions end up tricking
the optimization logic into thinking that the outer `nextContext` calls
in the create block are _relevant_ for the listener function body.

e.g.

```
if (create) {
  const _bla = nextContext();
  i0.listener(() => {
    restoreView();
    const _ctx1 = nextContext();
    /* do somth with _ctx1 */
  });
}
```

`_bla` can safely be removed here, even though a `ContextRead/Write` has
occurred inside the listener operation. We process listeners separately.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner d82eb27f69 fix: do not generate pure functions for listeners
We recently changed the pure function code to look for
all operations in a view. This ended up causing pure functions
to be generated for expressions in listeners. This might be fine
but diverges from the existing TDB and breaks compliance tests.

Culprit commit: https://github.com/angular/angular/commit/a61e41c88049eb12a0b23b1e7fa49a16b0eb91d7
2023-07-14 13:12:40 +00:00
Paul Gschwendtner 3b5218b768 ci: setup initial slack notifications for test failures in signal branch
Note: only going into the dev-infra channel for now until we get
everything green to avoid spam.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner 5fa6ce3e61 WIP: Implement type-checking for signal inputs
New implementation of type-checking for signal inputs. Much simpler, and
without causing signficant type check block changes that would require
more changes to the language service completion logic / or rewrites of
more than 50+ handwritten code output tests for type check blocks.

See previous commits for other explored solutions.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner d88ac6db12 WIP: Revert initial type-checking approach and completion adjustments
We will attempt a new solution that is not as invasive- and integrates
better with the constraints of the language service.
2023-07-14 13:12:40 +00:00
Paul Gschwendtner 46efe02e48 WIP: fixing tests and completion with signal based type checking
NOTE: This is very experimental and manual type check block code is not
updated. All of this complexity arises from the fact of attempting to
support bindings where they go to the same directive with multiple
inputs of the same name.

Potentially we can deprecate this behavior for input signal based
components and avoid this complexity. Alternative solution is to simply
repeat expression diagnostics if a binding goes to two same fields.
2023-07-14 13:12:39 +00:00
Paul Gschwendtner cd76360ea8 fixup! refactor(compiler): support o.BinaryExpression for assignments
Fix babel factory for linker
2023-07-14 13:12:39 +00:00
Pawel Kozlowski 09ad1ef5a2 refactor(compiler): add required file header
Add required file header.
2023-07-14 13:12:39 +00:00
Pawel Kozlowski 48f4f86835 test: add basic test for event listeners
Adding a passing test for a listener invocation
that changes signal value.
2023-07-14 13:12:39 +00:00
Pawel Kozlowski 183c92baed Revert "feat(core): propertyInterpolationCreate"
This reverts commit 2e3fe0747990647c5cfbba5429e90ed621b18e6e.

Based on the recent design discussion we are going to express property
interpolation as a computed function and reuse the propertyCreate
infrastructure.
2023-07-14 13:12:39 +00:00
Alex Rickabaugh e03ccb60ed wip: support local references in signal property bindings
This commit adds support for local refs in signal property bindings, by
generating variables in the creation block. To implement this support:

* a new instruction shallowReference() is introduced, which is not tied to
  the current view context.
* variables are now generated for creation mode as well as update mode
* a new template pipeline pass (`phaseCreationVarColocation`) handles
  ordering of reference variable declaration and assignment in the creation
  block (guaranteeing references are only read after declarations).
2023-07-14 13:12:39 +00:00
Alex Rickabaugh c402bb81f5 refactor(compiler): support o.BinaryExpression for assignments
Previously the compiler output AST did not support using
`o.BinaryExpression` for assignments (as TypeScript does). Instead
`o.WriteVarExpr` is defined, which only allows writing to string named
variables.
2023-07-14 13:12:39 +00:00
Paul Gschwendtner 0a7a8e496e test: disable input signal initialization test
Still needs to be implemented and discussed.
2023-07-14 13:12:39 +00:00
Paul Gschwendtner 779e1d872f test: update core API golden to add input API 2023-07-14 13:12:39 +00:00
Paul Gschwendtner 193a32e5c1 WIP: fix regressions in type checking after signal changes
There was an interesting issue that surfaced in the type checking
tests. Previously an input was type-checked using an expression like:

```ts
myDir.input = myrDir.input2 = userExpr
```

Note how the user expression was only specified once. With signals for
type-checking we cannot do this because both inputs cannot use the same
expression unless we extract it into a separate variable. This is what
this commit implemements.

```ts
const x1 = userExpr;
toWriteableSignal(myDir.input).set(x1);
toWriteableSignal(myDir.input2).set(x2);
```

Note: Even if no input is bound, due to e.g. the `inputs` decorator
field being used and no statically analyzable class member being
discoverable- the user expression should be *evaluated*. This was
something we broke with the initial signal input prototyping.
2023-07-14 13:12:39 +00:00
Pawel Kozlowski 41f8584aad feat(core): propertyInterpolationCreate
Skeleton of the propertyInterpolationCreate instruction.
2023-07-14 13:12:39 +00:00
Paul Gschwendtner 4b8e6b36ab build: fix lint errors 2023-07-14 13:12:39 +00:00
Pawel Kozlowski 4c58b2f8cf test: add failing tests for property interpolation
Adding failing tests for the property interpolation so we
keep those instructions on our radar.
2023-07-14 13:12:39 +00:00
Pawel Kozlowski 0e96528a70 refactor: fix lint issue
Minor refactor to fix the linting job.
2023-07-14 13:12:39 +00:00
Pawel Kozlowski 1d1338fced test: binding to a mix of zone and signal inputs
This commit adds a test for a case of a binding targetting
multiple inputs with the same name. Test passes for both
signal based components as well as a mix of zone and signal
based components.
2023-07-14 13:12:39 +00:00
Pawel Kozlowski 1344da62ad test: enable passing tests after recent code changes
Enabling newly passing tests.
2023-07-14 13:12:39 +00:00
Alex Rickabaugh 0c821b28f3 wip: one calculation for many input targets
Wrap bound expressions in `computed` where necessary to ensure all consumers
of a binding see the same value.
2023-07-14 13:12:39 +00:00
Alex Rickabaugh ebef1a592a wip: ignore dep polling when input signals change their backing
Previously, input signals had a bug where when their backing computation or
value would change, it would notify consumers, but consumers would poll the
input signal to check if its value really changed. This polling operation
would transitively poll the input signal's previous dependencies, which of
course would report that they hadn't changed.

This commit adds logic in the `InputSignalImpl` to skip dependency polling
whenever the signal's backing computation or value changes, until that value
is read for the first time.
2023-07-14 13:12:39 +00:00
Paul Gschwendtner 4c97f56223 fixup! wip: specialize virtual instructions
Fix bundle size tests after core changes for virtual update
2023-07-14 13:12:39 +00:00
Paul Gschwendtner 71e8bd8791 test: support signal library in compliance test fake_core package
Compliance tests do not depend on the real typings for
`@angular/core`, but rather a fake core package is used.

We want to be able to write compliance tests using signals, so we should
add some rough types to the fake core package. This will also be useful
in `fake_core` for type checking tests.
2023-07-14 13:12:39 +00:00
Paul Gschwendtner fcf9edbc78 test: generate partial golden for signal compliance test 2023-07-14 13:12:38 +00:00
Paul Gschwendtner 626d3ab586 test: ensure signal compliance test only runs with template pipeline 2023-07-14 13:12:38 +00:00
Paul Gschwendtner 3a607f51d6 refactor: remove debug print for type check block 2023-07-14 13:12:38 +00:00
Paul Gschwendtner ccf0dc1884 test: add test to ensure variable bindings work 2023-07-14 13:12:38 +00:00
Paul Gschwendtner ebf646a9c9 fixup! WIP: generate propertyCreate instructions in template pipeline
Fix expression lexical read transforms
2023-07-14 13:12:38 +00:00
Paul Gschwendtner 732e45a730 WIP: prepare tests for failing scenarios discovered during collab
* Local refs needs more testing
* Basic variable read failed
* We do not have any tests for listeners? there was some restore view
  issue: https://github.com/angular/angular/commit/3acfdf4861d5eeed5b2ad42978547da929cc3567
2023-07-14 13:12:38 +00:00
Paul Gschwendtner 6697d6ba2f fixup! WIP: setup template pipeline acceptance tests on CI and base setup
Only build requested tests
2023-07-14 13:12:38 +00:00
Pawel Kozlowski 399e173d8c fix(core): properly bind to the same input from multiple views
Before this fix bindings to the same input from multiple LViews didn't
work properly as the binding expression was assigned to LView only on
the first create pass (instead of assigning on each durective usage pass).
2023-07-14 13:12:38 +00:00
Alex Rickabaugh 05ecc619fc wip: allocate vars for PropertyCreateOp
This commit adds the `ConsumesVarsTrait` to `PropertyCreateOp` which causes
vars to be counted for use in the virtual instructions it might add. This
allows the DOM test to pass.
2023-07-14 13:12:38 +00:00
Pawel Kozlowski 52f508ab4d test: failing test case for non-primitive literals in a template 2023-07-14 13:12:38 +00:00
Pawel Kozlowski 2a61fea64b test: add test veryfing bindings to multiple directives 2023-07-14 13:12:38 +00:00
Pawel Kozlowski 10c1ae5b6e test: add failing test for dom property bindings
The test is skipped as we are not propagating number of vars
based on the bindings found in a template.
2023-07-14 13:12:38 +00:00
Pawel Kozlowski 9208e64e74 test: re-organize tests 2023-07-14 13:12:38 +00:00
JoostK 65d45e4919 test: add test to bind a signal to a signal input 2023-07-14 13:12:38 +00:00
JoostK 080bdfa77f test: allow signal component test to pass 2023-07-14 13:12:38 +00:00
Alex Rickabaugh 977bd97717 refactor(compiler): only reference a view via a variable if it's not ctx
Previously the template pipeline would prefer to use a local variable
definition in favor of ctx. This commit improves the logic to prefer `ctx`
for the root view even if a variable for the context is available.
2023-07-14 13:12:38 +00:00
Alex Rickabaugh a90dc4310c wip: bypass pure functions in input computations 2023-07-14 13:12:38 +00:00
Alex Rickabaugh 2426b2e1f1 wip: fix signals test
(required and initial values for inputs are mutually incompatible)
2023-07-14 13:12:38 +00:00
Alex Rickabaugh 912c7252f8 wip: switch to the real input() implementation 2023-07-14 13:12:38 +00:00
Paul Gschwendtner 8e64d9e1d8 refactor: fix lint warnings 2023-07-14 13:12:38 +00:00
Paul Gschwendtner 4d7b23e99e refactor: wire up propertyCreate instruction for JIT generation 2023-07-14 13:12:38 +00:00
Paul Gschwendtner 59d278c07c WIP: setup template pipeline acceptance tests on CI and base setup 2023-07-14 13:12:38 +00:00
Pawel Kozlowski 7e1de8cb6a refactor: making CI green-ish
Would be great to keep CI green, making changes in this direction.
2023-07-14 13:12:37 +00:00
Alex Rickabaugh f6bda0eb4d wip: working signal input and all 3 binding types 2023-07-14 13:12:37 +00:00
Alex Rickabaugh c7c4b9e390 wip: specialize virtual instructions
* splits propertyCreate virtual instructions into DOM vs zone inputs
* integrates with `InputSignal` setting (untested)
* deletes old interfaces/virtual_property impl in favor of Paul's
2023-07-14 13:12:37 +00:00
Alex Rickabaugh a77924ff38 input signals 2023-07-14 13:12:37 +00:00
Paul Gschwendtner d04408d67d fixup! WIP: create new instruction "propertyCreate"
More work
2023-07-14 13:12:37 +00:00
Paul Gschwendtner f556b29d05 fixup! WIP: generate propertyCreate instructions in template pipeline
More work
2023-07-14 13:12:37 +00:00
Paul Gschwendtner a9be2b3628 Fix that signals: true views are never running update mode 2023-07-14 13:12:37 +00:00
Paul Gschwendtner a34a479ee5 WIP: generate propertyCreate instructions in template pipeline 2023-07-14 13:12:35 +00:00
Paul Gschwendtner f0c9eeceba WIP: create new instruction "propertyCreate" 2023-07-14 13:11:53 +00:00
Paul Gschwendtner e737d45ad0 WIP: setup http server for test app to test runtime 2023-07-14 13:11:53 +00:00
Paul Gschwendtner c7bede629b WIP: type checking for signal inputs 2023-07-14 13:11:53 +00:00
Paul Gschwendtner 992f51bc6d WIP: Discover input class members in directive shared code. No type-chechking yet. 2023-07-14 13:11:53 +00:00
Paul Gschwendtner f2658e34a2 WIP: implementation of input function signature 2023-07-14 13:11:53 +00:00
Paul Gschwendtner 89813aea70 WIP: initial input function and test app 2023-07-14 13:11:53 +00:00
Alex Rickabaugh e6e5021eb9 wip: basic proof of concept for virtual instructions 2023-07-14 13:11:53 +00:00
7289 changed files with 125113 additions and 451929 deletions
+1 -3
View File
@@ -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
+2 -11
View File
@@ -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,16 +135,10 @@ 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
# Use HTTP remote cache
build:remote-cache --remote_cache=https://storage.googleapis.com/angular-team-cache
build:remote-cache --remote_accept_cached=true
build:remote-cache --remote_upload_local_results=true
build:remote-cache --google_default_credentials
# Ensure that tags like "no-remote-exec" get propagated to actions created by rules,
# even if the rule implementation does not explicitly pass them to the execution requirements.
# https://bazel.build/reference/command-line-reference#flag--experimental_allow_tags_propagation
@@ -177,9 +171,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
+715 -6
View File
@@ -1,16 +1,725 @@
# 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
- notify_webhook_on_fail:
webhook_url_env_var: SLACK_REACTIVITY_CHANNEL_WEBHOOK_URL
# 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
# Windows jobs
# Docs: https://circleci.com/docs/2.0/hello-world-windows/
test_win:
executor: windows-executor
steps:
- checkout
# Setup the proper NodeJS version. The image comes pre-installed with `nvm-windows`.
# Note: This happens immediately after checkout because environment initialization logic
# might rely on Node and shouldn't use the older image-provided NodeJS version.
- run:
name: Install NodeJS version from `.nvmrc`.
command: |
nvm on
nvm install $(cat .nvmrc)
nvm use $(cat .nvmrc)
- run:
name: Debug information for flakiness of Windows job
command: |
node -v
yarn -v
which node
which yarn
echo "Path: $PATH"
yarn node -v
- save_month_to_file
- capture_patches_for_cache_key
- init_environment
- restore_cache:
keys:
- *cache_key_win
- *cache_key_win_fallback
# Install project dependencies, and install Bazelisk globally. This is necessary as
# Windows might error when `bazel` is invoked from the project node modules. The Bazel
# invocation might modify the symlinked project `node_modules` again, causing failures.
- yarn_install
- run: yarn global add @bazel/bazelisk@${BAZELISK_VERSION}
- run:
name: Build all windows CI targets
command: |
$(yarn global bin)/bazelisk build //packages/compiler-cli/...
no_output_timeout: 15m
- run:
name: Test all windows CI targets
command: |
$(yarn global bin)/bazelisk test --test_tag_filters="-browser:chromium-local" //packages/compiler-cli/...
no_output_timeout: 15m
- save_cache:
key: *cache_key_win
paths:
- node_modules
- ~/AppData/Local/bazelisk
test_angular_devtools:
executor: test-browser-executor
steps:
- custom_attach_workspace
- init_environment
- run:
name: Run unit tests
command: yarn devtools:test
- run:
name: Test production build
command: yarn devtools:build:chrome
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
- test_win:
requires:
- setup
- test_angular_devtools:
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 -1
View File
@@ -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" \
+1 -1
View File
@@ -4,7 +4,7 @@ description: 'Installs the dependencies using Yarn'
runs:
using: 'composite'
steps:
- uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4
- uses: actions/cache@88522ab9f39a2ea568f7027eddc7d8d8bc9d59c8 # v3
with:
path: |
./node_modules/
+1
View File
@@ -68,6 +68,7 @@ merge:
# list of PR statuses that need to be successful
requiredStatuses:
- 'ci/circleci: build'
- 'google-internal-tests'
- 'pullapprove'
-38
View File
@@ -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@5774b71c01a55c4c998f858ee37d3b77ae704c31
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@5774b71c01a55c4c998f858ee37d3b77ae704c31
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@5774b71c01a55c4c998f858ee37d3b77ae704c31
- 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@5774b71c01a55c4c998f858ee37d3b77ae704c31
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'
-50
View File
@@ -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@0ad4b8fadaa221de15dcec353f45205ec38ea70b # 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@5774b71c01a55c4c998f858ee37d3b77ae704c31
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}}'
+3 -3
View File
@@ -22,10 +22,10 @@ jobs:
(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@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: ./.github/actions/yarn-install
- uses: angular/dev-infra/github-actions/bazel/configure-remote@c83e99a12397014162531ca125c94549db55dd84
- uses: angular/dev-infra/github-actions/setup-bazel-remote-exec@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
bazelrc: ./.bazelrc.user
@@ -34,7 +34,7 @@ jobs:
# 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@c83e99a12397014162531ca125c94549db55dd84
- uses: angular/dev-infra/github-actions/deploy-previews/pack-and-upload-artifact@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
workflow-artifact-name: 'aio'
pull-number: '${{github.event.pull_request.number}}'
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
- name: Configure Firebase deploy target
working-directory: aio/
@@ -34,7 +34,7 @@ jobs:
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@c83e99a12397014162531ca125c94549db55dd84
- uses: angular/dev-infra/github-actions/deploy-previews/upload-artifacts-to-firebase@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
github-token: '${{secrets.GITHUB_TOKEN}}'
workflow-artifact-name: 'aio'
@@ -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@4b433074a806bbbd4d319264430740cd46e62f27
- uses: angular/dev-infra/github-actions/branch-manager@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
exit: true
# Indicate that the benchmark command was received.
- uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4
- uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3
with:
comment-id: ${{github.event.comment.id}}
token: '${{secrets.BENCHMARK_POST_RESULTS_GITHUB_TOKEN}}'
@@ -28,7 +28,7 @@ jobs:
- uses: alessbell/pull-request-comment-branch@aad01d65d6982b8eacabed5e9a684cd8ceb98da6 # v1.1
id: comment-branch
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
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}}
@@ -38,7 +38,7 @@ jobs:
- uses: ./.github/actions/yarn-install
- uses: angular/dev-infra/github-actions/bazel/configure-remote@5774b71c01a55c4c998f858ee37d3b77ae704c31
- uses: angular/dev-infra/github-actions/setup-bazel-remote-exec@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
bazelrc: ./.bazelrc.user
@@ -53,7 +53,7 @@ jobs:
id: benchmark
name: Running benchmark
- uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4
- uses: peter-evans/create-or-update-comment@c6c9a1a66007646a28c153e2a8580a5bad27bcfa # v3
with:
issue-number: ${{github.event.issue.number}}
token: '${{secrets.BENCHMARK_POST_RESULTS_GITHUB_TOKEN}}'
+1 -309
View File
@@ -23,7 +23,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4b433074a806bbbd4d319264430740cd46e62f27
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@852c3d1898775ec9fbc87213e519f16e78805b57
with:
cache-node-modules: true
node-module-directories: |
@@ -55,311 +55,3 @@ jobs:
# it has been merged.
if: github.event_name == 'pull_request'
run: yarn ng-dev format changed --check ${{ github.event.pull_request.base.sha }}
devtools:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4b433074a806bbbd4d319264430740cd46e62f27
with:
cache-node-modules: true
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@4b433074a806bbbd4d319264430740cd46e62f27
- name: Install node modules
run: yarn install --frozen-lockfile
- name: Run unit tests
run: yarn devtools:test
- name: Test build
run: yarn devtools:build:chrome
test:
runs-on: ubuntu-latest-4core
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4b433074a806bbbd4d319264430740cd46e62f27
with:
cache-node-modules: true
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel Remote Caching
uses: angular/dev-infra/github-actions/bazel/configure-remote@4b433074a806bbbd4d319264430740cd46e62f27
- name: Install node modules
run: yarn install --frozen-lockfile --network-timeout 100000
- name: Run CI tests for framework
run: yarn test:ci
aio:
runs-on: 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: |
./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 for aio
run: yarn install --cwd aio --frozen-lockfile
- name: Run AIO tests with upstream packages
run: yarn --cwd aio test:ci
- name: Check generated bundle sizes
run: yarn --cwd aio payload-size
adev:
runs-on:
labels: ubuntu-latest-4core
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@4b433074a806bbbd4d319264430740cd46e62f27
- 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
aio-local:
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: |
./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
run: yarn install --cwd aio --frozen-lockfile
- name: Run AIO tests with local packages
run: yarn --cwd aio test-local:ci
- name: Check generated bundle sizes
run: yarn --cwd aio payload-size aio-local
aio-deploy:
needs: [aio]
if: needs.aio.result == 'success' && 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
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 for aio
run: yarn install --cwd aio --frozen-lockfile
- name: Set the stable branch environment variable
id: latest_version
run: |
echo "CI_STABLE_BRANCH=$(npm info @angular/core dist-tags.latest | sed -r 's/^\s*([0-9]+\.[0-9]+)\.[0-9]+.*$/\1.x/')" >> $GITHUB_OUTPUT
echo "CI_BRANCH=$(echo ${{ github.event.ref }} | cut -d '/' -f 3)" >> $GITHUB_OUTPUT
- name: Deploy aio to production
env:
CI_AIO_MIN_PWA_SCORE: '95'
CI_COMMIT: ${{ github.event.after }}
CI_REPO_NAME: 'angular'
CI_PULL_REQUEST: 'false'
CI_REPO_OWNER: 'angular'
CI_BUILD_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
CI_STABLE_BRANCH: ${{ steps.latest_version.outputs.CI_STABLE_BRANCH }}
CI_BRANCH: ${{ steps.latest_version.outputs.CI_BRANCH }}
CI_SECRET_AIO_DEPLOY_FIREBASE_TOKEN: ${{ secrets.AIO_DEPLOY_TOKEN }}
CI_SECRET_PAYLOAD_FIREBASE_TOKEN: ${{ secrets.AIO_PAYLOAD_TOKEN }}
run: yarn --cwd aio deploy-production
- name: Notify about failed deployment
if: ${{ failure() }}
uses: slackapi/slack-github-action@2a8087d4af6f83146a87539a70defe909fe6dbe6 # v=v1.24.0
with:
channel-id: 'C07DT5M6V,CKV1F72BG'
slack-message: 'Deploy to aio job failed for ${{ github.base_ref }} branch failed on build ${{ github.event.after }}: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}'
env:
SLACK_BOT_TOKEN: ${{ secrets.ANGULAR_ROBOT_SLACK_TOKEN }}
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@4b433074a806bbbd4d319264430740cd46e62f27
with:
cache-node-modules: true
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@4b433074a806bbbd4d319264430740cd46e62f27
- 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@4b433074a806bbbd4d319264430740cd46e62f27
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@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@4b433074a806bbbd4d319264430740cd46e62f27
- 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@4b433074a806bbbd4d319264430740cd46e62f27
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@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel Remote Caching
uses: angular/dev-infra/github-actions/bazel/configure-remote@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Saucelabs Variables
uses: angular/dev-infra/github-actions/saucelabs@50bb71e51d3372ca2e359adb9069f89beeac4ae7
- 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 }}
- 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}
saucelabs:
runs-on: ubuntu-latest-4core
env:
SAUCE_TUNNEL_IDENTIFIER: angular-framework-${{ github.run_number }}
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@4b433074a806bbbd4d319264430740cd46e62f27
with:
cache-node-modules: true
- name: Install node modules
run: yarn install --frozen-lockfile
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Saucelabs Variables
uses: angular/dev-infra/github-actions/saucelabs@50bb71e51d3372ca2e359adb9069f89beeac4ae7
- 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
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@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@4b433074a806bbbd4d319264430740cd46e62f27
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@4b433074a806bbbd4d319264430740cd46e62f27
- 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
+4 -4
View File
@@ -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@4b433074a806bbbd4d319264430740cd46e62f27
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: angular/dev-infra/github-actions/commit-message-based-labels@97cb376945b07e37671e3b38ee727ff00dc1394b
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@4b433074a806bbbd4d319264430740cd46e62f27
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: angular/dev-infra/github-actions/post-approval-changes@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
+19
View File
@@ -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@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
+5 -6
View File
@@ -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@4b433074a806bbbd4d319264430740cd46e62f27
- uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3
- uses: angular/dev-infra/github-actions/google-internal-tests@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
run-tests-guide-url: http://go/angular-g3sync-start
github-token: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
status:
runs-on: ubuntu-latest
steps:
- uses: angular/dev-infra/github-actions/unified-status-check@4b433074a806bbbd4d319264430740cd46e62f27
- uses: angular/dev-infra/github-actions/unified-status-check@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
-55
View File
@@ -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@5774b71c01a55c4c998f858ee37d3b77ae704c31
with:
cache-node-modules: true
node-module-directories: |
./aio/node_modules
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@5774b71c01a55c4c998f858ee37d3b77ae704c31
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@5774b71c01a55c4c998f858ee37d3b77ae704c31
- 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@5774b71c01a55c4c998f858ee37d3b77ae704c31
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
+4 -4
View File
@@ -25,12 +25,12 @@ jobs:
steps:
- name: 'Checkout code'
uses: actions/checkout@0ad4b8fadaa221de15dcec353f45205ec38ea70b # v4.1.4
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@65462800fd760344b1a7b4382951275a0abb4808 # v4.3.3
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@d39d31e687223d841ef683f52467bd88e9b21c14 # v3.25.3
uses: github/codeql-action/upload-sarif@46ed16ded91731b2df79a2893d3aea8e9f03b5c4 # v2.20.3
with:
sarif_file: results.sarif
+2 -2
View File
@@ -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@1421ff04811ecb45812930c5d1f61d912880e75f
uses: angular/dev-infra/github-actions/create-pr-for-changes@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
branch-prefix: update-cli-help
pr-title: 'docs: update Angular CLI help [${{github.ref_name}}]'
+2 -2
View File
@@ -24,7 +24,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
@@ -35,7 +35,7 @@ jobs:
- 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@c83e99a12397014162531ca125c94549db55dd84
uses: angular/dev-infra/github-actions/create-pr-for-changes@97cb376945b07e37671e3b38ee727ff00dc1394b
with:
branch-prefix: docs-update-events
pr-title: 'docs: update events'
-4
View File
@@ -57,7 +57,3 @@ baseline.json
# Husky
.husky/_
aio/content/examples/.DS_Store
# Ignore cache created with the Angular CLI.
.angular/
+3
View File
@@ -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
View File
@@ -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;
+3
View File
@@ -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
View File
@@ -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,
+4 -8
View File
@@ -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",
-5
View File
@@ -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,
},
};
+1 -1
View File
@@ -1 +1 @@
18.18.2
16.14.0
-1
View File
@@ -2,7 +2,6 @@
"printWidth": 100,
"tabWidth": 2,
"tabs": false,
"embeddedLanguageFormatting": "off",
"singleQuote": true,
"semicolon": true,
"quoteProps": "preserve",
+54 -131
View File
@@ -57,7 +57,8 @@
version: 3
availability:
users_unavailable: []
users_unavailable:
- atscott
# Meta field that goes unused by PullApprove to allow for defining aliases to be
# used throughout the config.
@@ -140,7 +141,7 @@ groups:
- alxhub
- crisbeto
- ~jelbourn
- thePunderWoman
- jessicajaniuk
# =========================================================
# Framework: Compiler
@@ -166,8 +167,6 @@ groups:
- AndrewKushnir
- atscott
- crisbeto
- devversion
- dylhunn
- JoostK
# =========================================================
@@ -183,8 +182,6 @@ groups:
- crisbeto
- devversion
- dylhunn
- thePunderWoman
- AndrewKushnir
# =========================================================
# Framework: Core
@@ -193,7 +190,7 @@ groups:
<<: *defaults
conditions:
- >
contains_any_globs(files.exclude("packages/core/schematics/*").exclude('packages/core/primitives/*'), [
contains_any_globs(files.exclude("packages/core/schematics/*"), [
'packages/core/**/{*,.*}',
'packages/examples/core/**/{*,.*}',
'packages/platform-browser/**/{*,.*}',
@@ -314,7 +311,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 +318,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 +336,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,9 +345,8 @@ groups:
- AndrewKushnir
- atscott
- crisbeto
- devversion
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -378,7 +366,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -415,7 +403,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -438,7 +426,7 @@ groups:
- andrewseguin
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -526,8 +514,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:
@@ -536,7 +524,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -615,7 +603,7 @@ groups:
reviewers:
users:
- alxhub
- thePunderWoman
- jessicajaniuk
# =========================================================
# Framework: Testing
@@ -644,7 +632,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -663,7 +651,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -682,7 +670,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -778,7 +766,7 @@ groups:
- atscott
- crisbeto
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -813,7 +801,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- josephperrott
- pkozlowski-opensource
@@ -867,7 +855,7 @@ groups:
reviewers:
users:
- AndrewKushnir
- thePunderWoman
- jessicajaniuk
# =========================================================
# Docs: Marketing
@@ -931,7 +919,13 @@ groups:
'aio/content/guide/releases.md',
'aio/content/guide/updating.md',
'aio/content/guide/deprecations.md',
'aio/content/guide/migration-legacy-message-id.md',
'aio/content/examples/deprecation-guide/**/{*,.*}',
'aio/content/guide/migration-renderer.md',
'aio/content/guide/migration-undecorated-classes.md',
'aio/content/guide/migration-dynamic-flag.md',
'aio/content/guide/migration-injectable.md',
'aio/content/guide/migration-localize.md',
'aio/content/guide/migration-module-with-providers.md',
'aio/content/guide/static-query-migration.md',
'aio/content/guide/update-to-latest-version.md',
@@ -939,14 +933,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
- thePunderWoman
- jelbourn
# =========================================================
@@ -1063,31 +1055,6 @@ groups:
- dylhunn
- mgechev
# =========================================================
# Docs: Angular Dev Site
# =========================================================
angular-dev:
<<: *defaults
conditions:
- >
contains_any_globs(files, [
'adev/**/{.*,*}'
])
reviewers:
users:
- josephperrott
- jelbourn
- bencodezen
- thePunderWoman
- dylhunn
- AndrewKushnir
- alxhub
- crisbeto
- devversion
- atscott
- pkozlowski-opensource
- ~JeanMeche
# =========================================================
# Docs-infra
# =========================================================
@@ -1144,7 +1111,7 @@ groups:
reviewers:
users:
- AndrewKushnir
- thePunderWoman
- jessicajaniuk
- josephperrott
# =========================================================
@@ -1161,11 +1128,9 @@ groups:
users:
- AleksanderBodurri
- devversion
- dgp1130
- josephperrott
- mgechev
- twerske
- ~JeanMeche
# =========================================================
# Dev-infra
@@ -1200,11 +1165,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/**/{*,.*}',
@@ -1243,7 +1206,7 @@ groups:
- atscott
- dylhunn
- ~jelbourn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
reviews:
request: 3 # Request reviews from 3 people
@@ -1269,7 +1232,7 @@ groups:
- atscott
- dylhunn
- ~jelbourn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
reviews:
request: 2 # Request reviews from 2 people
@@ -1295,7 +1258,7 @@ groups:
- atscott
- dylhunn
- ~jelbourn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
####################################################################################
@@ -1319,69 +1282,9 @@ groups:
- andrewseguin
- dgp1130
- ~jelbourn
- thePunderWoman
- jessicajaniuk
- josephperrott
# =========================================================
# Primitives
# =========================================================
# Angular team required reviews
primitives:
<<: *defaults
conditions:
- >
contains_any_globs(files, [
'packages/core/primitives/**/{*,.*}',
])
reviewers:
users:
- pkozlowski-opensource # Pawel Kozlowski
- alxhub # Alex Rickabaugh
- thePunderWoman # Jessica Janiuk
- dylhunn # Dylan Hunn
- AndrewKushnir # Andrew Kushnir
- atscott # Andrew Scott
reviews:
required: 1
reviewed_for: required
labels:
pending: 'requires: TGP'
approved: 'requires: TGP'
rejected: 'requires: TGP'
# External team required reviews
primitives-shared:
<<: *defaults
conditions:
- >
contains_any_globs(files, [
'packages/core/primitives/**/{*,.*}',
])
reviewers:
users:
- csmick # Cameron Smick
- lannka # Hongfei Ding
- eduhmc # Eduardo Huerta-Mercado
- ehlemur # Edward Lesmes
- ellenyuan # Ellen Yuan
- jatraman # Jatin Ramanathan
- jesse-good # Jesse Costello-Good
- mturco # Matt Turco
- neonstalwart # Ben Hockey
- nicholasyu-google # Nicholas Yu
- emspishak # Eric Spishak-Thomas
- iteriani # Thomas Nguyen
- tbondwilkinson # Tom Wilkinson
- rahatarmanahmed # Rahat Ahmed
reviews:
required: 1
reviewed_for: required
labels:
pending: 'requires: TGP'
approved: 'requires: TGP'
rejected: 'requires: TGP'
####################################################################################
# Override managed result groups
#
@@ -1458,6 +1361,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
+1 -1
View File
@@ -4,7 +4,7 @@
// 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",
-1
View File
@@ -13,7 +13,6 @@ exports_files([
"scripts/ci/payload-size.sh",
"scripts/ci/payload-size.js",
"package.json",
"angular.json",
])
filegroup(
+46 -1192
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -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:
@@ -389,6 +389,7 @@ The following documents can help you sort out issues with GitHub accounts and mu
[angular-group]: https://groups.google.com/forum/#!forum/angular
[coc]: https://github.com/angular/code-of-conduct/blob/main/CODE_OF_CONDUCT.md
[commit-message-format]: https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/edit#
[corporate-cla]: https://cla.developers.google.com/about/google-corporate
@@ -397,3 +398,7 @@ The following documents can help you sort out issues with GitHub accounts and mu
[discord]: https://discord.gg/angular
[individual-cla]: https://cla.developers.google.com/about/google-individual
[js-style-guide]: https://google.github.io/styleguide/jsguide.html
[jsfiddle]: https://jsfiddle.net/
[plunker]: https://plnkr.co/edit
[runnable]: https://runnable.com/
[stackoverflow]: https://stackoverflow.com/questions/tagged/angular
+1 -1
View File
@@ -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
+17 -36
View File
@@ -1,7 +1,7 @@
<h1 align="center">Angular - The modern web developer's platform</h1>
<p align="center">
<img src="adev/src/assets/images/press-kit/angular_icon_gradient.gif" 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/1-components-in-angular
[forms]: https://angular.dev/tutorials/learn-angular/15-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
+24 -17
View File
@@ -9,6 +9,20 @@ workspace(
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
load("//:yarn.bzl", "YARN_LABEL")
# Add a patch fix for rules_webtesting v0.3.5 required for enabling runfiles on Windows.
# TODO: Remove the http_archive for this transitive dependency when a release is cut
# for https://github.com/bazelbuild/rules_webtesting/commit/581b1557e382f93419da6a03b91a45c2ac9a9ec8
# and the version is updated in rules_nodejs.
http_archive(
name = "io_bazel_rules_webtesting",
patch_args = ["-p1"],
patches = [
"//:tools/bazel-repo-patches/rules_webtesting__windows_runfiles_fix.patch",
],
sha256 = "e9abb7658b6a129740c0b3ef6f5a2370864e102a5ba5ffca2cea565829ed825a",
urls = ["https://github.com/bazelbuild/rules_webtesting/releases/download/0.3.5/rules_webtesting.tar.gz"],
)
http_archive(
name = "build_bazel_rules_nodejs",
patches = [
@@ -50,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.
@@ -74,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",
@@ -102,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",
],
@@ -192,10 +199,10 @@ cldr_xml_data_repository(
# sass rules
http_archive(
name = "io_bazel_rules_sass",
sha256 = "a2aeaf52f9a17b07e91c116d64bca3fd1a709addd02a390efff1b78b8f88bc13",
strip_prefix = "rules_sass-9a4a4be9e8f208375892d27212cbbcf27685d333",
sha256 = "4bb36a654166e1b629717e55a9c0cc30590f6615c122c054f38409c20ee5ec6f",
strip_prefix = "rules_sass-f89fc59345a5074cf184ec215e9858152b7258e7",
urls = [
"https://github.com/bazelbuild/rules_sass/archive/9a4a4be9e8f208375892d27212cbbcf27685d333.zip",
"https://github.com/bazelbuild/rules_sass/archive/f89fc59345a5074cf184ec215e9858152b7258e7.zip",
],
)
-185
View File
@@ -1,185 +0,0 @@
load("@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")
load("@bazel_skylib//lib:collections.bzl", "collections")
load("//adev/tools/local_deps:index.bzl", "ensure_local_package_deps", "link_local_packages")
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_ASSETS = [
"//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",
]
APPLICATION_DEPS = [
"@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",
"@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 + [
"@npm//@angular/platform-browser-dynamic",
"@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",
]
# Create `npm_link` targets for all dependencies that correspond to a
# first-party Angular package that can be built from `HEAD`.
link_local_packages(
all_deps = collections.uniq(APPLICATION_DEPS + TEST_DEPS),
)
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 = ensure_local_package_deps(APPLICATION_DEPS) + APPLICATION_ASSETS + [
":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 = ensure_local_package_deps(APPLICATION_DEPS) + APPLICATION_ASSETS + [
":application_files_bin",
],
tags = [
"no-remote-exec",
],
)
architect_test(
name = "test",
args = [
"angular-dev:test",
"--no-watch",
],
chdir = package_name(),
data = ensure_local_package_deps(TEST_DEPS) + TEST_FILES + APPLICATION_ASSETS + [
"//aio/tools:windows-chromium-path",
"@npm//@angular/build-tooling/bazel/browsers/chromium",
],
env = {
"CHROME_BIN": "../$(CHROMIUM)",
},
toolchains = [
"@npm//@angular/build-tooling/bazel/browsers/chromium:toolchain_alias",
],
)
-103
View File
@@ -1,103 +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",
"cache": {
// Disable build caching as the cache folder will just be dropped
// when run under Bazel sandboxed execution.
"enabled": false
}
}
}
-73
View File
@@ -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"
}
]
}
}
-78
View File
@@ -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;
}
-30
View File
@@ -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>
-86
View File
@@ -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;
}
-37
View File
@@ -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();
});
});
-133
View File
@@ -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);
});
}
}
-24
View File
@@ -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);
-97
View File
@@ -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';
-12
View File
@@ -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';
-12
View File
@@ -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';
-74
View File
@@ -1,74 +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 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',
},
],
};
-28
View File
@@ -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="&Epsilon;&lambda;&lambda;&eta;&nu;&iota;&kappa;ά"
>
&Epsilon;&lambda;&lambda;&eta;&nu;&iota;&kappa;ά
</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,45 +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 {firstValueFrom} from 'rxjs';
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,
firstValueFrom(
this.httpClient
.get(`assets/content/${path}`, {
responseType: 'text',
})
.pipe(map((contents) => ({contents, id: path}))),
),
);
} 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,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 {Injectable, VERSION, computed, signal} from '@angular/core';
import {VERSIONS_CONFIG} from '../constants/versions';
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();
}
}
-207
View File
@@ -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-.',
},
]),
];
@@ -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 {html} from '@codemirror/lang-html';
import {angular} from '@codemirror/lang-angular';
import {css} from '@codemirror/lang-css';
import {sass} from '@codemirror/lang-sass';
import {javascript} from '@codemirror/lang-javascript';
import {angularComponent} from '../utils/component-ts-syntax';
import {LRLanguage, LanguageSupport} from '@codemirror/language';
export const LANGUAGES: Record<string, LanguageSupport | LRLanguage> = {
'component.ts': angularComponent(),
'main.ts': angularComponent(),
html: angular({base: html()}),
svg: html(),
ts: javascript({typescript: true}),
css: css(),
sass: sass(),
scss: sass(),
json: javascript(),
};
@@ -1,246 +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 type {TagStyle} from '@codemirror/language';
import {tags} from '@lezer/highlight';
export const SYNTAX_STYLES: TagStyle[] = [
/** A comment. */
{tag: tags.comment, color: 'var(--code-comment)'},
/** A language keyword. */
{tag: tags.keyword, color: 'var(--code-keyword)'},
/** A string literal */
{tag: tags.string, color: 'var(--code-string)'},
/** A number literal. */
{tag: tags.number, color: 'var(--code-number)'},
/** A tag name, subtag of typeName. */
{tag: tags.tagName, color: 'var(--code-tags)'},
/** The name of a class. */
{tag: tags.className, color: 'var(--code-component)'},
/** A line comment. */
{tag: tags.lineComment, color: 'var(--code-line-comment)'},
/** A block comment. */
{tag: tags.blockComment, color: 'var(--code-block-comment)'},
/** A documentation comment. */
{tag: tags.docComment, color: 'var(--code-doc-comment)'},
/** Any kind of identifier. */
{tag: tags.name, color: 'var(--code-name)'},
/** The name of a variable. */
{tag: tags.variableName, color: 'var(--code-variable-name)'},
/** A type name */
{tag: tags.typeName, color: 'var(--code-type-name)'},
/** A property or field name. */
{tag: tags.propertyName, color: 'var(--code-property-name)'},
/** An attribute name, subtag of propertyName. */
{tag: tags.attributeName, color: 'var(--code-attribute-name)'},
/** A label name. */
{tag: tags.labelName, color: 'var(--code-label-name)'},
/** A namespace name. */
{tag: tags.namespace, color: 'var(--code-namespace)'},
/** The name of a macro. */
{tag: tags.macroName, color: 'var(--code-macro-name)'},
/** A literal value. */
{tag: tags.literal, color: 'var(--code-literal)'},
/** A documentation string. */
{tag: tags.docString, color: 'var(--code-doc-string)'},
/** A character literal (subtag of string). */
{tag: tags.character, color: 'var(--code-character)'},
/** An attribute value (subtag of string). */
{tag: tags.attributeValue, color: 'var(--code-attribute-value)'},
/** An integer number literal. */
{tag: tags.integer, color: 'var(--code-integer)'},
/** A floating-point number literal. */
{tag: tags.float, color: 'var(--code-float)'},
/** A boolean literal. */
{tag: tags.bool, color: 'var(--code-bool)'},
/** Regular expression literal. */
{tag: tags.regexp, color: 'var(--code-regexp)'},
/** An escape literal, for example a backslash escape in a string. */
{tag: tags.escape, color: 'var(--code-escape)'},
/** A color literal . */
{tag: tags.color, color: 'var(--code-color)'},
/** A URL literal. */
{tag: tags.url, color: 'var(--code-url)'},
/** The keyword for the self or this object. */
{tag: tags.self, color: 'var(--code-self)'},
/** The keyword for null. */
{tag: tags.null, color: 'var(--code-null)'},
/** A keyword denoting some atomic value. */
{tag: tags.atom, color: 'var(--code-atom)'},
/** A keyword that represents a unit. */
{tag: tags.unit, color: 'var(--code-unit)'},
/** A modifier keyword. */
{tag: tags.modifier, color: 'var(--code-modifier)'},
/** A keyword that acts as an operator. */
{tag: tags.operatorKeyword, color: 'var(--code-operator-keyword)'},
/** A control-flow related keyword. */
{tag: tags.controlKeyword, color: 'var(--code-control-keyword)'},
/** A keyword that defines something. */
{tag: tags.definitionKeyword, color: 'var(--code-definition-keyword)'},
/** A keyword related to defining or interfacing with modules. */
{tag: tags.moduleKeyword, color: 'var(--code-module-keyword)'},
/** An operator. */
{tag: tags.operator, color: 'var(--code-operator)'},
/** An operator that dereferences something. */
{tag: tags.derefOperator, color: 'var(--code-deref-operator)'},
/** Arithmetic-related operator. */
{tag: tags.arithmeticOperator, color: 'var(--code-arithmetic-operator)'},
/** Logical operator. */
{tag: tags.logicOperator, color: 'var(--code-logic-operator)'},
/** Bit operator. */
{tag: tags.bitwiseOperator, color: 'var(--code-bitwise-operator)'},
/** Comparison operator. */
{tag: tags.compareOperator, color: 'var(--code-compare-operator)'},
/** Operator that updates its operand. */
{tag: tags.updateOperator, color: 'var(--code-update-operator)'},
/** Operator that defines something. */
{tag: tags.definitionOperator, color: 'var(--code-definition-operator)'},
/** Type-related operator. */
{tag: tags.typeOperator, color: 'var(--code-type-operator)'},
/** Control-flow operator. */
{tag: tags.controlOperator, color: 'var(--code-control-operator)'},
/** Program or markup punctuation. */
{tag: tags.punctuation, color: 'var(--code-punctuation)'},
/** Punctuation that separates things. */
{tag: tags.separator, color: 'var(--code-separator)'},
/** Bracket-style punctuation. */
{tag: tags.bracket, color: 'var(--code-bracket)'},
/** Angle brackets (usually `<` and `>` tokens). */
{tag: tags.angleBracket, color: 'var(--code-angle-bracket)'},
/** Square brackets (usually `[` and `]` tokens). */
{tag: tags.squareBracket, color: 'var(--code-square-bracket)'},
/** Parentheses (usually `(` and `)` tokens). Subtag of bracket. */
{tag: tags.paren, color: 'var(--code-paren)'},
/** Braces (usually `{` and `}` tokens). Subtag of bracket. */
{tag: tags.brace, color: 'var(--code-brace)'},
/** Content, for example plain text in XML or markup documents. */
{tag: tags.content, color: 'var(--code-content)'},
/** Content that represents a heading. */
{tag: tags.heading, color: 'var(--code-heading)'},
/** A level 1 heading. */
{tag: tags.heading1, color: 'var(--code-heading1)'},
/** A level 2 heading. */
{tag: tags.heading2, color: 'var(--code-heading2)'},
/** A level 3 heading. */
{tag: tags.heading3, color: 'var(--code-heading3)'},
/** A level 4 heading. */
{tag: tags.heading4, color: 'var(--code-heading4)'},
/** A level 5 heading. */
{tag: tags.heading5, color: 'var(--code-heading5)'},
/** A level 6 heading. */
{tag: tags.heading6, color: 'var(--code-heading6)'},
/** A prose separator (such as a horizontal rule). */
{tag: tags.contentSeparator, color: 'var(--code-content-separator)'},
/** Content that represents a list. */
{tag: tags.list, color: 'var(--code-list)'},
/** Content that represents a quote. */
{tag: tags.quote, color: 'var(--code-quote)'},
/** Content that is emphasized. */
{tag: tags.emphasis, color: 'var(--code-emphasis)'},
/** Content that is styled strong. */
{tag: tags.strong, color: 'var(--code-strong)'},
/** Content that is part of a link. */
{tag: tags.link, color: 'var(--code-link)'},
/** Content that is styled as code or monospace. */
{tag: tags.monospace, color: 'var(--code-monospace)'},
/** Content that has a strike-through style. */
{tag: tags.strikethrough, color: 'var(--code-strikethrough)'},
/** Inserted text in a change-tracking format. */
{tag: tags.inserted, color: 'var(--code-inserted)'},
/** Deleted text. */
{tag: tags.deleted, color: 'var(--code-deleted)'},
/** Changed text. */
{tag: tags.changed, color: 'var(--code-changed)'},
/** An invalid or unsyntactic element. */
{tag: tags.invalid, color: 'var(--code-invalid)'},
/** Metadata or meta-instruction. */
{tag: tags.meta, color: 'var(--code-meta)'},
/** Metadata that applies to the entire document. */
{tag: tags.documentMeta, color: 'var(--code-document-meta)'},
/** Metadata that annotates or adds attributes to a given syntactic element. */
{tag: tags.annotation, color: 'var(--code-annotation)'},
/** Processing instruction or preprocessor directive. Subtag of meta. */
{tag: tags.processingInstruction, color: 'var(--code-processing-instruction)'},
];
@@ -1,76 +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 CODE_EDITOR_THEME_STYLES = {
'&': {
position: 'relative',
width: '100%',
height: '100%',
'background-color': 'var(--code-editor-background)',
color: 'var(--code-editor-text-base-color)',
},
'.cm-gutters': {
border: 'none',
},
'.cm-gutter': {
'background-color': 'var(--code-editor-background)',
color: 'var(--code-editor-code-base-color)',
},
'.cm-line.cm-activeLine': {
'background-color': 'var(--code-editor-active-line-background)',
},
'.cm-activeLineGutter': {
'background-color': 'var(--code-editor-active-line-background)',
},
'&.cm-focused .cm-selectionBackground': {
'background-color': 'var(--code-editor-focused-selection-background) !important',
},
'.cm-selectionBackground': {
'background-color': 'var(--code-editor-selection-background) !important',
},
'.cm-cursor': {
'border-color': 'var(--code-editor-cursor-color)',
},
'.cm-tooltip': {
color: 'var(--code-editor-tooltip-color)',
border: 'var(--code-editor-tooltip-border)',
'border-radius': 'var(--code-editor-tooltip-border-radius)',
background: 'var(--code-editor-tooltip-background)',
'overflow-y': 'scroll',
'max-height': '70%',
'max-width': '100%',
'margin-right': '10px',
},
'.cm-tooltip.cm-tooltip-autocomplete > ul': {
background: 'var(--code-editor-autocomplete-background)',
},
'.cm-tooltip .keyword': {
color: 'var(--code-module-keyword)',
},
'.cm-tooltip .aliasName': {
color: 'var(--code-variable-name)',
},
'.cm-tooltip .localName': {
color: 'var(--code-variable-name)',
},
'.cm-tooltip-autocomplete ul li[aria-selected]': {
background: 'var(--code-editor-autocomplete-item-background)',
color: 'var(--code-editor-autocomplete-item-color)',
},
'.cm-tooltip-lint': {
background: 'var(--code-editor-lint-tooltip-background)',
color: 'var(--code-editor-lint-tooltip-color)',
},
'.cm-panels': {
background: 'var(--code-editor-panels-background)',
color: 'var(--code-editor-panels-color)',
},
'.cm-foldPlaceholder': {
background: 'var(--code-editor-fold-placeholder-background)',
},
};
@@ -1,118 +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 {
Completion,
CompletionContext,
CompletionResult,
autocompletion,
closeCompletion,
completeFromList,
insertCompletionText,
} from '@codemirror/autocomplete';
import {EditorView} from '@codemirror/view';
import {Subject, filter, take} from 'rxjs';
import {EditorFile} from '../code-mirror-editor.service';
import {TsVfsWorkerActions} from '../workers/enums/actions';
import {AutocompleteRequest} from '../workers/interfaces/autocomplete-request';
import {AutocompleteItem, AutocompleteResponse} from '../workers/interfaces/autocomplete-response';
import {ActionMessage} from '../workers/interfaces/message';
import {TransactionSpec} from '@codemirror/state';
// Factory method for autocomplete extension.
export const getAutocompleteExtension = (
emitter: Subject<ActionMessage>,
currentFile: Signal<EditorFile>,
sendRequestToTsVfs: (request: ActionMessage<AutocompleteRequest>) => void,
) => {
return autocompletion({
activateOnTyping: true,
override: [
async (context: CompletionContext): Promise<CompletionResult | null> => {
try {
const contextPositions = context.state.wordAt(context.pos);
sendRequestToTsVfs({
action: TsVfsWorkerActions.AUTOCOMPLETE_REQUEST,
data: {
file: currentFile().filename,
position: context.pos,
from: contextPositions?.from,
to: contextPositions?.to,
content: context.state.doc.toString(),
},
});
const completions = await new Promise<AutocompleteResponse | undefined>((resolve) => {
emitter
.pipe(
filter((event) => event.action === TsVfsWorkerActions.AUTOCOMPLETE_RESPONSE),
take(1),
)
.subscribe((message: ActionMessage<AutocompleteResponse>) => {
resolve(message.data);
});
});
if (!completions) {
return null;
}
const completionSource = completeFromList(
completions.map((completionItem) => {
const suggestions: Completion = {
type: completionItem.kind,
label: completionItem.name,
boost: 1 / Number(completionItem.sortText),
detail: completionItem?.codeActions?.[0]?.description,
apply: (view, completion, from, to) =>
applyWithCodeAction(view, {...completion, ...completionItem}, from, to),
};
return suggestions;
}),
);
return completionSource(context);
} catch (e) {
return null;
}
},
],
});
};
const applyWithCodeAction = (
view: EditorView,
completion: Completion & AutocompleteItem,
from: number,
to: number,
) => {
const transactionSpecs: TransactionSpec[] = [
insertCompletionText(view.state, completion.label, from, to),
];
if (completion.codeActions?.length) {
const {span, newText} = completion.codeActions[0].changes[0].textChanges[0];
transactionSpecs.push(
insertCompletionText(view.state, newText, span.start, span.start + span.length),
);
}
view.dispatch(
...transactionSpecs,
// avoid moving cursor to the autocompleted text
{selection: view.state.selection},
);
// Manually close the autocomplete picker after applying the completion
closeCompletion(view);
};

Some files were not shown because too many files have changed in this diff Show More