diff --git a/.bazelrc b/.bazelrc index bf195b6548ef..126ca0493e54 100644 --- a/.bazelrc +++ b/.bazelrc @@ -83,14 +83,14 @@ test:saucelabs --define=KARMA_WEB_TEST_MODE=SL_REQUIRED # Releases should always be stamped with version control info # This command assumes node on the path and is a workaround for # https://github.com/bazelbuild/bazel/issues/4802 -build:release --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=release" +build:release --workspace_status_command="yarn ng-dev release build-env-stamp --mode=release" build:release --stamp -build:snapshot --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=snapshot" +build:snapshot --workspace_status_command="yarn ng-dev release build-env-stamp --mode=snapshot" build:snapshot --stamp build:snapshot --//:enable_snapshot_repo_deps -build:e2e --workspace_status_command="yarn -s ng-dev release build-env-stamp --mode=release" +build:e2e --workspace_status_command="yarn ng-dev release build-env-stamp --mode=release" build:e2e --stamp test:e2e --test_timeout=3600 --experimental_ui_max_stdouterr_bytes=2097152 diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 3be6763ed0db..000000000000 --- a/.eslintignore +++ /dev/null @@ -1,14 +0,0 @@ -/bazel-out/ -/dist-schema/ -/goldens/public-api -/packages/angular_devkit/build_angular/src/babel-bazel.d.ts -/packages/angular_devkit/build_angular/test/ -/packages/angular_devkit/build_webpack/test/ -/packages/angular_devkit/schematics_cli/blank/project-files/ -/packages/angular_devkit/schematics_cli/blank/schematic-files/ -/packages/angular_devkit/schematics_cli/schematic/files/ -/tests/ -.yarn/ -dist/ -node_modules/ -third_party/ \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index fe7d80c8449d..8d909f8950c9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -11,6 +11,23 @@ "plugin:@typescript-eslint/recommended-requiring-type-checking", "prettier" ], + "ignorePatterns": [ + "bazel-out", + "dist-schema", + "goldens/public-api", + "modules/testing/builder/projects", + "packages/angular_devkit/build_angular/src/babel-bazel.d.ts", + "packages/angular_devkit/build_angular/test", + "packages/angular_devkit/build_webpack/test", + "packages/angular_devkit/schematics_cli/blank/project-files", + "packages/angular_devkit/schematics_cli/blank/schematic-files", + "packages/angular_devkit/schematics_cli/schematic/files", + "**/tests", + ".yarn", + "dist", + "**/node_modules", + "**/third_party" + ], "parser": "@typescript-eslint/parser", "parserOptions": { "project": "tsconfig.json", @@ -33,7 +50,7 @@ " * Copyright Google LLC All Rights Reserved.", " *", " * Use of this source code is governed by an MIT-style license that can be", - " * found in the LICENSE file at https://angular.io/license", + " * found in the LICENSE file at https://angular.dev/license", " " ], 2 @@ -83,30 +100,27 @@ ], /* TODO: evaluate usage of these rules and fix issues as needed */ - "no-case-declarations": "off", - "no-fallthrough": "off", - "no-underscore-dangle": "off", - "@typescript-eslint/await-thenable": "off", "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-empty-function": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-implied-eval": "off", "@typescript-eslint/no-var-requires": "off", - "@typescript-eslint/no-unnecessary-type-assertion": "off", "@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/no-unsafe-return": "off", "@typescript-eslint/no-unused-vars": "off", - "@typescript-eslint/prefer-regexp-exec": "off", "@typescript-eslint/require-await": "off", "@typescript-eslint/restrict-plus-operands": "off", "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/unbound-method": "off", "@typescript-eslint/no-unsafe-enum-comparison": "off", "@typescript-eslint/no-redundant-type-constituents": "off", - "@typescript-eslint/no-base-to-string": "off" + "@typescript-eslint/no-base-to-string": "off", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/prefer-promise-reject-errors": "off", + "@typescript-eslint/only-throw-error": "off", + "@typescript-eslint/no-unsafe-function-type": "off" }, "overrides": [ { @@ -114,6 +128,7 @@ "rules": { "import/no-extraneous-dependencies": ["error", { "devDependencies": true }], "max-lines-per-function": "off", + "no-case-declarations": "off", "no-console": "off" } } diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5764ed46e6a7..898698af3906 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,10 +1,10 @@ blank_issues_enabled: false contact_links: - - name: Docs or angular.io issue report + - name: Docs or angular.dev issue report url: https://github.com/angular/angular/issues/new - about: Report an issue in Angular's documentation or angular.io application + about: Report an issue in Angular's documentation or angular.dev application - name: Security issue disclosure - url: https://angular.io/guide/security#report-issues + url: https://angular.dev/best-practices/security about: Report a security issue in Angular Framework, Material, or CLI - name: Support request url: https://github.com/angular/angular-cli/blob/main/CONTRIBUTING.md#question diff --git a/.github/workflows/assistant-to-the-branch-manager.yml b/.github/workflows/assistant-to-the-branch-manager.yml index 11f099f38c37..560abdc2294f 100644 --- a/.github/workflows/assistant-to-the-branch-manager.yml +++ b/.github/workflows/assistant-to-the-branch-manager.yml @@ -13,9 +13,9 @@ jobs: assistant_to_the_branch_manager: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: persist-credentials: false - - uses: angular/dev-infra/github-actions/branch-manager@96a8277d21eb61a2370061717ffa8dee5668caa0 + - uses: angular/dev-infra/github-actions/branch-manager@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d11c6e6cc77..6ae8b75e0b30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,6 @@ on: - main - '[0-9]+.[0-9]+.x' - # Developers can make one-off pushes to `ci-*` branches to manually trigger full CI - # prior to opening a pull request. - - ci-* - pull_request: - types: [opened, synchronize, reopened] - concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -23,159 +17,145 @@ defaults: shell: bash jobs: - analyze: - runs-on: ubuntu-latest - outputs: - snapshots: ${{ steps.filter.outputs.snapshots }} - steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - with: - persist-credentials: false - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - id: filter - with: - filters: | - snapshots: - - 'tests/legacy-cli/e2e/ng-snapshot/package.json' - lint: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@96a8277d21eb61a2370061717ffa8dee5668caa0 - - name: Setup ESLint Caching - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 # v4.0.1 - with: - path: .eslintcache - key: ${{ runner.os }}-${{ hashFiles('.eslintrc.json') }} + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Install node modules - run: yarn install --frozen-lockfile --ignore-scripts + run: yarn install --immutable + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: yarn admin build-schema - name: Run ESLint run: yarn lint --cache-strategy content - name: Validate NgBot Configuration run: yarn ng-dev ngbot verify - name: Validate Circular Dependencies - run: yarn ts-circular-deps:check + run: yarn ts-circular-deps check - name: Run Validation - run: yarn -s admin validate + run: yarn admin validate - name: Check tooling setup - run: yarn -s check-tooling-setup - - name: Check commit message - # Commit message validation is only done on pull requests as its too late to validate once - # it has been merged. - if: github.event_name == 'pull_request' - run: yarn ng-dev commit-message validate-range ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} - - name: Check code format - # Code formatting checks are only done on pull requests as its too late to validate once - # it has been merged. - if: github.event_name == 'pull_request' - run: yarn ng-dev format changed --check ${{ github.event.pull_request.base.sha }} + run: yarn check-tooling-setup build: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Install node modules - run: yarn install --frozen-lockfile + run: yarn install --immutable - name: Build release targets run: yarn ng-dev release build - - name: Store PR release packages - if: github.event_name == 'pull_request' - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 - with: - name: packages - path: dist/releases/*.tgz - retention-days: 14 test: runs-on: ubuntu-latest steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Install node modules - run: yarn install --frozen-lockfile - - name: Run tests - run: yarn bazel test //packages/... + run: yarn install --immutable + - name: Run module and package tests + run: yarn bazel test //modules/... //packages/... e2e: strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - node: [18, 20] - subset: [npm, yarn, esbuild] + node: [18, 20, 22] + subset: [npm, esbuild] shard: [0, 1, 2, 3, 4, 5] exclude: - # Exclude Node.js v18 when running on a PR - - node: ${{ github.event_name != 'pull_request' && 'none' || '18' }} - # Exclude Windows when running on a PR - - os: ${{ github.event_name != 'pull_request' && 'none' || 'windows-latest' }} # Skip yarn subset on Windows - os: windows-latest subset: yarn - # Skip node 18 tests on Windows + # Skip pnpm subset on Windows + - os: windows-latest + subset: pnpm + # Skip Node.js v18 tests on Windows - os: windows-latest node: 18 + # Skip Node.js v20 tests on Windows + - os: windows-latest + node: 20 runs-on: ${{ matrix.os }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Install node modules - run: yarn install --frozen-lockfile + run: yarn install --immutable - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Run CLI E2E tests run: yarn bazel test --define=E2E_SHARD_TOTAL=6 --define=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests/legacy-cli:e2e.${{ matrix.subset }}_node${{ matrix.node }} + e2e-package-managers: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [22] + subset: [yarn, pnpm] + shard: [0, 1, 2] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Install node modules + run: yarn install --immutable + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Run CLI E2E tests + run: yarn bazel test --define=E2E_SHARD_TOTAL=3 --define=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests/legacy-cli:e2e.${{ matrix.subset }}_node${{ matrix.node }} + e2e-snapshots: - needs: analyze - if: needs.analyze.outputs.snapshots == 'true' || github.event_name == 'push' strategy: fail-fast: false matrix: os: [ubuntu-latest] node: [18] - subset: [npm, yarn, esbuild] + subset: [npm, esbuild] shard: [0, 1, 2, 3, 4, 5] runs-on: ${{ matrix.os }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Install node modules - run: yarn install --frozen-lockfile + run: yarn install --immutable - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Run CLI E2E tests run: yarn bazel test --define=E2E_SHARD_TOTAL=6 --define=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests/legacy-cli:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} browsers: - if: github.event_name == 'push' runs-on: ubuntu-latest name: Browser Compatibility Tests env: SAUCE_TUNNEL_IDENTIFIER: angular-cli-${{ github.workflow }}-${{ github.run_number }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Install node modules - run: yarn install --frozen-lockfile + run: yarn install --immutable - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Setup Bazel RBE - uses: angular/dev-infra/github-actions/bazel/configure-remote@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Run E2E Browser tests env: SAUCE_USERNAME: ${{ vars.SAUCE_USERNAME }} @@ -190,24 +170,23 @@ jobs: ./scripts/saucelabs/wait-for-tunnel.sh yarn bazel test --config=saucelabs //tests/legacy-cli:e2e.saucelabs ./scripts/saucelabs/stop-tunnel.sh - - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + - uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 if: ${{ failure() }} with: name: sauce-connect-log path: ${{ env.SAUCE_CONNECT_DIR_IN_HOST }}/sauce-connect.log publish-snapshots: - if: github.event_name == 'push' runs-on: ubuntu-latest env: CIRCLE_BRANCH: ${{ github.ref_name }} steps: - name: Initialize environment - uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - name: Install node modules - run: yarn install --frozen-lockfile + run: yarn install --immutable - name: Setup Bazel - uses: angular/dev-infra/github-actions/bazel/setup@96a8277d21eb61a2370061717ffa8dee5668caa0 + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 - run: yarn admin snapshots --verbose env: SNAPSHOT_BUILDS_GITHUB_TOKEN: ${{ secrets.SNAPSHOT_BUILDS_GITHUB_TOKEN }} diff --git a/.github/workflows/dev-infra.yml b/.github/workflows/dev-infra.yml index 445c90a16787..f38e14c62c72 100644 --- a/.github/workflows/dev-infra.yml +++ b/.github/workflows/dev-infra.yml @@ -12,14 +12,14 @@ jobs: labels: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - uses: angular/dev-infra/github-actions/commit-message-based-labels@96a8277d21eb61a2370061717ffa8dee5668caa0 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: angular/dev-infra/github-actions/commit-message-based-labels@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} post_approval_changes: runs-on: ubuntu-latest steps: - - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - - uses: angular/dev-infra/github-actions/post-approval-changes@96a8277d21eb61a2370061717ffa8dee5668caa0 + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - uses: angular/dev-infra/github-actions/post-approval-changes@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/feature-requests.yml b/.github/workflows/feature-requests.yml index aba8e451b23a..7818868ad6f8 100644 --- a/.github/workflows/feature-requests.yml +++ b/.github/workflows/feature-requests.yml @@ -16,6 +16,6 @@ jobs: if: github.repository == 'angular/angular-cli' runs-on: ubuntu-latest steps: - - uses: angular/dev-infra/github-actions/feature-request@96a8277d21eb61a2370061717ffa8dee5668caa0 + - uses: angular/dev-infra/github-actions/feature-request@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 with: angular-robot-key: ${{ secrets.ANGULAR_ROBOT_PRIVATE_KEY }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 000000000000..d488d8802ce9 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,166 @@ +name: Pull Request + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: {} + +defaults: + run: + shell: bash + +jobs: + analyze: + runs-on: ubuntu-latest + outputs: + snapshots: ${{ steps.filter.outputs.snapshots }} + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + with: + persist-credentials: false + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + snapshots: + - 'tests/legacy-cli/e2e/ng-snapshot/package.json' + + lint: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup ESLint Caching + uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4.0.2 + with: + path: .eslintcache + key: ${{ runner.os }}-${{ hashFiles('.eslintrc.json') }} + - name: Install node modules + run: yarn install --immutable + - name: Generate JSON schema types + # Schema types are required to correctly lint the TypeScript code + run: yarn admin build-schema + - name: Run ESLint + run: yarn lint --cache-strategy content + - name: Validate NgBot Configuration + run: yarn ng-dev ngbot verify + - name: Validate Circular Dependencies + run: yarn ts-circular-deps check + - name: Run Validation + run: yarn admin validate + - name: Check Package Licenses + uses: angular/dev-infra/github-actions/linting/licenses@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Check tooling setup + run: yarn check-tooling-setup + - name: Check commit message + # Commit message validation is only done on pull requests as its too late to validate once + # it has been merged. + run: yarn ng-dev commit-message validate-range ${{ github.event.pull_request.base.sha }} ${{ github.event.pull_request.head.sha }} + - name: Check code format + # Code formatting checks are only done on pull requests as its too late to validate once + # it has been merged. + run: yarn ng-dev format changed --check ${{ github.event.pull_request.base.sha }} + + build: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Install node modules + run: yarn install --immutable + - name: Build release targets + run: yarn ng-dev release build + - name: Store PR release packages + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 + with: + name: packages + path: dist/releases/*.tgz + retention-days: 14 + + test: + runs-on: ubuntu-latest + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Install node modules + run: yarn install --immutable + - name: Run module and package tests + run: yarn bazel test //modules/... //packages/... + + e2e: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [22] + subset: [npm, esbuild] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Install node modules + run: yarn install --immutable + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Run CLI E2E tests + run: yarn bazel test --define=E2E_SHARD_TOTAL=6 --define=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests/legacy-cli:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-package-managers: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [22] + subset: [yarn, pnpm] + shard: [0, 1, 2] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Install node modules + run: yarn install --immutable + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Run CLI E2E tests + run: yarn bazel test --define=E2E_SHARD_TOTAL=3 --define=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests/legacy-cli:e2e.${{ matrix.subset }}_node${{ matrix.node }} + + e2e-snapshots: + needs: analyze + if: needs.analyze.outputs.snapshots == 'true' + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + node: [18] + subset: [npm, esbuild] + shard: [0, 1, 2, 3, 4, 5] + runs-on: ${{ matrix.os }} + steps: + - name: Initialize environment + uses: angular/dev-infra/github-actions/npm/checkout-and-setup-node@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Install node modules + run: yarn install --immutable + - name: Setup Bazel + uses: angular/dev-infra/github-actions/bazel/setup@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Setup Bazel RBE + uses: angular/dev-infra/github-actions/bazel/configure-remote@d66f2009955fd4b3430d9cf7072d94f4b4da95e7 + - name: Run CLI E2E tests + run: yarn bazel test --define=E2E_SHARD_TOTAL=6 --define=E2E_SHARD_INDEX=${{ matrix.shard }} --config=e2e //tests/legacy-cli:e2e.snapshots.${{ matrix.subset }}_node${{ matrix.node }} diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 3f29feb6e4d3..da6b0e914ea5 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -25,12 +25,12 @@ jobs: steps: - name: 'Checkout code' - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 with: persist-credentials: false - name: 'Run analysis' - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 with: results_file: results.sarif results_format: sarif @@ -38,7 +38,7 @@ jobs: # Upload the results as artifacts. - name: 'Upload artifact' - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: name: SARIF file path: results.sarif @@ -46,6 +46,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: 'Upload to code-scanning' - uses: github/codeql-action/upload-sarif@8a470fddafa5cbb6266ee11b37ef4d8aae19c571 # v3.24.6 + uses: github/codeql-action/upload-sarif@eb055d739abdc2e8de2e5f4ba1a8b246daa779aa # v3.26.0 with: sarif_file: results.sarif diff --git a/.gitignore b/.gitignore index 91652321da0e..de3ad9a9154d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,14 @@ test-project-host-* dist/ dist-schema/ +# Yarn +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + # IDEs jsconfig.json diff --git a/.husky/commit-msg b/.husky/commit-msg index e2d00084b924..b5875311bb33 100755 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1 @@ -yarn -s ng-dev commit-message pre-commit-validate --file $1; +yarn ng-dev commit-message pre-commit-validate --file $1; diff --git a/.husky/pre-commit b/.husky/pre-commit index 05a8507f6444..975be1d51585 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1 @@ -yarn -s ng-dev format staged; \ No newline at end of file +yarn ng-dev format staged; \ No newline at end of file diff --git a/.husky/prepare-commit-msg b/.husky/prepare-commit-msg index 39e363e86656..09f489cdad08 100755 --- a/.husky/prepare-commit-msg +++ b/.husky/prepare-commit-msg @@ -1 +1 @@ -yarn -s ng-dev commit-message restore-commit-message-draft $1 $2; +yarn ng-dev commit-message restore-commit-message-draft $1 $2; diff --git a/.idea/angular-cli.iml b/.idea/angular-cli.iml deleted file mode 100644 index cff4053c5974..000000000000 --- a/.idea/angular-cli.iml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 28a804d8932a..000000000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 6d8c965387b0..000000000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/.idea/runConfigurations/Large_Tests.xml b/.idea/runConfigurations/Large_Tests.xml deleted file mode 100644 index 3d4f25fb3a76..000000000000 --- a/.idea/runConfigurations/Large_Tests.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - ', + ); + indexFileContent.toContain(' { await harness.modifyFile('src/tsconfig.app.json', (content) => { const tsConfig = JSON.parse(content); tsConfig.files ??= []; - tsConfig.files.push('main.server.ts', 'server.ts'); + tsConfig.files.push('main.server.ts'); return JSON.stringify(tsConfig); }); - await harness.writeFile('src/server.ts', `console.log('Hello!');`); - harness.useTarget('build', { ...BASE_OPTIONS, server: 'src/main.server.ts', @@ -57,7 +55,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/server/main.server.mjs').toExist(); harness - .expectFile('dist/browser/index.html') + .expectFile('dist/browser/index.csr.html') .content.not.toMatch(//); }); }); diff --git a/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts new file mode 100644 index 000000000000..2f360047b278 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/loader-import-attribute_spec.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "loader import attribute"', () => { + beforeEach(async () => { + await harness.modifyFile('tsconfig.json', (content) => { + return content.replace('"module": "ES2022"', '"module": "esnext"'); + }); + }); + + it('should inline text content for loader attribute set to "text"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "text" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('ABC'); + }); + + it('should inline binary content for loader attribute set to "binary"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "binary" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + // Should contain the binary encoding used esbuild and not the text content + harness.expectFile('dist/browser/main.js').content.toContain('__toBinary("QUJD")'); + harness.expectFile('dist/browser/main.js').content.not.toContain('ABC'); + }); + + it('should emit an output file for loader attribute set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + harness.expectFile('dist/browser/media/a.unknown').toExist(); + }); + + it('should emit an output file with hashing when enabled for loader attribute set to "file"', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + outputHashing: 'media' as any, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.unknown'); + expect(harness.hasFileMatch('dist/browser/media', /a-[0-9A-Z]{8}\.unknown$/)).toBeTrue(); + }); + + it('should allow overriding default `.txt` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.txt', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.txt" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.txt'); + harness.expectFile('dist/browser/media/a.txt').toExist(); + }); + + it('should allow overriding default `.js` extension behavior', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.js', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.js" with { loader: "file" };\n console.log(contents);', + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/main.js').content.toContain('a.js'); + harness.expectFile('dist/browser/media/a.js').toExist(); + }); + + it('should fail with an error if an invalid loader attribute value is used', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + await harness.writeFile('./src/a.unknown', 'ABC'); + await harness.writeFile( + 'src/main.ts', + '// @ts-expect-error\nimport contents from "./a.unknown" with { loader: "invalid" };\n console.log(contents);', + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unsupported loader import attribute'), + }), + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts index b573a9103489..a252a0580d0b 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-component_styles_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { concatMap, count, take, timeout } from 'rxjs'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-errors_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-errors_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts index 416f3d3fb5c9..3153e0bd659a 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-errors_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-errors_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; @@ -315,7 +315,7 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { }); const buildCount = await harness - .execute({ outputLogsOnFailure: true }) + .execute({ outputLogsOnFailure: false }) .pipe( timeout(BUILD_TIMEOUT), concatMap(async ({ result, logs }, index) => { diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts new file mode 100644 index 000000000000..efa632bf6574 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-general_spec.ts @@ -0,0 +1,111 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { concatMap, count, take, timeout } from 'rxjs'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuild updates in general cases"', () => { + it('detects changes after a file was deleted and recreated', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const fileAContent = ` + console.log('FILE-A'); + export {}; + `; + + // Create a file and add to application + await harness.writeFile('src/app/file-a.ts', fileAContent); + await harness.writeFile( + 'src/app/app.component.ts', + ` + import { Component } from '@angular/core'; + import './file-a'; + @Component({ + selector: 'app-root', + template: 'App component', + }) + export class AppComponent { } + `, + ); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(BUILD_TIMEOUT), + concatMap(async ({ result, logs }, index) => { + switch (index) { + case 0: + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/main.js').content.toContain('FILE-A'); + + // Delete the imported file + await harness.removeFile('src/app/file-a.ts'); + + break; + case 1: + // Should fail from missing import + expect(result?.success).toBeFalse(); + + // Remove the failing import + await harness.modifyFile('src/app/app.component.ts', (content) => + content.replace(`import './file-a';`, ''), + ); + + break; + case 2: + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.not.toContain('FILE-A'); + + // Recreate the file and the import + await harness.writeFile('src/app/file-a.ts', fileAContent); + await harness.modifyFile( + 'src/app/app.component.ts', + (content) => `import './file-a';\n` + content, + ); + + break; + case 3: + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toContain('FILE-A'); + + // Change the imported file + await harness.modifyFile('src/app/file-a.ts', (content) => + content.replace('FILE-A', 'FILE-B'), + ); + + break; + case 4: + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js').content.toContain('FILE-B'); + + break; + } + }), + take(5), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(5); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts index 82abac783c16..e58b2e031a90 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-global_styles_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { concatMap, count, take, timeout } from 'rxjs'; diff --git a/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts new file mode 100644 index 000000000000..df9dbc6f0c93 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-index-html_spec.ts @@ -0,0 +1,71 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { concatMap, count, take, timeout } from 'rxjs'; +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Maximum time in milliseconds for single build/rebuild + * This accounts for CI variability. + */ +export const BUILD_TIMEOUT = 30_000; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Rebuilds when input index HTML changes"', () => { + beforeEach(async () => { + // Application code is not needed for styles tests + await harness.writeFile('src/main.ts', 'console.log("TEST");'); + }); + + it('rebuilds output index HTML', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + watch: true, + }); + + const buildCount = await harness + .execute({ outputLogsOnFailure: false }) + .pipe( + timeout(30000), + concatMap(async ({ result }, index) => { + switch (index) { + case 0: + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('charset="utf-8"'); + + await harness.modifyFile('src/index.html', (content) => + content.replace('charset="utf-8"', 'abc'), + ); + break; + case 1: + expect(result?.success).toBe(true); + harness + .expectFile('dist/browser/index.html') + .content.not.toContain('charset="utf-8"'); + + await harness.modifyFile('src/index.html', (content) => + content.replace('abc', 'charset="utf-8"'), + ); + break; + case 2: + expect(result?.success).toBe(true); + harness.expectFile('dist/browser/index.html').content.toContain('charset="utf-8"'); + + break; + } + }), + take(3), + count(), + ) + .toPromise(); + + expect(buildCount).toBe(3); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts index 8e6fc0136864..00385d7e8793 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/rebuild-web-workers_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; diff --git a/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts new file mode 100644 index 000000000000..229636f0b8f8 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/stylesheet-url-resolution_spec.ts @@ -0,0 +1,404 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { OutputHashing } from '../../schema'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Stylesheet url() Resolution"', () => { + it('should show a note when using tilde prefix in a directly referenced stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + expect(logs).not.toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Preprocessor stylesheets may not show the exact'), + }), + ); + }); + + it('should show a note when using tilde prefix in an imported CSS stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + @import "a.css"; + `, + ); + await harness.writeFile( + 'src/a.css', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + }); + + it('should show a note when using tilde prefix in an imported Sass stylesheet', async () => { + await harness.writeFile( + 'src/styles.scss', + ` + @import "a"; + `, + ); + await harness.writeFile( + 'src/a.scss', + ` + .a { + background-image: url("~/image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the tilde and'), + }), + ); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Preprocessor stylesheets may not show the exact'), + }), + ); + }); + + it('should show a note when using caret prefix in a directly referenced stylesheet', async () => { + await harness.writeFile( + 'src/styles.css', + ` + .a { + background-image: url("^image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the caret and'), + }), + ); + }); + + it('should show a note when using caret prefix in an imported Sass stylesheet', async () => { + await harness.writeFile( + 'src/styles.scss', + ` + @import "a"; + `, + ); + await harness.writeFile( + 'src/a.scss', + ` + .a { + background-image: url("^image.jpg") + } + `, + ); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBe(false); + + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('You can remove the caret and'), + }), + ); + }); + + it('should not rebase a URL with a namespaced Sass variable reference that points to an absolute asset', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named; + .a { + background-image: url(named.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "https://example.com/example.png";`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain('url(https://example.com/example.png)'); + }); + + it('should not rebase a URL with a Sass variable reference that points to an absolute asset', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url($my-var) + } + `, + 'src/theme/b.scss': `$my-var: "https://example.com/example.png";`, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain('url(https://example.com/example.png)'); + }); + + it('should rebase a URL with a namespaced Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @use './b' as named; + .a { + background-image: url(named.$my-var) + } + `, + 'src/theme/b.scss': `@forward './c.scss' show $my-var;`, + 'src/theme/c.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with a Sass variable referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url($my-var) + } + `, + 'src/theme/b.scss': `$my-var: "./images/logo.svg";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with an leading interpolation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url(#{$my-var}logo.svg) + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should rebase a URL with interpolation using concatenation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + $extra-var: "2"; + $postfix-var: "xyz"; + .a { + background-image: url("#{$my-var}logo#{$extra-var+ "-" + $postfix-var}.svg") + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo2-xyz.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness + .expectFile('dist/browser/styles.css') + .content.toContain(`url("./media/logo2-xyz.svg")`); + harness.expectFile('dist/browser/media/logo2-xyz.svg').toExist(); + }); + + it('should rebase a URL with an non-leading interpolation referencing a local resource', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + background-image: url(./#{$my-var}logo.svg) + } + `, + 'src/theme/b.scss': `$my-var: "./images/";`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should not rebase Sass function definition with name ending in "url"', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + @import './b'; + .a { + $asset: my-function-url('logo'); + background-image: url($asset) + } + `, + 'src/theme/b.scss': `@function my-function-url($name) { @return "./images/" + $name + ".svg"; }`, + 'src/theme/images/logo.svg': ``, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url("./media/logo.svg")`); + harness.expectFile('dist/browser/media/logo.svg').toExist(); + }); + + it('should not process a URL that has been marked as external', async () => { + await harness.writeFiles({ + 'src/styles.scss': `@use 'theme/a';`, + 'src/theme/a.scss': ` + .a { + background-image: url("assets/logo.svg") + } + `, + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + outputHashing: OutputHashing.None, + externalDependencies: ['assets/*'], + styles: ['src/styles.scss'], + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/styles.css').content.toContain(`url(assets/logo.svg)`); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts index 4dba60311fd8..16e5f8b88e60 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/stylesheet_autoprefixer_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts new file mode 100644 index 000000000000..2c73e66d9f8b --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-incremental_spec.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript explicit incremental option usage"', () => { + it('should successfully build with incremental disabled', async () => { + // Disable tsconfig incremental option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.incremental = false; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts new file mode 100644 index 000000000000..738e454adb01 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-isolated-modules_spec.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "TypeScript isolated modules direct transpilation"', () => { + it('should successfully build with isolated modules enabled and disabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + + it('should successfully build with isolated modules enabled and enabled optimizations', async () => { + // Enable tsconfig isolatedModules option in tsconfig + await harness.modifyFile('tsconfig.json', (content) => { + const tsconfig = JSON.parse(content); + tsconfig.compilerOptions.isolatedModules = true; + + return JSON.stringify(tsconfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: true, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBe(true); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts index f983bb3556d0..41539df239f2 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-path-mapping_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts index 396efa55694e..c8dd39bfae5d 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-lazy_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import type { logging } from '@angular-devkit/core'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts similarity index 96% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts index 9f8be3d82f38..65f0540f2d1b 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-rebuild-touch-file_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { concatMap, count, take, timeout } from 'rxjs'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts index 198e9db71b84..e7d060de1262 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/typescript-resolve-json_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts new file mode 100644 index 000000000000..5ae62f020c1c --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/behavior/wasm-esm_spec.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (export "multiply" (func $multiply)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * ) + * ``` + */ +const exportWasmBase64 = + 'AGFzbQEAAAABBwFgAn9/AX8DAgEABwwBCG11bHRpcGx5AAAKCQEHACAAIAFsCwAXBG5hbWUBCwEACG11bHRpcGx5AgMBAAA='; +const exportWasmBytes = Buffer.from(exportWasmBase64, 'base64'); + +/** + * Compiled and base64 encoded WASM file for the following WAT: + * ``` + * (module + * (import "./values" "getValue" (func $getvalue (result i32))) + * (export "multiply" (func $multiply)) + * (export "subtract1" (func $subtract)) + * (func $multiply (param i32 i32) (result i32) + * local.get 0 + * local.get 1 + * i32.mul + * ) + * (func $subtract (param i32) (result i32) + * call $getvalue + * local.get 0 + * i32.sub + * ) + * ) + * ``` + */ +const importWasmBase64 = + 'AGFzbQEAAAABEANgAAF/YAJ/fwF/YAF/AX8CFQEILi92YWx1ZXMIZ2V0VmFsdWUAAAMDAgECBxgCCG11bHRpcGx5AAEJc3VidHJhY3QxAAIKEQIHACAAIAFsCwcAEAAgAGsLAC8EbmFtZQEfAwAIZ2V0dmFsdWUBCG11bHRpcGx5AghzdWJ0cmFjdAIHAwAAAQACAA=='; +const importWasmBytes = Buffer.from(importWasmBase64, 'base64'); + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Behavior: "Supports WASM/ES module integration"', () => { + it('should inject initialization code and add an export', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('multiply'); + }); + + it('should compile successfully with a provided type definition file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + await harness.writeFile( + 'src/multiply.wasm.d.ts', + 'export declare function multiply(a: number, b: number): number;', + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('multiply'); + }); + + it('should add WASM defined imports and include resolved TS file for import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/subtract.wasm', importWasmBytes); + + // Create TS file that is expect by WASM file + await harness.writeFile( + 'src/values.ts', + ` + export function getValue(): number { return 100; } + `, + ); + // The file is not imported into any actual TS files so it needs to be manually added to the TypeScript program + await harness.modifyFile('src/tsconfig.app.json', (content) => + content.replace('"main.ts",', '"main.ts","values.ts",'), + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { subtract1 } from './subtract.wasm'; + + console.log(subtract1(5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('subtract1'); + harness.expectFile('dist/browser/main.js').content.toContain('./values'); + harness.expectFile('dist/browser/main.js').content.toContain('getValue'); + }); + + it('should add WASM defined imports and include resolved JS file for import', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/subtract.wasm', importWasmBytes); + + // Create JS file that is expect by WASM file + await harness.writeFile( + 'src/values.js', + ` + export function getValue() { return 100; } + `, + ); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { subtract1 } from './subtract.wasm'; + + console.log(subtract1(5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure initialization code and export name is present in output code + harness.expectFile('dist/browser/main.js').content.toContain('WebAssembly.instantiate'); + harness.expectFile('dist/browser/main.js').content.toContain('subtract1'); + harness.expectFile('dist/browser/main.js').content.toContain('./values'); + harness.expectFile('dist/browser/main.js').content.toContain('getValue'); + }); + + it('should inline WASM files less than 10kb', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', exportWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + + // Ensure WASM is present in output code + harness.expectFile('dist/browser/main.js').content.toContain(exportWasmBase64); + }); + + it('should show an error on invalid WASM file', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', 'NOT_WASM'); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching('Unable to analyze WASM file'), + }), + ); + }); + + it('should show an error if using Zone.js', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + polyfills: ['zone.js'], + }); + + // Create WASM file + await harness.writeFile('src/multiply.wasm', importWasmBytes); + + // Create main file that uses the WASM file + await harness.writeFile( + 'src/main.ts', + ` + // @ts-ignore + import { multiply } from './multiply.wasm'; + + console.log(multiply(4, 5)); + `, + ); + + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); + expect(result?.success).toBeFalse(); + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + 'WASM/ES module integration imports are not supported with Zone.js applications', + ), + }), + ); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/web-workers-application_spec.ts b/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/behavior/web-workers-application_spec.ts rename to packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts index 284479cbc151..e42c5c5fd0df 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/behavior/web-workers-application_spec.ts +++ b/packages/angular/build/src/builders/application/tests/behavior/web-workers-application_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts b/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts rename to packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts index 51b348149e04..ad29d985f712 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/allowed-common-js-dependencies_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts rename to packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts index e0f24a7e2403..6824e06dd1f9 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/app-shell_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/app-shell_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/assets_spec.ts b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/assets_spec.ts rename to packages/angular/build/src/builders/application/tests/options/assets_spec.ts index 0b8bb2247571..96ae3c0d943e 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/assets_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/assets_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/base-href_spec.ts b/packages/angular/build/src/builders/application/tests/options/base-href_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/base-href_spec.ts rename to packages/angular/build/src/builders/application/tests/options/base-href_spec.ts index 1bccf4032dc8..b47e90782d07 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/base-href_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/base-href_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/browser_spec.ts b/packages/angular/build/src/builders/application/tests/options/browser_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/browser_spec.ts rename to packages/angular/build/src/builders/application/tests/options/browser_spec.ts index f9ece316d320..7a795fdb0883 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/browser_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/browser_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/bundle-budgets_spec.ts b/packages/angular/build/src/builders/application/tests/options/bundle-budgets_spec.ts similarity index 85% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/bundle-budgets_spec.ts rename to packages/angular/build/src/builders/application/tests/options/bundle-budgets_spec.ts index 289b0c9f1a58..4614d5a5788e 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/bundle-budgets_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/bundle-budgets_spec.ts @@ -3,14 +3,19 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; -import { lazyModuleFiles, lazyModuleFnImport } from '../../../../testing/test-utils'; import { buildApplication } from '../../index'; import { Type } from '../../schema'; -import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; +import { + APPLICATION_BUILDER_INFO, + BASE_OPTIONS, + describeBuilder, + lazyModuleFiles, + lazyModuleFnImport, +} from '../setup'; describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { const CSS_EXTENSIONS = ['css', 'scss', 'less']; @@ -88,6 +93,39 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { ); }); + it(`should not warn when non-injected style is not within the baseline threshold`, async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + optimization: false, + styles: [ + { + input: 'src/lazy-styles.css', + inject: false, + bundleName: 'lazy-styles', + }, + ], + budgets: [ + { type: Type.Bundle, name: 'lazy-styles', warning: '1kb', error: '1kb', baseline: '2kb' }, + ], + }); + + await harness.writeFile( + 'src/lazy-styles.css', + ` + .foo { color: green; padding: 1px; } + `.repeat(24), + ); + + const { result, logs } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + expect(logs).not.toContain( + jasmine.objectContaining({ + level: 'warn', + message: jasmine.stringMatching('lazy-styles failed to meet minimum budget'), + }), + ); + }); + CSS_EXTENSIONS.forEach((ext) => { it(`shows warnings for large component ${ext} when using 'anyComponentStyle' when AOT`, async () => { const cssContent = ` diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/cross-origin_spec.ts b/packages/angular/build/src/builders/application/tests/options/cross-origin_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/cross-origin_spec.ts rename to packages/angular/build/src/builders/application/tests/options/cross-origin_spec.ts index fdb366a30c0b..82bb04016418 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/cross-origin_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/cross-origin_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/define_spec.ts b/packages/angular/build/src/builders/application/tests/options/define_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/define_spec.ts rename to packages/angular/build/src/builders/application/tests/options/define_spec.ts index 3c6c8897d5eb..d4e3319553f2 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/define_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/define_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/delete-output-path_spec.ts b/packages/angular/build/src/builders/application/tests/options/delete-output-path_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/delete-output-path_spec.ts rename to packages/angular/build/src/builders/application/tests/options/delete-output-path_spec.ts index 1a7a11b3d4e0..7c0ceaab7145 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/delete-output-path_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/delete-output-path_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/deploy-url_spec.ts b/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/deploy-url_spec.ts rename to packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts index 5c864cd5b4b1..a03ca2b026e7 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/deploy-url_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/deploy-url_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/external-dependencies_spec.ts b/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/external-dependencies_spec.ts rename to packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts index 13707e96ca3f..27106874bca6 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/external-dependencies_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/external-dependencies_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/extract-licenses_spec.ts b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/extract-licenses_spec.ts rename to packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts index 7d0800bf70bc..402200a27f9d 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/extract-licenses_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/extract-licenses_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/i18n-missing-translation_spec.ts b/packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/i18n-missing-translation_spec.ts rename to packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts index 93b90a6fc1ec..d29c0a84adbc 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/i18n-missing-translation_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/i18n-missing-translation_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts b/packages/angular/build/src/builders/application/tests/options/index_spec.ts similarity index 89% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts rename to packages/angular/build/src/builders/application/tests/options/index_spec.ts index 5b6fac44a471..83e3cc132fe5 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/index_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/index_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -205,5 +205,27 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/index.html').content.not.toContain('modulepreload'); harness.expectFile('dist/browser/index.html').content.not.toContain('chunk-'); }); + + it(`should generate 'index.csr.html' instead of 'index.html' by default when ssr is enabled.`, async () => { + await harness.modifyFile('src/tsconfig.app.json', (content) => { + const tsConfig = JSON.parse(content); + tsConfig.files ??= []; + tsConfig.files.push('main.server.ts'); + + return JSON.stringify(tsConfig); + }); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toExist(); + harness.expectFile('dist/browser/index.csr.html').toExist(); + harness.expectFile('dist/browser/index.html').toNotExist(); + }); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/inline-style-language_spec.ts b/packages/angular/build/src/builders/application/tests/options/inline-style-language_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/inline-style-language_spec.ts rename to packages/angular/build/src/builders/application/tests/options/inline-style-language_spec.ts index 5c51be7f3ae6..632bc6f1db7b 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/inline-style-language_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/inline-style-language_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { concatMap, count, take, timeout } from 'rxjs'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/loader_spec.ts b/packages/angular/build/src/builders/application/tests/options/loader_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/loader_spec.ts rename to packages/angular/build/src/builders/application/tests/options/loader_spec.ts index 6a30a2359dae..d7a6858d6e4b 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/loader_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/loader_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/named-chunks_spec.ts b/packages/angular/build/src/builders/application/tests/options/named-chunks_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/named-chunks_spec.ts rename to packages/angular/build/src/builders/application/tests/options/named-chunks_spec.ts index 1adf8f91b557..06f72f27c902 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/named-chunks_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/named-chunks_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-fonts-inline_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-fonts-inline_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-fonts-inline_spec.ts rename to packages/angular/build/src/builders/application/tests/options/optimization-fonts-inline_spec.ts index 8c2cf1d2e59f..a84aeeccfdf9 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-fonts-inline_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/optimization-fonts-inline_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-inline-critical_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-inline-critical_spec.ts rename to packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.ts index 7d47faa498c3..ab56a9bc84dd 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-inline-critical_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/optimization-inline-critical_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts b/packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts rename to packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts index 9a8ede16af23..0ce1c6dc92b5 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/optimization-remove-special-comments_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/output-hashing_spec.ts b/packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/output-hashing_spec.ts rename to packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts index badfa066ac90..a4988148c879 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/output-hashing_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/output-hashing_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/output-path_spec.ts b/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts similarity index 93% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/output-path_spec.ts rename to packages/angular/build/src/builders/application/tests/options/output-path_spec.ts index 580aa2e6e6c1..f8d4513c7de7 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/output-path_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/output-path_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -286,10 +286,14 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { }, }); - const { result } = await harness.executeOnce(); + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); expect(result?.success).toBeFalse(); - expect(result?.error).toContain( - `'outputPath.browser' cannot be configured to an empty string when SSR is enabled`, + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + `'outputPath.browser' cannot be configured to an empty string when SSR is enabled`, + ), + }), ); }); }); @@ -349,10 +353,14 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { }, }); - const { result } = await harness.executeOnce(); + const { result, logs } = await harness.executeOnce({ outputLogsOnFailure: false }); expect(result?.success).toBeFalse(); - expect(result?.error).toContain( - `'outputPath.browser' and 'outputPath.server' cannot be configured to the same value`, + expect(logs).toContain( + jasmine.objectContaining({ + message: jasmine.stringMatching( + `'outputPath.browser' and 'outputPath.server' cannot be configured to the same value`, + ), + }), ); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/polyfills_spec.ts b/packages/angular/build/src/builders/application/tests/options/polyfills_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/polyfills_spec.ts rename to packages/angular/build/src/builders/application/tests/options/polyfills_spec.ts index e3429d900cef..290ea281208d 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/polyfills_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/polyfills_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/scripts_spec.ts b/packages/angular/build/src/builders/application/tests/options/scripts_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/scripts_spec.ts rename to packages/angular/build/src/builders/application/tests/options/scripts_spec.ts index 0d41e9e35424..757ff81acbac 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/scripts_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/scripts_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts b/packages/angular/build/src/builders/application/tests/options/server_spec.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts rename to packages/angular/build/src/builders/application/tests/options/server_spec.ts index 7149aee6f20e..a01a4eef73e2 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/server_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/server_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular/build/src/builders/application/tests/options/service-worker_spec.ts b/packages/angular/build/src/builders/application/tests/options/service-worker_spec.ts new file mode 100644 index 000000000000..958cd5007960 --- /dev/null +++ b/packages/angular/build/src/builders/application/tests/options/service-worker_spec.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { buildApplication } from '../../index'; +import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup'; + +describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { + describe('Option: "serviceWorker"', () => { + beforeEach(async () => { + const manifest = { + index: '/index.html', + assetGroups: [ + { + name: 'app', + installMode: 'prefetch', + resources: { + files: ['/favicon.ico', '/index.html'], + }, + }, + { + name: 'assets', + installMode: 'lazy', + updateMode: 'prefetch', + resources: { + files: [ + '/assets/**', + '/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)', + ], + }, + }, + ], + }; + + await harness.writeFile('src/ngsw-config.json', JSON.stringify(manifest)); + }); + + it('should not generate SW config when option is unset', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toNotExist(); + }); + + it('should not generate SW config when option is false', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toNotExist(); + }); + + it('should generate SW config when option is true', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectFile('dist/browser/ngsw.json').toExist(); + }); + + it('should generate SW config referencing index output', async () => { + harness.useTarget('build', { + ...BASE_OPTIONS, + serviceWorker: true, + index: { + input: 'src/index.html', + output: 'index.csr.html', + }, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBe(true); + + const config = await harness.readFile('dist/browser/ngsw.json'); + expect(JSON.parse(config)).toEqual(jasmine.objectContaining({ index: '/index.csr.html' })); + }); + }); +}); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/sourcemap_spec.ts b/packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts similarity index 68% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/sourcemap_spec.ts rename to packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts index 9dfc7a57534d..ddd36477bd99 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/sourcemap_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/sourcemap_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -124,6 +124,74 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { harness.expectFile('dist/browser/main.js.map').content.toContain('/common/index.ts'); }); + it(`should not include 'sourceMappingURL' sourcemaps when hidden suboption is true`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true, hidden: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness + .expectFile('dist/browser/main.js') + .content.not.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.not.toContain('sourceMappingURL=styles.css.map'); + }); + + it(`should include 'sourceMappingURL' sourcemaps when hidden suboption is false`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true, hidden: false }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness.expectFile('dist/browser/main.js').content.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.toContain('sourceMappingURL=styles.css.map'); + }); + + it(`should include 'sourceMappingURL' sourcemaps when hidden suboption is not set`, async () => { + await harness.writeFile('src/styles.css', `div { flex: 1 }`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + styles: ['src/styles.css'], + sourceMap: { scripts: true, styles: true }, + }); + + const { result } = await harness.executeOnce(); + + expect(result?.success).toBeTrue(); + + harness.expectFile('dist/browser/main.js.map').toExist(); + harness.expectFile('dist/browser/main.js').content.toContain('sourceMappingURL=main.js.map'); + + harness.expectFile('dist/browser/styles.css.map').toExist(); + harness + .expectFile('dist/browser/styles.css') + .content.toContain('sourceMappingURL=styles.css.map'); + }); + it('should add "x_google_ignoreList" extension to script sourcemap files when true', async () => { harness.useTarget('build', { ...BASE_OPTIONS, diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/ssr_spec.ts b/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts similarity index 54% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/ssr_spec.ts rename to packages/angular/build/src/builders/application/tests/options/ssr_spec.ts index 2caef3e3e14f..e5e31e1a408f 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/ssr_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/ssr_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; @@ -50,5 +50,47 @@ describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => { expect(result?.success).toBeTrue(); harness.expectFile('dist/server/server.mjs').toExist(); }); + + it(`should emit 'server' directory when 'ssr' is 'true'`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: true, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toExist(); + }); + + it(`should not emit 'server' directory when 'ssr' is 'false'`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: false, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toNotExist(); + }); + + it(`should not emit 'server' directory when 'ssr' is not set`, async () => { + await harness.writeFile('file.mjs', `console.log('Hello!');`); + + harness.useTarget('build', { + ...BASE_OPTIONS, + server: 'src/main.server.ts', + ssr: undefined, + }); + + const { result } = await harness.executeOnce(); + expect(result?.success).toBeTrue(); + harness.expectDirectory('dist/server').toNotExist(); + }); }); }); diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/styles_spec.ts b/packages/angular/build/src/builders/application/tests/options/styles_spec.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/styles_spec.ts rename to packages/angular/build/src/builders/application/tests/options/styles_spec.ts index e03a5c9bc74d..eb8d973ae904 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/styles_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/styles_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { buildApplication } from '../../index'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/options/subresource-integrity_spec.ts b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/builders/application/tests/options/subresource-integrity_spec.ts rename to packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts index 1d7b80756788..4afb87ebaed3 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/options/subresource-integrity_spec.ts +++ b/packages/angular/build/src/builders/application/tests/options/subresource-integrity_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; diff --git a/packages/angular_devkit/build_angular/src/builders/application/tests/setup.ts b/packages/angular/build/src/builders/application/tests/setup.ts similarity index 77% rename from packages/angular_devkit/build_angular/src/builders/application/tests/setup.ts rename to packages/angular/build/src/builders/application/tests/setup.ts index bd98be89e442..93a5cda2a1df 100644 --- a/packages/angular_devkit/build_angular/src/builders/application/tests/setup.ts +++ b/packages/angular/build/src/builders/application/tests/setup.ts @@ -3,12 +3,14 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Schema } from '../schema'; -export { describeBuilder } from '../../../testing'; +// TODO: Consider using package.json imports field instead of relative path +// after the switch to rules_js. +export * from '../../../../../../../modules/testing/builder/src'; export const APPLICATION_BUILDER_INFO = Object.freeze({ name: '@angular-devkit/build-angular:application', diff --git a/packages/angular/build/src/builders/dev-server/builder.ts b/packages/angular/build/src/builders/dev-server/builder.ts new file mode 100644 index 000000000000..d9ad825dd6e6 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/builder.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import type { BuilderContext } from '@angular-devkit/architect'; +import type { Plugin } from 'esbuild'; +import type http from 'node:http'; +import { checkPort } from '../../utils/check-port'; +import { + type IndexHtmlTransform, + buildApplicationInternal, + purgeStaleBuildCache, +} from './internal'; +import { normalizeOptions } from './options'; +import type { DevServerBuilderOutput } from './output'; +import type { Schema as DevServerBuilderOptions } from './schema'; +import { serveWithVite } from './vite-server'; + +/** + * A Builder that executes a development server based on the provided browser target option. + * + * Usage of the `transforms` and/or `extensions` parameters is NOT supported and may cause + * unexpected build output or build failures. + * + * @param options Dev Server options. + * @param context The build context. + * @param extensions An optional object containing an array of build plugins (esbuild-based) + * and/or HTTP request middleware. + * + * @experimental Direct usage of this function is considered experimental. + */ +export async function* execute( + options: DevServerBuilderOptions, + context: BuilderContext, + extensions?: { + buildPlugins?: Plugin[]; + middleware?: (( + req: http.IncomingMessage, + res: http.ServerResponse, + next: (err?: unknown) => void, + ) => void)[]; + indexHtmlTransformer?: IndexHtmlTransform; + }, +): AsyncIterable { + // Determine project name from builder context target + const projectName = context.target?.project; + if (!projectName) { + context.logger.error(`The "dev-server" builder requires a target to be specified.`); + + return; + } + + const { builderName, normalizedOptions } = await initialize(options, projectName, context); + + // Warn if the initial options provided by the user enable prebundling but caching is disabled + if (options.prebundle && !normalizedOptions.cacheOptions.enabled) { + context.logger.warn( + `Prebundling has been configured but will not be used because caching has been disabled.`, + ); + } + + yield* serveWithVite( + normalizedOptions, + builderName, + (options, context, plugins) => + buildApplicationInternal(options, context, { codePlugins: plugins }), + context, + { indexHtml: extensions?.indexHtmlTransformer }, + extensions, + ); +} + +async function initialize( + initialOptions: DevServerBuilderOptions, + projectName: string, + context: BuilderContext, +) { + // Purge old build disk cache. + await purgeStaleBuildCache(context); + + const normalizedOptions = await normalizeOptions(context, projectName, initialOptions); + const builderName = await context.getBuilderNameForTarget(normalizedOptions.buildTarget); + + if ( + !/^127\.\d+\.\d+\.\d+/g.test(normalizedOptions.host) && + normalizedOptions.host !== '::1' && + normalizedOptions.host !== 'localhost' + ) { + context.logger.warn(` +Warning: This is a simple server for use in testing or debugging Angular applications +locally. It hasn't been reviewed for security issues. + +Binding this server to an open connection can result in compromising your application or +computer. Using a different host than the one passed to the "--host" flag might result in +websocket connection issues. + `); + } + + normalizedOptions.port = await checkPort(normalizedOptions.port, normalizedOptions.host); + + return { + builderName, + normalizedOptions, + }; +} diff --git a/packages/angular/build/src/builders/dev-server/index.ts b/packages/angular/build/src/builders/dev-server/index.ts new file mode 100644 index 000000000000..0410d1b0f2d8 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/index.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { createBuilder } from '@angular-devkit/architect'; +import { execute } from './builder'; +import type { DevServerBuilderOutput } from './output'; +import type { Schema as DevServerBuilderOptions } from './schema'; + +export { + type DevServerBuilderOptions, + type DevServerBuilderOutput, + execute as executeDevServerBuilder, +}; +export default createBuilder(execute); + +// Temporary export to support specs +export { execute as executeDevServer }; diff --git a/packages/angular/build/src/builders/dev-server/internal.ts b/packages/angular/build/src/builders/dev-server/internal.ts new file mode 100644 index 000000000000..d3681f90cc00 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/internal.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export { type BuildOutputFile, BuildOutputFileType } from '@angular/build'; +export { createRxjsEsmResolutionPlugin } from '../../tools/esbuild/rxjs-esm-resolution-plugin'; +export { JavaScriptTransformer } from '../../tools/esbuild/javascript-transformer'; +export { getFeatureSupport, isZonelessApp } from '../../tools/esbuild/utils'; +export { renderPage } from '../../utils/server-rendering/render-page'; +export { type IndexHtmlTransform } from '../../utils/index-file/index-html-generator'; +export { purgeStaleBuildCache } from '../../utils/purge-cache'; +export { getSupportedBrowsers } from '../../utils/supported-browsers'; +export { transformSupportedBrowsersToTargets } from '../../tools/esbuild/utils'; +export { buildApplicationInternal } from '../../builders/application'; +export type { ApplicationBuilderInternalOptions } from '../../builders/application/options'; +export type { ExternalResultMetadata } from '../../tools/esbuild/bundler-execution-result'; diff --git a/packages/angular/build/src/builders/dev-server/options.ts b/packages/angular/build/src/builders/dev-server/options.ts new file mode 100644 index 000000000000..080e168699bc --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/options.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderContext, targetFromTargetString } from '@angular-devkit/architect'; +import path from 'node:path'; +import { normalizeOptimization } from '../../utils'; +import { normalizeCacheOptions } from '../../utils/normalize-cache'; +import { ApplicationBuilderOptions } from '../application'; +import { Schema as DevServerOptions } from './schema'; + +export type NormalizedDevServerOptions = Awaited>; + +/** + * Normalize the user provided options by creating full paths for all path based options + * and converting multi-form options into a single form that can be directly used + * by the build process. + * + * @param context The context for current builder execution. + * @param projectName The name of the project for the current execution. + * @param options An object containing the options to use for the build. + * @returns An object containing normalized options required to perform the build. + */ +export async function normalizeOptions( + context: BuilderContext, + projectName: string, + options: DevServerOptions, +) { + const { workspaceRoot, logger } = context; + const projectMetadata = await context.getProjectMetadata(projectName); + const projectRoot = path.join(workspaceRoot, (projectMetadata.root as string | undefined) ?? ''); + + const cacheOptions = normalizeCacheOptions(projectMetadata, workspaceRoot); + + // Target specifier defaults to the current project's build target using a development configuration + const buildTargetSpecifier = options.buildTarget ?? `::development`; + const buildTarget = targetFromTargetString(buildTargetSpecifier, projectName, 'build'); + + // Get the application builder options. + const browserBuilderName = await context.getBuilderNameForTarget(buildTarget); + const rawBuildOptions = await context.getTargetOptions(buildTarget); + const buildOptions = (await context.validateOptions( + rawBuildOptions, + browserBuilderName, + )) as unknown as ApplicationBuilderOptions; + const optimization = normalizeOptimization(buildOptions.optimization); + + if (options.prebundle) { + if (!cacheOptions.enabled) { + // Warn if the initial options provided by the user enable prebundling but caching is disabled + logger.warn( + 'Prebundling has been configured but will not be used because caching has been disabled.', + ); + } else if (optimization.scripts) { + // Warn if the initial options provided by the user enable prebundling but script optimization is enabled. + logger.warn( + 'Prebundling has been configured but will not be used because scripts optimization is enabled.', + ); + } + } + + let inspect: false | { host?: string; port?: number } = false; + const inspectRaw = options.inspect; + if (inspectRaw === true || inspectRaw === '' || inspectRaw === 'true') { + inspect = { + host: undefined, + port: undefined, + }; + } else if (typeof inspectRaw === 'string' && inspectRaw !== 'false') { + const port = +inspectRaw; + if (isFinite(port)) { + inspect = { + host: undefined, + port, + }; + } else { + const [host, port] = inspectRaw.split(':'); + inspect = { + host, + port: isNaN(+port) ? undefined : +port, + }; + } + } + + // Initial options to keep + const { + host, + port, + poll, + open, + verbose, + watch, + liveReload, + hmr, + headers, + proxyConfig, + servePath, + ssl, + sslCert, + sslKey, + prebundle, + } = options; + + // Return all the normalized options + return { + buildTarget, + host: host ?? 'localhost', + port: port ?? 4200, + poll, + open, + verbose, + watch, + liveReload, + hmr, + headers, + workspaceRoot, + projectRoot, + cacheOptions, + proxyConfig, + servePath, + ssl, + sslCert, + sslKey, + // Prebundling defaults to true but requires caching to function + prebundle: cacheOptions.enabled && !optimization.scripts && prebundle, + inspect, + }; +} diff --git a/packages/angular/build/src/builders/dev-server/output.ts b/packages/angular/build/src/builders/dev-server/output.ts new file mode 100644 index 000000000000..c166994a429b --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/output.ts @@ -0,0 +1,18 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { BuilderOutput } from '@angular-devkit/architect'; + +/** + * @experimental Direct usage of this type is considered experimental. + */ +export interface DevServerBuilderOutput extends BuilderOutput { + baseUrl: string; + port?: number; + address?: string; +} diff --git a/packages/angular/build/src/builders/dev-server/schema.json b/packages/angular/build/src/builders/dev-server/schema.json new file mode 100644 index 000000000000..3adce45eb71a --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/schema.json @@ -0,0 +1,115 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Dev Server Target", + "description": "Dev Server target options for Build Facade.", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "A build builder target to serve in the format of `project:target[:configuration]`. You can also pass in more than one configuration name as a comma-separated list. Example: `project:target:production,staging`.", + "pattern": "^[^:\\s]*:[^:\\s]*(:[^\\s]+)?$" + }, + "port": { + "type": "number", + "description": "Port to listen on.", + "default": 4200 + }, + "host": { + "type": "string", + "description": "Host to listen on.", + "default": "localhost" + }, + "proxyConfig": { + "type": "string", + "description": "Proxy configuration file. For more information, see https://angular.dev/tools/cli/serve#proxying-to-a-backend-server." + }, + "ssl": { + "type": "boolean", + "description": "Serve using HTTPS.", + "default": false + }, + "sslKey": { + "type": "string", + "description": "SSL key to use for serving HTTPS." + }, + "sslCert": { + "type": "string", + "description": "SSL certificate to use for serving HTTPS." + }, + "headers": { + "type": "object", + "description": "Custom HTTP headers to be added to all responses.", + "propertyNames": { + "pattern": "^[-_A-Za-z0-9]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "open": { + "type": "boolean", + "description": "Opens the url in default browser.", + "default": false, + "alias": "o" + }, + "verbose": { + "type": "boolean", + "description": "Adds more details to output logging." + }, + "liveReload": { + "type": "boolean", + "description": "Whether to reload the page on change, using live-reload.", + "default": true + }, + "servePath": { + "type": "string", + "description": "The pathname where the application will be served." + }, + "hmr": { + "type": "boolean", + "description": "Enable hot module replacement.", + "default": false + }, + "watch": { + "type": "boolean", + "description": "Rebuild on change.", + "default": true + }, + "poll": { + "type": "number", + "description": "Enable and define the file watching poll time period in milliseconds." + }, + "inspect": { + "default": false, + "description": "Activate debugging inspector. This option only has an effect when 'SSR' or 'SSG' are enabled.", + "oneOf": [ + { + "type": "string", + "description": "Activate the inspector on host and port in the format of `[[host:]port]`. See the security warning in https://nodejs.org/docs/latest-v22.x/api/cli.html#warning-binding-inspector-to-a-public-ipport-combination-is-insecure regarding the host parameter usage." + }, + { "type": "boolean" } + ] + }, + "prebundle": { + "description": "Enable and control the Vite-based development server's prebundling capabilities. To enable prebundling, the Angular CLI cache must also be enabled.", + "default": true, + "oneOf": [ + { "type": "boolean" }, + { + "type": "object", + "properties": { + "exclude": { + "description": "List of package imports that should not be prebundled by the development server. The packages will be bundled into the application code itself.", + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false, + "required": ["exclude"] + } + ] + } + }, + "additionalProperties": false, + "required": ["buildTarget"] +} diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts new file mode 100644 index 000000000000..57679680ddb6 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-assets_spec.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + const javascriptFileContent = + "import {foo} from 'unresolved'; /* a comment */const foo = `bar`;\n\n\n"; + + describe('Behavior: "browser builder assets"', () => { + it('serves a project JavaScript asset unmodified', async () => { + await harness.writeFile('src/extra.js', javascriptFileContent); + + setupTarget(harness, { + assets: ['src/extra.js'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'extra.js'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(javascriptFileContent); + }); + + it('serves a project TypeScript asset unmodified', async () => { + await harness.writeFile('src/extra.ts', javascriptFileContent); + + setupTarget(harness, { + assets: ['src/extra.ts'], + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'extra.ts'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(javascriptFileContent); + }); + + it('should return 404 for non existing assets', async () => { + setupTarget(harness, { + assets: [], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'does-not-exist.js'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(404); + }); + + it(`should return the asset that matches 'index.html' when path has a trailing '/'`, async () => { + await harness.writeFile( + 'src/login/index.html', + '

Login page

', + ); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login/'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(200); + expect(await response?.text()).toContain('

Login page

'); + }); + + it(`should return the asset that matches '.html' when path has no trailing '/'`, async () => { + await harness.writeFile('src/login/new.html', '

Login page

'); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login/new'); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(200); + expect(await response?.text()).toContain('

Login page

'); + }); + + it(`should return a redirect when an asset directory is accessed without a trailing '/'`, async () => { + await harness.writeFile( + 'src/login/index.html', + '

Login page

', + ); + + setupTarget(harness, { + assets: ['src/login'], + optimization: { + scripts: true, + }, + }); + + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, 'login', { + request: { redirect: 'manual' }, + }); + + expect(result?.success).toBeTrue(); + expect(await response?.status).toBe(301); + expect(await response?.headers.get('Location')).toBe('/login/'); + }); + }); +}); diff --git a/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts b/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts new file mode 100644 index 000000000000..813796079b17 --- /dev/null +++ b/packages/angular/build/src/builders/dev-server/tests/behavior/build-base-href_spec.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { executeDevServer } from '../../index'; +import { executeOnceAndFetch } from '../execute-fetch'; +import { describeServeBuilder } from '../jasmine-helpers'; +import { BASE_OPTIONS, DEV_SERVER_BUILDER_INFO } from '../setup'; + +describeServeBuilder(executeDevServer, DEV_SERVER_BUILDER_INFO, (harness, setupTarget) => { + describe('Behavior: "buildTarget baseHref"', () => { + beforeEach(async () => { + setupTarget(harness, { + baseHref: '/test/', + }); + + // Application code is not needed for these tests + await harness.writeFile('src/main.ts', 'console.log("foo");'); + }); + + it('uses the baseHref defined in the "buildTarget" options as the serve path', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test/main.js'); + + expect(result?.success).toBeTrue(); + const baseUrl = new URL(`${result?.baseUrl}/`); + expect(baseUrl.pathname).toBe('/test/'); + expect(await response?.text()).toContain('console.log'); + }); + + it('serves the application from baseHref location without trailing slash', async () => { + harness.useTarget('serve', { + ...BASE_OPTIONS, + }); + + const { result, response } = await executeOnceAndFetch(harness, '/test'); + + expect(result?.success).toBeTrue(); + expect(await response?.text()).toContain(''; + + rewriter.on('startTag', (tag) => { + rewriter.emitStartTag(tag); + + if (tag.tagName === 'body') { + rewriter.emitRaw(jsActionContractScript); + } + }); + + return transformedContent(); +} diff --git a/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts new file mode 100644 index 000000000000..6c0747730c29 --- /dev/null +++ b/packages/angular/build/src/utils/index-file/add-event-dispatch-contract_spec.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { addEventDispatchContract } from './add-event-dispatch-contract'; + +describe('addEventDispatchContract', () => { + it('should inline event dispatcher script', async () => { + const result = await addEventDispatchContract(` + + + +

Hello World!

+ + + `); + + expect(result).toMatch( + /\s*`); } - let linkTags: string[] = []; + let headerLinkTags: string[] = []; + let bodyLinkTags: string[] = []; for (const src of stylesheets) { const attrs = [`rel="stylesheet"`, `href="${deployUrl}${src}"`]; @@ -144,7 +147,7 @@ export async function augmentIndexHtml( attrs.push(generateSriAttributes(content)); } - linkTags.push(``); + headerLinkTags.push(``); } if (params.hints?.length) { @@ -180,7 +183,14 @@ export async function augmentIndexHtml( attrs.push(generateSriAttributes(content)); } - linkTags.push(``); + const tag = ``; + if (hint.mode === 'modulepreload') { + // Module preloads should be placed by the inserted script elements in the body since + // they are only useful in combination with the scripts. + bodyLinkTags.push(tag); + } else { + headerLinkTags.push(tag); + } } } @@ -190,7 +200,7 @@ export async function augmentIndexHtml( const foundPreconnects = new Set(); rewriter - .on('startTag', (tag) => { + .on('startTag', (tag, rawTagHtml) => { switch (tag.tagName) { case 'html': // Adjust document locale if specified @@ -224,6 +234,13 @@ export async function augmentIndexHtml( foundPreconnects.add(href); } } + break; + default: + if (tag.selfClosing && !VALID_SELF_CLOSING_TAGS.has(tag.tagName)) { + errors.push(`Invalid self-closing element in index HTML file: '${rawTagHtml}'.`); + + return; + } } rewriter.emitStartTag(tag); @@ -231,7 +248,7 @@ export async function augmentIndexHtml( .on('endTag', (tag) => { switch (tag.tagName) { case 'head': - for (const linkTag of linkTags) { + for (const linkTag of headerLinkTags) { rewriter.emitRaw(linkTag); } if (imageDomains) { @@ -241,9 +258,14 @@ export async function augmentIndexHtml( } } } - linkTags = []; + headerLinkTags = []; break; case 'body': + for (const linkTag of bodyLinkTags) { + rewriter.emitRaw(linkTag); + } + bodyLinkTags = []; + // Add script tags for (const scriptTag of scriptTags) { rewriter.emitRaw(scriptTag); @@ -260,9 +282,9 @@ export async function augmentIndexHtml( return { content: - linkTags.length || scriptTags.length + headerLinkTags.length || scriptTags.length ? // In case no body/head tags are not present (dotnet partial templates) - linkTags.join('') + scriptTags.join('') + content + headerLinkTags.join('') + scriptTags.join('') + content : content, warnings, errors, diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html_spec.ts b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts similarity index 93% rename from packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html_spec.ts rename to packages/angular/build/src/utils/index-file/augment-index-html_spec.ts index 4203b7f3cb3c..61aaa0674ed8 100644 --- a/packages/angular_devkit/build_angular/src/utils/index-file/augment-index-html_spec.ts +++ b/packages/angular/build/src/utils/index-file/augment-index-html_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { tags } from '@angular-devkit/core'; @@ -296,10 +296,10 @@ describe('augment-index-html', () => { - - + + `); @@ -320,10 +320,10 @@ describe('augment-index-html', () => { - - + + `); @@ -481,7 +481,7 @@ describe('augment-index-html', () => { it('should add image preconnects if it encounters preconnect elements for other resources', async () => { const imageDomains = ['https://www.example2.com', 'https://www.example3.com']; - const { content, warnings } = await augmentIndexHtml({ + const { content } = await augmentIndexHtml({ ...indexGeneratorOptions, html: '', imageDomains, @@ -500,4 +500,38 @@ describe('augment-index-html', () => { `); }); + + describe('self-closing tags', () => { + it('should return an error when used on a not supported element', async () => { + const { errors } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ` + + + + + ' + `, + }); + + expect(errors.length).toEqual(1); + expect(errors).toEqual([`Invalid self-closing element in index HTML file: ''.`]); + }); + + it('should not return an error when used on a supported element', async () => { + const { errors } = await augmentIndexHtml({ + ...indexGeneratorOptions, + html: ` + + +
+ + + ' + `, + }); + + expect(errors.length).toEqual(0); + }); + }); }); diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/html-rewriting-stream.ts b/packages/angular/build/src/utils/index-file/html-rewriting-stream.ts similarity index 94% rename from packages/angular_devkit/build_angular/src/utils/index-file/html-rewriting-stream.ts rename to packages/angular/build/src/utils/index-file/html-rewriting-stream.ts index 3cdfc52bd50b..5ae7c397904d 100644 --- a/packages/angular_devkit/build_angular/src/utils/index-file/html-rewriting-stream.ts +++ b/packages/angular/build/src/utils/index-file/html-rewriting-stream.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Readable } from 'node:stream'; diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/index-html-generator.ts b/packages/angular/build/src/utils/index-file/index-html-generator.ts similarity index 66% rename from packages/angular_devkit/build_angular/src/utils/index-file/index-html-generator.ts rename to packages/angular/build/src/utils/index-file/index-html-generator.ts index ff30004569ef..bf40e2e7acac 100644 --- a/packages/angular_devkit/build_angular/src/utils/index-file/index-html-generator.ts +++ b/packages/angular/build/src/utils/index-file/index-html-generator.ts @@ -3,22 +3,23 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { NormalizedCachedOptions } from '../normalize-cache'; import { NormalizedOptimizationOptions } from '../normalize-optimization'; +import { addEventDispatchContract } from './add-event-dispatch-contract'; import { CrossOriginValue, Entrypoint, FileInfo, augmentIndexHtml } from './augment-index-html'; import { InlineCriticalCssProcessor } from './inline-critical-css'; import { InlineFontsProcessor } from './inline-fonts'; -import { addStyleNonce } from './style-nonce'; +import { addNonce } from './nonce'; type IndexHtmlGeneratorPlugin = ( html: string, options: IndexHtmlGeneratorProcessOptions, -) => Promise; +) => Promise | string; export type HintMode = 'prefetch' | 'preload' | 'modulepreload' | 'preconnect' | 'dns-prefetch'; @@ -40,45 +41,80 @@ export interface IndexHtmlGeneratorOptions { optimization?: NormalizedOptimizationOptions; cache?: NormalizedCachedOptions; imageDomains?: string[]; + generateDedicatedSSRContent?: boolean; } export type IndexHtmlTransform = (content: string) => Promise; -export interface IndexHtmlTransformResult { +export interface IndexHtmlPluginTransformResult { content: string; warnings: string[]; errors: string[]; } +export interface IndexHtmlProcessResult { + csrContent: string; + ssrContent?: string; + warnings: string[]; + errors: string[]; +} + export class IndexHtmlGenerator { private readonly plugins: IndexHtmlGeneratorPlugin[]; + private readonly csrPlugins: IndexHtmlGeneratorPlugin[] = []; + private readonly ssrPlugins: IndexHtmlGeneratorPlugin[] = []; constructor(readonly options: IndexHtmlGeneratorOptions) { - const extraPlugins: IndexHtmlGeneratorPlugin[] = []; - if (this.options.optimization?.fonts.inline) { - extraPlugins.push(inlineFontsPlugin(this)); + const extraCommonPlugins: IndexHtmlGeneratorPlugin[] = []; + if (options?.optimization?.fonts.inline) { + extraCommonPlugins.push(inlineFontsPlugin(this), addNonce); } - if (this.options.optimization?.styles.inlineCritical) { - extraPlugins.push(inlineCriticalCssPlugin(this)); + // Common plugins + this.plugins = [augmentIndexHtmlPlugin(this), ...extraCommonPlugins, postTransformPlugin(this)]; + + // CSR plugins + if (options?.optimization?.styles?.inlineCritical) { + this.csrPlugins.push(inlineCriticalCssPlugin(this)); } - this.plugins = [ - augmentIndexHtmlPlugin(this), - ...extraPlugins, - // Runs after the `extraPlugins` to capture any nonce or - // `style` tags that might've been added by them. - addStyleNoncePlugin(), - postTransformPlugin(this), - ]; + this.csrPlugins.push(addNoncePlugin()); + + // SSR plugins + if (options.generateDedicatedSSRContent) { + this.ssrPlugins.push(addEventDispatchContractPlugin(), addNoncePlugin()); + } } - async process(options: IndexHtmlGeneratorProcessOptions): Promise { + async process(options: IndexHtmlGeneratorProcessOptions): Promise { let content = await this.readIndex(this.options.indexPath); const warnings: string[] = []; const errors: string[] = []; - for (const plugin of this.plugins) { + content = await this.runPlugins(content, this.plugins, options, warnings, errors); + const [csrContent, ssrContent] = await Promise.all([ + this.runPlugins(content, this.csrPlugins, options, warnings, errors), + this.ssrPlugins.length + ? this.runPlugins(content, this.ssrPlugins, options, warnings, errors) + : undefined, + ]); + + return { + ssrContent, + csrContent, + warnings, + errors, + }; + } + + private async runPlugins( + content: string, + plugins: IndexHtmlGeneratorPlugin[], + options: IndexHtmlGeneratorProcessOptions, + warnings: string[], + errors: string[], + ): Promise { + for (const plugin of plugins) { const result = await plugin(content, options); if (typeof result === 'string') { content = result; @@ -95,11 +131,7 @@ export class IndexHtmlGenerator { } } - return { - content, - warnings, - errors, - }; + return content; } async readAsset(path: string): Promise { @@ -160,10 +192,14 @@ function inlineCriticalCssPlugin(generator: IndexHtmlGenerator): IndexHtmlGenera inlineCriticalCssProcessor.process(html, { outputPath: options.outputPath }); } -function addStyleNoncePlugin(): IndexHtmlGeneratorPlugin { - return (html) => addStyleNonce(html); +function addNoncePlugin(): IndexHtmlGeneratorPlugin { + return (html) => addNonce(html); } function postTransformPlugin({ options }: IndexHtmlGenerator): IndexHtmlGeneratorPlugin { return async (html) => (options.postTransform ? options.postTransform(html) : html); } + +function addEventDispatchContractPlugin(): IndexHtmlGeneratorPlugin { + return (html) => addEventDispatchContract(html); +} diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css.ts b/packages/angular/build/src/utils/index-file/inline-critical-css.ts similarity index 99% rename from packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css.ts rename to packages/angular/build/src/utils/index-file/inline-critical-css.ts index dc90d24df317..fe68c8abe105 100644 --- a/packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css.ts +++ b/packages/angular/build/src/utils/index-file/inline-critical-css.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import Critters from 'critters'; diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css_spec.ts b/packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts similarity index 96% rename from packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css_spec.ts rename to packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts index 240ad91c3572..4c68304cd9d6 100644 --- a/packages/angular_devkit/build_angular/src/utils/index-file/inline-critical-css_spec.ts +++ b/packages/angular/build/src/utils/index-file/inline-critical-css_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { tags } from '@angular-devkit/core'; @@ -125,7 +125,7 @@ describe('InlineCriticalCssProcessor', () => { '', ); // Nonces shouldn't be added inside the `noscript` tags. - expect(content).toContain(''); + expect(content).toContain(''); expect(content).toContain(' + + + + + `); + + expect(result).toContain(``); + expect(result).toContain(''); + expect(result).toContain(``); + }); }); diff --git a/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts new file mode 100644 index 000000000000..bd85b6ee00dd --- /dev/null +++ b/packages/angular/build/src/utils/index-file/valid-self-closing-tags.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** A list of valid self closing HTML elements */ +export const VALID_SELF_CLOSING_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', + /** SVG tags */ + 'circle', + 'ellipse', + 'line', + 'path', + 'polygon', + 'polyline', + 'rect', + 'text', + 'tspan', + 'linearGradient', + 'radialGradient', + 'stop', + 'image', + 'pattern', + 'defs', + 'g', + 'marker', + 'mask', + 'style', + 'symbol', + 'use', + 'view', + /** MathML tags */ + 'mspace', + 'mphantom', + 'mrow', + 'mfrac', + 'msqrt', + 'mroot', + 'mstyle', + 'merror', + 'mpadded', + 'mtable', +]); diff --git a/packages/angular/build/src/utils/index.ts b/packages/angular/build/src/utils/index.ts new file mode 100644 index 000000000000..1a7cb15cd9c3 --- /dev/null +++ b/packages/angular/build/src/utils/index.ts @@ -0,0 +1,12 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export * from './normalize-asset-patterns'; +export * from './normalize-optimization'; +export * from './normalize-source-maps'; +export * from './load-proxy-config'; diff --git a/packages/angular/build/src/utils/load-esm.ts b/packages/angular/build/src/utils/load-esm.ts new file mode 100644 index 000000000000..6a6220f66288 --- /dev/null +++ b/packages/angular/build/src/utils/load-esm.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** + * Lazily compiled dynamic import loader function. + */ +let load: ((modulePath: string | URL) => Promise) | undefined; + +/** + * This uses a dynamic import to load a module which may be ESM. + * CommonJS code can load ESM code via a dynamic import. Unfortunately, TypeScript + * will currently, unconditionally downlevel dynamic import into a require call. + * require calls cannot load ESM code and will result in a runtime error. To workaround + * this, a Function constructor is used to prevent TypeScript from changing the dynamic import. + * Once TypeScript provides support for keeping the dynamic import this workaround can + * be dropped. + * + * @param modulePath The path of the module to load. + * @returns A Promise that resolves to the dynamically imported module. + */ +export function loadEsmModule(modulePath: string | URL): Promise { + load ??= new Function('modulePath', `return import(modulePath);`) as Exclude< + typeof load, + undefined + >; + + return load(modulePath); +} diff --git a/packages/angular_devkit/build_angular/src/utils/load-proxy-config.ts b/packages/angular/build/src/utils/load-proxy-config.ts similarity index 96% rename from packages/angular_devkit/build_angular/src/utils/load-proxy-config.ts rename to packages/angular/build/src/utils/load-proxy-config.ts index 1b1939d26b70..2ed21c05ba2a 100644 --- a/packages/angular_devkit/build_angular/src/utils/load-proxy-config.ts +++ b/packages/angular/build/src/utils/load-proxy-config.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { isDynamicPattern } from 'fast-glob'; @@ -18,8 +18,7 @@ import { loadEsmModule } from './load-esm'; export async function loadProxyConfiguration( root: string, proxyConfig: string | undefined, - normalize = false, -) { +): Promise | undefined> { if (!proxyConfig) { return undefined; } @@ -81,11 +80,7 @@ export async function loadProxyConfiguration( } } - if (normalize) { - proxyConfiguration = normalizeProxyConfiguration(proxyConfiguration); - } - - return proxyConfiguration; + return normalizeProxyConfiguration(proxyConfiguration); } /** diff --git a/packages/angular_devkit/build_angular/src/utils/load-translations.ts b/packages/angular/build/src/utils/load-translations.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/utils/load-translations.ts rename to packages/angular/build/src/utils/load-translations.ts index d481e6aa83ae..c6afe9a1ecd9 100644 --- a/packages/angular_devkit/build_angular/src/utils/load-translations.ts +++ b/packages/angular/build/src/utils/load-translations.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import type { Diagnostics } from '@angular/localize/tools'; diff --git a/packages/angular/build/src/utils/normalize-asset-patterns.ts b/packages/angular/build/src/utils/normalize-asset-patterns.ts new file mode 100644 index 000000000000..246b6190fdf8 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-asset-patterns.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { statSync } from 'fs'; +import assert from 'node:assert'; +import * as path from 'path'; +import { AssetPattern, AssetPatternClass } from '../builders/application/schema'; + +export class MissingAssetSourceRootException extends Error { + constructor(path: string) { + super(`The ${path} asset path must start with the project source root.`); + } +} + +export function normalizeAssetPatterns( + assetPatterns: AssetPattern[], + workspaceRoot: string, + projectRoot: string, + projectSourceRoot: string | undefined, +): (AssetPatternClass & { output: string })[] { + if (assetPatterns.length === 0) { + return []; + } + + // When sourceRoot is not available, we default to ${projectRoot}/src. + const sourceRoot = projectSourceRoot || path.join(projectRoot, 'src'); + const resolvedSourceRoot = path.resolve(workspaceRoot, sourceRoot); + + return assetPatterns.map((assetPattern) => { + // Normalize string asset patterns to objects. + if (typeof assetPattern === 'string') { + const assetPath = path.normalize(assetPattern); + const resolvedAssetPath = path.resolve(workspaceRoot, assetPath); + + // Check if the string asset is within sourceRoot. + if (!resolvedAssetPath.startsWith(resolvedSourceRoot)) { + throw new MissingAssetSourceRootException(assetPattern); + } + + let glob: string, input: string; + let isDirectory = false; + + try { + isDirectory = statSync(resolvedAssetPath).isDirectory(); + } catch { + isDirectory = true; + } + + if (isDirectory) { + // Folders get a recursive star glob. + glob = '**/*'; + // Input directory is their original path. + input = assetPath; + } else { + // Files are their own glob. + glob = path.basename(assetPath); + // Input directory is their original dirname. + input = path.dirname(assetPath); + } + + // Output directory for both is the relative path from source root to input. + const output = path.relative(resolvedSourceRoot, path.resolve(workspaceRoot, input)); + + assetPattern = { glob, input, output }; + } else { + assetPattern.output = path.join('.', assetPattern.output ?? ''); + } + + assert(assetPattern.output !== undefined); + + if (assetPattern.output.startsWith('..')) { + throw new Error('An asset cannot be written to a location outside of the output path.'); + } + + return assetPattern as AssetPatternClass & { output: string }; + }); +} diff --git a/packages/angular/build/src/utils/normalize-cache.ts b/packages/angular/build/src/utils/normalize-cache.ts new file mode 100644 index 000000000000..9dc7ba6ae2a6 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-cache.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { join, resolve } from 'node:path'; + +/** Version placeholder is replaced during the build process with actual package version */ +const VERSION = '0.0.0-PLACEHOLDER'; + +export interface NormalizedCachedOptions { + /** Whether disk cache is enabled. */ + enabled: boolean; + /** Disk cache path. Example: `/.angular/cache/v12.0.0`. */ + path: string; + /** Disk cache base path. Example: `/.angular/cache`. */ + basePath: string; +} + +interface CacheMetadata { + enabled?: boolean; + environment?: 'local' | 'ci' | 'all'; + path?: string; +} + +function hasCacheMetadata(value: unknown): value is { cli: { cache: CacheMetadata } } { + return ( + !!value && + typeof value === 'object' && + 'cli' in value && + !!value['cli'] && + typeof value['cli'] === 'object' && + 'cache' in value['cli'] + ); +} + +export function normalizeCacheOptions( + projectMetadata: unknown, + worspaceRoot: string, +): NormalizedCachedOptions { + const cacheMetadata = hasCacheMetadata(projectMetadata) ? projectMetadata.cli.cache : {}; + + const { + // Webcontainers do not currently benefit from persistent disk caching and can lead to increased browser memory usage + enabled = !process.versions.webcontainer, + environment = 'local', + path = '.angular/cache', + } = cacheMetadata; + const isCI = process.env['CI'] === '1' || process.env['CI']?.toLowerCase() === 'true'; + + let cacheEnabled = enabled; + if (cacheEnabled) { + switch (environment) { + case 'ci': + cacheEnabled = isCI; + break; + case 'local': + cacheEnabled = !isCI; + break; + } + } + + const cacheBasePath = resolve(worspaceRoot, path); + + return { + enabled: cacheEnabled, + basePath: cacheBasePath, + path: join(cacheBasePath, VERSION), + }; +} diff --git a/packages/angular/build/src/utils/normalize-optimization.ts b/packages/angular/build/src/utils/normalize-optimization.ts new file mode 100644 index 000000000000..fcd5b556f27f --- /dev/null +++ b/packages/angular/build/src/utils/normalize-optimization.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { + FontsClass, + OptimizationClass, + OptimizationUnion, + StylesClass, +} from '../builders/application/schema'; + +export type NormalizedOptimizationOptions = Required< + Omit +> & { + fonts: FontsClass; + styles: StylesClass; +}; + +export function normalizeOptimization( + optimization: OptimizationUnion = true, +): NormalizedOptimizationOptions { + if (typeof optimization === 'object') { + const styleOptimization = !!optimization.styles; + + return { + scripts: !!optimization.scripts, + styles: + typeof optimization.styles === 'object' + ? optimization.styles + : { + minify: styleOptimization, + removeSpecialComments: styleOptimization, + inlineCritical: styleOptimization, + }, + fonts: + typeof optimization.fonts === 'object' + ? optimization.fonts + : { + inline: !!optimization.fonts, + }, + }; + } + + return { + scripts: optimization, + styles: { + minify: optimization, + inlineCritical: optimization, + removeSpecialComments: optimization, + }, + fonts: { + inline: optimization, + }, + }; +} diff --git a/packages/angular/build/src/utils/normalize-source-maps.ts b/packages/angular/build/src/utils/normalize-source-maps.ts new file mode 100644 index 000000000000..ddeb3e5322d4 --- /dev/null +++ b/packages/angular/build/src/utils/normalize-source-maps.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { SourceMapClass, SourceMapUnion } from '../builders/application/schema'; + +export function normalizeSourceMaps(sourceMap: SourceMapUnion): SourceMapClass { + const scripts = typeof sourceMap === 'object' ? sourceMap.scripts : sourceMap; + const styles = typeof sourceMap === 'object' ? sourceMap.styles : sourceMap; + const hidden = (typeof sourceMap === 'object' && sourceMap.hidden) || false; + const vendor = (typeof sourceMap === 'object' && sourceMap.vendor) || false; + + return { + vendor, + hidden, + scripts, + styles, + }; +} diff --git a/packages/angular_devkit/build_angular/src/utils/postcss-configuration.ts b/packages/angular/build/src/utils/postcss-configuration.ts similarity index 83% rename from packages/angular_devkit/build_angular/src/utils/postcss-configuration.ts rename to packages/angular/build/src/utils/postcss-configuration.ts index 80b6488b1128..1861f9f2b1db 100644 --- a/packages/angular_devkit/build_angular/src/utils/postcss-configuration.ts +++ b/packages/angular/build/src/utils/postcss-configuration.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { readFile, readdir } from 'node:fs/promises'; @@ -18,13 +18,19 @@ interface RawPostcssConfiguration { } const postcssConfigurationFiles: string[] = ['postcss.config.json', '.postcssrc.json']; - -interface SearchDirectory { +const tailwindConfigFiles: string[] = [ + 'tailwind.config.js', + 'tailwind.config.cjs', + 'tailwind.config.mjs', + 'tailwind.config.ts', +]; + +export interface SearchDirectory { root: string; files: Set; } -async function generateSearchDirectories(roots: string[]): Promise { +export async function generateSearchDirectories(roots: string[]): Promise { return await Promise.all( roots.map((root) => readdir(root, { withFileTypes: true }).then((entries) => ({ @@ -50,6 +56,12 @@ function findFile( return undefined; } +export function findTailwindConfiguration( + searchDirectories: SearchDirectory[], +): string | undefined { + return findFile(searchDirectories, tailwindConfigFiles); +} + async function readPostcssConfiguration( configurationFile: string, ): Promise { @@ -60,12 +72,8 @@ async function readPostcssConfiguration( } export async function loadPostcssConfiguration( - workspaceRoot: string, - projectRoot: string, + searchDirectories: SearchDirectory[], ): Promise { - // A configuration file can exist in the project or workspace root - const searchDirectories = await generateSearchDirectories([projectRoot, workspaceRoot]); - const configPath = findFile(searchDirectories, postcssConfigurationFiles); if (!configPath) { return undefined; diff --git a/packages/angular_devkit/build_angular/src/utils/purge-cache.ts b/packages/angular/build/src/utils/purge-cache.ts similarity index 95% rename from packages/angular_devkit/build_angular/src/utils/purge-cache.ts rename to packages/angular/build/src/utils/purge-cache.ts index 765bef3d1419..5851d052d54a 100644 --- a/packages/angular_devkit/build_angular/src/utils/purge-cache.ts +++ b/packages/angular/build/src/utils/purge-cache.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { BuilderContext } from '@angular-devkit/architect'; diff --git a/packages/angular/build/src/utils/resolve-assets.ts b/packages/angular/build/src/utils/resolve-assets.ts new file mode 100644 index 000000000000..c9732501ce29 --- /dev/null +++ b/packages/angular/build/src/utils/resolve-assets.ts @@ -0,0 +1,45 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import glob from 'fast-glob'; +import path from 'node:path'; + +export async function resolveAssets( + entries: { + glob: string; + ignore?: string[]; + input: string; + output: string; + flatten?: boolean; + followSymlinks?: boolean; + }[], + root: string, +): Promise<{ source: string; destination: string }[]> { + const defaultIgnore = ['.gitkeep', '**/.DS_Store', '**/Thumbs.db']; + + const outputFiles: { source: string; destination: string }[] = []; + + for (const entry of entries) { + const cwd = path.resolve(root, entry.input); + const files = await glob(entry.glob, { + cwd, + dot: true, + ignore: entry.ignore ? defaultIgnore.concat(entry.ignore) : defaultIgnore, + followSymbolicLinks: entry.followSymlinks, + }); + + for (const file of files) { + const src = path.join(cwd, file); + const filePath = entry.flatten ? path.basename(file) : file; + + outputFiles.push({ source: src, destination: path.join(entry.output, filePath) }); + } + } + + return outputFiles; +} diff --git a/packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel b/packages/angular/build/src/utils/routes-extractor/BUILD.bazel similarity index 79% rename from packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel rename to packages/angular/build/src/utils/routes-extractor/BUILD.bazel index 36f352cbddc4..f9c6f8827f1f 100644 --- a/packages/angular_devkit/build_angular/src/utils/routes-extractor/BUILD.bazel +++ b/packages/angular/build/src/utils/routes-extractor/BUILD.bazel @@ -1,14 +1,14 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license load("//tools:defaults.bzl", "ts_library") # NOTE This is built as ESM as this is included in the users server bundle. licenses(["notice"]) -package(default_visibility = ["//packages/angular_devkit/build_angular:__subpackages__"]) +package(default_visibility = ["//packages/angular/build:__subpackages__"]) ts_library( name = "routes-extractor", diff --git a/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts b/packages/angular/build/src/utils/routes-extractor/extractor.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts rename to packages/angular/build/src/utils/routes-extractor/extractor.ts index 6c6d442e5634..14708c05f705 100644 --- a/packages/angular_devkit/build_angular/src/utils/routes-extractor/extractor.ts +++ b/packages/angular/build/src/utils/routes-extractor/extractor.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts similarity index 97% rename from packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts rename to packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts index d6a2448984f2..ca9e986bbb89 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/loader-hooks.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import assert from 'node:assert'; @@ -12,7 +12,6 @@ import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { fileURLToPath } from 'url'; import { JavaScriptTransformer } from '../../../tools/esbuild/javascript-transformer'; -import { callInitializeIfNeeded } from './node-18-utils'; /** * Node.js ESM loader to redirect imports to in memory files. @@ -37,8 +36,6 @@ const javascriptTransformer = new JavaScriptTransformer( 1, ); -callInitializeIfNeeded(initialize); - export function initialize(data: ESMInMemoryFileLoaderWorkerData) { // This path does not actually exist but is used to overlay the in memory files with the // actual filesystem for resolution purposes. diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts similarity index 85% rename from packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts rename to packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts index cf2bd309eaaf..b23fe297bc19 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts +++ b/packages/angular/build/src/utils/server-rendering/esm-in-memory-loader/register-hooks.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { register } from 'node:module'; diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/fetch-patch.ts b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts similarity index 87% rename from packages/angular_devkit/build_angular/src/utils/server-rendering/fetch-patch.ts rename to packages/angular/build/src/utils/server-rendering/fetch-patch.ts index b0eb6c0e9666..5ed2d88270c5 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/fetch-patch.ts +++ b/packages/angular/build/src/utils/server-rendering/fetch-patch.ts @@ -3,14 +3,13 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { lookup as lookupMimeType } from 'mrmime'; import { readFile } from 'node:fs/promises'; import { extname } from 'node:path'; import { workerData } from 'node:worker_threads'; -import { Response, fetch } from 'undici'; /** * This is passed as workerData when setting up the worker via the `piscina` package. @@ -25,8 +24,7 @@ const assetsCache: Map; co const RESOLVE_PROTOCOL = 'resolve:'; export function patchFetchToLoadInMemoryAssets(): void { - const global = globalThis as unknown as { fetch: typeof fetch }; - const originalFetch = global.fetch; + const originalFetch = globalThis.fetch; const patchedFetch: typeof fetch = async (input, init) => { let url: URL; if (input instanceof URL) { @@ -39,7 +37,8 @@ export function patchFetchToLoadInMemoryAssets(): void { return originalFetch(input, init); } - const { pathname, protocol } = url; + const { protocol } = url; + const pathname = decodeURIComponent(url.pathname); if (protocol !== RESOLVE_PROTOCOL || !assetFiles[pathname]) { // Only handle relative requests or files that are in assets. @@ -71,5 +70,5 @@ export function patchFetchToLoadInMemoryAssets(): void { }); }; - global.fetch = patchedFetch; + globalThis.fetch = patchedFetch; } diff --git a/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts new file mode 100644 index 000000000000..a3a3384545a4 --- /dev/null +++ b/packages/angular/build/src/utils/server-rendering/load-esm-from-memory.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { assertIsError } from '../error'; +import { loadEsmModule } from '../load-esm'; +import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports'; + +export function loadEsmModuleFromMemory( + path: './main.server.mjs', +): Promise; +export function loadEsmModuleFromMemory( + path: './render-utils.server.mjs', +): Promise; +export function loadEsmModuleFromMemory(path: string): Promise { + return loadEsmModule(new URL(path, 'memory://')).catch((e) => { + assertIsError(e); + + // While the error is an 'instanceof Error', it is extended with non transferable properties + // and cannot be transferred from a worker when using `--import`. This results in the error object + // displaying as '[Object object]' when read outside of the worker. Therefore, we reconstruct the error message here. + const error: Error & { code?: string } = new Error(e.message); + error.stack = e.stack; + error.name = e.name; + error.code = e.code; + + throw error; + }); +} diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts b/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts similarity index 94% rename from packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts rename to packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts index de5674b3c4c4..eb6f0f0dfb8c 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/main-bundle-exports.ts +++ b/packages/angular/build/src/utils/server-rendering/main-bundle-exports.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import type { ApplicationRef, Type, ɵConsole } from '@angular/core'; diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts b/packages/angular/build/src/utils/server-rendering/prerender.ts similarity index 80% rename from packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts rename to packages/angular/build/src/utils/server-rendering/prerender.ts index 9ce7e843d6bc..f8796a7ac861 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/prerender.ts +++ b/packages/angular/build/src/utils/server-rendering/prerender.ts @@ -3,15 +3,15 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { readFile } from 'node:fs/promises'; -import { extname, posix } from 'node:path'; +import { extname, join, posix } from 'node:path'; +import { pathToFileURL } from 'node:url'; import Piscina from 'piscina'; import { BuildOutputFile, BuildOutputFileType } from '../../tools/esbuild/bundler-context'; import { BuildOutputAsset } from '../../tools/esbuild/bundler-execution-result'; -import { getESMLoaderArgs } from './esm-in-memory-loader/node-18-utils'; import type { RenderResult, ServerContext } from './render-page'; import type { RenderWorkerData } from './render-worker'; import type { @@ -81,7 +81,11 @@ export async function prerenderPages( } // Get routes to prerender - const { routes: allRoutes, warnings: routesWarnings } = await getAllRoutes( + const { + routes: allRoutes, + warnings: routesWarnings, + errors: routesErrors, + } = await getAllRoutes( workspaceRoot, outputFilesForWorker, assetsReversed, @@ -92,11 +96,15 @@ export async function prerenderPages( verbose, ); + if (routesErrors?.length) { + errors.push(...routesErrors); + } + if (routesWarnings?.length) { warnings.push(...routesWarnings); } - if (allRoutes.size < 1) { + if (allRoutes.size < 1 || errors.length > 0) { return { errors, warnings, @@ -158,7 +166,12 @@ async function renderPages( const warnings: string[] = []; const errors: string[] = []; - const workerExecArgv = getESMLoaderArgs(); + const workerExecArgv = [ + '--import', + // Loader cannot be an absolute path on Windows. + pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, + ]; + if (sourcemap) { workerExecArgv.push('--enable-source-maps'); } @@ -185,22 +198,27 @@ async function renderPages( const isAppShellRoute = appShellRoute === route; const serverContext: ServerContext = isAppShellRoute ? 'app-shell' : 'ssg'; const render: Promise = renderWorker.run({ route, serverContext }); - const renderResult: Promise = render.then(({ content, warnings, errors }) => { - if (content !== undefined) { - const outPath = isAppShellRoute - ? 'index.html' - : posix.join(removeLeadingSlash(route), 'index.html'); - output[outPath] = content; - } - - if (warnings) { - warnings.push(...warnings); - } - - if (errors) { - errors.push(...errors); - } - }); + const renderResult: Promise = render + .then(({ content, warnings, errors }) => { + if (content !== undefined) { + const outPath = isAppShellRoute + ? 'index.html' + : posix.join(removeLeadingSlash(route), 'index.html'); + output[outPath] = content; + } + + if (warnings) { + warnings.push(...warnings); + } + + if (errors) { + errors.push(...errors); + } + }) + .catch((err) => { + errors.push(`An error occurred while prerendering route '${route}'.\n\n${err.stack}`); + void renderWorker.destroy(); + }); renderingPromises.push(renderResult); } @@ -226,7 +244,7 @@ async function getAllRoutes( prerenderOptions: PrerenderOptions, sourcemap: boolean, verbose: boolean, -): Promise<{ routes: Set; warnings?: string[] }> { +): Promise<{ routes: Set; warnings?: string[]; errors?: string[] }> { const { routesFile, discoverRoutes } = prerenderOptions; const routes = new RoutesSet(); const { route: appShellRoute } = appShellOptions; @@ -246,7 +264,12 @@ async function getAllRoutes( return { routes }; } - const workerExecArgv = getESMLoaderArgs(); + const workerExecArgv = [ + '--import', + // Loader cannot be an absolute path on Windows. + pathToFileURL(join(__dirname, 'esm-in-memory-loader/register-hooks.js')).href, + ]; + if (sourcemap) { workerExecArgv.push('--enable-source-maps'); } @@ -265,8 +288,12 @@ async function getAllRoutes( recordTiming: false, }); + const errors: string[] = []; const { routes: extractedRoutes, warnings }: RoutersExtractorWorkerResult = await renderWorker .run({}) + .catch((err) => { + errors.push(`An error occurred while extracting routes.\n\n${err.stack}`); + }) .finally(() => { void renderWorker.destroy(); }); @@ -275,7 +302,7 @@ async function getAllRoutes( routes.add(route); } - return { routes, warnings }; + return { routes, warnings, errors }; } function addLeadingSlash(value: string): string { diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts b/packages/angular/build/src/utils/server-rendering/render-page.ts similarity index 80% rename from packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts rename to packages/angular/build/src/utils/server-rendering/render-page.ts index a7cf0af1e577..aaf4509c35a2 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-page.ts +++ b/packages/angular/build/src/utils/server-rendering/render-page.ts @@ -3,13 +3,13 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import type { ApplicationRef, StaticProvider } from '@angular/core'; import assert from 'node:assert'; import { basename } from 'node:path'; -import { loadEsmModule } from '../load-esm'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports'; export interface RenderOptions { @@ -39,7 +39,7 @@ export async function renderPage({ document, inlineCriticalCss, outputFiles, - loadBundle = loadEsmModule, + loadBundle = loadEsmModuleFromMemory, }: RenderOptions): Promise { const { default: bootstrapAppFnOrModule } = await loadBundle('./main.server.mjs'); const { ɵSERVER_CONTEXT, renderModule, renderApplication, ɵresetCompiledComponents, ɵConsole } = @@ -73,26 +73,46 @@ export async function renderPage({ }, ]; - let html: string | undefined; assert( bootstrapAppFnOrModule, 'The file "./main.server.mjs" does not have a default export for an AppServerModule or a bootstrapping function.', ); + let renderAppPromise: Promise; if (isBootstrapFn(bootstrapAppFnOrModule)) { - html = await renderApplication(bootstrapAppFnOrModule, { + renderAppPromise = renderApplication(bootstrapAppFnOrModule, { document, url: route, platformProviders, }); } else { - html = await renderModule(bootstrapAppFnOrModule, { + renderAppPromise = renderModule(bootstrapAppFnOrModule, { document, url: route, extraProviders: platformProviders, }); } + // The below should really handled by the framework!!!. + // See: https://github.com/angular/angular/issues/51549 + let timer: NodeJS.Timeout; + const renderingTimeout = new Promise( + (_, reject) => + (timer = setTimeout( + () => + reject( + new Error( + `Page ${new URL(route, 'resolve://').pathname} did not render in 30 seconds.`, + ), + ), + 30_000, + )), + ); + + const html = await Promise.race([renderAppPromise, renderingTimeout]).finally(() => + clearTimeout(timer), + ); + if (inlineCriticalCss) { const { InlineCriticalCssProcessor } = await import( '../../utils/index-file/inline-critical-css' diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts b/packages/angular/build/src/utils/server-rendering/render-worker.ts similarity index 86% rename from packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts rename to packages/angular/build/src/utils/server-rendering/render-worker.ts index e5c71d31d441..e7e439838a21 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/render-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/render-worker.ts @@ -3,11 +3,10 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { workerData } from 'node:worker_threads'; -import { loadEsmModule } from '../load-esm'; import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; import { RenderResult, ServerContext, renderPage } from './render-page'; @@ -35,7 +34,6 @@ function render(options: RenderOptions): Promise { outputFiles, document, inlineCriticalCss, - loadBundle: async (path) => await loadEsmModule(new URL(path, 'memory://')), }); } diff --git a/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts similarity index 83% rename from packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts rename to packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts index 36b46e3fcaed..44dbfb3cb2e3 100644 --- a/packages/angular_devkit/build_angular/src/utils/server-rendering/routes-extractor-worker.ts +++ b/packages/angular/build/src/utils/server-rendering/routes-extractor-worker.ts @@ -3,14 +3,13 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { workerData } from 'node:worker_threads'; -import { loadEsmModule } from '../load-esm'; import type { ESMInMemoryFileLoaderWorkerData } from './esm-in-memory-loader/loader-hooks'; import { patchFetchToLoadInMemoryAssets } from './fetch-patch'; -import { MainServerBundleExports, RenderUtilsServerBundleExports } from './main-bundle-exports'; +import { loadEsmModuleFromMemory } from './load-esm-from-memory'; export interface RoutesExtractorWorkerData extends ESMInMemoryFileLoaderWorkerData { document: string; @@ -30,12 +29,8 @@ const { document, verbose } = workerData as RoutesExtractorWorkerData; /** Renders an application based on a provided options. */ async function extractRoutes(): Promise { - const { extractRoutes } = await loadEsmModule( - new URL('./render-utils.server.mjs', 'memory://'), - ); - const { default: bootstrapAppFnOrModule } = await loadEsmModule( - new URL('./main.server.mjs', 'memory://'), - ); + const { extractRoutes } = await loadEsmModuleFromMemory('./render-utils.server.mjs'); + const { default: bootstrapAppFnOrModule } = await loadEsmModuleFromMemory('./main.server.mjs'); const skippedRedirects: string[] = []; const skippedOthers: string[] = []; diff --git a/packages/angular_devkit/build_angular/src/utils/service-worker.ts b/packages/angular/build/src/utils/service-worker.ts similarity index 98% rename from packages/angular_devkit/build_angular/src/utils/service-worker.ts rename to packages/angular/build/src/utils/service-worker.ts index cd013b90fe59..96447012652f 100644 --- a/packages/angular_devkit/build_angular/src/utils/service-worker.ts +++ b/packages/angular/build/src/utils/service-worker.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import type { Config, Filesystem } from '@angular/service-worker/config'; @@ -180,6 +180,7 @@ export async function augmentAppWithServiceWorkerEsbuild( workspaceRoot: string, configPath: string, baseHref: string, + indexHtml: string | undefined, outputFiles: BuildOutputFile[], assetFiles: BuildOutputAsset[], ): Promise<{ manifest: string; assetFiles: BuildOutputAsset[] }> { @@ -188,6 +189,10 @@ export async function augmentAppWithServiceWorkerEsbuild( try { const configurationData = await fsPromises.readFile(configPath, 'utf-8'); config = JSON.parse(configurationData) as Config; + + if (indexHtml) { + config.index = indexHtml; + } } catch (error) { assertIsError(error); if (error.code === 'ENOENT') { diff --git a/packages/angular/build/src/utils/stats-table.ts b/packages/angular/build/src/utils/stats-table.ts new file mode 100644 index 000000000000..b007fd7a4aa5 --- /dev/null +++ b/packages/angular/build/src/utils/stats-table.ts @@ -0,0 +1,290 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import { stripVTControlCharacters } from 'node:util'; +import { BudgetCalculatorResult } from './bundle-calculator'; +import { colors as ansiColors } from './color'; +import { formatSize } from './format-bytes'; + +export type BundleStatsData = [ + files: string, + names: string, + rawSize: number | string, + estimatedTransferSize: number | string, +]; +export interface BundleStats { + initial: boolean; + stats: BundleStatsData; +} + +export function generateEsbuildBuildStatsTable( + [browserStats, serverStats]: [browserStats: BundleStats[], serverStats: BundleStats[]], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], + verbose?: boolean, +): string { + const bundleInfo = generateBuildStatsData( + browserStats, + colors, + showTotalSize, + showEstimatedTransferSize, + budgetFailures, + verbose, + ); + + if (serverStats.length) { + const m = (x: string) => (colors ? ansiColors.magenta(x) : x); + if (browserStats.length) { + bundleInfo.unshift([m('Browser bundles')]); + // Add seperators between browser and server logs + bundleInfo.push([], []); + } + + bundleInfo.push( + [m('Server bundles')], + ...generateBuildStatsData(serverStats, colors, false, false, undefined, verbose), + ); + } + + return generateTableText(bundleInfo, colors); +} + +export function generateBuildStatsTable( + data: BundleStats[], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], +): string { + const bundleInfo = generateBuildStatsData( + data, + colors, + showTotalSize, + showEstimatedTransferSize, + budgetFailures, + true, + ); + + return generateTableText(bundleInfo, colors); +} + +function generateBuildStatsData( + data: BundleStats[], + colors: boolean, + showTotalSize: boolean, + showEstimatedTransferSize: boolean, + budgetFailures?: BudgetCalculatorResult[], + verbose?: boolean, +): (string | number)[][] { + if (data.length === 0) { + return []; + } + + const g = (x: string) => (colors ? ansiColors.green(x) : x); + const c = (x: string) => (colors ? ansiColors.cyan(x) : x); + const r = (x: string) => (colors ? ansiColors.redBright(x) : x); + const y = (x: string) => (colors ? ansiColors.yellowBright(x) : x); + const bold = (x: string) => (colors ? ansiColors.bold(x) : x); + const dim = (x: string) => (colors ? ansiColors.dim(x) : x); + + const getSizeColor = (name: string, file?: string, defaultColor = c) => { + const severity = budgets.get(name) || (file && budgets.get(file)); + switch (severity) { + case 'warning': + return y; + case 'error': + return r; + default: + return defaultColor; + } + }; + + const changedEntryChunksStats: BundleStatsData[] = []; + const changedLazyChunksStats: BundleStatsData[] = []; + + let initialTotalRawSize = 0; + let changedLazyChunksCount = 0; + let initialTotalEstimatedTransferSize; + const maxLazyChunksWithoutBudgetFailures = 15; + + const budgets = new Map(); + if (budgetFailures) { + for (const { label, severity } of budgetFailures) { + // In some cases a file can have multiple budget failures. + // Favor error. + if (label && (!budgets.has(label) || budgets.get(label) === 'warning')) { + budgets.set(label, severity); + } + } + } + + // Sort descending by raw size + data.sort((a, b) => { + if (a.stats[2] > b.stats[2]) { + return -1; + } + + if (a.stats[2] < b.stats[2]) { + return 1; + } + + return 0; + }); + + for (const { initial, stats } of data) { + const [files, names, rawSize, estimatedTransferSize] = stats; + if ( + !initial && + !verbose && + changedLazyChunksStats.length >= maxLazyChunksWithoutBudgetFailures && + !budgets.has(names) && + !budgets.has(files) + ) { + // Limit the number of lazy chunks displayed in the stats table when there is no budget failure and not in verbose mode. + changedLazyChunksCount++; + continue; + } + + const getRawSizeColor = getSizeColor(names, files); + let data: BundleStatsData; + if (showEstimatedTransferSize) { + data = [ + g(files), + dim(names), + getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + c( + typeof estimatedTransferSize === 'number' + ? formatSize(estimatedTransferSize) + : estimatedTransferSize, + ), + ]; + } else { + data = [ + g(files), + dim(names), + getRawSizeColor(typeof rawSize === 'number' ? formatSize(rawSize) : rawSize), + '', + ]; + } + + if (initial) { + changedEntryChunksStats.push(data); + if (typeof rawSize === 'number') { + initialTotalRawSize += rawSize; + } + if (showEstimatedTransferSize && typeof estimatedTransferSize === 'number') { + if (initialTotalEstimatedTransferSize === undefined) { + initialTotalEstimatedTransferSize = 0; + } + initialTotalEstimatedTransferSize += estimatedTransferSize; + } + } else { + changedLazyChunksStats.push(data); + changedLazyChunksCount++; + } + } + + const bundleInfo: (string | number)[][] = []; + const baseTitles = ['Names', 'Raw size']; + + if (showEstimatedTransferSize) { + baseTitles.push('Estimated transfer size'); + } + + // Entry chunks + if (changedEntryChunksStats.length) { + bundleInfo.push(['Initial chunk files', ...baseTitles].map(bold), ...changedEntryChunksStats); + + if (showTotalSize) { + const initialSizeTotalColor = getSizeColor('bundle initial', undefined, (x) => x); + const totalSizeElements = [ + ' ', + 'Initial total', + initialSizeTotalColor(formatSize(initialTotalRawSize)), + ]; + if (showEstimatedTransferSize) { + totalSizeElements.push( + typeof initialTotalEstimatedTransferSize === 'number' + ? formatSize(initialTotalEstimatedTransferSize) + : '-', + ); + } + bundleInfo.push([], totalSizeElements.map(bold)); + } + } + + // Seperator + if (changedEntryChunksStats.length && changedLazyChunksStats.length) { + bundleInfo.push([]); + } + + // Lazy chunks + if (changedLazyChunksStats.length) { + bundleInfo.push(['Lazy chunk files', ...baseTitles].map(bold), ...changedLazyChunksStats); + + if (changedLazyChunksCount > changedLazyChunksStats.length) { + bundleInfo.push([ + dim( + `...and ${changedLazyChunksCount - changedLazyChunksStats.length} more lazy chunks files. ` + + 'Use "--verbose" to show all the files.', + ), + ]); + } + } + + return bundleInfo; +} + +function generateTableText(bundleInfo: (string | number)[][], colors: boolean): string { + const skipText = (value: string) => value.includes('...and '); + const longest: number[] = []; + for (const item of bundleInfo) { + for (let i = 0; i < item.length; i++) { + if (item[i] === undefined) { + continue; + } + + const currentItem = item[i].toString(); + if (skipText(currentItem)) { + continue; + } + + const currentLongest = (longest[i] ??= 0); + const currentItemLength = stripVTControlCharacters(currentItem).length; + if (currentLongest < currentItemLength) { + longest[i] = currentItemLength; + } + } + } + + const seperator = colors ? ansiColors.dim(' | ') : ' | '; + const outputTable: string[] = []; + for (const item of bundleInfo) { + for (let i = 0; i < longest.length; i++) { + if (item[i] === undefined) { + continue; + } + + const currentItem = item[i].toString(); + if (skipText(currentItem)) { + continue; + } + + const currentItemLength = stripVTControlCharacters(currentItem).length; + const stringPad = ' '.repeat(longest[i] - currentItemLength); + // Values in columns at index 2 and 3 (Raw and Estimated sizes) are always right aligned. + item[i] = i >= 2 ? stringPad + currentItem : currentItem + stringPad; + } + + outputTable.push(item.join(seperator)); + } + + return outputTable.join('\n'); +} diff --git a/packages/angular_devkit/build_angular/src/utils/supported-browsers.ts b/packages/angular/build/src/utils/supported-browsers.ts similarity index 86% rename from packages/angular_devkit/build_angular/src/utils/supported-browsers.ts rename to packages/angular/build/src/utils/supported-browsers.ts index 0dbe083ca14a..79674a62beae 100644 --- a/packages/angular_devkit/build_angular/src/utils/supported-browsers.ts +++ b/packages/angular/build/src/utils/supported-browsers.ts @@ -3,13 +3,15 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import { logging } from '@angular-devkit/core'; import browserslist from 'browserslist'; -export function getSupportedBrowsers(projectRoot: string, logger: logging.LoggerApi): string[] { +export function getSupportedBrowsers( + projectRoot: string, + logger: { warn(message: string): void }, +): string[] { browserslist.defaults = [ 'last 2 Chrome versions', 'last 1 Firefox version', diff --git a/packages/angular/build/src/utils/tty.ts b/packages/angular/build/src/utils/tty.ts new file mode 100644 index 000000000000..0d669c0301e3 --- /dev/null +++ b/packages/angular/build/src/utils/tty.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +function _isTruthy(value: undefined | string): boolean { + // Returns true if value is a string that is anything but 0 or false. + return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'; +} + +export function isTTY(): boolean { + // If we force TTY, we always return true. + const force = process.env['NG_FORCE_TTY']; + if (force !== undefined) { + return _isTruthy(force); + } + + return !!process.stdout.isTTY && !_isTruthy(process.env['CI']); +} diff --git a/packages/angular/build/src/utils/url.ts b/packages/angular/build/src/utils/url.ts new file mode 100644 index 000000000000..d3f1e5791276 --- /dev/null +++ b/packages/angular/build/src/utils/url.ts @@ -0,0 +1,16 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +export function urlJoin(...parts: string[]): string { + const [p, ...rest] = parts; + + // Remove trailing slash from first part + // Join all parts with `/` + // Dedupe double slashes from path names + return p.replace(/\/$/, '') + ('/' + rest.join('/')).replace(/\/\/+/g, '/'); +} diff --git a/packages/angular_devkit/build_angular/src/utils/version.ts b/packages/angular/build/src/utils/version.ts similarity index 83% rename from packages/angular_devkit/build_angular/src/utils/version.ts rename to packages/angular/build/src/utils/version.ts index 00bd3c8fb14a..80c531336bcb 100644 --- a/packages/angular_devkit/build_angular/src/utils/version.ts +++ b/packages/angular/build/src/utils/version.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ /* eslint-disable no-console */ @@ -56,16 +56,24 @@ export function assertCompatibleAngularVersion(projectRoot: string): void | neve return; } - const supportedAngularSemver = projectRequire('@angular-devkit/build-angular/package.json')[ - 'peerDependencies' - ]['@angular/compiler-cli']; + let supportedAngularSemver; + try { + supportedAngularSemver = projectRequire('@angular/build/package.json')['peerDependencies'][ + '@angular/compiler-cli' + ]; + } catch { + supportedAngularSemver = projectRequire('@angular-devkit/build-angular/package.json')[ + 'peerDependencies' + ]['@angular/compiler-cli']; + } + const angularVersion = new SemVer(angularPkgJson['version']); if (!satisfies(angularVersion, supportedAngularSemver, { includePrerelease: true })) { console.error( `This version of CLI is only compatible with Angular versions ${supportedAngularSemver},\n` + `but Angular version ${angularVersion} was found instead.\n` + - 'Please visit the link below to find instructions on how to update Angular.\nhttps://update.angular.io/', + 'Please visit the link below to find instructions on how to update Angular.\nhttps://update.angular.dev/', ); process.exit(3); diff --git a/packages/angular/cli/BUILD.bazel b/packages/angular/cli/BUILD.bazel index c6d55809442d..bfdcaca10e98 100644 --- a/packages/angular/cli/BUILD.bazel +++ b/packages/angular/cli/BUILD.bazel @@ -1,7 +1,7 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") load("//tools:defaults.bzl", "pkg_npm", "ts_library") @@ -54,8 +54,9 @@ ts_library( "//packages/angular_devkit/schematics/tasks", "//packages/angular_devkit/schematics/tools", "@npm//@angular/core", + "@npm//@inquirer/prompts", + "@npm//@listr2/prompt-adapter-inquirer", "@npm//@types/ini", - "@npm//@types/inquirer", "@npm//@types/node", "@npm//@types/npm-package-arg", "@npm//@types/pacote", @@ -64,12 +65,11 @@ ts_library( "@npm//@types/yargs", "@npm//@types/yarnpkg__lockfile", "@npm//@yarnpkg/lockfile", - "@npm//ansi-colors", "@npm//ini", "@npm//jsonc-parser", + "@npm//listr2", "@npm//npm-package-arg", - "@npm//open", - "@npm//ora", + "@npm//npm-pick-manifest", "@npm//pacote", "@npm//semver", "@npm//yargs", @@ -78,7 +78,9 @@ ts_library( # @external_begin CLI_SCHEMA_DATA = [ - "//packages/angular_devkit/build_angular:src/builders/application/schema.json", + "//packages/angular/build:src/builders/application/schema.json", + "//packages/angular/build:src/builders/dev-server/schema.json", + "//packages/angular/build:src/builders/extract-i18n/schema.json", "//packages/angular_devkit/build_angular:src/builders/app-shell/schema.json", "//packages/angular_devkit/build_angular:src/builders/browser/schema.json", "//packages/angular_devkit/build_angular:src/builders/browser-esbuild/schema.json", diff --git a/packages/angular/cli/bin/bootstrap.js b/packages/angular/cli/bin/bootstrap.js index 75e454ee74ff..96b978296dcc 100644 --- a/packages/angular/cli/bin/bootstrap.js +++ b/packages/angular/cli/bin/bootstrap.js @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ /** diff --git a/packages/angular/cli/bin/ng.js b/packages/angular/cli/bin/ng.js index 7b2825c9f248..8c39f94f8408 100755 --- a/packages/angular/cli/bin/ng.js +++ b/packages/angular/cli/bin/ng.js @@ -4,7 +4,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ /* eslint-disable no-console */ @@ -55,13 +55,13 @@ if (version[0] % 2 === 1) { ); require('./bootstrap'); -} else if (version[0] < 18 || (version[0] === 18 && version[1] < 13)) { - // Error and exit if less than 18.13 +} else if (version[0] < 18 || (version[0] === 18 && version[1] < 19)) { + // Error and exit if less than 18.19 console.error( 'Node.js version ' + process.version + ' detected.\n' + - 'The Angular CLI requires a minimum Node.js version of v18.13.\n\n' + + 'The Angular CLI requires a minimum Node.js version of v18.19.\n\n' + 'Please update your Node.js version or visit https://nodejs.org/ for additional instructions.\n', ); diff --git a/packages/angular/cli/lib/cli/index.ts b/packages/angular/cli/lib/cli/index.ts index 70eedbbe5e85..361d44f0bbb5 100644 --- a/packages/angular/cli/lib/cli/index.ts +++ b/packages/angular/cli/lib/cli/index.ts @@ -3,14 +3,14 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; -import { format } from 'util'; +import { format, stripVTControlCharacters } from 'node:util'; import { CommandModuleError } from '../../src/command-builder/command-module'; import { runCommand } from '../../src/command-builder/command-runner'; -import { colors, removeColor } from '../../src/utilities/color'; +import { colors, supportColor } from '../../src/utilities/color'; import { ngDebug } from '../../src/utilities/environment-options'; import { writeErrorToLogFile } from '../../src/utilities/log-file'; @@ -38,20 +38,21 @@ export default async function (options: { cliArgs: string[] }) { const colorLevels: Record string> = { info: (s) => s, debug: (s) => s, - warn: (s) => colors.bold.yellow(s), - error: (s) => colors.bold.red(s), - fatal: (s) => colors.bold.red(s), + warn: (s) => colors.bold(colors.yellow(s)), + error: (s) => colors.bold(colors.red(s)), + fatal: (s) => colors.bold(colors.red(s)), }; const logger = new logging.IndentLogger('cli-main-logger'); const logInfo = console.log; const logError = console.error; + const useColor = supportColor(); const loggerFinished = logger.forEach((entry) => { if (!ngDebug && entry.level === 'debug') { return; } - const color = colors.enabled ? colorLevels[entry.level] : removeColor; + const color = useColor ? colorLevels[entry.level] : stripVTControlCharacters; const message = color(entry.message); switch (entry.level) { diff --git a/packages/angular/cli/lib/config/workspace-schema.json b/packages/angular/cli/lib/config/workspace-schema.json index 2695b69e9215..650ae3ae18f2 100644 --- a/packages/angular/cli/lib/config/workspace-schema.json +++ b/packages/angular/cli/lib/config/workspace-schema.json @@ -354,6 +354,9 @@ "description": "The builder used for this package.", "not": { "enum": [ + "@angular/build:application", + "@angular/build:dev-server", + "@angular/build:extract-i18n", "@angular-devkit/build-angular:application", "@angular-devkit/build-angular:app-shell", "@angular-devkit/build-angular:browser", @@ -389,6 +392,28 @@ "additionalProperties": false, "required": ["builder"] }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:application" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/application/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -401,12 +426,12 @@ "description": "A default named configuration to use when a target configuration is not provided." }, "options": { - "$ref": "../../../../angular_devkit/build_angular/src/builders/application/schema.json" + "$ref": "../../../../angular/build/src/builders/application/schema.json" }, "configurations": { "type": "object", "additionalProperties": { - "$ref": "../../../../angular_devkit/build_angular/src/builders/application/schema.json" + "$ref": "../../../../angular/build/src/builders/application/schema.json" } } } @@ -477,6 +502,28 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:dev-server" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/dev-server/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -499,6 +546,28 @@ } } }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "builder": { + "const": "@angular/build:extract-i18n" + }, + "defaultConfiguration": { + "type": "string", + "description": "A default named configuration to use when a target configuration is not provided." + }, + "options": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + }, + "configurations": { + "type": "object", + "additionalProperties": { + "$ref": "../../../../angular/build/src/builders/extract-i18n/schema.json" + } + } + } + }, { "type": "object", "additionalProperties": false, diff --git a/packages/angular/cli/lib/init.ts b/packages/angular/cli/lib/init.ts index c23499622c66..dc3d54ab1ded 100644 --- a/packages/angular/cli/lib/init.ts +++ b/packages/angular/cli/lib/init.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import 'symbol-observable'; diff --git a/packages/angular/cli/package.json b/packages/angular/cli/package.json index 2b69df806dbb..90e3249cd004 100644 --- a/packages/angular/cli/package.json +++ b/packages/angular/cli/package.json @@ -25,19 +25,18 @@ "@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER", "@angular-devkit/core": "0.0.0-PLACEHOLDER", "@angular-devkit/schematics": "0.0.0-PLACEHOLDER", + "@inquirer/prompts": "5.3.8", + "@listr2/prompt-adapter-inquirer": "2.0.15", "@schematics/angular": "0.0.0-PLACEHOLDER", "@yarnpkg/lockfile": "1.1.0", - "ansi-colors": "4.1.3", - "ini": "4.1.2", - "inquirer": "9.2.15", - "jsonc-parser": "3.2.1", - "npm-package-arg": "11.0.1", - "npm-pick-manifest": "9.0.0", - "open": "8.4.2", - "ora": "5.4.1", - "pacote": "17.0.6", + "ini": "4.1.3", + "jsonc-parser": "3.3.1", + "listr2": "8.2.4", + "npm-package-arg": "11.0.3", + "npm-pick-manifest": "9.1.0", + "pacote": "18.0.6", "resolve": "1.22.8", - "semver": "7.6.0", + "semver": "7.6.3", "symbol-observable": "4.0.0", "yargs": "17.7.2" }, @@ -45,6 +44,7 @@ "migrations": "@schematics/angular/migrations/migration-collection.json", "packageGroup": { "@angular/cli": "0.0.0-PLACEHOLDER", + "@angular/build": "0.0.0-PLACEHOLDER", "@angular/ssr": "0.0.0-PLACEHOLDER", "@angular-devkit/architect": "0.0.0-EXPERIMENTAL-PLACEHOLDER", "@angular-devkit/build-angular": "0.0.0-PLACEHOLDER", diff --git a/packages/angular/cli/src/analytics/analytics-collector.ts b/packages/angular/cli/src/analytics/analytics-collector.ts index b43d7e5e16f5..379006bef2b9 100644 --- a/packages/angular/cli/src/analytics/analytics-collector.ts +++ b/packages/angular/cli/src/analytics/analytics-collector.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { randomUUID } from 'crypto'; @@ -31,7 +31,10 @@ export class AnalyticsCollector { private readonly requestParameterStringified: string; private readonly userParameters: Record; - constructor(private context: CommandContext, userId: string) { + constructor( + private context: CommandContext, + userId: string, + ) { const requestParameters: Partial> = { [RequestParameter.ProtocolVersion]: 2, [RequestParameter.ClientId]: userId, diff --git a/packages/angular/cli/src/analytics/analytics-parameters.mts b/packages/angular/cli/src/analytics/analytics-parameters.mts new file mode 100644 index 000000000000..0fd81c4531f0 --- /dev/null +++ b/packages/angular/cli/src/analytics/analytics-parameters.mts @@ -0,0 +1,105 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +/** This is a copy of analytics-parameters.ts and is needed for `yarn admin validate-user-analytics` due to ts-node. */ + +/** + * GA built-in request parameters + * @see https://www.thyngster.com/ga4-measurement-protocol-cheatsheet + * @see http://go/depot/google3/analytics/container_tag/templates/common/gold/mpv2_schema.js + */ +export enum RequestParameter { + ClientId = 'cid', + DebugView = '_dbg', + GtmVersion = 'gtm', + Language = 'ul', + NewToSite = '_nsi', + NonInteraction = 'ni', + PageLocation = 'dl', + PageTitle = 'dt', + ProtocolVersion = 'v', + SessionEngaged = 'seg', + SessionId = 'sid', + SessionNumber = 'sct', + SessionStart = '_ss', + TrackingId = 'tid', + TrafficType = 'tt', + UserAgentArchitecture = 'uaa', + UserAgentBitness = 'uab', + UserAgentFullVersionList = 'uafvl', + UserAgentMobile = 'uamb', + UserAgentModel = 'uam', + UserAgentPlatform = 'uap', + UserAgentPlatformVersion = 'uapv', + UserId = 'uid', +} + +/** + * User scoped custom dimensions. + * @notes + * - User custom dimensions limit is 25. + * - `up.*` string type. + * - `upn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum UserCustomDimension { + UserId = 'up.ng_user_id', + OsArchitecture = 'up.ng_os_architecture', + NodeVersion = 'up.ng_node_version', + NodeMajorVersion = 'upn.ng_node_major_version', + AngularCLIVersion = 'up.ng_cli_version', + AngularCLIMajorVersion = 'upn.ng_cli_major_version', + PackageManager = 'up.ng_package_manager', + PackageManagerVersion = 'up.ng_pkg_manager_version', + PackageManagerMajorVersion = 'upn.ng_pkg_manager_major_v', +} + +/** + * Event scoped custom dimensions. + * @notes + * - Event custom dimensions limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomDimension { + Command = 'ep.ng_command', + SchematicCollectionName = 'ep.ng_schematic_collection_name', + SchematicName = 'ep.ng_schematic_name', + Standalone = 'ep.ng_standalone', + SSR = 'ep.ng_ssr', + Style = 'ep.ng_style', + Routing = 'ep.ng_routing', + InlineTemplate = 'ep.ng_inline_template', + InlineStyle = 'ep.ng_inline_style', + BuilderTarget = 'ep.ng_builder_target', + Aot = 'ep.ng_aot', + Optimization = 'ep.ng_optimization', +} + +/** + * Event scoped custom mertics. + * @notes + * - Event scoped custom mertics limit is 50. + * - `ep.*` string type. + * - `epn.*` number type. + * @see https://support.google.com/analytics/answer/10075209?hl=en + */ +export enum EventCustomMetric { + AllChunksCount = 'epn.ng_all_chunks_count', + LazyChunksCount = 'epn.ng_lazy_chunks_count', + InitialChunksCount = 'epn.ng_initial_chunks_count', + ChangedChunksCount = 'epn.ng_changed_chunks_count', + DurationInMs = 'epn.ng_duration_ms', + CssSizeInBytes = 'epn.ng_css_size_bytes', + JsSizeInBytes = 'epn.ng_js_size_bytes', + NgComponentCount = 'epn.ng_component_count', + AllProjectsCount = 'epn.all_projects_count', + LibraryProjectsCount = 'epn.libs_projects_count', + ApplicationProjectsCount = 'epn.apps_projects_count', +} diff --git a/packages/angular/cli/src/analytics/analytics-parameters.ts b/packages/angular/cli/src/analytics/analytics-parameters.ts index 04a6ee188e96..7249131f348e 100644 --- a/packages/angular/cli/src/analytics/analytics-parameters.ts +++ b/packages/angular/cli/src/analytics/analytics-parameters.ts @@ -3,9 +3,11 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ +/** Any changes in this file needs to be done in the mts version. */ + export type PrimitiveTypes = string | number | boolean; /** diff --git a/packages/angular/cli/src/analytics/analytics.ts b/packages/angular/cli/src/analytics/analytics.ts index e928d3469d5e..f107f6f5ca22 100644 --- a/packages/angular/cli/src/analytics/analytics.ts +++ b/packages/angular/cli/src/analytics/analytics.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { json, tags } from '@angular-devkit/core'; @@ -12,7 +12,7 @@ import type { CommandContext } from '../command-builder/command-module'; import { colors } from '../utilities/color'; import { getWorkspace } from '../utilities/config'; import { analyticsDisabled } from '../utilities/environment-options'; -import { loadEsmModule } from '../utilities/load-esm'; +import { askConfirmation } from '../utilities/prompt'; import { isTTY } from '../utilities/tty'; /* eslint-disable no-console */ @@ -75,24 +75,19 @@ export async function promptAnalytics( } if (force || isTTY()) { - const { default: inquirer } = await loadEsmModule('inquirer'); - const answers = await inquirer.prompt<{ analytics: boolean }>([ - { - type: 'confirm', - name: 'analytics', - message: tags.stripIndents` - Would you like to share pseudonymous usage data about this project with the Angular Team - at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more - details and how to change this setting, see https://angular.io/analytics. - - `, - default: false, - }, - ]); - - await setAnalyticsConfig(global, answers.analytics); - - if (answers.analytics) { + const answer = await askConfirmation( + ` +Would you like to share pseudonymous usage data about this project with the Angular Team +at Google under Google's Privacy Policy at https://policies.google.com/privacy. For more +details and how to change this setting, see https://angular.dev/cli/analytics. + + `, + false, + ); + + await setAnalyticsConfig(global, answer); + + if (answer) { console.log(''); console.log( tags.stripIndent` diff --git a/packages/angular/cli/src/command-builder/architect-base-command-module.ts b/packages/angular/cli/src/command-builder/architect-base-command-module.ts index bf370c8375f0..5835a14101bd 100644 --- a/packages/angular/cli/src/command-builder/architect-base-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-base-command-module.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Architect, Target } from '@angular-devkit/architect'; @@ -41,7 +41,7 @@ export abstract class ArchitectBaseCommandModule protected readonly missingTargetChoices: MissingTargetChoice[] | undefined; protected async runSingleTarget(target: Target, options: OtherOptions): Promise { - const architectHost = await this.getArchitectHost(); + const architectHost = this.getArchitectHost(); let builderName: string; try { diff --git a/packages/angular/cli/src/command-builder/architect-command-module.ts b/packages/angular/cli/src/command-builder/architect-command-module.ts index a57c74f0eeef..4855b629b360 100644 --- a/packages/angular/cli/src/command-builder/architect-command-module.ts +++ b/packages/angular/cli/src/command-builder/architect-command-module.ts @@ -3,9 +3,11 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ +import { Target } from '@angular-devkit/architect'; +import { workspaces } from '@angular-devkit/core'; import { Argv } from 'yargs'; import { getProjectByCwd } from '../utilities/config'; import { memoize } from '../utilities/memoize'; @@ -28,7 +30,33 @@ export abstract class ArchitectCommandModule { abstract readonly multiTarget: boolean; + findDefaultBuilderName?( + project: workspaces.ProjectDefinition, + target: Target, + ): Promise; + async builder(argv: Argv): Promise> { + const target = this.getArchitectTarget(); + + // Add default builder if target is not in project and a command default is provided + if (this.findDefaultBuilderName && this.context.workspace) { + for (const [project, projectDefinition] of this.context.workspace.projects) { + if (projectDefinition.targets.has(target)) { + continue; + } + + const defaultBuilder = await this.findDefaultBuilderName(projectDefinition, { + project, + target, + }); + if (defaultBuilder) { + projectDefinition.targets.set(target, { + builder: defaultBuilder, + }); + } + } + } + const project = this.getArchitectProject(); const { jsonHelp, getYargsCompletions, help } = this.context.args.options; @@ -44,7 +72,7 @@ export abstract class ArchitectCommandModule `One or more named builder configurations as a comma-separated ` + `list as specified in the "configurations" section in angular.json.\n` + `The builder uses the named configurations to run the given target.\n` + - `For more information, see https://angular.io/guide/workspace-config#alternate-build-configurations.`, + `For more information, see https://angular.dev/reference/configs/workspace-config#alternate-build-configurations.`, alias: 'c', type: 'string', // Show only in when using --help and auto completion because otherwise comma seperated configuration values will be invalid. @@ -60,7 +88,6 @@ export abstract class ArchitectCommandModule return localYargs; } - const target = this.getArchitectTarget(); const schemaOptions = await this.getArchitectTargetOptions({ project, target, diff --git a/packages/angular/cli/src/command-builder/command-module.ts b/packages/angular/cli/src/command-builder/command-module.ts index 3e3a13e3ce38..e608c4b1d089 100644 --- a/packages/angular/cli/src/command-builder/command-module.ts +++ b/packages/angular/cli/src/command-builder/command-module.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging, schema, strings } from '@angular-devkit/core'; @@ -176,8 +176,8 @@ export abstract class CommandModule implements CommandModuleI const userId = await getAnalyticsUserId( this.context, - // Don't prompt for `ng update` and `ng analytics` commands. - ['update', 'analytics'].includes(this.commandName), + // Don't prompt on `ng update`, 'ng version' or `ng analytics`. + ['version', 'update', 'analytics'].includes(this.commandName), ); return userId ? new AnalyticsCollector(this.context, userId) : undefined; diff --git a/packages/angular/cli/src/command-builder/command-runner.ts b/packages/angular/cli/src/command-builder/command-runner.ts index bacf9ac98626..0c2242414ce1 100644 --- a/packages/angular/cli/src/command-builder/command-runner.ts +++ b/packages/angular/cli/src/command-builder/command-runner.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; @@ -19,6 +19,7 @@ import { colors } from '../utilities/color'; import { AngularWorkspace, getWorkspace } from '../utilities/config'; import { assertIsError } from '../utilities/error'; import { PackageManagerUtils } from '../utilities/package-manager'; +import { VERSION } from '../utilities/version'; import { CommandContext, CommandModuleError } from './command-module'; import { CommandModuleConstructor, @@ -90,6 +91,11 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis usageInstance.help = () => jsonHelpUsage(); } + // Add default command to support version option when no subcommand is specified + localYargs.command('*', false, (builder) => + builder.version('version', 'Show Angular CLI version.', VERSION.full), + ); + await localYargs .scriptName('ng') // https://github.com/yargs/yargs/blob/main/docs/advanced.md#customizing-yargs-parser @@ -118,7 +124,7 @@ export async function runCommand(args: string[], logger: logging.Logger): Promis 'deprecated: %s': colors.yellow('deprecated:') + ' %s', 'Did you mean %s?': 'Unknown command. Did you mean %s?', }) - .epilogue('For more information, see https://angular.io/cli/.\n') + .epilogue('For more information, see https://angular.dev/cli/.\n') .demandCommand(1, demandCommandFailureMessage) .recommendCommands() .middleware(normalizeOptionsMiddleware) diff --git a/packages/angular/cli/src/command-builder/schematics-command-module.ts b/packages/angular/cli/src/command-builder/schematics-command-module.ts index f04a028363a3..139f7d89059f 100644 --- a/packages/angular/cli/src/command-builder/schematics-command-module.ts +++ b/packages/angular/cli/src/command-builder/schematics-command-module.ts @@ -3,24 +3,22 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import { normalize as devkitNormalize, schema } from '@angular-devkit/core'; +import { JsonValue, normalize as devkitNormalize, schema } from '@angular-devkit/core'; import { Collection, UnsuccessfulWorkflowExecution, formats } from '@angular-devkit/schematics'; import { FileSystemCollectionDescription, FileSystemSchematicDescription, NodeWorkflow, } from '@angular-devkit/schematics/tools'; -import type { CheckboxQuestion, Question } from 'inquirer'; import { relative, resolve } from 'path'; import { Argv } from 'yargs'; import { isPackageNameSafeForAnalytics } from '../analytics/analytics'; import { EventCustomDimension } from '../analytics/analytics-parameters'; import { getProjectByCwd, getSchematicDefaults } from '../utilities/config'; import { assertIsError } from '../utilities/error'; -import { loadEsmModule } from '../utilities/load-esm'; import { memoize } from '../utilities/memoize'; import { isTTY } from '../utilities/tty'; import { @@ -172,76 +170,104 @@ export abstract class SchematicsCommandModule if (options.interactive !== false && isTTY()) { workflow.registry.usePromptProvider(async (definitions: Array) => { - const questions = definitions - .filter((definition) => !options.defaults || definition.default === undefined) - .map((definition) => { - const question: Question = { - name: definition.id, - message: definition.message, - default: definition.default, - }; - - const validator = definition.validator; - if (validator) { - question.validate = (input) => validator(input); - - // Filter allows transformation of the value prior to validation - question.filter = async (input) => { - for (const type of definition.propertyTypes) { - let value; - switch (type) { - case 'string': - value = String(input); - break; - case 'integer': - case 'number': - value = Number(input); - break; - default: - value = input; - break; + let prompts: typeof import('@inquirer/prompts') | undefined; + const answers: Record = {}; + + for (const definition of definitions) { + if (options.defaults && definition.default !== undefined) { + continue; + } + + // Only load prompt package if needed + prompts ??= await import('@inquirer/prompts'); + + switch (definition.type) { + case 'confirmation': + answers[definition.id] = await prompts.confirm({ + message: definition.message, + default: definition.default as boolean | undefined, + }); + break; + case 'list': + if (!definition.items?.length) { + continue; + } + + answers[definition.id] = await ( + definition.multiselect ? prompts.checkbox : prompts.select + )({ + message: definition.message, + validate: (values) => { + if (!definition.validator) { + return true; } - // Can be a string if validation fails - const isValid = (await validator(value)) === true; - if (isValid) { - return value; - } - } - - return input; - }; - } - switch (definition.type) { - case 'confirmation': - question.type = 'confirm'; - break; - case 'list': - question.type = definition.multiselect ? 'checkbox' : 'list'; - (question as CheckboxQuestion).choices = definition.items?.map((item) => { - return typeof item == 'string' - ? item + return definition.validator(Object.values(values).map(({ value }) => value)); + }, + default: definition.default, + choices: definition.items?.map((item) => + typeof item == 'string' + ? { + name: item, + value: item, + } : { name: item.label, value: item.value, - }; - }); - break; - default: - question.type = definition.type; - break; - } + }, + ), + }); + break; + case 'input': { + let finalValue: JsonValue | undefined; + answers[definition.id] = await prompts.input({ + message: definition.message, + default: definition.default as string | undefined, + async validate(value) { + if (definition.validator === undefined) { + return true; + } - return question; - }); + let lastValidation: ReturnType = false; + for (const type of definition.propertyTypes) { + let potential; + switch (type) { + case 'string': + potential = String(value); + break; + case 'integer': + case 'number': + potential = Number(value); + break; + default: + potential = value; + break; + } + lastValidation = await definition.validator(potential); + + // Can be a string if validation fails + if (lastValidation === true) { + finalValue = potential; + + return true; + } + } - if (questions.length) { - const { default: inquirer } = await loadEsmModule('inquirer'); + return lastValidation; + }, + }); - return inquirer.prompt(questions); - } else { - return {}; + // Use validated value if present. + // This ensures the correct type is inserted into the final schema options. + if (finalValue !== undefined) { + answers[definition.id] = finalValue; + } + break; + } + } } + + return answers; }); } diff --git a/packages/angular/cli/src/command-builder/utilities/command.ts b/packages/angular/cli/src/command-builder/utilities/command.ts index 5ba067e38209..04a88c1f7113 100644 --- a/packages/angular/cli/src/command-builder/utilities/command.ts +++ b/packages/angular/cli/src/command-builder/utilities/command.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/command-builder/utilities/json-help.ts b/packages/angular/cli/src/command-builder/utilities/json-help.ts index 2f1969e1e092..6e673804ed84 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-help.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-help.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import yargs from 'yargs'; diff --git a/packages/angular/cli/src/command-builder/utilities/json-schema.ts b/packages/angular/cli/src/command-builder/utilities/json-schema.ts index c8649db75020..2b17c1eb0226 100644 --- a/packages/angular/cli/src/command-builder/utilities/json-schema.ts +++ b/packages/angular/cli/src/command-builder/utilities/json-schema.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { json } from '@angular-devkit/core'; @@ -165,8 +165,8 @@ export async function parseJsonSchemaToOptions( const alias = json.isJsonArray(current.aliases) ? [...current.aliases].map((x) => '' + x) : current.alias - ? ['' + current.alias] - : []; + ? ['' + current.alias] + : []; const format = typeof current.format == 'string' ? current.format : undefined; const visible = current.visible === undefined || current.visible === true; const hidden = !!current.hidden || !visible; diff --git a/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts index c19d1c8d3038..709f9e5a7c67 100644 --- a/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts +++ b/packages/angular/cli/src/command-builder/utilities/normalize-options-middleware.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import * as yargs from 'yargs'; diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts index 1be2e0a9aee1..ed17f97af942 100644 --- a/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts +++ b/packages/angular/cli/src/command-builder/utilities/schematic-engine-host.ts @@ -3,16 +3,15 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { RuleFactory, SchematicsException, Tree } from '@angular-devkit/schematics'; import { FileSystemCollectionDesc, NodeModulesEngineHost } from '@angular-devkit/schematics/tools'; import { readFileSync } from 'fs'; import { parse as parseJson } from 'jsonc-parser'; -import { createRequire } from 'module'; +import { Module, createRequire } from 'module'; import { dirname, resolve } from 'path'; -import { TextEncoder } from 'util'; import { Script } from 'vm'; import { assertIsError } from '../../utilities/error'; @@ -204,38 +203,24 @@ function wrap( // Setup a wrapper function to capture the module's exports const schematicCode = readFileSync(schematicFile, 'utf8'); - // `module` is required due to @angular/localize ng-add being in UMD format - const headerCode = '(function() {\nvar exports = {};\nvar module = { exports };\n'; - const footerCode = exportName - ? `\nreturn module.exports['${exportName}'];});` - : '\nreturn module.exports;});'; - - const script = new Script(headerCode + schematicCode + footerCode, { + const script = new Script(Module.wrap(schematicCode), { filename: schematicFile, - lineOffset: 3, + lineOffset: 1, }); - - const context = { - __dirname: schematicDirectory, - __filename: schematicFile, - Buffer, - // TextEncoder is used by the compiler to generate i18n message IDs. See: - // https://github.com/angular/angular/blob/main/packages/compiler/src/i18n/digest.ts#L17 - // It is referenced globally, because it may be run either on the browser or the server. - // Usually Node exposes it globally, but in order for it to work, our custom context - // has to expose it too. Issue context: https://github.com/angular/angular/issues/48940. - TextEncoder, - console, - process, - get global() { - return this; - }, - require: customRequire, + const schematicModule = new Module(schematicFile); + const moduleFactory = script.runInThisContext(); + + return () => { + moduleFactory( + schematicModule.exports, + customRequire, + schematicModule, + schematicFile, + schematicDirectory, + ); + + return exportName ? schematicModule.exports[exportName] : schematicModule.exports; }; - - const exportsFactory = script.runInNewContext(context); - - return exportsFactory; } function loadBuiltinModule(id: string): unknown { diff --git a/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts index 0b056ed64436..f5caa0754d88 100644 --- a/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts +++ b/packages/angular/cli/src/command-builder/utilities/schematic-workflow.ts @@ -3,13 +3,17 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import { logging, tags } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; import { NodeWorkflow } from '@angular-devkit/schematics/tools'; import { colors } from '../../utilities/color'; +function removeLeadingSlash(value: string): string { + return value[0] === '/' ? value.slice(1) : value; +} + export function subscribeToWorkflow( workflow: NodeWorkflow, logger: logging.LoggerApi, @@ -24,24 +28,21 @@ export function subscribeToWorkflow( const reporterSubscription = workflow.reporter.subscribe((event) => { // Strip leading slash to prevent confusion. - const eventPath = event.path.charAt(0) === '/' ? event.path.substring(1) : event.path; + const eventPath = removeLeadingSlash(event.path); switch (event.kind) { case 'error': error = true; - const desc = event.description == 'alreadyExist' ? 'already exists' : 'does not exist'; - logger.error(`ERROR! ${eventPath} ${desc}.`); + logger.error( + `ERROR! ${eventPath} ${event.description == 'alreadyExist' ? 'already exists' : 'does not exist'}.`, + ); break; case 'update': - logs.push(tags.oneLine` - ${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes) - `); + logs.push(`${colors.cyan('UPDATE')} ${eventPath} (${event.content.length} bytes)`); files.add(eventPath); break; case 'create': - logs.push(tags.oneLine` - ${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes) - `); + logs.push(`${colors.green('CREATE')} ${eventPath} (${event.content.length} bytes)`); files.add(eventPath); break; case 'delete': @@ -49,8 +50,7 @@ export function subscribeToWorkflow( files.add(eventPath); break; case 'rename': - const eventToPath = event.to.charAt(0) === '/' ? event.to.substring(1) : event.to; - logs.push(`${colors.blue('RENAME')} ${eventPath} => ${eventToPath}`); + logs.push(`${colors.blue('RENAME')} ${eventPath} => ${removeLeadingSlash(event.to)}`); files.add(eventPath); break; } diff --git a/packages/angular/cli/src/commands/add/cli.ts b/packages/angular/cli/src/commands/add/cli.ts index dc3de137a0d5..ccc830eaa1f0 100644 --- a/packages/angular/cli/src/commands/add/cli.ts +++ b/packages/angular/cli/src/commands/add/cli.ts @@ -3,12 +3,13 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import { tags } from '@angular-devkit/core'; import { NodePackageDoesNotSupportSchematics } from '@angular-devkit/schematics/tools'; +import { Listr, color, figures } from 'listr2'; import { createRequire } from 'module'; +import assert from 'node:assert'; import npa from 'npm-package-arg'; import { dirname, join } from 'path'; import { Range, compare, intersects, prerelease, satisfies, valid } from 'semver'; @@ -23,7 +24,6 @@ import { SchematicsCommandArgs, SchematicsCommandModule, } from '../../command-builder/schematics-command-module'; -import { colors } from '../../utilities/color'; import { assertIsError } from '../../utilities/error'; import { NgAddSaveDependency, @@ -31,11 +31,11 @@ import { fetchPackageManifest, fetchPackageMetadata, } from '../../utilities/package-metadata'; -import { askConfirmation } from '../../utilities/prompt'; -import { Spinner } from '../../utilities/spinner'; import { isTTY } from '../../utilities/tty'; import { VERSION } from '../../utilities/version'; +class CommandError extends Error {} + interface AddCommandArgs extends SchematicsCommandArgs { collection: string; verbose?: boolean; @@ -43,6 +43,15 @@ interface AddCommandArgs extends SchematicsCommandArgs { 'skip-confirmation'?: boolean; } +interface AddCommandTaskContext { + packageIdentifier: npa.Result; + usingYarn?: boolean; + savePackage?: NgAddSaveDependency; + collectionName?: string; + executeSchematic: AddCommandModule['executeSchematic']; + hasMismatchedPeer: AddCommandModule['hasMismatchedPeer']; +} + /** * The set of packages that should have certain versions excluded from consideration * when attempting to find a compatible version for a package. @@ -92,7 +101,7 @@ export default class AddCommandModule .strict(false); const collectionName = await this.getCollectionName(); - const workflow = await this.getOrCreateWorkflowForBuilder(collectionName); + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); try { const collection = workflow.engine.createCollection(collectionName); @@ -137,181 +146,226 @@ export default class AddCommandModule } } - const spinner = new Spinner(); - - spinner.start('Determining package manager...'); - const usingYarn = packageManager.name === PackageManager.Yarn; - spinner.info(`Using package manager: ${colors.grey(packageManager.name)}`); - - if ( - packageIdentifier.name && - packageIdentifier.type === 'range' && - packageIdentifier.rawSpec === '*' - ) { - // only package name provided; search for viable version - // plus special cases for packages that did not have peer deps setup - spinner.start('Searching for compatible package version...'); - - let packageMetadata; - try { - packageMetadata = await fetchPackageMetadata(packageIdentifier.name, logger, { - registry, - usingYarn, - verbose, - }); - } catch (e) { - assertIsError(e); - spinner.fail(`Unable to load package information from registry: ${e.message}`); + const taskContext: AddCommandTaskContext = { + packageIdentifier, + executeSchematic: this.executeSchematic.bind(this), + hasMismatchedPeer: this.hasMismatchedPeer.bind(this), + }; + + const tasks = new Listr([ + { + title: 'Determining Package Manager', + task(context, task) { + context.usingYarn = packageManager.name === PackageManager.Yarn; + task.output = `Using package manager: ${color.dim(packageManager.name)}`; + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Searching for compatible package version', + enabled: packageIdentifier.type === 'range' && packageIdentifier.rawSpec === '*', + async task(context, task) { + assert( + context.packageIdentifier.name, + 'Registry package identifiers should always have a name.', + ); - return 1; - } + // only package name provided; search for viable version + // plus special cases for packages that did not have peer deps setup + let packageMetadata; + try { + packageMetadata = await fetchPackageMetadata(context.packageIdentifier.name, logger, { + registry, + usingYarn: context.usingYarn, + verbose, + }); + } catch (e) { + assertIsError(e); + throw new CommandError( + `Unable to load package information from registry: ${e.message}`, + ); + } - // Start with the version tagged as `latest` if it exists - const latestManifest = packageMetadata.tags['latest']; - if (latestManifest) { - packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); - } + // Start with the version tagged as `latest` if it exists + const latestManifest = packageMetadata.tags['latest']; + if (latestManifest) { + context.packageIdentifier = npa.resolve(latestManifest.name, latestManifest.version); + } - // Adjust the version based on name and peer dependencies - if ( - latestManifest?.peerDependencies && - Object.keys(latestManifest.peerDependencies).length === 0 - ) { - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } else if (!latestManifest || (await this.hasMismatchedPeer(latestManifest))) { - // 'latest' is invalid so search for most recent matching package - - // Allow prelease versions if the CLI itself is a prerelease - const allowPrereleases = prerelease(VERSION.full); - - const versionExclusions = packageVersionExclusions[packageMetadata.name]; - const versionManifests = Object.values(packageMetadata.versions).filter( - (value: PackageManifest) => { - // Prerelease versions are not stable and should not be considered by default - if (!allowPrereleases && prerelease(value.version)) { - return false; - } - // Deprecated versions should not be used or considered - if (value.deprecated) { - return false; - } - // Excluded package versions should not be considered - if ( - versionExclusions && - satisfies(value.version, versionExclusions, { includePrerelease: true }) - ) { - return false; + // Adjust the version based on name and peer dependencies + if ( + latestManifest?.peerDependencies && + Object.keys(latestManifest.peerDependencies).length === 0 + ) { + task.output = `Found compatible package version: ${color.blue(latestManifest.version)}.`; + } else if (!latestManifest || (await context.hasMismatchedPeer(latestManifest))) { + // 'latest' is invalid so search for most recent matching package + + // Allow prelease versions if the CLI itself is a prerelease + const allowPrereleases = prerelease(VERSION.full); + + const versionExclusions = packageVersionExclusions[packageMetadata.name]; + const versionManifests = Object.values(packageMetadata.versions).filter( + (value: PackageManifest) => { + // Prerelease versions are not stable and should not be considered by default + if (!allowPrereleases && prerelease(value.version)) { + return false; + } + // Deprecated versions should not be used or considered + if (value.deprecated) { + return false; + } + // Excluded package versions should not be considered + if ( + versionExclusions && + satisfies(value.version, versionExclusions, { includePrerelease: true }) + ) { + return false; + } + + return true; + }, + ); + + // Sort in reverse SemVer order so that the newest compatible version is chosen + versionManifests.sort((a, b) => compare(b.version, a.version, true)); + + let found = false; + for (const versionManifest of versionManifests) { + const mismatch = await context.hasMismatchedPeer(versionManifest); + if (mismatch) { + continue; + } + + context.packageIdentifier = npa.resolve( + versionManifest.name, + versionManifest.version, + ); + found = true; } - return true; - }, - ); + if (!found) { + task.output = "Unable to find compatible package. Using 'latest' tag."; + } else { + task.output = `Found compatible package version: ${color.blue(context.packageIdentifier.toString())}.`; + } + } else { + task.output = `Found compatible package version: ${color.blue(context.packageIdentifier.toString())}.`; + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Loading package information from registry', + async task(context, task) { + let manifest; + try { + manifest = await fetchPackageManifest(context.packageIdentifier.toString(), logger, { + registry, + verbose, + usingYarn: context.usingYarn, + }); + } catch (e) { + assertIsError(e); + throw new CommandError( + `Unable to fetch package information for '${context.packageIdentifier}': ${e.message}`, + ); + } - // Sort in reverse SemVer order so that the newest compatible version is chosen - versionManifests.sort((a, b) => compare(b.version, a.version, true)); + context.savePackage = manifest['ng-add']?.save; + context.collectionName = manifest.name; - let newIdentifier; - for (const versionManifest of versionManifests) { - if (!(await this.hasMismatchedPeer(versionManifest))) { - newIdentifier = npa.resolve(versionManifest.name, versionManifest.version); - break; + if (await context.hasMismatchedPeer(manifest)) { + task.output = color.yellow( + figures.warning + + ' Package has unmet peer dependencies. Adding the package may not succeed.', + ); + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + title: 'Confirming installation', + enabled: !skipConfirmation, + async task(context, task) { + if (!isTTY()) { + task.output = + `'--skip-confirmation' can be used to bypass installation confirmation. ` + + `Ensure package name is correct prior to '--skip-confirmation' option usage.`; + throw new CommandError('No terminal detected'); } - } - if (!newIdentifier) { - spinner.warn("Unable to find compatible package. Using 'latest' tag."); - } else { - packageIdentifier = newIdentifier; - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } - } else { - spinner.succeed( - `Found compatible package version: ${colors.grey(packageIdentifier.toString())}.`, - ); - } - } + const { ListrInquirerPromptAdapter } = await import('@listr2/prompt-adapter-inquirer'); + const { confirm } = await import('@inquirer/prompts'); + const shouldProceed = await task.prompt(ListrInquirerPromptAdapter).run(confirm, { + message: + `The package ${color.blue(context.packageIdentifier.toString())} will be installed and executed.\n` + + 'Would you like to proceed?', + default: true, + theme: { prefix: '' }, + }); + + if (!shouldProceed) { + throw new CommandError('Command aborted'); + } + }, + rendererOptions: { persistentOutput: true }, + }, + { + async task(context, task) { + // Only show if installation will actually occur + task.title = 'Installing package'; + + if (context.savePackage === false) { + task.title += ' in temporary location'; + + // Temporary packages are located in a different directory + // Hence we need to resolve them using the temp path + const { success, tempNodeModules } = await packageManager.installTemp( + context.packageIdentifier.toString(), + registry ? [`--registry="${registry}"`] : undefined, + ); + const tempRequire = createRequire(tempNodeModules + '/'); + assert(context.collectionName, 'Collection name should always be available'); + const resolvedCollectionPath = tempRequire.resolve( + join(context.collectionName, 'package.json'), + ); + + if (!success) { + throw new CommandError('Unable to install package'); + } - let collectionName = packageIdentifier.name; - let savePackage: NgAddSaveDependency | undefined; + context.collectionName = dirname(resolvedCollectionPath); + } else { + const success = await packageManager.install( + context.packageIdentifier.toString(), + context.savePackage, + registry ? [`--registry="${registry}"`] : undefined, + undefined, + ); + + if (!success) { + throw new CommandError('Unable to install package'); + } + } + }, + rendererOptions: { bottomBar: Infinity }, + }, + // TODO: Rework schematic execution as a task and insert here + ]); try { - spinner.start('Loading package information from registry...'); - const manifest = await fetchPackageManifest(packageIdentifier.toString(), logger, { - registry, - verbose, - usingYarn, - }); + const result = await tasks.run(taskContext); + assert(result.collectionName, 'Collection name should always be available'); - savePackage = manifest['ng-add']?.save; - collectionName = manifest.name; - - if (await this.hasMismatchedPeer(manifest)) { - spinner.warn('Package has unmet peer dependencies. Adding the package may not succeed.'); - } else { - spinner.succeed(`Package information loaded.`); - } + return this.executeSchematic({ ...options, collection: result.collectionName }); } catch (e) { - assertIsError(e); - spinner.fail(`Unable to fetch package information for '${packageIdentifier}': ${e.message}`); - - return 1; - } - - if (!skipConfirmation) { - const confirmationResponse = await askConfirmation( - `\nThe package ${colors.blue(packageIdentifier.raw)} will be installed and executed.\n` + - 'Would you like to proceed?', - true, - false, - ); - - if (!confirmationResponse) { - if (!isTTY()) { - logger.error( - 'No terminal detected. ' + - `'--skip-confirmation' can be used to bypass installation confirmation. ` + - `Ensure package name is correct prior to '--skip-confirmation' option usage.`, - ); - } - - logger.error('Command aborted.'); - - return 1; - } - } - - if (savePackage === false) { - // Temporary packages are located in a different directory - // Hence we need to resolve them using the temp path - const { success, tempNodeModules } = await packageManager.installTemp( - packageIdentifier.raw, - registry ? [`--registry="${registry}"`] : undefined, - ); - const tempRequire = createRequire(tempNodeModules + '/'); - const resolvedCollectionPath = tempRequire.resolve(join(collectionName, 'package.json')); - - if (!success) { + if (e instanceof CommandError) { return 1; } - collectionName = dirname(resolvedCollectionPath); - } else { - const success = await packageManager.install( - packageIdentifier.raw, - savePackage, - registry ? [`--registry="${registry}"`] : undefined, - ); - - if (!success) { - return 1; - } + throw e; } - - return this.executeSchematic({ ...options, collection: collectionName }); } private async isProjectVersionValid(packageIdentifier: npa.Result): Promise { @@ -347,7 +401,17 @@ export default class AddCommandModule } private async getCollectionName(): Promise { - const [, collectionName] = this.context.args.positional; + let [, collectionName] = this.context.args.positional; + + // The CLI argument may specify also a version, like `ng add @my/lib@13.0.0`, + // but here we need only the name of the package, like `@my/lib` + try { + const packageIdentifier = npa(collectionName); + collectionName = packageIdentifier.name ?? collectionName; + } catch (e) { + assertIsError(e); + this.context.logger.error(e.message); + } return collectionName; } @@ -397,10 +461,10 @@ export default class AddCommandModule }); } catch (e) { if (e instanceof NodePackageDoesNotSupportSchematics) { - this.context.logger.error(tags.oneLine` - The package that you are trying to add does not support schematics. You can try using - a different version of the package or contact the package author to add ng-add support. - `); + this.context.logger.error( + 'The package that you are trying to add does not support schematics.' + + 'You can try using a different version of the package or contact the package author to add ng-add support.', + ); return 1; } diff --git a/packages/angular/cli/src/commands/analytics/cli.ts b/packages/angular/cli/src/commands/analytics/cli.ts index 8e3753ababb1..56841a95bd6b 100644 --- a/packages/angular/cli/src/commands/analytics/cli.ts +++ b/packages/angular/cli/src/commands/analytics/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'node:path'; diff --git a/packages/angular/cli/src/commands/analytics/info/cli.ts b/packages/angular/cli/src/commands/analytics/info/cli.ts index bfcba4a3da0e..e4434d35baee 100644 --- a/packages/angular/cli/src/commands/analytics/info/cli.ts +++ b/packages/angular/cli/src/commands/analytics/info/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/commands/analytics/settings/cli.ts b/packages/angular/cli/src/commands/analytics/settings/cli.ts index ff965e228781..16f07b353d1a 100644 --- a/packages/angular/cli/src/commands/analytics/settings/cli.ts +++ b/packages/angular/cli/src/commands/analytics/settings/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/commands/build/cli.ts b/packages/angular/cli/src/commands/build/cli.ts index 196585a4b122..b98fc46c48e7 100644 --- a/packages/angular/cli/src/commands/build/cli.ts +++ b/packages/angular/cli/src/commands/build/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'path'; diff --git a/packages/angular/cli/src/commands/build/long-description.md b/packages/angular/cli/src/commands/build/long-description.md index 3a8885825f9c..b2c14d8f23fe 100644 --- a/packages/angular/cli/src/commands/build/long-description.md +++ b/packages/angular/cli/src/commands/build/long-description.md @@ -1,5 +1,5 @@ The command can be used to build a project of type "application" or "library". -When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, and `watch` options are applied. +When used to build a library, a different builder is invoked, and only the `ts-config`, `configuration`, `poll` and `watch` options are applied. All other options apply only to building applications. The application builder uses the [esbuild](https://esbuild.github.io/) build tool, with default configuration options specified in the workspace configuration file (`angular.json`) or with a named alternative configuration. @@ -15,4 +15,4 @@ either by direct editing or with the `ng config` command. These include `assets`, `styles`, and `scripts` objects that provide runtime-global resources to include in the project. Resources in CSS, such as images and fonts, are automatically written and fingerprinted at the root of the output folder. -For further details, see [Workspace Configuration](guide/workspace-config). +For further details, see [Workspace Configuration](reference/configs/workspace-config). diff --git a/packages/angular/cli/src/commands/cache/clean/cli.ts b/packages/angular/cli/src/commands/cache/clean/cli.ts index f07cd5613c96..4ede8b0a60a2 100644 --- a/packages/angular/cli/src/commands/cache/clean/cli.ts +++ b/packages/angular/cli/src/commands/cache/clean/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { promises as fs } from 'fs'; diff --git a/packages/angular/cli/src/commands/cache/cli.ts b/packages/angular/cli/src/commands/cache/cli.ts index bc4115d8cfde..046673995846 100644 --- a/packages/angular/cli/src/commands/cache/cli.ts +++ b/packages/angular/cli/src/commands/cache/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'path'; diff --git a/packages/angular/cli/src/commands/cache/info/cli.ts b/packages/angular/cli/src/commands/cache/info/cli.ts index 15fcf3ba857f..ec1802c65695 100644 --- a/packages/angular/cli/src/commands/cache/info/cli.ts +++ b/packages/angular/cli/src/commands/cache/info/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { tags } from '@angular-devkit/core'; diff --git a/packages/angular/cli/src/commands/cache/long-description.md b/packages/angular/cli/src/commands/cache/long-description.md index 8da4bb9e5364..3ebfec598c4e 100644 --- a/packages/angular/cli/src/commands/cache/long-description.md +++ b/packages/angular/cli/src/commands/cache/long-description.md @@ -2,7 +2,7 @@ Angular CLI saves a number of cachable operations on disk by default. When you re-run the same build, the build system restores the state of the previous build and re-uses previously performed operations, which decreases the time taken to build and test your applications and libraries. -To amend the default cache settings, add the `cli.cache` object to your [Workspace Configuration](guide/workspace-config). +To amend the default cache settings, add the `cli.cache` object to your [Workspace Configuration](reference/configs/workspace-config). The object goes under `cli.cache` at the top level of the file, outside the `projects` sections. ```jsonc @@ -12,13 +12,13 @@ The object goes under `cli.cache` at the top level of the file, outside the `pro "cli": { "cache": { // ... - } + }, }, - "projects": {} + "projects": {}, } ``` -For more information, see [cache options](guide/workspace-config#cache-options). +For more information, see [cache options](reference/configs/workspace-config#cache-options). ### Cache environments @@ -34,7 +34,7 @@ To change the environment setting to `all`, run the following command: ng config cli.cache.environment all ``` -For more information, see `environment` in [cache options](guide/workspace-config#cache-options). +For more information, see `environment` in [cache options](reference/configs/workspace-config#cache-options).
diff --git a/packages/angular/cli/src/commands/cache/settings/cli.ts b/packages/angular/cli/src/commands/cache/settings/cli.ts index 97e79cd1005b..9a4f654f7ac7 100644 --- a/packages/angular/cli/src/commands/cache/settings/cli.ts +++ b/packages/angular/cli/src/commands/cache/settings/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/commands/cache/utilities.ts b/packages/angular/cli/src/commands/cache/utilities.ts index c9783e02f942..3f82b2d3a91e 100644 --- a/packages/angular/cli/src/commands/cache/utilities.ts +++ b/packages/angular/cli/src/commands/cache/utilities.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { isJsonObject } from '@angular-devkit/core'; diff --git a/packages/angular/cli/src/commands/command-config.ts b/packages/angular/cli/src/commands/command-config.ts index 6bb4fc7d2679..cd048cbb2240 100644 --- a/packages/angular/cli/src/commands/command-config.ts +++ b/packages/angular/cli/src/commands/command-config.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { CommandModuleConstructor } from '../command-builder/utilities/command'; @@ -16,7 +16,6 @@ export type CommandNames = | 'completion' | 'config' | 'deploy' - | 'doc' | 'e2e' | 'extract-i18n' | 'generate' @@ -60,10 +59,7 @@ export const RootCommands: Record< 'deploy': { factory: () => import('./deploy/cli'), }, - 'doc': { - factory: () => import('./doc/cli'), - aliases: ['d'], - }, + 'e2e': { factory: () => import('./e2e/cli'), aliases: ['e'], @@ -90,7 +86,7 @@ export const RootCommands: Record< }, 'serve': { factory: () => import('./serve/cli'), - aliases: ['s'], + aliases: ['dev', 's'], }, 'test': { factory: () => import('./test/cli'), @@ -105,10 +101,13 @@ export const RootCommands: Record< }, }; -export const RootCommandsAliases = Object.values(RootCommands).reduce((prev, current) => { - current.aliases?.forEach((alias) => { - prev[alias] = current; - }); +export const RootCommandsAliases = Object.values(RootCommands).reduce( + (prev, current) => { + current.aliases?.forEach((alias) => { + prev[alias] = current; + }); - return prev; -}, {} as Record); + return prev; + }, + {} as Record, +); diff --git a/packages/angular/cli/src/commands/completion/cli.ts b/packages/angular/cli/src/commands/completion/cli.ts index 8c777a9b8d32..4cf0ef89bff8 100644 --- a/packages/angular/cli/src/commands/completion/cli.ts +++ b/packages/angular/cli/src/commands/completion/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'path'; @@ -51,7 +51,7 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi ' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' + ' is typically done with the `-g` flag in `npm install -g @angular/cli`.' + '\n\n' + - 'For more information, see https://angular.io/cli/completion#global-install', + 'For more information, see https://angular.dev/cli/completion#global-install', ); } diff --git a/packages/angular/cli/src/commands/completion/long-description.md b/packages/angular/cli/src/commands/completion/long-description.md index 26569cff5097..b75803ac9cb0 100644 --- a/packages/angular/cli/src/commands/completion/long-description.md +++ b/packages/angular/cli/src/commands/completion/long-description.md @@ -4,7 +4,7 @@ discover and use CLI commands without lots of memorization. ![A demo of Angular CLI autocompletion in a terminal. The user types several partial `ng` commands, using autocompletion to finish several arguments and list contextual options. -](generated/images/guide/cli/completion.gif) +](assets/images/guide/cli/completion.gif) ## Automated setup diff --git a/packages/angular/cli/src/commands/config/cli.ts b/packages/angular/cli/src/commands/config/cli.ts index bb5cee4e66fd..caa3c2504030 100644 --- a/packages/angular/cli/src/commands/config/cli.ts +++ b/packages/angular/cli/src/commands/config/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { JsonValue } from '@angular-devkit/core'; @@ -185,7 +185,7 @@ function normalizeValue(value: string | undefined | boolean | number): JsonValue // and convert them into a numberic entities. // Example: 73b61974-182c-48e4-b4c6-30ddf08c5c98 -> 73. // These values should never contain comments, therefore using `JSON.parse` is safe. - return JSON.parse(valueString); + return JSON.parse(valueString) as JsonValue; } catch { return value; } diff --git a/packages/angular/cli/src/commands/config/long-description.md b/packages/angular/cli/src/commands/config/long-description.md index 94ebfca237eb..db32cb294152 100644 --- a/packages/angular/cli/src/commands/config/long-description.md +++ b/packages/angular/cli/src/commands/config/long-description.md @@ -8,6 +8,6 @@ The configurable property names match command option names, except that in the configuration file, all names must use camelCase, while on the command line options can be given dash-case. -For further details, see [Workspace Configuration](guide/workspace-config). +For further details, see [Workspace Configuration](reference/configs/workspace-config). For configuration of CLI usage analytics, see [ng analytics](cli/analytics). diff --git a/packages/angular/cli/src/commands/deploy/cli.ts b/packages/angular/cli/src/commands/deploy/cli.ts index 6ccb4d0244ea..947dc90af2d4 100644 --- a/packages/angular/cli/src/commands/deploy/cli.ts +++ b/packages/angular/cli/src/commands/deploy/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'node:path'; @@ -15,7 +15,7 @@ export default class DeployCommandModule extends ArchitectCommandModule implements CommandModuleImplementation { - // The below choices should be kept in sync with the list in https://angular.io/guide/deployment + // The below choices should be kept in sync with the list in https://angular.dev/tools/cli/deployment override missingTargetChoices: MissingTargetChoice[] = [ { name: 'Amazon S3', diff --git a/packages/angular/cli/src/commands/deploy/long-description.md b/packages/angular/cli/src/commands/deploy/long-description.md index 9d13ad2a9890..0436390680a4 100644 --- a/packages/angular/cli/src/commands/deploy/long-description.md +++ b/packages/angular/cli/src/commands/deploy/long-description.md @@ -3,7 +3,7 @@ When a project name is not supplied, executes the `deploy` builder for the defau To use the `ng deploy` command, use `ng add` to add a package that implements deployment capabilities to your favorite platform. Adding the package automatically updates your workspace configuration, adding a deployment -[CLI builder](guide/cli-builder). +[CLI builder](tools/cli/cli-builder). For example: ```json diff --git a/packages/angular/cli/src/commands/doc/cli.ts b/packages/angular/cli/src/commands/doc/cli.ts deleted file mode 100644 index d6f9d571248a..000000000000 --- a/packages/angular/cli/src/commands/doc/cli.ts +++ /dev/null @@ -1,91 +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.io/license - */ - -import open from 'open'; -import { Argv } from 'yargs'; -import { - CommandModule, - CommandModuleImplementation, - Options, -} from '../../command-builder/command-module'; -import { RootCommands } from '../command-config'; - -interface DocCommandArgs { - keyword: string; - search?: boolean; - version?: string; -} - -export default class DocCommandModule - extends CommandModule - implements CommandModuleImplementation -{ - command = 'doc '; - aliases = RootCommands['doc'].aliases; - describe = - 'Opens the official Angular documentation (angular.io) in a browser, and searches for a given keyword.'; - longDescriptionPath?: string; - - builder(localYargs: Argv): Argv { - return localYargs - .positional('keyword', { - description: 'The keyword to search for, as provided in the search bar in angular.io.', - type: 'string', - demandOption: true, - }) - .option('search', { - description: `Search all of angular.io. Otherwise, searches only API reference documentation.`, - alias: ['s'], - type: 'boolean', - default: false, - }) - .option('version', { - description: - 'The version of Angular to use for the documentation. ' + - 'If not provided, the command uses your current Angular core version.', - type: 'string', - }) - .strict(); - } - - async run(options: Options): Promise { - let domain = 'angular.io'; - - if (options.version) { - // version can either be a string containing "next" - if (options.version === 'next') { - domain = 'next.angular.io'; - } else if (options.version === 'rc') { - domain = 'rc.angular.io'; - // or a number where version must be a valid Angular version (i.e. not 0, 1 or 3) - } else if (!isNaN(+options.version) && ![0, 1, 3].includes(+options.version)) { - domain = `v${options.version}.angular.io`; - } else { - this.context.logger.error( - 'Version should either be a number (2, 4, 5, 6...), "rc" or "next"', - ); - - return 1; - } - } else { - // we try to get the current Angular version of the project - // and use it if we can find it - try { - /* eslint-disable-next-line import/no-extraneous-dependencies */ - const currentNgVersion = (await import('@angular/core')).VERSION.major; - domain = `v${currentNgVersion}.angular.io`; - } catch {} - } - - await open( - options.search - ? `https://${domain}/docs?search=${options.keyword}` - : `https://${domain}/api?query=${options.keyword}`, - ); - } -} diff --git a/packages/angular/cli/src/commands/e2e/cli.ts b/packages/angular/cli/src/commands/e2e/cli.ts index 40df309c6d32..85d9aab173a0 100644 --- a/packages/angular/cli/src/commands/e2e/cli.ts +++ b/packages/angular/cli/src/commands/e2e/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { MissingTargetChoice } from '../../command-builder/architect-base-command-module'; @@ -16,6 +16,10 @@ export default class E2eCommandModule implements CommandModuleImplementation { override missingTargetChoices: MissingTargetChoice[] = [ + { + name: 'Playwright', + value: 'playwright-ng-schematics', + }, { name: 'Cypress', value: '@cypress/schematic', diff --git a/packages/angular/cli/src/commands/extract-i18n/cli.ts b/packages/angular/cli/src/commands/extract-i18n/cli.ts index a0d4bc366dfb..4f3dea2d8e7e 100644 --- a/packages/angular/cli/src/commands/extract-i18n/cli.ts +++ b/packages/angular/cli/src/commands/extract-i18n/cli.ts @@ -3,9 +3,12 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ +import { workspaces } from '@angular-devkit/core'; +import { createRequire } from 'node:module'; +import { join } from 'node:path'; import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; import { CommandModuleImplementation } from '../../command-builder/command-module'; @@ -17,4 +20,38 @@ export default class ExtractI18nCommandModule command = 'extract-i18n [project]'; describe = 'Extracts i18n messages from source code.'; longDescriptionPath?: string | undefined; + + override async findDefaultBuilderName( + project: workspaces.ProjectDefinition, + ): Promise { + // Only application type projects have a default i18n extraction target + if (project.extensions['projectType'] !== 'application') { + return; + } + + const buildTarget = project.targets.get('build'); + if (!buildTarget) { + // No default if there is no build target + return; + } + + // Provide a default based on the defined builder for the 'build' target + switch (buildTarget.builder) { + case '@angular-devkit/build-angular:application': + case '@angular-devkit/build-angular:browser-esbuild': + case '@angular-devkit/build-angular:browser': + return '@angular-devkit/build-angular:extract-i18n'; + case '@angular/build:application': + return '@angular/build:extract-i18n'; + } + + // For other builders, check for `@angular-devkit/build-angular` and use if found. + // This package is safer to use since it supports both application builder types. + try { + const projectRequire = createRequire(join(this.context.root, project.root) + '/'); + projectRequire.resolve('@angular-devkit/build-angular'); + + return '@angular-devkit/build-angular:extract-i18n'; + } catch {} + } } diff --git a/packages/angular/cli/src/commands/generate/cli.ts b/packages/angular/cli/src/commands/generate/cli.ts index 424d609ed19a..4be29c3eaea0 100644 --- a/packages/angular/cli/src/commands/generate/cli.ts +++ b/packages/angular/cli/src/commands/generate/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { strings } from '@angular-devkit/core'; diff --git a/packages/angular/cli/src/commands/lint/cli.ts b/packages/angular/cli/src/commands/lint/cli.ts index d6072d5549e6..cb7897284951 100644 --- a/packages/angular/cli/src/commands/lint/cli.ts +++ b/packages/angular/cli/src/commands/lint/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'path'; diff --git a/packages/angular/cli/src/commands/lint/long-description.md b/packages/angular/cli/src/commands/lint/long-description.md index 1c912b2489d7..5e5fa3da951c 100644 --- a/packages/angular/cli/src/commands/lint/long-description.md +++ b/packages/angular/cli/src/commands/lint/long-description.md @@ -1,7 +1,7 @@ The command takes an optional project name, as specified in the `projects` section of the `angular.json` workspace configuration file. When a project name is not supplied, executes the `lint` builder for all projects. -To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](guide/cli-builder). +To use the `ng lint` command, use `ng add` to add a package that implements linting capabilities. Adding the package automatically updates your workspace configuration, adding a lint [CLI builder](tools/cli/cli-builder). For example: ```json diff --git a/packages/angular/cli/src/commands/make-this-awesome/cli.ts b/packages/angular/cli/src/commands/make-this-awesome/cli.ts index 0c258a023f7b..6a17c5614b94 100644 --- a/packages/angular/cli/src/commands/make-this-awesome/cli.ts +++ b/packages/angular/cli/src/commands/make-this-awesome/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Argv } from 'yargs'; diff --git a/packages/angular/cli/src/commands/new/cli.ts b/packages/angular/cli/src/commands/new/cli.ts index caa8801fe980..9163708726b6 100644 --- a/packages/angular/cli/src/commands/new/cli.ts +++ b/packages/angular/cli/src/commands/new/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'node:path'; @@ -55,7 +55,7 @@ export default class NewCommandModule ? collectionNameFromArgs : await this.getCollectionFromConfig(); - const workflow = await this.getOrCreateWorkflowForBuilder(collectionName); + const workflow = this.getOrCreateWorkflowForBuilder(collectionName); const collection = workflow.engine.createCollection(collectionName); const options = await this.getSchematicOptions(collection, this.schematicName, workflow); diff --git a/packages/angular/cli/src/commands/run/cli.ts b/packages/angular/cli/src/commands/run/cli.ts index 5c463eb3674d..bd65dac53fc3 100644 --- a/packages/angular/cli/src/commands/run/cli.ts +++ b/packages/angular/cli/src/commands/run/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { Target } from '@angular-devkit/architect'; diff --git a/packages/angular/cli/src/commands/serve/cli.ts b/packages/angular/cli/src/commands/serve/cli.ts index 48a1103355b2..3b38fa122acd 100644 --- a/packages/angular/cli/src/commands/serve/cli.ts +++ b/packages/angular/cli/src/commands/serve/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { ArchitectCommandModule } from '../../command-builder/architect-command-module'; diff --git a/packages/angular/cli/src/commands/test/cli.ts b/packages/angular/cli/src/commands/test/cli.ts index 837d57787eb4..fde58fda5d6e 100644 --- a/packages/angular/cli/src/commands/test/cli.ts +++ b/packages/angular/cli/src/commands/test/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { join } from 'path'; diff --git a/packages/angular/cli/src/commands/update/cli.ts b/packages/angular/cli/src/commands/update/cli.ts index fe262dbf968d..b9e991e3ea4a 100644 --- a/packages/angular/cli/src/commands/update/cli.ts +++ b/packages/angular/cli/src/commands/update/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { SchematicDescription, UnsuccessfulWorkflowExecution } from '@angular-devkit/schematics'; @@ -12,9 +12,10 @@ import { FileSystemSchematicDescription, NodeWorkflow, } from '@angular-devkit/schematics/tools'; -import { SpawnSyncReturns, execSync, spawnSync } from 'child_process'; -import { existsSync, promises as fs } from 'fs'; -import { createRequire } from 'module'; +import { Listr } from 'listr2'; +import { SpawnSyncReturns, execSync, spawnSync } from 'node:child_process'; +import { existsSync, promises as fs } from 'node:fs'; +import { createRequire } from 'node:module'; import npa from 'npm-package-arg'; import pickManifest from 'npm-pick-manifest'; import * as path from 'path'; @@ -30,7 +31,7 @@ import { } from '../../command-builder/command-module'; import { SchematicEngineHost } from '../../command-builder/utilities/schematic-engine-host'; import { subscribeToWorkflow } from '../../command-builder/utilities/schematic-workflow'; -import { colors } from '../../utilities/color'; +import { colors, figures } from '../../utilities/color'; import { disableVersionCheck } from '../../utilities/environment-options'; import { assertIsError } from '../../utilities/error'; import { writeErrorToLogFile } from '../../utilities/log-file'; @@ -67,21 +68,25 @@ interface MigrationSchematicDescription extends SchematicDescription { version?: string; optional?: boolean; + documentation?: string; } interface MigrationSchematicDescriptionWithVersion extends MigrationSchematicDescription { version: string; } +class CommandError extends Error {} + const ANGULAR_PACKAGES_REGEXP = /^@(?:angular|nguniversal)\//; const UPDATE_SCHEMATIC_COLLECTION = path.join(__dirname, 'schematic/collection.json'); export default class UpdateCommandModule extends CommandModule { override scope = CommandScope.In; protected override shouldReportAnalytics = false; + private readonly resolvePaths = [__dirname, this.context.root]; command = 'update [packages..]'; - describe = 'Updates your workspace and its dependencies. See https://update.angular.io/.'; + describe = 'Updates your workspace and its dependencies. See https://update.angular.dev/.'; longDescriptionPath = join(__dirname, 'long-description.md'); builder(localYargs: Argv): Argv { @@ -239,7 +244,7 @@ export default class UpdateCommandModule extends CommandModule favor @schematics/update from this package // Otherwise, use packages from the active workspace (migrations) - resolvePaths: [__dirname, this.context.root], + resolvePaths: this.resolvePaths, schemaValidation: true, engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), }); @@ -301,12 +306,12 @@ export default class UpdateCommandModule extends CommandModule }; const binKeys = Object.keys(bin); if (binKeys.length) { @@ -1082,12 +1112,11 @@ export default class UpdateCommandModule extends CommandModule 1 ? 's' : '' } that can be executed.`, ); - logger.info(''); // Extra trailing newline. if (!isTTY()) { for (const migration of optionalMigrations) { const { title } = getMigrationTitleAndDescription(migration); - logger.info(colors.cyan(colors.symbols.pointer) + ' ' + colors.bold(title)); + logger.info(colors.cyan(figures.pointer) + ' ' + colors.bold(title)); logger.info(colors.gray(` ng update ${packageName} --name ${migration.name}`)); logger.info(''); // Extra trailing newline. } @@ -1095,13 +1124,18 @@ export default class UpdateCommandModule extends CommandModule { - const { title } = getMigrationTitleAndDescription(migration); + const { title, documentation } = getMigrationTitleAndDescription(migration); return { - name: title, + name: `[${colors.white(migration.name)}] ${title}${documentation ? ` (${documentation})` : ''}`, value: migration.name, }; }), @@ -1179,11 +1213,15 @@ function coerceVersionNumber(version: string | undefined): string | undefined { function getMigrationTitleAndDescription(migration: MigrationSchematicDescription): { title: string; description: string; + documentation?: string; } { const [title, ...description] = migration.description.split('. '); return { title: title.endsWith('.') ? title : title + '.', description: description.join('.\n '), + documentation: migration.documentation + ? new URL(migration.documentation, 'https://angular.dev').href + : undefined, }; } diff --git a/packages/angular/cli/src/commands/update/long-description.md b/packages/angular/cli/src/commands/update/long-description.md index 72df66ce35da..612971de0c4d 100644 --- a/packages/angular/cli/src/commands/update/long-description.md +++ b/packages/angular/cli/src/commands/update/long-description.md @@ -19,4 +19,4 @@ For example, use the following command to take the latest 10.x.x version and use ng update @angular/cli@^10 @angular/core@^10 ``` -For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.io/). +For detailed information and guidance on updating your application, see the interactive [Angular Update Guide](https://update.angular.dev/). diff --git a/packages/angular/cli/src/commands/update/schematic/index.ts b/packages/angular/cli/src/commands/update/schematic/index.ts index 6ead29f03e3a..9b56ec01d363 100644 --- a/packages/angular/cli/src/commands/update/schematic/index.ts +++ b/packages/angular/cli/src/commands/update/schematic/index.ts @@ -3,15 +3,14 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import { logging, tags } from '@angular-devkit/core'; +import { logging } from '@angular-devkit/core'; import { Rule, SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; import * as npa from 'npm-package-arg'; import type { Manifest } from 'pacote'; import * as semver from 'semver'; -import { assertIsError } from '../../../utilities/error'; import { NgPackageManifestProperties, NpmRepositoryPackageJson, @@ -249,9 +248,11 @@ function _validateUpdatePackages( }); if (!force && peerErrors) { - throw new SchematicsException(tags.stripIndents`Incompatible peer dependencies found. - Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together. - You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`); + throw new SchematicsException( + 'Incompatible peer dependencies found.\n' + + 'Peer dependency warnings when installing dependencies means that those dependencies might not work correctly together.\n' + + `You can use the '--force' option to ignore incompatible peer dependencies and instead address these warnings later.`, + ); } } @@ -262,7 +263,7 @@ function _performUpdate( logger: logging.LoggerApi, migrateOnly: boolean, ): void { - const packageJsonContent = tree.read('/package.json'); + const packageJsonContent = tree.read('/package.json')?.toString(); if (!packageJsonContent) { throw new SchematicsException('Could not find a package.json. Are you in a Node project?'); } @@ -309,11 +310,12 @@ function _performUpdate( logger.warn(`Package ${name} was not found in dependencies.`); } }); - - const newContent = JSON.stringify(packageJson, null, 2); - if (packageJsonContent.toString() != newContent || migrateOnly) { + const eofMatches = packageJsonContent.match(/\r?\n$/); + const eof = eofMatches?.[0] ?? ''; + const newContent = JSON.stringify(packageJson, null, 2) + eof; + if (packageJsonContent != newContent || migrateOnly) { if (!migrateOnly) { - tree.overwrite('/package.json', JSON.stringify(packageJson, null, 2)); + tree.overwrite('/package.json', newContent); } const externalMigrations: {}[] = []; diff --git a/packages/angular/cli/src/commands/update/schematic/index_spec.ts b/packages/angular/cli/src/commands/update/schematic/index_spec.ts index 19197195cb4b..3954e3c78254 100644 --- a/packages/angular/cli/src/commands/update/schematic/index_spec.ts +++ b/packages/angular/cli/src/commands/update/schematic/index_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { normalize, virtualFs } from '@angular-devkit/core'; @@ -282,4 +282,57 @@ describe('@schematics/update', () => { expect(hasPeerdepMsg('typescript')).toBeTruthy(); expect(hasPeerdepMsg('@angular/localize')).toBeFalsy(); }, 45000); + + it('does not remove newline at the end of package.json', async () => { + const newlineStyles = ['\n', '\r\n']; + for (const newline of newlineStyles) { + const packageJsonContent = `{ + "name": "blah", + "dependencies": { + "@angular-devkit-tests/update-base": "1.0.0" + } + }${newline}`; + const inputTree = new UnitTestTree( + new HostTree( + new virtualFs.test.TestHost({ + '/package.json': packageJsonContent, + }), + ), + ); + + const resultTree = await schematicRunner.runSchematic( + 'update', + { packages: ['@angular-devkit-tests/update-base'] }, + inputTree, + ); + + const resultTreeContent = resultTree.readContent('/package.json'); + expect(resultTreeContent.endsWith(newline)).toBeTrue(); + } + }); + + it('does not add a newline at the end of package.json', async () => { + const packageJsonContent = `{ + "name": "blah", + "dependencies": { + "@angular-devkit-tests/update-base": "1.0.0" + } + }`; + const inputTree = new UnitTestTree( + new HostTree( + new virtualFs.test.TestHost({ + '/package.json': packageJsonContent, + }), + ), + ); + + const resultTree = await schematicRunner.runSchematic( + 'update', + { packages: ['@angular-devkit-tests/update-base'] }, + inputTree, + ); + + const resultTreeContent = resultTree.readContent('/package.json'); + expect(resultTreeContent.endsWith('}')).toBeTrue(); + }); }); diff --git a/packages/angular/cli/src/commands/version/cli.ts b/packages/angular/cli/src/commands/version/cli.ts index fe029b6c1321..3e2c27d31a1c 100644 --- a/packages/angular/cli/src/commands/version/cli.ts +++ b/packages/angular/cli/src/commands/version/cli.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import nodeModule from 'node:module'; @@ -23,7 +23,7 @@ interface PartialPackageInfo { /** * Major versions of Node.js that are officially supported by Angular. */ -const SUPPORTED_NODE_MAJORS = [18, 20]; +const SUPPORTED_NODE_MAJORS = [18, 20, 22]; const PACKAGE_PATTERNS = [ /^@angular\/.*/, diff --git a/packages/angular/cli/src/typings-bazel.d.ts b/packages/angular/cli/src/typings-bazel.d.ts deleted file mode 100644 index 780d1dc372ff..000000000000 --- a/packages/angular/cli/src/typings-bazel.d.ts +++ /dev/null @@ -1,14 +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.io/license - */ - -/* eslint-disable import/no-extraneous-dependencies */ -// Workaround for https://github.com/bazelbuild/rules_nodejs/issues/1033 -// Alternative approach instead of https://github.com/angular/angular/pull/33226 -declare module '@yarnpkg/lockfile' { - export * from '@types/yarnpkg__lockfile'; -} diff --git a/packages/angular/cli/src/typings.ts b/packages/angular/cli/src/typings.ts index e7b7d14c0ca3..0ccb3728b882 100644 --- a/packages/angular/cli/src/typings.ts +++ b/packages/angular/cli/src/typings.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ declare module 'npm-pick-manifest' { diff --git a/packages/angular/cli/src/utilities/color.ts b/packages/angular/cli/src/utilities/color.ts index ff201f3e157a..3915d99ce248 100644 --- a/packages/angular/cli/src/utilities/color.ts +++ b/packages/angular/cli/src/utilities/color.ts @@ -3,47 +3,22 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import * as ansiColors from 'ansi-colors'; -import { WriteStream } from 'tty'; +import { WriteStream } from 'node:tty'; -function supportColor(): boolean { - if (process.env.FORCE_COLOR !== undefined) { - // 2 colors: FORCE_COLOR = 0 (Disables colors), depth 1 - // 16 colors: FORCE_COLOR = 1, depth 4 - // 256 colors: FORCE_COLOR = 2, depth 8 - // 16,777,216 colors: FORCE_COLOR = 3, depth 16 - // See: https://nodejs.org/dist/latest-v12.x/docs/api/tty.html#tty_writestream_getcolordepth_env - // and https://github.com/nodejs/node/blob/b9f36062d7b5c5039498e98d2f2c180dca2a7065/lib/internal/tty.js#L106; - switch (process.env.FORCE_COLOR) { - case '': - case 'true': - case '1': - case '2': - case '3': - return true; - default: - return false; - } - } +export { color as colors, figures } from 'listr2'; - if (process.stdout instanceof WriteStream) { - return process.stdout.getColorDepth() > 1; +export function supportColor(stream: NodeJS.WritableStream = process.stdout): boolean { + if (stream instanceof WriteStream) { + return stream.hasColors(); } - return false; -} - -export function removeColor(text: string): string { - // This has been created because when colors.enabled is false unstyle doesn't work - // see: https://github.com/doowb/ansi-colors/blob/a4794363369d7b4d1872d248fc43a12761640d8e/index.js#L38 - return text.replace(ansiColors.ansiRegex, ''); + try { + // The hasColors function does not rely on any instance state and should ideally be static + return WriteStream.prototype.hasColors(); + } catch { + return process.env['FORCE_COLOR'] !== undefined && process.env['FORCE_COLOR'] !== '0'; + } } - -// Create a separate instance to prevent unintended global changes to the color configuration -const colors = ansiColors.create(); -colors.enabled = supportColor(); - -export { colors }; diff --git a/packages/angular/cli/src/utilities/completion.ts b/packages/angular/cli/src/utilities/completion.ts index c37609044e7e..07483065caed 100644 --- a/packages/angular/cli/src/utilities/completion.ts +++ b/packages/angular/cli/src/utilities/completion.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { json, logging } from '@angular-devkit/core'; @@ -16,7 +16,7 @@ import { getWorkspace } from '../utilities/config'; import { forceAutocomplete } from '../utilities/environment-options'; import { isTTY } from '../utilities/tty'; import { assertIsError } from './error'; -import { loadEsmModule } from './load-esm'; +import { askConfirmation } from './prompt'; /** Interface for the autocompletion configuration stored in the global workspace. */ interface CompletionConfig { @@ -88,7 +88,7 @@ Appended \`source <(ng completion script)\` to \`${rcFile}\`. Restart your termi ' Angular CLI. For autocompletion to work, the CLI will need to be on your `$PATH`, which' + ' is typically done with the `-g` flag in `npm install -g @angular/cli`.' + '\n\n' + - 'For more information, see https://angular.io/cli/completion#global-install', + 'For more information, see https://angular.dev/cli/completion#global-install', ); } @@ -130,8 +130,8 @@ async function shouldPromptForAutocompletionSetup( return forceAutocomplete; } - // Don't prompt on `ng update` or `ng completion`. - if (command === 'update' || command === 'completion') { + // Don't prompt on `ng update`, 'ng version' or `ng completion`. + if (['version', 'update', 'completion'].includes(command)) { return false; } @@ -179,24 +179,17 @@ async function shouldPromptForAutocompletionSetup( } async function promptForAutocompletion(): Promise { - // Dynamically load `inquirer` so users don't have to pay the cost of parsing and executing it for - // the 99% of builds that *don't* prompt for autocompletion. - const { default: inquirer } = await loadEsmModule('inquirer'); - const { autocomplete } = await inquirer.prompt<{ autocomplete: boolean }>([ - { - name: 'autocomplete', - type: 'confirm', - message: ` + const autocomplete = await askConfirmation( + ` Would you like to enable autocompletion? This will set up your terminal so pressing TAB while typing Angular CLI commands will show possible options and autocomplete arguments. (Enabling autocompletion will modify configuration files in your home directory.) - ` - .split('\n') - .join(' ') - .trim(), - default: true, - }, - ]); + ` + .split('\n') + .join(' ') + .trim(), + true, + ); return autocomplete; } diff --git a/packages/angular/cli/src/utilities/config.ts b/packages/angular/cli/src/utilities/config.ts index b4d3a99729ea..af370a164a35 100644 --- a/packages/angular/cli/src/utilities/config.ts +++ b/packages/angular/cli/src/utilities/config.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { json, workspaces } from '@angular-devkit/core'; diff --git a/packages/angular/cli/src/utilities/environment-options.ts b/packages/angular/cli/src/utilities/environment-options.ts index 264984bb432a..0f01ce8b09cb 100644 --- a/packages/angular/cli/src/utilities/environment-options.ts +++ b/packages/angular/cli/src/utilities/environment-options.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ function isPresent(variable: string | undefined): variable is string { diff --git a/packages/angular/cli/src/utilities/eol.ts b/packages/angular/cli/src/utilities/eol.ts index 8e9de0b699d2..02e837649144 100644 --- a/packages/angular/cli/src/utilities/eol.ts +++ b/packages/angular/cli/src/utilities/eol.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { EOL } from 'node:os'; diff --git a/packages/angular/cli/src/utilities/error.ts b/packages/angular/cli/src/utilities/error.ts index 3b37aafc9dc3..c00e13e79726 100644 --- a/packages/angular/cli/src/utilities/error.ts +++ b/packages/angular/cli/src/utilities/error.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import assert from 'assert'; diff --git a/packages/angular/cli/src/utilities/find-up.ts b/packages/angular/cli/src/utilities/find-up.ts index 3427d7ba15f4..ed0adb0f78bb 100644 --- a/packages/angular/cli/src/utilities/find-up.ts +++ b/packages/angular/cli/src/utilities/find-up.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { existsSync } from 'fs'; diff --git a/packages/angular/cli/src/utilities/json-file.ts b/packages/angular/cli/src/utilities/json-file.ts index 1239dbc1cbd9..f960462c4ecf 100644 --- a/packages/angular/cli/src/utilities/json-file.ts +++ b/packages/angular/cli/src/utilities/json-file.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { JsonValue } from '@angular-devkit/core'; diff --git a/packages/angular/cli/src/utilities/load-esm.ts b/packages/angular/cli/src/utilities/load-esm.ts index 6f3bd2f73f54..6a6220f66288 100644 --- a/packages/angular/cli/src/utilities/load-esm.ts +++ b/packages/angular/cli/src/utilities/load-esm.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ /** diff --git a/packages/angular/cli/src/utilities/log-file.ts b/packages/angular/cli/src/utilities/log-file.ts index 41dc036fc028..dbccaaf24879 100644 --- a/packages/angular/cli/src/utilities/log-file.ts +++ b/packages/angular/cli/src/utilities/log-file.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { appendFileSync, mkdtempSync, realpathSync } from 'fs'; diff --git a/packages/angular/cli/src/utilities/memoize.ts b/packages/angular/cli/src/utilities/memoize.ts index 6994dbf5e9c1..2ae55e4b383a 100644 --- a/packages/angular/cli/src/utilities/memoize.ts +++ b/packages/angular/cli/src/utilities/memoize.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ /** @@ -13,41 +13,34 @@ * * @see https://en.wikipedia.org/wiki/Memoization */ -export function memoize( - target: Object, - propertyKey: string | symbol, - descriptor: TypedPropertyDescriptor, -): TypedPropertyDescriptor { - const descriptorPropertyName = descriptor.get ? 'get' : 'value'; - const originalMethod: unknown = descriptor[descriptorPropertyName]; - - if (typeof originalMethod !== 'function') { +export function memoize( + target: (this: This, ...args: Args) => Return, + context: ClassMemberDecoratorContext, +) { + if (context.kind !== 'method' && context.kind !== 'getter') { throw new Error('Memoize decorator can only be used on methods or get accessors.'); } - const cache = new Map(); + const cache = new Map(); - return { - ...descriptor, - [descriptorPropertyName]: function (this: unknown, ...args: unknown[]) { - for (const arg of args) { - if (!isJSONSerializable(arg)) { - throw new Error( - `Argument ${isNonPrimitive(arg) ? arg.toString() : arg} is JSON serializable.`, - ); - } + return function (this: This, ...args: Args): Return { + for (const arg of args) { + if (!isJSONSerializable(arg)) { + throw new Error( + `Argument ${isNonPrimitive(arg) ? arg.toString() : arg} is JSON serializable.`, + ); } + } - const key = JSON.stringify(args); - if (cache.has(key)) { - return cache.get(key); - } + const key = JSON.stringify(args); + if (cache.has(key)) { + return cache.get(key) as Return; + } - const result = originalMethod.apply(this, args); - cache.set(key, result); + const result = target.apply(this, args); + cache.set(key, result); - return result; - }, + return result; }; } diff --git a/packages/angular/cli/src/utilities/memoize_spec.ts b/packages/angular/cli/src/utilities/memoize_spec.ts index c1d06fdf4c4e..1c65340764e9 100644 --- a/packages/angular/cli/src/utilities/memoize_spec.ts +++ b/packages/angular/cli/src/utilities/memoize_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { memoize } from './memoize'; diff --git a/packages/angular/cli/src/utilities/package-manager.ts b/packages/angular/cli/src/utilities/package-manager.ts index 74710a05df64..28273c698013 100644 --- a/packages/angular/cli/src/utilities/package-manager.ts +++ b/packages/angular/cli/src/utilities/package-manager.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { isJsonObject, json } from '@angular-devkit/core'; @@ -14,7 +14,6 @@ import { join } from 'path'; import { PackageManager } from '../../lib/config/workspace-schema'; import { AngularWorkspace, getProjectByCwd } from './config'; import { memoize } from './memoize'; -import { Spinner } from './spinner'; interface PackageManagerOptions { saveDev: string; @@ -166,9 +165,6 @@ export class PackageManagerUtils { ): Promise { const { cwd = process.cwd(), silent = false } = options; - const spinner = new Spinner(); - spinner.start('Installing packages...'); - return new Promise((resolve) => { const bufferedOutput: { stream: NodeJS.WriteStream; data: Buffer }[] = []; @@ -179,12 +175,9 @@ export class PackageManagerUtils { cwd, }).on('close', (code: number) => { if (code === 0) { - spinner.succeed('Packages successfully installed.'); resolve(true); } else { - spinner.stop(); bufferedOutput.forEach(({ stream, data }) => stream.write(data)); - spinner.fail('Packages installation failed, see above.'); resolve(false); } }); diff --git a/packages/angular/cli/src/utilities/package-metadata.ts b/packages/angular/cli/src/utilities/package-metadata.ts index 9eed9b78e9f4..b10292f93e78 100644 --- a/packages/angular/cli/src/utilities/package-metadata.ts +++ b/packages/angular/cli/src/utilities/package-metadata.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { logging } from '@angular-devkit/core'; diff --git a/packages/angular/cli/src/utilities/package-tree.ts b/packages/angular/cli/src/utilities/package-tree.ts index 9b082e6c9d9f..923f1c732b4d 100644 --- a/packages/angular/cli/src/utilities/package-tree.ts +++ b/packages/angular/cli/src/utilities/package-tree.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import * as fs from 'fs'; @@ -44,7 +44,7 @@ export interface PackageTreeNode { export async function readPackageJson(packageJsonPath: string): Promise { try { - return JSON.parse((await fs.promises.readFile(packageJsonPath)).toString()); + return JSON.parse((await fs.promises.readFile(packageJsonPath)).toString()) as PackageJson; } catch { return undefined; } diff --git a/packages/angular/cli/src/utilities/project.ts b/packages/angular/cli/src/utilities/project.ts index 8598859fb6d2..b1c9cb14d458 100644 --- a/packages/angular/cli/src/utilities/project.ts +++ b/packages/angular/cli/src/utilities/project.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { normalize } from '@angular-devkit/core'; @@ -12,6 +12,11 @@ import * as os from 'os'; import * as path from 'path'; import { findUp } from './find-up'; +interface PackageDependencies { + dependencies?: Record; + devDependencies?: Record; +} + export function findWorkspaceFile(currentDirectory = process.cwd()): string | null { const possibleConfigFiles = ['angular.json', '.angular.json']; const configFilePath = findUp(possibleConfigFiles, currentDirectory); @@ -27,7 +32,7 @@ export function findWorkspaceFile(currentDirectory = process.cwd()): string | nu try { const packageJsonText = fs.readFileSync(packageJsonPath, 'utf-8'); - const packageJson = JSON.parse(packageJsonText); + const packageJson = JSON.parse(packageJsonText) as PackageDependencies; if (!containsCliDep(packageJson)) { // No CLI dependency return null; @@ -41,10 +46,7 @@ export function findWorkspaceFile(currentDirectory = process.cwd()): string | nu return configFilePath; } -function containsCliDep(obj?: { - dependencies?: Record; - devDependencies?: Record; -}): boolean { +function containsCliDep(obj?: PackageDependencies): boolean { const pkgName = '@angular/cli'; if (!obj) { return false; diff --git a/packages/angular/cli/src/utilities/prompt.ts b/packages/angular/cli/src/utilities/prompt.ts index 968e14676142..3d4e8c67ce09 100644 --- a/packages/angular/cli/src/utilities/prompt.ts +++ b/packages/angular/cli/src/utilities/prompt.ts @@ -3,17 +3,9 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ -import type { - CheckboxChoiceOptions, - CheckboxQuestion, - ListChoiceOptions, - ListQuestion, - Question, -} from 'inquirer'; -import { loadEsmModule } from './load-esm'; import { isTTY } from './tty'; export async function askConfirmation( @@ -25,23 +17,21 @@ export async function askConfirmation( return noTTYResponse ?? defaultResponse; } - const question: Question = { - type: 'confirm', - name: 'confirmation', - prefix: '', + const { confirm } = await import('@inquirer/prompts'); + const answer = await confirm({ message, default: defaultResponse, - }; + theme: { + prefix: '', + }, + }); - const { default: inquirer } = await loadEsmModule('inquirer'); - const answers = await inquirer.prompt([question]); - - return answers['confirmation']; + return answer; } export async function askQuestion( message: string, - choices: ListChoiceOptions[], + choices: { name: string; value: string | null }[], defaultResponseIndex: number, noTTYResponse: null | string, ): Promise { @@ -49,40 +39,36 @@ export async function askQuestion( return noTTYResponse; } - const question: ListQuestion = { - type: 'list', - name: 'answer', - prefix: '', + const { select } = await import('@inquirer/prompts'); + const answer = await select({ message, choices, default: defaultResponseIndex, - }; - - const { default: inquirer } = await loadEsmModule('inquirer'); - const answers = await inquirer.prompt([question]); + theme: { + prefix: '', + }, + }); - return answers['answer']; + return answer; } export async function askChoices( message: string, - choices: CheckboxChoiceOptions[], + choices: { name: string; value: string }[], noTTYResponse: string[] | null, ): Promise { if (!isTTY()) { return noTTYResponse; } - const question: CheckboxQuestion = { - type: 'checkbox', - name: 'answer', - prefix: '', + const { checkbox } = await import('@inquirer/prompts'); + const answers = await checkbox({ message, choices, - }; - - const { default: inquirer } = await loadEsmModule('inquirer'); - const answers = await inquirer.prompt([question]); + theme: { + prefix: '', + }, + }); - return answers['answer']; + return answers; } diff --git a/packages/angular/cli/src/utilities/spinner.ts b/packages/angular/cli/src/utilities/spinner.ts deleted file mode 100644 index 3deda119aee5..000000000000 --- a/packages/angular/cli/src/utilities/spinner.ts +++ /dev/null @@ -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.io/license - */ - -import ora from 'ora'; -import { colors } from './color'; - -export class Spinner { - private readonly spinner: ora.Ora; - - /** When false, only fail messages will be displayed. */ - enabled = true; - - constructor(text?: string) { - this.spinner = ora({ - text, - // The below 2 options are needed because otherwise CTRL+C will be delayed - // when the underlying process is sync. - hideCursor: false, - discardStdin: false, - }); - } - - set text(text: string) { - this.spinner.text = text; - } - - succeed(text?: string): void { - if (this.enabled) { - this.spinner.succeed(text); - } - } - - info(text?: string): void { - this.spinner.info(text); - } - - fail(text?: string): void { - this.spinner.fail(text && colors.redBright(text)); - } - - warn(text?: string): void { - this.spinner.warn(text && colors.yellowBright(text)); - } - - stop(): void { - this.spinner.stop(); - } - - start(text?: string): void { - if (this.enabled) { - this.spinner.start(text); - } - } -} diff --git a/packages/angular/cli/src/utilities/tty.ts b/packages/angular/cli/src/utilities/tty.ts index 1e5658ebfd57..db6543926941 100644 --- a/packages/angular/cli/src/utilities/tty.ts +++ b/packages/angular/cli/src/utilities/tty.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ function _isTruthy(value: undefined | string): boolean { @@ -11,12 +11,12 @@ function _isTruthy(value: undefined | string): boolean { return value !== undefined && value !== '0' && value.toUpperCase() !== 'FALSE'; } -export function isTTY(): boolean { +export function isTTY(stream: NodeJS.WriteStream = process.stdout): boolean { // If we force TTY, we always return true. const force = process.env['NG_FORCE_TTY']; if (force !== undefined) { return _isTruthy(force); } - return !!process.stdout.isTTY && !_isTruthy(process.env['CI']); + return !!stream.isTTY && !_isTruthy(process.env['CI']); } diff --git a/packages/angular/cli/src/utilities/version.ts b/packages/angular/cli/src/utilities/version.ts index 777c3de165f6..71a6f4c70cee 100644 --- a/packages/angular/cli/src/utilities/version.ts +++ b/packages/angular/cli/src/utilities/version.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { readFileSync } from 'fs'; diff --git a/packages/angular/create/BUILD.bazel b/packages/angular/create/BUILD.bazel index 50142d83e444..e65dbcb98d1c 100644 --- a/packages/angular/create/BUILD.bazel +++ b/packages/angular/create/BUILD.bazel @@ -1,7 +1,7 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license load("//tools:defaults.bzl", "pkg_npm", "ts_library") @@ -26,6 +26,9 @@ genrule( pkg_npm( name = "npm_package", + pkg_deps = [ + "//packages/angular/cli:package.json", + ], tags = ["release-package"], visibility = ["//visibility:public"], deps = [ diff --git a/packages/angular/create/README.md b/packages/angular/create/README.md index becdfac12ffc..46135476e406 100644 --- a/packages/angular/create/README.md +++ b/packages/angular/create/README.md @@ -2,7 +2,7 @@ ## Create an Angular CLI workspace -Scaffold an Angular CLI workspace without needing to install the Angular CLI globally. All of the [ng new](https://angular.io/cli/new) options and features are supported. +Scaffold an Angular CLI workspace without needing to install the Angular CLI globally. All of the [ng new](https://angular.dev/cli/new) options and features are supported. ## Usage diff --git a/packages/angular/create/package.json b/packages/angular/create/package.json index 48f351dfb089..a5ad3fce4ff9 100644 --- a/packages/angular/create/package.json +++ b/packages/angular/create/package.json @@ -9,9 +9,7 @@ "code generation", "schematics" ], - "bin": { - "create": "./src/index.js" - }, + "bin": "./src/index.js", "dependencies": { "@angular/cli": "0.0.0-PLACEHOLDER" } diff --git a/packages/angular/create/src/index.ts b/packages/angular/create/src/index.ts index 7d4aaf280637..15e521ed964d 100644 --- a/packages/angular/create/src/index.ts +++ b/packages/angular/create/src/index.ts @@ -4,7 +4,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { spawnSync } from 'child_process'; diff --git a/packages/angular/pwa/BUILD.bazel b/packages/angular/pwa/BUILD.bazel index 4d3df6fd38c3..5a8a3ecd3cca 100644 --- a/packages/angular/pwa/BUILD.bazel +++ b/packages/angular/pwa/BUILD.bazel @@ -1,7 +1,7 @@ # Copyright Google Inc. All Rights Reserved. # # Use of this source code is governed by an MIT-style license that can be -# found in the LICENSE file at https://angular.io/license +# found in the LICENSE file at https://angular.dev/license load("@npm//@bazel/jasmine:index.bzl", "jasmine_node_test") load("//tools:defaults.bzl", "pkg_npm", "ts_library") @@ -18,10 +18,11 @@ ts_library( "pwa/index.ts", "//packages/angular/pwa:pwa/schema.ts", ], - data = glob( + data = [ + "collection.json", + "pwa/schema.json", + ] + glob( include = [ - "collection.json", - "pwa/schema.json", "pwa/files/**/*", ], ), diff --git a/packages/angular/pwa/README.md b/packages/angular/pwa/README.md index 952f1f963efa..c7ecbdaa99af 100644 --- a/packages/angular/pwa/README.md +++ b/packages/angular/pwa/README.md @@ -1,8 +1,8 @@ # `@angular/pwa` -This is a [schematic](https://angular.io/guide/schematics) for adding +This is a [schematic](https://angular.dev/tools/cli/schematics) for adding [Progressive Web App](https://web.dev/progressive-web-apps/) support to an Angular project. Run the -schematic with the [Angular CLI](https://angular.io/cli): +schematic with the [Angular CLI](https://angular.dev/tools/cli): ```shell ng add @angular/pwa --project @@ -19,5 +19,5 @@ Executing the command mentioned above will perform the following actions: 1. Installs icon files to support the installed Progressive Web App (PWA). 1. Creates the service worker configuration file called `ngsw-config.json`, specifying caching behaviors and other settings. -See [Getting started with service workers](https://angular.io/guide/service-worker-getting-started) +See [Getting started with service workers](https://angular.dev/ecosystem/service-workers/getting-started) for more information. diff --git a/packages/angular/pwa/package.json b/packages/angular/pwa/package.json index 5859ef6811a6..778029e66822 100644 --- a/packages/angular/pwa/package.json +++ b/packages/angular/pwa/package.json @@ -17,7 +17,7 @@ "parse5-html-rewriting-stream": "7.0.0" }, "peerDependencies": { - "@angular/cli": "^17.0.0 || ^17.3.0-next.0" + "@angular/cli": "^18.0.0" }, "peerDependenciesMeta": { "@angular/cli": { diff --git a/packages/angular/pwa/pwa/files/root/manifest.webmanifest b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest similarity index 71% rename from packages/angular/pwa/pwa/files/root/manifest.webmanifest rename to packages/angular/pwa/pwa/files/assets/manifest.webmanifest index 7d096fae01c5..f8c1e3960511 100644 --- a/packages/angular/pwa/pwa/files/root/manifest.webmanifest +++ b/packages/angular/pwa/pwa/files/assets/manifest.webmanifest @@ -8,49 +8,49 @@ "start_url": "./", "icons": [ { - "src": "assets/icons/icon-72x72.png", + "src": "<%= iconsPath %>/icon-72x72.png", "sizes": "72x72", "type": "image/png", "purpose": "maskable any" }, { - "src": "assets/icons/icon-96x96.png", + "src": "<%= iconsPath %>/icon-96x96.png", "sizes": "96x96", "type": "image/png", "purpose": "maskable any" }, { - "src": "assets/icons/icon-128x128.png", + "src": "<%= iconsPath %>/icon-128x128.png", "sizes": "128x128", "type": "image/png", "purpose": "maskable any" }, { - "src": "assets/icons/icon-144x144.png", + "src": "<%= iconsPath %>/icon-144x144.png", "sizes": "144x144", "type": "image/png", "purpose": "maskable any" }, { - "src": "assets/icons/icon-152x152.png", + "src": "<%= iconsPath %>/icon-152x152.png", "sizes": "152x152", "type": "image/png", "purpose": "maskable any" }, { - "src": "assets/icons/icon-192x192.png", + "src": "<%= iconsPath %>/icon-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable any" }, { - "src": "assets/icons/icon-384x384.png", + "src": "<%= iconsPath %>/icon-384x384.png", "sizes": "384x384", "type": "image/png", "purpose": "maskable any" }, { - "src": "assets/icons/icon-512x512.png", + "src": "<%= iconsPath %>/icon-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable any" diff --git a/packages/angular/pwa/pwa/index.ts b/packages/angular/pwa/pwa/index.ts index f817c4764905..550e359e47f8 100644 --- a/packages/angular/pwa/pwa/index.ts +++ b/packages/angular/pwa/pwa/index.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { @@ -104,23 +104,6 @@ export default function (options: PwaOptions): Rule { } } - // Add manifest to asset configuration - const assetEntry = posix.join( - project.sourceRoot ?? posix.join(project.root, 'src'), - 'manifest.webmanifest', - ); - for (const target of [...buildTargets, ...testTargets]) { - if (target.options) { - if (Array.isArray(target.options.assets)) { - target.options.assets.push(assetEntry); - } else { - target.options.assets = [assetEntry]; - } - } else { - target.options = { assets: [assetEntry] }; - } - } - // Find all index.html files in build targets const indexFiles = new Set(); for (const target of buildTargets) { @@ -146,11 +129,36 @@ export default function (options: PwaOptions): Rule { const { title, ...swOptions } = options; await writeWorkspace(host, workspace); + let assetsDir = posix.join(sourcePath, 'assets'); + let iconsPath: string; + if (host.exists(assetsDir)) { + // Add manifest to asset configuration + const assetEntry = posix.join( + project.sourceRoot ?? posix.join(project.root, 'src'), + 'manifest.webmanifest', + ); + for (const target of [...buildTargets, ...testTargets]) { + if (target.options) { + if (Array.isArray(target.options.assets)) { + target.options.assets.push(assetEntry); + } else { + target.options.assets = [assetEntry]; + } + } else { + target.options = { assets: [assetEntry] }; + } + } + iconsPath = 'assets'; + } else { + assetsDir = posix.join(project.root, 'public'); + iconsPath = 'icons'; + } return chain([ externalSchematic('@schematics/angular', 'service-worker', swOptions), - mergeWith(apply(url('./files/root'), [template({ ...options }), move(sourcePath)])), - mergeWith(apply(url('./files/assets'), [move(posix.join(sourcePath, 'assets'))])), + mergeWith( + apply(url('./files/assets'), [template({ ...options, iconsPath }), move(assetsDir)]), + ), ...[...indexFiles].map((path) => updateIndexFile(path)), ]); }; diff --git a/packages/angular/pwa/pwa/index_spec.ts b/packages/angular/pwa/pwa/index_spec.ts index e6b0d4e576bb..3e0216b8cb2b 100644 --- a/packages/angular/pwa/pwa/index_spec.ts +++ b/packages/angular/pwa/pwa/index_spec.ts @@ -3,7 +3,7 @@ * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license + * found in the LICENSE file at https://angular.dev/license */ import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing'; @@ -54,7 +54,7 @@ describe('PWA Schematic', () => { it('should create icon files', async () => { const dimensions = [72, 96, 128, 144, 152, 192, 384, 512]; - const iconPath = '/projects/bar/src/assets/icons/icon-'; + const iconPath = '/projects/bar/public/icons/icon-'; const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); dimensions.forEach((d) => { @@ -63,6 +63,15 @@ describe('PWA Schematic', () => { }); }); + it('should reference the icons in the manifest correctly', async () => { + const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); + const manifest = JSON.parse(manifestText); + for (const icon of manifest.icons) { + expect(icon.src).toMatch(/^icons\/icon-\d+x\d+.png/); + } + }); + it('should run the service worker schematic', async () => { const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); const configText = tree.readContent('/angular.json'); @@ -74,13 +83,13 @@ describe('PWA Schematic', () => { it('should create a manifest file', async () => { const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - expect(tree.exists('/projects/bar/src/manifest.webmanifest')).toBeTrue(); + expect(tree.exists('/projects/bar/public/manifest.webmanifest')).toBeTrue(); }); it('should set the name & short_name in the manifest file', async () => { const tree = await schematicRunner.runSchematic('ng-add', defaultOptions, appTree); - const manifestText = tree.readContent('/projects/bar/src/manifest.webmanifest'); + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); const manifest = JSON.parse(manifestText); expect(manifest.name).toEqual(defaultOptions.title); @@ -91,7 +100,7 @@ describe('PWA Schematic', () => { const options = { ...defaultOptions, title: undefined }; const tree = await schematicRunner.runSchematic('ng-add', options, appTree); - const manifestText = tree.readContent('/projects/bar/src/manifest.webmanifest'); + const manifestText = tree.readContent('/projects/bar/public/manifest.webmanifest'); const manifest = JSON.parse(manifestText); expect(manifest.name).toEqual(defaultOptions.project); @@ -125,17 +134,6 @@ describe('PWA Schematic', () => { expect(content).toMatch(/