Compare commits

..

79 Commits

Author SHA1 Message Date
Paul Gschwendtner 836530fdfd test: update symbol golden for defer
The golden JSON file is from  and doesn't support the virtual instruction
logic we've added in the signals branch. Surprisingly, this shows that something
is wrong with this test.
2023-10-13 10:37:48 +00:00
Paul Gschwendtner ca81661c95 fix(core): fix property_create instruction breaking binding to Zone targets
The property create instruction is currently failing to bind to Zone
targets if there is no advance call before the virtual instruction
that selects the intended element.
2023-10-12 17:29:21 +00:00
Paul Gschwendtner 5bf5cac714 refactor(core): rework queries to work with new signal graph API 2023-10-12 17:29:21 +00:00
Paul Gschwendtner 70861909a7 refactor(core): add more signal todos for input signal 2023-10-12 17:28:05 +00:00
Paul Gschwendtner f54cd057ad ci: enable github action tests for signals branch 2023-10-12 17:28:05 +00:00
Paul Gschwendtner 4ae88dc9be test: update compliance signal tests after template pipeline fixes/changes
The template pipeline now properly extracts the attribute constants,
so the older compliance tests from the signal branch need to be updated
to account for this new fix.
2023-10-12 17:28:05 +00:00
Paul Gschwendtner c8ac2b6d1e 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-10-12 17:28:05 +00:00
Paul Gschwendtner 8151dd3cb6 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-10-12 17:28:03 +00:00
Paul Gschwendtner 51ce1d6228 feat(compiler): support public aliases for signal output API 2023-10-12 17:27:39 +00:00
Paul Gschwendtner f79b106dfc 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-10-12 17:27:39 +00:00
Paul Gschwendtner 7504f24fe2 test: rework and enable outputs test to verify functionality 2023-10-12 17:27:39 +00:00
Pawel Kozlowski 63084b6502 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-10-12 17:27:39 +00:00
Paul Gschwendtner 1200ae9c6e test: update partial output golden for content queries 2023-10-12 17:27:39 +00:00
Paul Gschwendtner 0dbccabdf9 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-10-12 17:27:39 +00:00
Pawel Kozlowski 1d2f7545c5 feat(core): reactive content queries
Runtime part of the reactive content queries.
2023-10-12 17:27:39 +00:00
Pawel Kozlowski bf16d7e414 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-10-12 17:27:39 +00:00
Pawel Kozlowski 6106fcf4c7 fix(core): adjust reactive query types
Improvements to the types signature in viewChild / viewChildren.
2023-10-12 17:27:39 +00:00
Paul Gschwendtner 3e01740e43 feat(compiler): support generating queries for signal components 2023-10-12 17:27:39 +00:00
Pawel Kozlowski 968700c5f9 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-10-12 17:27:39 +00:00
Paul Gschwendtner f108800804 test: update api golden for core for new query APIs 2023-10-12 17:27:39 +00:00
Pawel Kozlowski c77976d9aa feat(core): reactive queries
Initial runtime implementation of the reactive view queries
(in preparation for the compiler work).
2023-10-12 17:27:39 +00:00
Pawel Kozlowski 2c754081e1 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-10-12 17:27:38 +00:00
Paul Gschwendtner 1a2a6659a0 feat: support property interpolation in signal components 2023-10-12 17:27:38 +00:00
Pawel Kozlowski 1067f8a439 test: fix signal tests after property binding changes
Property bindings are wrapped into computed now.
2023-10-12 17:27:38 +00:00
Pawel Kozlowski e94b474b96 refactor(core): fix lint issues
Fixing lint issues that I've just introduced...
2023-10-12 17:27:38 +00:00
Pawel Kozlowski 34f002db7e 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-10-12 17:27:38 +00:00
Paul Gschwendtner a0af416ca1 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-10-12 17:27:38 +00:00
Paul Gschwendtner 6dc61179ca 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-10-12 17:27:38 +00:00
Paul Gschwendtner c09cace82b 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-10-12 17:27:38 +00:00
Paul Gschwendtner 6840f93f37 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-10-12 17:27:38 +00:00
Paul Gschwendtner 3f5e3001f4 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-10-12 17:27:38 +00:00
Pawel Kozlowski e5e405ee6e refactor(compiler): add required file header
Add required file header.
2023-10-12 17:27:37 +00:00
Pawel Kozlowski b263c00d36 test: add basic test for event listeners
Adding a passing test for a listener invocation
that changes signal value.
2023-10-12 17:27:20 +00:00
Pawel Kozlowski 3bd2fcd044 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-10-12 17:27:20 +00:00
Alex Rickabaugh 7feda88abb 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-10-12 17:27:20 +00:00
Alex Rickabaugh f7265d34ac 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-10-12 17:26:52 +00:00
Paul Gschwendtner 5264c18b53 test: disable input signal initialization test
Still needs to be implemented and discussed.
2023-10-12 17:26:52 +00:00
Paul Gschwendtner 5ab3598746 test: update core API golden to add input API 2023-10-12 17:26:52 +00:00
Paul Gschwendtner f877b544a7 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-10-12 17:26:52 +00:00
Pawel Kozlowski f5edc3e9a8 feat(core): propertyInterpolationCreate
Skeleton of the propertyInterpolationCreate instruction.
2023-10-12 17:26:52 +00:00
Paul Gschwendtner 9bf377a424 build: fix lint errors 2023-10-12 17:26:52 +00:00
Pawel Kozlowski 0eced157a9 test: add failing tests for property interpolation
Adding failing tests for the property interpolation so we
keep those instructions on our radar.
2023-10-12 17:26:52 +00:00
Pawel Kozlowski df4196e63f refactor: fix lint issue
Minor refactor to fix the linting job.
2023-10-12 17:26:52 +00:00
Pawel Kozlowski 5dea0312d6 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-10-12 17:26:52 +00:00
Pawel Kozlowski 7b15e261ad test: enable passing tests after recent code changes
Enabling newly passing tests.
2023-10-12 17:26:51 +00:00
Alex Rickabaugh a59dcb52bd 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-10-12 17:26:51 +00:00
Paul Gschwendtner 6cd5dfa966 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-10-12 17:26:51 +00:00
Paul Gschwendtner 809a96e65d test: generate partial golden for signal compliance test 2023-10-12 17:26:51 +00:00
Paul Gschwendtner 2c6154fe14 test: ensure signal compliance test only runs with template pipeline 2023-10-12 17:26:51 +00:00
Paul Gschwendtner bdbb76f344 refactor: remove debug print for type check block 2023-10-12 17:26:51 +00:00
Paul Gschwendtner 24d689730d test: add test to ensure variable bindings work 2023-10-12 17:26:51 +00:00
Paul Gschwendtner 6310ccc221 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-10-12 17:26:51 +00:00
Pawel Kozlowski faf5b21275 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-10-12 17:26:51 +00:00
Alex Rickabaugh 317ddb0d00 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-10-12 17:26:51 +00:00
Pawel Kozlowski b7f33034e8 test: failing test case for non-primitive literals in a template 2023-10-12 17:26:51 +00:00
Pawel Kozlowski a79e202f33 test: add test veryfing bindings to multiple directives 2023-10-12 17:26:51 +00:00
Pawel Kozlowski 92f4d0127f 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-10-12 17:26:51 +00:00
Pawel Kozlowski 43cf37aa3e test: re-organize tests 2023-10-12 17:26:51 +00:00
JoostK 41db51bf91 test: add test to bind a signal to a signal input 2023-10-12 17:26:51 +00:00
JoostK 1ae67024cb test: allow signal component test to pass 2023-10-12 17:26:51 +00:00
Alex Rickabaugh b04e75a175 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-10-12 17:26:50 +00:00
Alex Rickabaugh d56cbc2fdf wip: bypass pure functions in input computations 2023-10-12 17:26:50 +00:00
Alex Rickabaugh 49cbecaeab wip: fix signals test
(required and initial values for inputs are mutually incompatible)
2023-10-12 17:26:50 +00:00
Alex Rickabaugh a6702e92aa wip: switch to the real input() implementation 2023-10-12 17:26:50 +00:00
Paul Gschwendtner dadd751a48 refactor: fix lint warnings 2023-10-12 17:26:50 +00:00
Paul Gschwendtner 48054443da refactor: wire up propertyCreate instruction for JIT generation 2023-10-12 17:26:50 +00:00
Paul Gschwendtner 84e0908b29 WIP: setup template pipeline acceptance tests on CI and base setup 2023-10-12 17:26:50 +00:00
Alex Rickabaugh 0f228885bf wip: working signal input and all 3 binding types 2023-10-12 17:26:50 +00:00
Alex Rickabaugh af93b126dd 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-10-12 17:26:50 +00:00
Alex Rickabaugh f7408579a4 input signals 2023-10-12 17:26:50 +00:00
Paul Gschwendtner c00ab4bcea Fix that signals: true views are never running update mode 2023-10-12 17:26:49 +00:00
Paul Gschwendtner f0a92e2097 WIP: generate propertyCreate instructions in template pipeline 2023-10-12 17:26:49 +00:00
Paul Gschwendtner e35ac097be WIP: create new instruction "propertyCreate" 2023-10-12 17:26:49 +00:00
Paul Gschwendtner 647f57407b WIP: setup http server for test app to test runtime 2023-10-12 17:18:38 +00:00
Paul Gschwendtner 2bb73d1d09 WIP: type checking for signal inputs 2023-10-12 17:18:38 +00:00
Paul Gschwendtner ea48506f67 WIP: Discover input class members in directive shared code. No type-chechking yet. 2023-10-12 17:18:37 +00:00
Paul Gschwendtner d49aec75df WIP: implementation of input function signature 2023-10-12 17:18:37 +00:00
Paul Gschwendtner f050c5b19a WIP: initial input function and test app 2023-10-12 17:18:37 +00:00
Alex Rickabaugh 565032c038 wip: basic proof of concept for virtual instructions 2023-10-12 17:18:37 +00:00
6857 changed files with 304278 additions and 457276 deletions
-1
View File
@@ -16,7 +16,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
+18 -4
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.
@@ -56,6 +56,11 @@ build --enable_runfiles
build:release --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=release"
build:release --stamp
# Building AIO against local Angular deps requires stamping
# versions in Angular packages due to CLI version checks.
build:aio_local_deps --stamp
build:aio_local_deps --workspace_status_command="yarn -s --cwd aio local-workspace-status"
# Snapshots should also be stamped with version control information.
build:snapshot-build --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=snapshot"
build:snapshot-build --stamp
@@ -69,6 +74,14 @@ build:snapshot-build --stamp
##########################################################
build --flag_alias=aio_build_config=//aio:flag_aio_build_config
####################################
# AIO first party dep substitution #
# Turn on with #
# --config=aio_local_deps #
####################################
build:aio_local_deps --//aio:flag_aio_local_deps
###############################
# Output #
###############################
@@ -105,6 +118,10 @@ common:remote --jobs=200
build:remote --google_default_credentials
# Limit the number of test jobs for on an AIO local deps build. The example tests running
# concurrently pushes the circleci executor RAM usage to its limits.
test:aio_local_deps --jobs=24
# Force remote exeuctions to consider the entire run as linux
build:remote --cpu=k8
build:remote --host_cpu=k8
@@ -160,9 +177,6 @@ test:saucelabs --flaky_test_attempts=1
# --ng_perf will ask the Ivy compiler to produce performance results for each build.
build --flag_alias=ng_perf=//packages/compiler-cli:ng_perf
# --adev_fast will run adev build/serve in a faster mode, skipping things like prerendering
# for local development.
build --flag_alias=fast_adev=//adev:fast_build_mode
####################################################
# User bazel configuration
+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@sha256:6c7a82701665dda03e92d682360fa2e4358e07dab8d1b6a7e48f7d36e3d98117
LABEL name="Angular dev environment" \
+1 -1
View File
@@ -1,4 +1,4 @@
name: 'Docs or angular.dev Bug Report'
name: 'Docs or angular.io Bug Report'
description: Report an issue in Angular's documentation or angular.io application
body:
+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@704facf57e6136b1bc63b828d79edcd491f0ee84 # v3
with:
path: |
./node_modules/
-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@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- name: Install node modules
run: yarn install --frozen-lockfile
- name: Build adev to ensure it continues to work
run: yarn bazel build //adev:build
- uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
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@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # 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@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
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}}'
+42
View File
@@ -0,0 +1,42 @@
# This workflow builds the AIO previews for pull requests when a certain label is applied.
# The actual deployment happens as part of a dedicated second workflow to avoid security
# issues where the building would otherwise occur in an authorized context where secrets
# could be leaked. More details can be found here:
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/.
name: Build AIO app for preview deployment
on:
pull_request:
types: [synchronize, labeled]
permissions: read-all
jobs:
aio-build:
runs-on: ubuntu-latest
# We only want to build and deploy the AIO app if the `aio: preview` label has been
# added, or if the label is already applied and new changes have been made in the PR.
if: |
(github.event.action == 'labeled' && github.event.label.name == 'aio: preview') ||
(github.event.action == 'synchronize' && contains(github.event.pull_request.labels.*.name, 'aio: preview'))
steps:
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- uses: ./.github/actions/yarn-install
- uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
bazelrc: ./.bazelrc.user
# Build the web package. Note: We run Bazel from a low-resource Github action container,
# so we manually need to instruct Bazel to run more actions concurrently as by default
# the number of concurrent actions is determined based on the host resources.
- run: bazel build //aio:build --jobs=32 --announce_rc --verbose_failures
- uses: angular/dev-infra/github-actions/previews/pack-and-upload-artifact@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
workflow-artifact-name: 'aio'
pull-number: '${{github.event.pull_request.number}}'
artifact-build-revision: '${{github.event.pull_request.head.sha}}'
deploy-directory: './dist/bin/aio/build'
+44
View File
@@ -0,0 +1,44 @@
# This workflow runs whenever the AIO build workflow has completed. Deployment happens
# as part of a dedicated second workflow to avoid security issues where the building would
# otherwise occur in an authorized context where secrets could be leaked.
#
# More details can be found here:
# https://securitylab.github.com/research/github-actions-preventing-pwn-requests/.
name: Deploying AIO preview to Firebase
on:
workflow_run:
workflows: ['Build AIO app for preview deployment']
types: [completed]
permissions:
# Needed in order to be able to comment on the pull request.
pull-requests: write
env:
PREVIEW_PROJECT: ng-dev-previews
PREVIEW_SITE: ng-dev-previews-fw
jobs:
aio-deploy:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4
- name: Configure Firebase deploy target
working-directory: aio/
run: |
# We can use `npx` as the Firebase deploy actions uses it too.
npx -y firebase-tools@latest target:clear --project ${{env.PREVIEW_PROJECT}} hosting aio
npx -y firebase-tools@latest target:apply --project ${{env.PREVIEW_PROJECT}} hosting aio ${{env.PREVIEW_SITE}}
- uses: angular/dev-infra/github-actions/previews/upload-artifacts-to-firebase@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
github-token: '${{secrets.GITHUB_TOKEN}}'
workflow-artifact-name: 'aio'
firebase-config-dir: './aio/'
firebase-public-dir: './aio/dist'
firebase-project-id: '${{env.PREVIEW_PROJECT}}'
firebase-service-key: '${{secrets.FIREBASE_PREVIEW_SERVICE_TOKEN}}'
@@ -13,9 +13,9 @@ jobs:
assistant_to_the_branch_manager:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
with:
persist-credentials: false
- uses: angular/dev-infra/github-actions/branch-manager@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- uses: angular/dev-infra/github-actions/branch-manager@031962443584a0ac5cbd9d1c1b78b241453e4702
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@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
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@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
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}}'
+64
View File
@@ -0,0 +1,64 @@
name: CI
on:
pull_request_target:
types: [opened, synchronize, reopened]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
permissions: {}
defaults:
run:
shell: bash
jobs:
saucelabs:
runs-on: ubuntu-latest-4core
env:
SAUCE_TUNNEL_IDENTIFIER: angular-framework-${{ github.run_number }}
SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }}
SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
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@031962443584a0ac5cbd9d1c1b78b241453e4702
- 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
+105 -108
View File
@@ -4,8 +4,8 @@ on:
push:
branches:
- main
- signals
- '[0-9]+.[0-9]+.x'
- 'remove-aio-stuff'
pull_request:
types: [opened, synchronize, reopened]
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
node-module-directories: |
@@ -36,6 +36,8 @@ jobs:
run: yarn install --frozen-lockfile --cwd aio
- name: Check code lint
run: yarn -s tslint
- name: Check code lint in aio
run: yarn -s --cwd aio lint
- name: Check for circular dependencies
run: yarn -s ts-circular-deps:check
- name: Validate pull approve configuration
@@ -59,13 +61,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Install node modules
run: yarn install --frozen-lockfile
- name: Run unit tests
@@ -77,32 +79,103 @@ jobs:
runs-on: ubuntu-latest-4core
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel Remote Caching
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Install node modules
run: yarn install --frozen-lockfile --network-timeout 100000
- name: Run CI tests for framework
run: yarn test:ci
adev:
aio:
runs-on: ubuntu-latest-4core
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
node-module-directories: |
./aio/node_modules
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- 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
aio-local:
runs-on:
labels: ubuntu-latest-4core
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
node-module-directories: |
./aio/node_modules
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Install node modules
run: yarn install --frozen-lockfile
- name: Build adev to ensure it continues to work
run: yarn bazel test //adev:test
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@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
node-module-directories: |
./aio/node_modules
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- 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@e28cf165c92ffef168d23c5c9000cffc8a25e117 # 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'
@@ -110,13 +183,13 @@ jobs:
labels: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Install node modules
run: yarn install --frozen-lockfile
- run: echo "https://${{secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN}}:@github.com" > ${HOME}/.git_credentials
@@ -128,7 +201,7 @@ jobs:
labels: ubuntu-latest-4core
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
node-module-directories: |
@@ -136,9 +209,9 @@ jobs:
./packages/zone.js/node_modules
./packages/zone.js/test/typings/node_modules
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Install node modules
run: yarn install --frozen-lockfile
- run: |
@@ -146,38 +219,40 @@ jobs:
//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: |
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/
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
# 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
- 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
runs-on: ubuntu-latest-4-cores
env:
JOBS: 2
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
# Checking out the pull request commit is intended here as we need to run the changed code tests.
@@ -185,94 +260,16 @@ jobs:
- name: Install node modules
run: yarn install --frozen-lockfile
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel Remote Caching
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- name: Setup Saucelabs Variables
uses: angular/dev-infra/github-actions/saucelabs@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Set up Sauce Tunnel Daemon
run: yarn bazel run //tools/saucelabs-daemon/background-service -- $JOBS &
env:
SAUCE_TUNNEL_IDENTIFIER: angular-framework-${{ github.run_number }}
SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }}
SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
- name: Run all saucelabs bazel tests
run: |
TESTS=$(./node_modules/.bin/bazelisk query --output label '(kind(karma_web_test, ...) intersect attr("tags", "saucelabs", ...)) except attr("tags", "fixme-saucelabs", ...)')
yarn bazel test --config=saucelabs --jobs=$JOBS ${TESTS}
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@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
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@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- name: Setup Saucelabs Variables
uses: angular/dev-infra/github-actions/saucelabs@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- 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@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- name: Install node modules
run: yarn install --frozen-lockfile
- name: Build adev to ensure it continues to work
run: yarn bazel build //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@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
- uses: angular/dev-infra/github-actions/commit-message-based-labels@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- uses: angular/dev-infra/github-actions/commit-message-based-labels@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
post_approval_changes:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
- uses: angular/dev-infra/github-actions/post-approval-changes@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- uses: angular/dev-infra/github-actions/post-approval-changes@031962443584a0ac5cbd9d1c1b78b241453e4702
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@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
+2 -2
View File
@@ -13,8 +13,8 @@ jobs:
statuses: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
- uses: angular/dev-infra/github-actions/google-internal-tests@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
- uses: angular/dev-infra/github-actions/google-internal-tests@031962443584a0ac5cbd9d1c1b78b241453e4702
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@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
- uses: angular/dev-infra/github-actions/unified-status-check@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }}
+4 -4
View File
@@ -22,15 +22,15 @@ jobs:
version: ['https://next.angular.io/', 'https://angular.io/']
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
node-module-directories: |
./aio/node_modules
- name: Setup Bazel
uses: angular/dev-infra/github-actions/bazel/setup@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/setup@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Setup Bazel RBE
uses: angular/dev-infra/github-actions/bazel/configure-remote@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/bazel/configure-remote@031962443584a0ac5cbd9d1c1b78b241453e4702
- name: Install node modules in aio
run: yarn install --frozen-lockfile --cwd aio
- name: Run basic e2e and deployment config tests.
@@ -44,7 +44,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Initialize environment
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
cache-node-modules: true
node-module-directories: |
+4 -4
View File
@@ -25,12 +25,12 @@ jobs:
steps:
- name: 'Checkout code'
uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
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@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3
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@8f596b4ae3cb3c588a5c46780b86dd53fef16c52 # v3.25.2
uses: github/codeql-action/upload-sarif@ddccb873888234080b77e9bc2d4764d5ccaaccf9 # v2.21.9
with:
sarif_file: results.sarif
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
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
@@ -28,11 +28,11 @@ jobs:
# This is needed as otherwise the PR creation will fail with `shallow update not allowed` when the forked branch is not in sync.
fetch-depth: 0
- name: Generate CLI help
run: node adev/scripts/update-cli-help/index.mjs
run: node aio/scripts/update-cli-help/index.mjs
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@f122b83d2a49e24eb9dfcd54fca9fc97612b832d
uses: angular/dev-infra/github-actions/create-pr-for-changes@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
branch-prefix: update-cli-help
pr-title: 'docs: update Angular CLI help [${{github.ref_name}}]'
+48
View File
@@ -0,0 +1,48 @@
# Update the `events.json` file that powers the [AIO events page](https://angular.io/events) (if
# necessary) and create a pull request.
#
# For more details on the overall process, see
# [aio/scripts/generate-events/README.md](../../aio/scripts/generate-events/README.md).
name: Update AIO events
on:
workflow_dispatch:
inputs: {}
schedule:
# Run every day at 15:00.
- cron: '0 15 * * *'
# Declare default permissions as read only.
permissions:
contents: read
jobs:
update_events:
name: Update `events.json` (if necessary)
if: github.repository == 'angular/angular'
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4.1.0
with:
# Setting `persist-credentials: false` prevents the github-action account from being the
# account that is attempted to be used for authentication, instead the remote is set to
# an authenticated URL.
persist-credentials: false
# This is needed as otherwise the PR creation will fail with `shallow update not allowed` when the forked branch is not in sync.
fetch-depth: 0
- name: Generate `events.json`
run: node aio/scripts/generate-events/index.mjs --ignore-invalid-dates
- name: Create a PR (if necessary)
uses: angular/dev-infra/github-actions/create-pr-for-changes@031962443584a0ac5cbd9d1c1b78b241453e4702
with:
branch-prefix: docs-update-events
pr-title: 'docs: update events'
pr-description: |
Generated `events.json` with the latest events retrieved from the Firebase DB.
pr-labels: |
action: review
area: docs
target: patch
angular-robot-token: ${{ secrets.ANGULAR_ROBOT_ACCESS_TOKEN }}
-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 -71
View File
@@ -5,48 +5,7 @@ import {FormatConfig} from '@angular/ng-dev';
*/
export const format: FormatConfig = {
'prettier': {
'matchers': [
'**/*.{yaml,yml}',
'adev/**/*.{js,ts}',
'devtools/**/*.{js,ts}',
'integration/**/*.{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/compiler/**/*.{js,ts}',
'packages/core/**/*.{js,ts}',
'packages/docs/**/*.{js,ts}',
'packages/elements/**/*.{js,ts}',
'packages/examples/**/*.{js,ts}',
'packages/forms/**/*.{js,ts}',
'packages/language-service/**/*.{js,ts}',
'packages/localize/**/*.{js,ts}',
'packages/platform-browser/**/*.{js,ts}',
'packages/platform-browser-dynamic/**/*.{js,ts}',
'packages/platform-server/**/*.{js,ts}',
'packages/misc/**/*.{js,ts}',
'packages/private/**/*.{js,ts}',
'packages/router/**/*.{js,ts}',
'packages/service-worker/**/*.{js,ts}',
'packages/upgrade/**/*.{js,ts}',
'packages/zone.js/**/*.{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/**',
// Do not format the locale files which are checked-in for Google3, but generated using
// the `generate-locales-tool` from `packages/common/locales`.
'!packages/core/src/i18n/locale_en.ts',
'!packages/common/locales/closure-locale.ts',
'!packages/common/src/i18n/currencies.ts',
],
'matchers': ['**/*.{yaml,yml}'],
},
'clang-format': {
'matchers': [
@@ -70,35 +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/compiler/**/*.{js,ts}',
'!packages/core/**/*.{js,ts}',
'!packages/docs/**/*.{js,ts}',
'!packages/elements/**/*.{js,ts}',
'!packages/examples/**/*.{js,ts}',
'!packages/forms/**/*.{js,ts}',
'!packages/language-service/**/*.{js,ts}',
'!packages/localize/**/*.{js,ts}',
'!packages/platform-browser/**/*.{js,ts}',
'!packages/platform-browser-dynamic/**/*.{js,ts}',
'!packages/platform-server/**/*.{js,ts}',
'!packages/misc/**/*.{js,ts}',
'!packages/private/**/*.{js,ts}',
'!packages/router/**/*.{js,ts}',
'!packages/service-worker/**/*.{js,ts}',
'!packages/upgrade/**/*.{js,ts}',
'!packages/zone.js/**/*.{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.20.0
18.13.0
-1
View File
@@ -2,7 +2,6 @@
"printWidth": 100,
"tabWidth": 2,
"tabs": false,
"embeddedLanguageFormatting": "off",
"singleQuote": true,
"semicolon": true,
"quoteProps": "preserve",
+48 -124
View File
@@ -140,7 +140,7 @@ groups:
- alxhub
- crisbeto
- ~jelbourn
- thePunderWoman
- jessicajaniuk
# =========================================================
# Framework: Compiler
@@ -166,9 +166,8 @@ groups:
- AndrewKushnir
- atscott
- crisbeto
- devversion
- dylhunn
- JoostK
- dylhunn
# =========================================================
# Framework: Migrations
@@ -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/**/{*,.*}',
@@ -322,9 +319,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',
@@ -344,10 +339,7 @@ groups:
'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/**/{*,.*}',
])
reviewers:
@@ -356,9 +348,8 @@ groups:
- AndrewKushnir
- atscott
- crisbeto
- devversion
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -378,7 +369,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -415,7 +406,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -438,7 +429,7 @@ groups:
- andrewseguin
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -526,8 +517,10 @@ 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/**/{*,.*}',
'aio/content/guide/universal-ngmodule.md',
'aio/content/examples/universal-ngmodule/**/{*,.*}'
])
reviewers:
users:
@@ -536,7 +529,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -615,7 +608,7 @@ groups:
reviewers:
users:
- alxhub
- thePunderWoman
- jessicajaniuk
# =========================================================
# Framework: Testing
@@ -644,7 +637,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -663,7 +656,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -682,7 +675,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -778,7 +771,7 @@ groups:
- atscott
- crisbeto
- dylhunn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
# =========================================================
@@ -813,7 +806,7 @@ groups:
- AndrewKushnir
- atscott
- dylhunn
- thePunderWoman
- jessicajaniuk
- josephperrott
- pkozlowski-opensource
@@ -867,7 +860,7 @@ groups:
reviewers:
users:
- AndrewKushnir
- thePunderWoman
- jessicajaniuk
# =========================================================
# Docs: Marketing
@@ -939,14 +932,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 +1054,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 +1110,7 @@ groups:
reviewers:
users:
- AndrewKushnir
- thePunderWoman
- jessicajaniuk
- josephperrott
# =========================================================
@@ -1165,7 +1131,6 @@ groups:
- josephperrott
- mgechev
- twerske
- ~JeanMeche
# =========================================================
# Dev-infra
@@ -1200,11 +1165,10 @@ 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 +1207,7 @@ groups:
- atscott
- dylhunn
- ~jelbourn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
reviews:
request: 3 # Request reviews from 3 people
@@ -1269,7 +1233,7 @@ groups:
- atscott
- dylhunn
- ~jelbourn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
reviews:
request: 2 # Request reviews from 2 people
@@ -1295,7 +1259,7 @@ groups:
- atscott
- dylhunn
- ~jelbourn
- thePunderWoman
- jessicajaniuk
- pkozlowski-opensource
####################################################################################
@@ -1319,69 +1283,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: optional
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: optional
labels:
pending: 'requires: TGP'
approved: 'requires: TGP'
rejected: 'requires: TGP'
####################################################################################
# Override managed result groups
#
@@ -1458,6 +1362,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
+5
View File
@@ -5,7 +5,12 @@
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"BazelBuild.vscode-bazel",
"gkalpak.aio-docs-utils",
"ms-vscode.vscode-typescript-tslint-plugin",
"xaver.clang-format",
// The following extensions are useful when working on angular.io (i.e. inside the `aio/` directory).
//"angular.ng-template",
//"dbaeumer.vscode-eslint",
//"errata-ai.vale-server",
],
}
-1
View File
@@ -13,7 +13,6 @@ exports_files([
"scripts/ci/payload-size.sh",
"scripts/ci/payload-size.js",
"package.json",
"angular.json",
])
filegroup(
+320 -1262
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -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 -21
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,10 +61,6 @@ Get started with Angular, learn the fundamentals and explore advanced topics on
- [Lazy Loading][lazyloading]
- [Animations][animations]
### Local Development
To contribute to the Angular Docs, check out the [Angular.dev README](adev/README.md)
## Development Setup
### Prerequisites
@@ -133,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]
@@ -145,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
+44 -15
View File
@@ -2,12 +2,27 @@ workspace(
name = "angular",
managed_directories = {
"@npm": ["node_modules"],
"@aio_npm": ["aio/node_modules"],
},
)
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 = [
@@ -49,17 +64,7 @@ load("@rules_nodejs//nodejs:repositories.bzl", "nodejs_register_toolchains")
nodejs_register_toolchains(
name = "nodejs",
node_repositories = {
"18.20.0-darwin_arm64": ("node-v18.20.0-darwin-arm64.tar.gz", "node-v18.20.0-darwin-arm64", "10066ad4dd9e03ea5c4c45ef8775420ff37b860de09bbdf87b97e0c07b1ea036"),
"18.20.0-darwin_amd64": ("node-v18.20.0-darwin-x64.tar.gz", "node-v18.20.0-darwin-x64", "062ba71618e88e06321de5caa038843c350aababa2d315f3ca7b8551f8e66c1c"),
"18.20.0-linux_arm64": ("node-v18.20.0-linux-arm64.tar.xz", "node-v18.20.0-linux-arm64", "afe51da9ffb38ac1e3a20d9a06efd403ced4bf8831ab554a02a088dd8392fd79"),
"18.20.0-linux_ppc64le": ("node-v18.20.0-linux-ppc64le.tar.xz", "node-v18.20.0-linux-ppc64le", "9e652bbf53a3e37285b11dfb9d6a9bb8b02010c3b50e5c8229d4cc10e72681da"),
"18.20.0-linux_s390x": ("node-v18.20.0-linux-s390x.tar.xz", "node-v18.20.0-linux-s390x", "a6c2a5796b9d9e9bf21da62ec89ff30b41a8108880b9eaa3c912f1ce795a7743"),
"18.20.0-linux_amd64": ("node-v18.20.0-linux-x64.tar.xz", "node-v18.20.0-linux-x64", "03eea148e56785babb27930b05ed6bf311aaa3bc573c0399dd63cad2fe5713c7"),
"18.20.0-windows_amd64": ("node-v18.20.0-win-x64.zip", "node-v18.20.0-win-x64", "1c0aab05cc6836a8f5148cca345b92ebc948a4a2013f18d117b7ade6ff05aca6"),
},
# We need at least Node 18.17 due to some transitive dependencies.
node_version = "18.20.0",
node_version = "18.13.0",
)
# Download npm dependencies.
@@ -75,7 +80,7 @@ yarn_install(
"//:.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-680aab4562e5bb684518ca496cb449a6c447601d.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",
@@ -94,6 +99,30 @@ yarn_install(
yarn_lock = "//:yarn.lock",
)
yarn_install(
name = "aio_npm",
# Note that we add the postinstall scripts here so that the dependencies are re-installed
# when the postinstall patches are modified.
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",
],
# Currently disabled due to:
# 1. Missing Windows support currently.
# 2. Incompatibilites with the `ts_library` rule.
exports_directories_only = False,
manual_build_file_contents = npm_package_archives(),
package_json = "//aio:package.json",
# We prefer to symlink the `node_modules` to only maintain a single install.
# See https://github.com/angular/dev-infra/pull/446#issuecomment-1059820287 for details.
symlink_node_modules = True,
yarn = YARN_LABEL,
yarn_lock = "//aio:yarn.lock",
)
yarn_install(
name = "aio_example_deps",
# Rename the default js_library target from "node_modules" as this obscures the
@@ -167,10 +196,10 @@ cldr_xml_data_repository(
# sass rules
http_archive(
name = "io_bazel_rules_sass",
sha256 = "ca27b3dcd294b134ccc9d0a1c6b63b810e115204e87a2b3d4cc247f5676d0a85",
strip_prefix = "rules_sass-c3f163fd9f570846c2aae4bf16ac69b4217e6f3b",
sha256 = "923b2c470e981b6e96b17e5b9ec7a6141eab808d52ebffdbd94695f5a42a5184",
strip_prefix = "rules_sass-9a327e5badc3fdd5141479410675c356133e1e41",
urls = [
"https://github.com/bazelbuild/rules_sass/archive/c3f163fd9f570846c2aae4bf16ac69b4217e6f3b.zip",
"https://github.com/bazelbuild/rules_sass/archive/9a327e5badc3fdd5141479410675c356133e1e41.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/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",
],
)
-37
View File
@@ -1,37 +0,0 @@
# [Angular.dev](https://www.angular.dev)
This site is built with Angular.
The content is written primarly in Markdown format located in `src/content`. For simple edits, you can directly edit the file on GitHub and generate a Pull Request.
## Local Development
For local development, [yarn](https://yarnpkg.com/) is the preferred package manager. 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
```
## Contributing
Want to report a bug, contribute some code, or improve the documentation? Excellent!
Read through our [contributing guidelines](CONTRIBUTING.md) to learn about our submission process, coding rules, and more.
And if you're new, check out one of our issues labeled as <kbd>[help wanted](https://github.com/angular/angular/labels/help%20wanted)</kbd> or <kbd>[good first issue](https://github.com/angular/angular/labels/good%20first%20issue)</kbd>.
### Code of Conduct
Help us keep Angular open and inclusive. Please read and follow our [Code of Conduct](CODE_OF_CONDUCT.md).
-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;
}
-3
View File
@@ -1,3 +0,0 @@
# Generating data for `angular.dev/cli`
This script updates the Angular CLI help JSON files stored in `adev/src/content/cli/help`. This files are used to generate the [angular.dev CLI](https://angular.dev/cli) pages.
-154
View File
@@ -1,154 +0,0 @@
import {execSync} from 'node:child_process';
import {readFile, writeFile, readdir, mkdtemp, realpath, copyFile, unlink} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import {get} from 'node:https';
import {dirname, resolve as resolvePath, posix, join} from 'node:path';
import {fileURLToPath} from 'node:url';
import {existsSync, constants as fsConstants} from 'node:fs';
const GITHUB_API = 'https://api.github.com/repos/';
const CLI_BUILDS_REPO = 'angular/cli-builds';
const GITHUB_API_CLI_BUILDS = posix.join(GITHUB_API, CLI_BUILDS_REPO);
const scriptDir = dirname(fileURLToPath(import.meta.url));
const CLI_HELP_CONTENT_PATH = resolvePath(scriptDir, '../../src/content/cli/help');
const CLI_SHA_PATH = join(CLI_HELP_CONTENT_PATH, 'build-info.json');
async function main() {
if (!existsSync(CLI_SHA_PATH)) {
throw new Error(`${CLI_SHA_PATH} does not exist.`);
}
const branch = process.env.GITHUB_REF;
const {sha: currentSha} = JSON.parse(await readFile(CLI_SHA_PATH, 'utf-8'));
const latestSha = await getShaFromCliBuilds(branch);
console.log(`Comparing ${currentSha}...${latestSha}.`);
const affectedFiles = await getAffectedFiles(currentSha, latestSha);
const changedHelpFiles = affectedFiles.filter((file) => file.startsWith('help/'));
if (changedHelpFiles.length === 0) {
console.log(`No 'help/**' files changed between ${currentSha} and ${latestSha}.`);
return;
}
console.log(
`The below help files changed between ${currentSha} and ${latestSha}:\n` +
changedHelpFiles.map((f) => '* ' + f).join('\n'),
);
const temporaryDir = await realpath(await mkdtemp(join(tmpdir(), 'cli-src-')));
const execOptions = {cwd: temporaryDir, stdio: 'inherit'};
execSync('git init', execOptions);
execSync('git remote add origin https://github.com/angular/cli-builds.git', execOptions);
// fetch a commit
execSync(`git fetch origin ${latestSha}`, execOptions);
// reset this repository's main branch to the commit of interest
execSync('git reset --hard FETCH_HEAD', execOptions);
// get sha when files where changed
const shaWhenFilesChanged = execSync(`git rev-list -1 ${latestSha} "help/"`, {
encoding: 'utf8',
cwd: temporaryDir,
stdio: ['ignore', 'pipe', 'ignore'],
}).trim();
// Delete existing JSON help files.
const helpFilesUnlink = (await readdir(CLI_HELP_CONTENT_PATH))
.filter((f) => f.endsWith('.json'))
.map((f) => unlink(join(CLI_HELP_CONTENT_PATH, f)));
await Promise.allSettled(helpFilesUnlink);
// Copy new help files
const tempHelpDir = join(temporaryDir, 'help');
const helpFilesCopy = (await readdir(tempHelpDir)).map((f) => {
const src = join(tempHelpDir, f);
const dest = join(CLI_HELP_CONTENT_PATH, f);
return copyFile(src, dest, fsConstants.COPYFILE_FICLONE);
});
await Promise.allSettled(helpFilesCopy);
// Write SHA to file.
await writeFile(
CLI_SHA_PATH,
JSON.stringify(
{
branchName: branch,
sha: shaWhenFilesChanged,
},
undefined,
2,
),
);
console.log('\nChanges: ');
execSync(`git status --porcelain`, {stdio: 'inherit'});
console.log(`Successfully updated help files in '${CLI_HELP_CONTENT_PATH}'.\n`);
}
/**
* Get SHA of a branch.
*
* @param {string} branch
* @param {string} headSha
* @returns Promise<string>
*/
async function getShaFromCliBuilds(branch) {
const sha = await httpGet(`${GITHUB_API_CLI_BUILDS}/commits/${branch}`, {
headers: {Accept: 'application/vnd.github.VERSION.sha'},
});
if (!sha) {
throw new Error(`Unable to extract the SHA for '${branch}'.`);
}
return sha;
}
/**
* Get the affected files.
*
* @param {string} baseSha
* @param {string} headSha
* @returns Promise<string[]>
*/
async function getAffectedFiles(baseSha, headSha) {
const {files} = JSON.parse(
await httpGet(`${GITHUB_API_CLI_BUILDS}/compare/${baseSha}...${headSha}`),
);
return files.map((f) => f.filename);
}
function httpGet(url, options = {}) {
options.headers ??= {};
options.headers[
'Authorization'
] = `token ${process.env.ANGULAR_CLI_BUILDS_READONLY_GITHUB_TOKEN}`;
// User agent is required
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api?apiVersion=2022-11-28#user-agent-required
options.headers['User-Agent'] = `AIO_Angular_CLI_Sources_Update`;
return new Promise((resolve, reject) => {
get(url, options, (res) => {
let data = '';
res
.on('data', (chunk) => {
data += chunk;
})
.on('end', () => {
resolve(data);
});
}).on('error', (e) => {
reject(e);
});
});
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
-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,92 +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, LOCAL_STORAGE} from '@angular/docs';
import {AnalyticsService} from './analytics.service';
import {MockLocalStorage} from '@angular/docs/testing';
describe('AnalyticsService', () => {
let service: AnalyticsService;
let injector: Injector;
let gtagSpy: jasmine.Spy;
let gtagAppendNodeSpy: jasmine.Spy;
let windowOnErrorHandler: (event: ErrorEvent) => void;
let mockWindow: any;
let mockLocalStorage = new MockLocalStorage();
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: []},
{provide: LOCAL_STORAGE, useValue: mockLocalStorage},
],
});
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,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 {inject, Injectable} from '@angular/core';
import {WINDOW, ENVIRONMENT, LOCAL_STORAGE, STORAGE_KEY, setCookieConsent} 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);
private readonly localStorage = inject(LOCAL_STORAGE);
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);
};
// Cookie banner consent initial state
// This code is modified in the @angular/docs package in the cookie-popup component.
// Docs: https://developers.google.com/tag-platform/security/guides/consent
if (this.localStorage) {
if (this.localStorage.getItem(STORAGE_KEY) === 'true') {
setCookieConsent('granted');
} else {
setCookieConsent('denied');
}
} else {
// In case localStorage is not available, we default to denying cookies.
setCookieConsent('denied');
}
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,73 +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 'v17';
// Temporarily commenting out till this works correctly
// 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 reset, 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 environments 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(),
};

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