diff --git a/.github/workflows/link-check-daily.yml b/.github/workflows/link-check-daily.yml deleted file mode 100644 index b9469bd0b163..000000000000 --- a/.github/workflows/link-check-daily.yml +++ /dev/null @@ -1,106 +0,0 @@ -name: 'Link Checker: Daily' - -# **What it does**: This script once a day checks all English links and reports in issue if any are broken. -# **Why we have it**: We want to know if any links break internally or externally. -# **Who does it impact**: Docs content. - -on: - workflow_dispatch: - schedule: - - cron: '20 16 * * *' # Run every day at 16:20 UTC / 8:20 PST - -permissions: - contents: read - issues: write - -jobs: - check_all_english_links: - name: Check all links - if: github.repository == 'github/docs-internal' - runs-on: ubuntu-latest - steps: - - name: Check that gh CLI is installed - run: gh --version - - - name: Check out repo's default branch - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - uses: ./.github/actions/node-npm-setup - - - name: Figure out which docs-early-access branch to checkout, if internal repo - if: ${{ github.repository == 'github/docs-internal' }} - id: check-early-access - env: - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} - run: npm run what-docs-early-access-branch - - - name: Check out docs-early-access too, if internal repo - if: ${{ github.repository == 'github/docs-internal' }} - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - with: - repository: github/docs-early-access - token: ${{ secrets.DOCS_BOT_PAT_BASE }} - path: docs-early-access - ref: ${{ steps.check-early-access.outputs.branch }} - - - name: Merge docs-early-access repo's folders - if: ${{ github.repository == 'github/docs-internal' }} - run: src/early-access/scripts/merge-early-access.sh - - - name: Restore disk-cache file for external link checking - uses: actions/cache@v5 - with: - path: external-link-checker-db.json - key: external-link-checker-${{ hashFiles('src/links/scripts/rendered-content-link-checker.ts') }} - - - name: Insight into external link checker DB json file (before) - run: | - if [ -f external-link-checker-db.json ]; then - echo "external-link-checker-db.json exists" - echo -n "Number of URLs in cache: " - jq '.urls | keys_unsorted' external-link-checker-db.json | wc -l - else - echo "external-link-checker-db.json does not exist" - fi - - - name: Run link checker - env: - DISABLE_REWRITE_ASSET_URLS: true - LEVEL: 'critical' - # Set this to true in repo scope to enable debug logs - # ACTIONS_RUNNER_DEBUG = true - ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} - REPORT_AUTHOR: docs-bot - REPORT_LABEL: broken link report - REPORT_REPOSITORY: github/docs-content - CREATE_REPORT: true - CHECK_EXTERNAL_LINKS: true - PATIENT: true - # This means that we'll *re-check* external URLs once a week. - # But mind you that the number has a 10% chance of "jitter" - # to avoid a stampeding herd when they all expire some day. - EXTERNAL_LINK_CHECKER_MAX_AGE_DAYS: 7 - # If we're unable to connect or the server returns a 50x error, - # treat it as a warning and not as a broken link. - EXTERNAL_SERVER_ERRORS_AS_WARNINGS: true - FAIL_ON_FLAW: false - timeout-minutes: 120 - run: npm run rendered-content-link-checker - - - name: Insight into external link checker DB json file (after) - run: | - if [ -f external-link-checker-db.json ]; then - echo "external-link-checker-db.json exists" - echo -n "Number of URLs in cache: " - jq '.urls | keys_unsorted' external-link-checker-db.json | wc -l - else - echo "external-link-checker-db.json does not exist" - fi - - - uses: ./.github/actions/slack-alert - if: ${{ failure() && github.event_name != 'workflow_dispatch' }} - with: - slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} - slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} diff --git a/.github/workflows/link-check-external.yml b/.github/workflows/link-check-external.yml new file mode 100644 index 000000000000..af8a04adc6ad --- /dev/null +++ b/.github/workflows/link-check-external.yml @@ -0,0 +1,66 @@ +name: Check External Links + +# Runs weekly (Wednesday) at 16:20 UTC +# Validates external URLs in content files + +on: + schedule: + - cron: '20 16 * * 3' # Wednesday at 16:20 UTC + workflow_dispatch: + inputs: + max_urls: + description: 'Maximum number of URLs to check (leave blank for all)' + type: number + +permissions: + contents: read + issues: write + +jobs: + check-external-links: + if: github.repository == 'github/docs-internal' + runs-on: ubuntu-latest + timeout-minutes: 180 # 3 hours for external checks + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - uses: ./.github/actions/node-npm-setup + + - name: Install dependencies + run: npm ci + + - name: Check external links + env: + ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + CACHE_MAX_AGE_DAYS: '7' + run: | + if [[ -n "${{ inputs.max_urls }}" ]]; then + npm run check-links-external -- --max ${{ inputs.max_urls }} + else + npm run check-links-external + fi + + - name: Upload report artifact + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: external-link-report + path: artifacts/external-link-report.* + retention-days: 14 + + - name: Create issue if broken links found + if: failure() + uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v5 + with: + token: ${{ secrets.DOCS_BOT_PAT_WORKFLOW }} + repository: github/docs-content + title: '🌐 Broken External Links Report' + content-filepath: artifacts/external-link-report.md + labels: broken link report + + - uses: ./.github/actions/slack-alert + if: ${{ failure() && github.event_name != 'workflow_dispatch' }} + with: + slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} + slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} diff --git a/.github/workflows/link-check-internal.yml b/.github/workflows/link-check-internal.yml new file mode 100644 index 000000000000..a6071cd20b64 --- /dev/null +++ b/.github/workflows/link-check-internal.yml @@ -0,0 +1,159 @@ +name: Check Internal Links + +# Runs weekly (Tuesday) at 16:20 UTC +# On schedule: checks English free-pro-team and latest enterprise-server +# On workflow_dispatch: run any version/language combo + +on: + schedule: + - cron: '20 16 * * 2' # Tuesday at 16:20 UTC + workflow_dispatch: + inputs: + version: + description: 'Version to check (e.g., free-pro-team@latest, enterprise-server@3.19)' + type: string + required: true + language: + description: 'Language to check (e.g., en, es, ja)' + type: string + required: true + default: 'en' + +permissions: + contents: read + issues: write + +jobs: + # Determine which version/language combos to run + setup-matrix: + if: github.repository == 'github/docs-internal' + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - uses: ./.github/actions/node-npm-setup + + - name: Set matrix + id: set-matrix + run: | + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + # Manual run: use the provided version and language + echo 'matrix={"include":[{"version":"${{ inputs.version }}","language":"${{ inputs.language }}"}]}' >> $GITHUB_OUTPUT + else + # Scheduled run: English free-pro-team + English latest enterprise-server + LATEST_GHES=$(npx tsx -e "import { latest } from './src/versions/lib/enterprise-server-releases'; console.log(latest)") + echo "matrix={\"include\":[{\"version\":\"free-pro-team@latest\",\"language\":\"en\"},{\"version\":\"enterprise-server@${LATEST_GHES}\",\"language\":\"en\"}]}" >> $GITHUB_OUTPUT + fi + + - uses: ./.github/actions/slack-alert + if: ${{ failure() && github.event_name != 'workflow_dispatch' }} + with: + slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} + slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} + + check-internal-links: + if: github.repository == 'github/docs-internal' + needs: setup-matrix + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.setup-matrix.outputs.matrix) }} + env: + # Disable Elasticsearch for faster warmServer + ELASTICSEARCH_URL: '' + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - uses: ./.github/actions/node-npm-setup + + - name: Install dependencies + run: npm ci + + # Clone translations if not English + - name: Clone translations + if: matrix.language != 'en' + uses: ./.github/actions/clone-translations + with: + token: ${{ secrets.DOCS_BOT_PAT_READPUBLICKEY }} + + - name: Check internal links + env: + VERSION: ${{ matrix.version }} + LANGUAGE: ${{ matrix.language }} + CHECK_ANCHORS: true + ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: npm run check-links-internal + + - name: Upload report artifact + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: link-report-${{ matrix.version }}-${{ matrix.language }} + path: artifacts/link-report-*.md + retention-days: 5 + + - uses: ./.github/actions/slack-alert + if: ${{ failure() && github.event_name != 'workflow_dispatch' }} + with: + slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} + slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} + + # Create combined report after all matrix jobs complete + create-report: + if: always() && github.repository == 'github/docs-internal' + needs: [setup-matrix, check-internal-links] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Download all artifacts + uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1 + with: + path: reports + pattern: link-report-* + merge-multiple: true + + - name: Combine reports + id: combine + run: | + # Check if any reports exist + if ls reports/*.md 1> /dev/null 2>&1; then + echo "has_reports=true" >> $GITHUB_OUTPUT + + # Combine all markdown reports + echo "# Internal Links Report" > combined-report.md + echo "" >> combined-report.md + echo "Generated: $(date -u +'%Y-%m-%d %H:%M UTC')" >> combined-report.md + echo "[Action run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})" >> combined-report.md + echo "" >> combined-report.md + + for report in reports/*.md; do + echo "---" >> combined-report.md + cat "$report" >> combined-report.md + echo "" >> combined-report.md + done + else + echo "has_reports=false" >> $GITHUB_OUTPUT + echo "No broken link reports generated - all links valid!" + fi + + - name: Create issue if broken links found + if: steps.combine.outputs.has_reports == 'true' + uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v5 + with: + token: ${{ secrets.DOCS_BOT_PAT_WORKFLOW }} + repository: github/docs-content + title: '🔗 Broken Internal Links Report' + content-filepath: combined-report.md + labels: broken link report + + - uses: ./.github/actions/slack-alert + if: ${{ failure() && github.event_name != 'workflow_dispatch' }} + with: + slack_channel_id: ${{ secrets.DOCS_ALERTS_SLACK_CHANNEL_ID }} + slack_token: ${{ secrets.SLACK_DOCS_BOT_TOKEN }} diff --git a/.github/workflows/link-check-on-pr.yml b/.github/workflows/link-check-on-pr.yml index 484bc4c17b43..755fc0581e1c 100644 --- a/.github/workflows/link-check-on-pr.yml +++ b/.github/workflows/link-check-on-pr.yml @@ -1,7 +1,7 @@ name: 'Link Checker: On PR' -# **What it does**: Renders the content of every page and check all internal links on PR. -# **Why we have it**: To make sure all links connect correctly on changed files. +# **What it does**: Checks internal links in changed content files. +# **Why we have it**: To catch broken links before they're merged. # **Who does it impact**: Docs content. on: @@ -11,17 +11,17 @@ on: permissions: contents: read - # TODO: Uncomment if we uncomment below - # Needed for the 'trilom/file-changes-action' action - # pull-requests: read + pull-requests: write + issues: write -# This allows a subsequently queued workflow run to interrupt previous runs +# Cancel in-progress runs for the same PR concurrency: group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true jobs: check-links: + name: Check links runs-on: ubuntu-latest if: github.repository == 'github/docs-internal' || github.repository == 'github/docs' steps: @@ -35,19 +35,25 @@ jobs: with: token: ${{ secrets.DOCS_BOT_PAT_BASE }} - - name: Link check all pages (internal links only) + - name: Get changed files + id: changed-files + uses: tj-actions/changed-files@48d8f15b2aaa3d255ca5af3eba4870f807ce6b3c # v45 + with: + files: | + content/**/*.md + data/**/*.md + + - name: Check links in changed files + if: steps.changed-files.outputs.any_changed == 'true' env: - LEVEL: 'critical' + FILES_CHANGED: ${{ steps.changed-files.outputs.all_changed_files }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} ACTION_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} SHOULD_COMMENT: ${{ secrets.DOCS_BOT_PAT_BASE != '' }} - CHECK_EXTERNAL_LINKS: false - CREATE_REPORT: false - CHECK_ANCHORS: true - # Not strictly necessary bit it makes warmServer() a bit faster - # because it only bothers with English to begin with, which - # we're filtering on anyway once the list of all pages has - # been loaded. - ENABLED_LANGUAGES: en FAIL_ON_FLAW: true - run: npm run rendered-content-link-checker + ENABLED_LANGUAGES: en + run: npm run check-links-pr + + - name: No content changes + if: steps.changed-files.outputs.any_changed != 'true' + run: echo "No content files changed. Skipping link check." diff --git a/Dockerfile b/Dockerfile index 1a4e587d8c0b..6cfc5a1aebb5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # --------------------------------------------------------------- # To update the sha: # https://github.com/github/gh-base-image/pkgs/container/gh-base-image%2Fgh-base-noble -FROM ghcr.io/github/gh-base-image/gh-base-noble:20260113-125234-g605df3bee AS base +FROM ghcr.io/github/gh-base-image/gh-base-noble:20260123-171033-gd75f3193f AS base # Install curl for Node install and determining the early access branch # Install git for cloning docs-early-access & translations repos diff --git a/content/actions/reference/workflows-and-actions/expressions.md b/content/actions/reference/workflows-and-actions/expressions.md index 418da047a5fe..c7c1915b7d16 100644 --- a/content/actions/reference/workflows-and-actions/expressions.md +++ b/content/actions/reference/workflows-and-actions/expressions.md @@ -81,21 +81,6 @@ env: * {% data variables.product.prodname_dotcom %} ignores case when comparing strings. * Objects and arrays are only considered equal when they are the same instance. -{% data variables.product.prodname_dotcom %} provides a way to create conditional logic in expressions using binary logical operators (`&&` and `||`). This pattern can be used to achieve similar functionality to the ternary operator (`?:`) found in many programming languages, while actually using only binary operators. - -### Example - -{% raw %} - -```yaml -env: - MY_ENV_VAR: ${{ github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches' }} -``` - -{% endraw %} - -In this example, we're using a combination of `&&` and `||` operators to set the value of the `MY_ENV_VAR` environment variable based on whether the {% data variables.product.prodname_dotcom %} reference is set to `refs/heads/main` or not. If it is, the variable is set to `value_for_main_branch`. Otherwise, it is set to `value_for_other_branches`. It is important to note that the first value after the `&&` must be truthy. Otherwise, the value after the `||` will always be returned. - ## Functions {% data variables.product.prodname_dotcom %} offers a set of built-in functions that you can use in expressions. Some functions cast values to a string to perform comparisons. {% data variables.product.prodname_dotcom %} casts data types to a string using these conversions: @@ -287,6 +272,43 @@ Creates a hash for all `.rb` files in the `lib` directory at root level, includi `hashFiles('/lib/**/*.rb', '!/lib/foo/*.rb')` +### case + +`case( pred1, val1, pred2, val2, ..., default )` + +Evaluates predicates in order and returns the value corresponding to the first predicate that evaluates to `true`. If no predicate matches, it returns the last argument as the default value. + +#### Example with a single predicate + +{% raw %} + +```yaml +env: + MY_ENV_VAR: ${{ case(github.ref == 'refs/heads/main', 'production', 'development') }} +``` + +{% endraw %} + +Sets `MY_ENV_VAR` to `production` when the ref is `refs/heads/main`, otherwise sets it to `development`. + +#### Example with multiple predicates + +{% raw %} + +```yaml +env: + MY_ENV_VAR: ${{ case( + github.ref == 'refs/heads/main', 'production', + github.ref == 'refs/heads/staging', 'staging', + startsWith(github.ref, 'refs/heads/feature/'), 'development', + 'unknown' + ) }} +``` + +{% endraw %} + +Sets `MY_ENV_VAR` based on the branch: `production` for `main`, `staging` for `staging`, `development` for branches starting with `feature/`, or `unknown` for all other branches. + ## Status check functions You can use the following status check functions as expressions in `if` conditionals. A default status check of `success()` is applied unless you include one of these functions. For more information about `if` conditionals, see [AUTOTITLE](/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idif) and [AUTOTITLE](/actions/creating-actions/metadata-syntax-for-github-actions#runsstepsif). diff --git a/content/code-security/concepts/code-scanning/codeql/codeql-query-packs.md b/content/code-security/concepts/code-scanning/codeql/codeql-query-packs.md new file mode 100644 index 000000000000..70f80ffb838e --- /dev/null +++ b/content/code-security/concepts/code-scanning/codeql/codeql-query-packs.md @@ -0,0 +1,48 @@ +--- +title: CodeQL query packs +intro: You can choose from different built-in {% data variables.product.prodname_codeql %} query suites to use in your {% data variables.product.prodname_codeql %} {% data variables.product.prodname_code_scanning %} setup. +product: '{% data reusables.gated-features.codeql %}' +versions: + fpt: '*' + ghes: '*' + ghec: '*' +topics: + - Code scanning + - CodeQL +contentType: concepts +--- + +{% data reusables.code-scanning.codeql-cli-version-ghes %} + +## About {% data variables.product.prodname_codeql %} packs + +{% data variables.product.prodname_codeql %} packs are used to create, share, depend on, and run {% data variables.product.prodname_codeql %} queries and libraries. {% data variables.product.prodname_codeql %} packs contain queries, library files, query suites, and metadata. You can customize your {% data variables.product.prodname_codeql %} analysis by downloading packs created by others and running them on your codebase. + +The {% data variables.product.prodname_codeql_cli %} bundle includes queries that are maintained by {% data variables.product.company_short %} experts, security researchers, and community contributors. If you want to run queries developed by other organizations, {% data variables.product.prodname_codeql %} query packs provide an efficient and reliable way to download and run queries, while model packs ({% data variables.release-phases.public_preview %}) can be used to expand {% data variables.product.prodname_code_scanning %} analysis to recognize libraries and frameworks that are not supported by default. + +## Types of {% data variables.product.prodname_codeql %} packs + +There are three types of {% data variables.product.prodname_codeql %} packs: query packs, library packs, and model packs. + +* Query packs contain a set of pre-compiled queries that can be evaluated on a {% data variables.product.prodname_codeql %} database. Query packs are designed to be run. When a query pack is published, the bundle includes all the transitive dependencies and pre-compiled representations of each query, in addition to the query sources. This ensures consistent and efficient execution of the queries in the pack. + +* Library packs are designed to be used by query packs (or other library packs) and do not contain queries themselves. The libraries are not compiled separately. + +* Model packs can be used to expand {% data variables.product.prodname_code_scanning %} analysis to recognize libraries and frameworks that are not supported by default. Model packs are currently in {% data variables.release-phases.public_preview %} and subject to change. During the {% data variables.release-phases.public_preview %}, model packs are available for {% data variables.code-scanning.codeql_model_packs_support %} analysis. For more information about creating your own model packs, see [AUTOTITLE](/code-security/codeql-cli/using-the-advanced-functionality-of-the-codeql-cli/creating-and-working-with-codeql-packs#creating-a-codeql-model-pack). + +## Where to find query packs + +The standard {% data variables.product.prodname_codeql %} packs for all supported languages are published in the [{% data variables.product.prodname_container_registry %}](https://github.com/orgs/codeql/packages). If you installed the {% data variables.product.prodname_codeql_cli %} in the standard way, using the {% data variables.product.prodname_codeql_cli %} bundle, the core query packs are already downloaded and available to you. They are: + + * `codeql/cpp-queries` + * `codeql/csharp-queries` + * `codeql/go-queries` + * `codeql/java-queries` + * `codeql/javascript-queries` + * `codeql/python-queries` + * `codeql/ruby-queries` + * `codeql/swift-queries` + +You can also use the {% data variables.product.prodname_codeql_cli %} to create your own {% data variables.product.prodname_codeql %} packs, add dependencies to packs, and install or update dependencies. + +You can publish {% data variables.product.prodname_codeql %} packs that you have created, using the {% data variables.product.prodname_codeql_cli %}. For more information on publishing and downloading {% data variables.product.prodname_codeql %} packs, see [AUTOTITLE](/code-security/codeql-cli/using-the-advanced-functionality-of-the-codeql-cli/publishing-and-using-codeql-packs). diff --git a/content/code-security/concepts/code-scanning/codeql/index.md b/content/code-security/concepts/code-scanning/codeql/index.md index 91accd1bae58..008a3575d495 100644 --- a/content/code-security/concepts/code-scanning/codeql/index.md +++ b/content/code-security/concepts/code-scanning/codeql/index.md @@ -16,4 +16,5 @@ children: - /about-codeql-for-vs-code - /about-codeql-workspaces - /query-reference-files + - /codeql-query-packs --- diff --git a/content/code-security/concepts/supply-chain-security/about-dependabot-pull-requests.md b/content/code-security/concepts/supply-chain-security/about-dependabot-pull-requests.md new file mode 100644 index 000000000000..1b539d1883cc --- /dev/null +++ b/content/code-security/concepts/supply-chain-security/about-dependabot-pull-requests.md @@ -0,0 +1,42 @@ +--- +title: About Dependabot pull requests +intro: 'Understand the frequency and customization options of pull requests for version and security updates.' +shortTitle: Dependabot pull requests +versions: + fpt: '*' + ghec: '*' + ghes: '*' +contentType: concepts +--- + +## Pull requests for security updates + +If you've enabled security updates, pull requests for security updates are triggered by a {% data variables.product.prodname_dependabot %} alert for a dependency on your default branch. {% data variables.product.prodname_dependabot %} automatically raises a pull request to update the vulnerable dependency. + +Each pull request contains everything you need to quickly and safely review and merge a proposed fix into your project. This includes information about the vulnerability like release notes, changelog entries, and commit details. Details of which vulnerability a pull request resolves are hidden from anyone who does not have access to {% data variables.product.prodname_dependabot_alerts %} for the repository. + +When you merge a pull request that contains a security update, the corresponding {% data variables.product.prodname_dependabot %} alert is marked as resolved for your repository. For more information about {% data variables.product.prodname_dependabot %} pull requests, see [AUTOTITLE](/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates). + +{% data reusables.dependabot.automated-tests-note %} + +### Customizing pull requests for security updates + +You can customize how {% data variables.product.prodname_dependabot %} raises pull requests for security updates, so that they best fit your project's security priorities and processes. For example: +* **Optimize {% data variables.product.prodname_dependabot %} pull requests to prioritize meaningful updates** by grouping multiple updates into a single pull request. +* Apply custom labels to **integrate {% data variables.product.prodname_dependabot %}'s pull requests** into your existing workflows. + +Similar to version updates, customization options for security updates are defined in the `dependabot.yml` file. If you have already customized the `dependabot.yml` for version updates, then many of the configuration options that you have defined could automatically apply to security updates, too. However, there are a couple of important points to note: +* {% data variables.product.prodname_dependabot_security_updates %} are **always triggered by a security advisory**, rather than running according to the `schedule` you have set in the `dependabot.yml` for version updates. +* {% data variables.product.prodname_dependabot %} raises pull requests for security updates against the **default branch only**. If your configuration sets a value for `target-branch`, then the customization for that package ecosystem will only apply to version updates by default. + +For more information, see [AUTOTITLE](/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/customizing-dependabot-security-prs). + +## Pull requests for version updates + +For version updates, you specify how often to check each ecosystem for new versions in the configuration file: daily, weekly, or monthly. + +{% data reusables.dependabot.initial-updates %} For more information, see [AUTOTITLE](/code-security/dependabot/dependabot-version-updates/optimizing-pr-creation-version-updates). + +## Commands for {% data variables.product.prodname_dependabot %} pull requests + +{% data variables.product.prodname_dependabot %} responds to simple commands in comments. Each pull request contains details of the commands you can use to process the pull request (for example: to merge, squash, reopen, close, or rebase the pull request) under the "{% data variables.product.prodname_dependabot %} commands and options" section. The aim is to make it as easy as possible for you to triage these automatically generated pull requests. For more information, see [AUTOTITLE](/code-security/reference/supply-chain-security/dependabot-pull-request-comment-commands). diff --git a/content/code-security/concepts/supply-chain-security/about-dependabot-security-updates.md b/content/code-security/concepts/supply-chain-security/about-dependabot-security-updates.md index 28c67b3d048c..9755c2a11e49 100644 --- a/content/code-security/concepts/supply-chain-security/about-dependabot-security-updates.md +++ b/content/code-security/concepts/supply-chain-security/about-dependabot-security-updates.md @@ -65,14 +65,6 @@ If you enable _{% data variables.product.prodname_dependabot_security_updates %} {% data reusables.dependabot.dependabot-actions-support %} -## About pull requests for security updates - -Each pull request contains everything you need to quickly and safely review and merge a proposed fix into your project. This includes information about the vulnerability like release notes, changelog entries, and commit details. Details of which vulnerability a pull request resolves are hidden from anyone who does not have access to {% data variables.product.prodname_dependabot_alerts %} for the repository. - -When you merge a pull request that contains a security update, the corresponding {% data variables.product.prodname_dependabot %} alert is marked as resolved for your repository. For more information about {% data variables.product.prodname_dependabot %} pull requests, see [AUTOTITLE](/code-security/dependabot/working-with-dependabot/managing-pull-requests-for-dependency-updates). - -{% data reusables.dependabot.automated-tests-note %} - ## About grouped security updates To further reduce the number of pull requests you may be seeing, you can enable grouped security updates to group sets of dependencies together (per package ecosystem). {% data variables.product.prodname_dependabot %} then raises a single pull request to update as many vulnerable dependencies as possible in the group to secure versions at the same time. diff --git a/content/code-security/concepts/supply-chain-security/about-dependabot-version-updates.md b/content/code-security/concepts/supply-chain-security/about-dependabot-version-updates.md index c0bbfd9a6e5a..6426aa641a70 100644 --- a/content/code-security/concepts/supply-chain-security/about-dependabot-version-updates.md +++ b/content/code-security/concepts/supply-chain-security/about-dependabot-version-updates.md @@ -62,16 +62,6 @@ For each action in the file, {% data variables.product.prodname_dependabot %} ch To enable this feature, see [AUTOTITLE](/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/keeping-your-actions-up-to-date-with-dependabot). -## Frequency of {% data variables.product.prodname_dependabot %} pull requests - -You specify how often to check each ecosystem for new versions in the configuration file: daily, weekly, or monthly. - -{% data reusables.dependabot.initial-updates %} For more information, see [AUTOTITLE](/code-security/dependabot/dependabot-version-updates/optimizing-pr-creation-version-updates). - -If you've enabled security updates, you'll sometimes see extra pull requests for security updates. These are triggered by a {% data variables.product.prodname_dependabot %} alert for a dependency on your default branch. {% data variables.product.prodname_dependabot %} automatically raises a pull request to update the vulnerable dependency. - -{% data reusables.dependabot.version-updates-skip-scheduled-runs %} - ## About automatic deactivation of {% data variables.product.prodname_dependabot_updates %} {% data reusables.dependabot.automatic-deactivation-link %} diff --git a/content/code-security/concepts/supply-chain-security/index.md b/content/code-security/concepts/supply-chain-security/index.md index 663847714aa2..3157f2f7802a 100644 --- a/content/code-security/concepts/supply-chain-security/index.md +++ b/content/code-security/concepts/supply-chain-security/index.md @@ -16,6 +16,7 @@ children: - about-dependabot-alerts - about-dependabot-security-updates - about-dependabot-version-updates + - about-dependabot-pull-requests - about-the-dependabot-yml-file - about-dependabot-auto-triage-rules - about-dependabot-on-github-actions-runners diff --git a/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/customizing-dependabot-security-prs.md b/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/customizing-dependabot-security-prs.md index 7a9fbbdab26c..7ae2313a7a37 100644 --- a/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/customizing-dependabot-security-prs.md +++ b/content/code-security/how-tos/secure-your-supply-chain/manage-your-dependency-security/customizing-dependabot-security-prs.md @@ -19,20 +19,13 @@ redirect_from: contentType: how-tos --- -## About customizing pull requests for security updates - -You can customize how {% data variables.product.prodname_dependabot %} raises pull requests for security updates, so that they best fit your project's security priorities and processes. For example: -* **Optimize {% data variables.product.prodname_dependabot %} pull requests to prioritize meaningful updates** by grouping multiple updates into a single pull request. -* Applying custom labels to **integrate {% data variables.product.prodname_dependabot %}'s pull requests** into your existing workflows. - -Similar to version updates, customization options for security updates are defined in the `dependabot.yml` file. If you have already customized the `dependabot.yml` for version updates, then many of the configuration options that you have defined could automatically apply to security updates, too. However, there's a couple of important points to note: -* {% data variables.product.prodname_dependabot_security_updates %} are **always triggered by a security advisory**, rather than running according to the `schedule` you have set in the `dependabot.yml` for version updates. -* {% data variables.product.prodname_dependabot %} raises pull requests for security updates against the **default branch only**. If your configuration sets a value for `target-branch`, then the customization for that package ecosystem will only apply to version updates by default. +## Preparing to customize pull requests If you haven't yet configured a `dependabot.yml` file for your repository and you want to customize pull requests for security updates, you must first: -* Check in a `dependabot.yml` file into the `.github` directory of your repository. For more information, see [AUTOTITLE](/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates#enabling-dependabot-version-updates). -* Set all the required keys. For more information, see [Required keys](/code-security/dependabot/working-with-dependabot/dependabot-options-reference#required-keys). -* If you want the customization for a package ecosystem to **only apply to security updates** (and exclude version updates), set the `open-pull-requests-limit` key to `0`. + +1. Check in a `dependabot.yml` file into the `.github` directory of your repository. For more information, see [AUTOTITLE](/code-security/dependabot/dependabot-version-updates/configuring-dependabot-version-updates#enabling-dependabot-version-updates). +1. Set all the required keys. For more information, see [Required keys](/code-security/dependabot/working-with-dependabot/dependabot-options-reference#required-keys). +1. If you want the customization for a package ecosystem to **only apply to security updates** (and exclude version updates), set the `open-pull-requests-limit` key to `0`. You can then consider what your needs and priorities are for security updates, and apply a combination of the customization options outlined below. diff --git a/content/code-security/tutorials/customize-code-scanning/customizing-analysis-with-codeql-packs.md b/content/code-security/tutorials/customize-code-scanning/customizing-analysis-with-codeql-packs.md index 63c8bdb91b64..5e00cd146905 100644 --- a/content/code-security/tutorials/customize-code-scanning/customizing-analysis-with-codeql-packs.md +++ b/content/code-security/tutorials/customize-code-scanning/customizing-analysis-with-codeql-packs.md @@ -18,39 +18,10 @@ redirect_from: contentType: tutorials --- -## About {% data variables.product.prodname_codeql %} packs - -{% data reusables.code-scanning.codeql-cli-version-ghes %} - - {% data variables.product.prodname_codeql %} packs are used to create, share, depend on, and run {% data variables.product.prodname_codeql %} queries and libraries. {% data variables.product.prodname_codeql %} packs contain queries, library files, query suites, and metadata. You can customize your {% data variables.product.prodname_codeql %} analysis by downloading packs created by others and running them on your codebase. - -There are three types of {% data variables.product.prodname_codeql %} packs: query packs, library packs, and model packs. - -* Query packs contain a set of pre-compiled queries that can be evaluated on a {% data variables.product.prodname_codeql %} database. Query packs are designed to be run. When a query pack is published, the bundle includes all the transitive dependencies and pre-compiled representations of each query, in addition to the query sources. This ensures consistent and efficient execution of the queries in the pack. - -* Library packs are designed to be used by query packs (or other library packs) and do not contain queries themselves. The libraries are not compiled separately. - -* Model packs can be used to expand {% data variables.product.prodname_code_scanning %} analysis to recognize libraries and frameworks that are not supported by default. Model packs are currently in {% data variables.release-phases.public_preview %} and subject to change. During the {% data variables.release-phases.public_preview %}, model packs are available for {% data variables.code-scanning.codeql_model_packs_support %} analysis. For more information about creating your own model packs, see [AUTOTITLE](/code-security/codeql-cli/using-the-advanced-functionality-of-the-codeql-cli/creating-and-working-with-codeql-packs#creating-a-codeql-model-pack). - -The standard {% data variables.product.prodname_codeql %} packs for all supported languages are published in the [{% data variables.product.prodname_container_registry %}](https://github.com/orgs/codeql/packages). If you installed the {% data variables.product.prodname_codeql_cli %} in the standard way, using the {% data variables.product.prodname_codeql_cli %} bundle, the core query packs are already downloaded and available to you. They are: - - * `codeql/cpp-queries` - * `codeql/csharp-queries` - * `codeql/go-queries` - * `codeql/java-queries` - * `codeql/javascript-queries` - * `codeql/python-queries` - * `codeql/ruby-queries` - * `codeql/swift-queries` - -You can also use the {% data variables.product.prodname_codeql_cli %} to create your own {% data variables.product.prodname_codeql %} packs, add dependencies to packs, and install or update dependencies. For more information, see [AUTOTITLE](/code-security/codeql-cli/using-the-advanced-functionality-of-the-codeql-cli/creating-and-working-with-codeql-packs#creating-and-working-with-codeql-packs). - -You can publish {% data variables.product.prodname_codeql %} packs that you have created, using the {% data variables.product.prodname_codeql_cli %}. For more information on publishing and downloading {% data variables.product.prodname_codeql %} packs, see [AUTOTITLE](/code-security/codeql-cli/using-the-advanced-functionality-of-the-codeql-cli/publishing-and-using-codeql-packs). +You can customize your {% data variables.product.prodname_codeql %} analysis by downloading packs created by others and running them on your codebase. For more information, see [AUTOTITLE](/code-security/concepts/code-scanning/codeql/codeql-query-packs). ## Downloading and using {% data variables.product.prodname_codeql %} query packs -The {% data variables.product.prodname_codeql_cli %} bundle includes queries that are maintained by {% data variables.product.company_short %} experts, security researchers, and community contributors. If you want to run queries developed by other organizations, {% data variables.product.prodname_codeql %} query packs provide an efficient and reliable way to download and run queries, while model packs ({% data variables.release-phases.public_preview %}) can be used to expand {% data variables.product.prodname_code_scanning %} analysis to recognize libraries and frameworks that are not supported by default. For more information about query packs, see [AUTOTITLE](/code-security/code-scanning/introduction-to-code-scanning/about-code-scanning-with-codeql#about-codeql-queries). For information about writing your own model packs, see [AUTOTITLE](/code-security/codeql-cli/using-the-advanced-functionality-of-the-codeql-cli/creating-and-working-with-codeql-packs#creating-a-model-pack). - Before you can use a {% data variables.product.prodname_codeql %} query pack to analyze a database, you must download any packages you require from the {% data variables.product.company_short %} {% data variables.product.prodname_container_registry %}. This can be done either by using the `--download` flag as part of the `codeql database analyze` command, or running `codeql pack download`. If a package is not publicly available, you will need to use a {% data variables.product.prodname_github_app %} or {% data variables.product.pat_generic %} to authenticate. For more information and an example, see [AUTOTITLE](/code-security/codeql-cli/getting-started-with-the-codeql-cli/uploading-codeql-analysis-results-to-github#uploading-results-to-github). | Option | Required | Usage | diff --git a/content/copilot/concepts/agents/coding-agent/about-custom-agents.md b/content/copilot/concepts/agents/coding-agent/about-custom-agents.md index e0f0f3964ec6..e0837e0e73f2 100644 --- a/content/copilot/concepts/agents/coding-agent/about-custom-agents.md +++ b/content/copilot/concepts/agents/coding-agent/about-custom-agents.md @@ -1,7 +1,7 @@ --- title: About custom agents shortTitle: Custom agents -intro: '{% data variables.copilot.custom_agents_caps_short %} enhance {% data variables.copilot.copilot_coding_agent %} with specialized assistance tailored to your needs.' +intro: '{% data variables.copilot.custom_agents_caps_short %} enhance {% data variables.copilot.copilot_coding_agent %} with assistance tailored to your needs.' product: '{% data reusables.gated-features.copilot-coding-agent %}
Sign up for {% data variables.product.prodname_copilot_short %} {% octicon "link-external" height:16 %}' versions: feature: copilot @@ -11,24 +11,26 @@ topics: ## About {% data variables.copilot.custom_agents_short %} -{% data variables.copilot.custom_agents_caps_short %} are specialized versions of {% data variables.copilot.copilot_coding_agent %} that you can tailor to your unique workflows, coding conventions, and use cases. Instead of repeatedly providing the same instructions and context, {% data variables.copilot.custom_agents_short %} allow you to define specialized agents that act like tailored teammates—following standards, using the right tools, and implementing team-specific practices. +{% data variables.copilot.custom_agents_caps_short %} are specialized versions of {% data variables.copilot.copilot_coding_agent %} that you can tailor to your unique workflows, coding conventions, and use cases. They act like tailored teammates that follow your standards, use the right tools, and implement team-specific practices. You define these agents once instead of repeatedly providing the same instructions and context. -{% data variables.copilot.custom_agents_caps_short %} are defined using Markdown files, called {% data variables.copilot.agent_profiles %}, that specify prompts, tools, and MCP servers. This allows individuals and teams to encode their conventions, frameworks, and desired outcomes directly into {% data variables.product.prodname_copilot_short %}. The {% data variables.copilot.agent_profile %} serves as the artifact that defines the {% data variables.copilot.copilot_custom_agent_short %}'s behavior, and assigning the agent to a task or issue instantiates the {% data variables.copilot.copilot_custom_agent_short %}. +You define {% data variables.copilot.custom_agents_short %} using Markdown files called {% data variables.copilot.agent_profiles %}. These files specify prompts, tools, and MCP servers. This allows you to encode your conventions, frameworks, and desired outcomes directly into {% data variables.product.prodname_copilot_short %}. + +The {% data variables.copilot.agent_profile %} defines the {% data variables.copilot.copilot_custom_agent_short %}'s behavior. When you assign the agent to a task or issue, it instantiates the {% data variables.copilot.copilot_custom_agent_short %}. ## {% data variables.copilot.agent_profile_caps %} format {% data variables.copilot.agent_profiles_caps %} are Markdown files with YAML frontmatter. In their simplest form, they include: -* **Name**: A unique identifier for the {% data variables.copilot.copilot_custom_agent_short %} -* **Description**: Explains the agent's purpose and capabilities -* **Prompt**: Custom instructions that define the agent's behavior and expertise -* **Tools**: Specific tools the agent can access. This is optional, and the default is access to all available tools, including built-in tools and MCP server tools. +* **Name**: A unique identifier for the {% data variables.copilot.copilot_custom_agent_short %}. +* **Description**: Explains the agent's purpose and capabilities. +* **Prompt**: Custom instructions that define the agent's behavior and expertise. +* **Tools** (optional): Specific tools the agent can access. By default, agents can access all available tools, including built-in tools and MCP server tools. -Organization and enterprise-level {% data variables.copilot.agent_profiles %} can also include MCP server configurations within the {% data variables.copilot.agent_profile %}, using the `mcp-server` property. +Organization and enterprise-level {% data variables.copilot.agent_profiles %} can also include MCP server configurations using the `mcp-server` property. ### Example {% data variables.copilot.agent_profile %} -This is a basic {% data variables.copilot.agent_profile %} with name, description, and prompt configured. +This example is a basic {% data variables.copilot.agent_profile %} with name, description, and prompt configured. ```text --- @@ -49,18 +51,27 @@ Focus on the following instructions: ## Where you can configure {% data variables.copilot.custom_agents_short %} -You can define {% data variables.copilot.agent_profiles %} at the repository level (`.github/agents/CUSTOM-AGENT-NAME.md` in your repository) for project-specific agents, or at the organization or enterprise level (`/agents/CUSTOM-AGENT-NAME.md` in a `.github-private` repository) for broader availability. See [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-organization/prepare-for-custom-agents) and [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/prepare-for-custom-agents). +You can define {% data variables.copilot.agent_profiles %} at different levels: + +* **Repository level**: Create `.github/agents/CUSTOM-AGENT-NAME.md` in your repository for project-specific agents. +* **Organization or enterprise level**: Create `/agents/CUSTOM-AGENT-NAME.md` in a `.github-private` repository for broader availability. + +For more information, see [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-organization/prepare-for-custom-agents) and [AUTOTITLE](/copilot/how-tos/administer-copilot/manage-for-enterprise/manage-agents/prepare-for-custom-agents). ## Where you can use {% data variables.copilot.custom_agents_short %} {% data reusables.copilot.custom-agents-ide-preview %} -Once created, your {% data variables.copilot.custom_agents_short %} are available wherever you can use {% data variables.copilot.copilot_coding_agent %}, including {% data variables.product.prodname_dotcom_the_website %} (the agents tab and panel, issue assignment, pull requests), the {% data variables.copilot.copilot_cli %}, and in {% data variables.product.prodname_vscode %}, JetBrains IDEs, Eclipse, and Xcode. +Once you create {% data variables.copilot.custom_agents_short %}, you can use them wherever {% data variables.copilot.copilot_coding_agent %} is available: + +* {% data variables.product.prodname_dotcom_the_website %}: The agents tab and panel, issue assignment, and pull requests +* {% data variables.copilot.copilot_cli %} +* IDEs: {% data variables.product.prodname_vscode %}, JetBrains IDEs, Eclipse, and Xcode -{% data variables.copilot.agent_profiles_caps %} can be used directly in {% data variables.product.prodname_vscode %}, JetBrains IDEs, Eclipse, and Xcode, though some properties may function differently, or be ignored, between environments. +You can use {% data variables.copilot.agent_profiles %} directly in {% data variables.product.prodname_vscode %}, JetBrains IDEs, Eclipse, and Xcode. Some properties may function differently or be ignored between environments. -For more information on using {% data variables.copilot.custom_agents_short %} in {% data variables.product.prodname_vscode %} specifically, see [{% data variables.copilot.custom_agents_caps_short %} in {% data variables.product.prodname_vscode_shortname %}](https://code.visualstudio.com/docs/copilot/customization/custom-agents) in the {% data variables.product.prodname_vscode_shortname %} documentation. +For more information on using {% data variables.copilot.custom_agents_short %} in {% data variables.product.prodname_vscode %}, see [{% data variables.copilot.custom_agents_caps_short %} in {% data variables.product.prodname_vscode_shortname %}](https://code.visualstudio.com/docs/copilot/customization/custom-agents). ## Next steps -To start creating your own {% data variables.copilot.custom_agents_short %}, see [AUTOTITLE](/copilot/how-tos/use-copilot-agents/coding-agent/create-custom-agents). +To create your own {% data variables.copilot.custom_agents_short %}, see [AUTOTITLE](/copilot/how-tos/use-copilot-agents/coding-agent/create-custom-agents). diff --git a/content/copilot/get-started/what-is-github-copilot.md b/content/copilot/get-started/what-is-github-copilot.md index a188cb7254ae..8707d849287e 100644 --- a/content/copilot/get-started/what-is-github-copilot.md +++ b/content/copilot/get-started/what-is-github-copilot.md @@ -1,6 +1,6 @@ --- title: What is GitHub Copilot? -intro: 'Learn what {% data variables.product.prodname_copilot %} is and what you can do with it.' +intro: 'Learn what {% data variables.product.prodname_copilot_short %} is and what you can do with it.' versions: feature: copilot topics: @@ -29,55 +29,59 @@ category: - Learn about Copilot --- -{% data variables.product.prodname_copilot %} is an AI coding assistant that helps you write code faster and with less effort, allowing you to focus more energy on problem solving and collaboration. +{% data variables.product.prodname_copilot %} is an AI coding assistant that helps you write code faster and with less effort. Then, you can focus more energy on problem solving and collaboration. -{% data variables.product.prodname_copilot %} has been proven to increase developer productivity and accelerate the pace of software development. See [Research: quantifying {% data variables.product.prodname_copilot %}’s impact on developer productivity and happiness](https://github.blog/2022-09-07-research-quantifying-github-copilots-impact-on-developer-productivity-and-happiness/) in the {% data variables.product.prodname_dotcom %} blog. +Research shows that {% data variables.product.prodname_copilot_short %} increases developer productivity and accelerates software development. See [Research: quantifying {% data variables.product.prodname_copilot %}’s impact on developer productivity and happiness](https://github.blog/2022-09-07-research-quantifying-github-copilots-impact-on-developer-productivity-and-happiness/) in the {% data variables.product.prodname_dotcom %} blog. -## {% data variables.product.prodname_copilot_short %} features +## Features -{% data variables.product.prodname_copilot %} includes a suite of features. You can use {% data variables.product.prodname_copilot_short %} to: +You can use {% data variables.product.prodname_copilot_short %} to: -* Get code suggestions as you type in your IDE -* Chat with {% data variables.product.prodname_copilot_short %} to ask for help with your code -* Ask {% data variables.product.prodname_copilot_short %} for help using the command line -* Organize and share task-specific context with {% data variables.copilot.copilot_spaces %} to get more relevant answers -* Generate a description of the changes in a pull request -* Work on code changes and create a pull request for you to review _({% data variables.copilot.copilot_pro_plus_short %}, {% data variables.copilot.copilot_business_short %}, and {% data variables.copilot.copilot_enterprise_short %} only)_ +* Get code suggestions as you type in your IDE. +* Chat with {% data variables.product.prodname_copilot_short %} to get help with your code. +* Ask for help using the command line. +* Organize and share context with {% data variables.copilot.copilot_spaces %} to get more relevant answers. +* Generate descriptions of changes in a pull request. +* Work on code changes and create a pull request for you to review. Available in {% data variables.copilot.copilot_pro_plus_short %}, {% data variables.copilot.copilot_business_short %}, and {% data variables.copilot.copilot_enterprise_short %} only. -{% data variables.product.prodname_copilot_short %} is available: +Use {% data variables.product.prodname_copilot_short %} in the following places: -* In your IDE -* In {% data variables.product.prodname_mobile %}, as a chat interface -* In {% data variables.product.prodname_windows_terminal %} Canary, through the Terminal Chat interface -* On the command line, through the {% data variables.product.prodname_cli %} -* On the {% data variables.product.github %} website +* Your IDE +* {% data variables.product.prodname_mobile %}, as a chat interface +* {% data variables.product.prodname_windows_terminal %} Canary, through the Terminal Chat interface +* The command line, through the {% data variables.product.prodname_cli %} +* The {% data variables.product.github %} website See [AUTOTITLE](/copilot/about-github-copilot/github-copilot-features). -## Getting access to {% data variables.product.prodname_copilot_short %} +## Get access -There are a few ways you can start using {% data variables.product.prodname_copilot_short %}, depending on your role and needs. +You can start using {% data variables.product.prodname_copilot_short %} in several ways, depending on your role and needs. -### For individuals +### Individuals -* **Try {% data variables.product.prodname_copilot_short %} for free**: Use {% data variables.copilot.copilot_free_short %} to explore core {% data variables.product.prodname_copilot_short %} features with no paid plan required. -* **Subscribe to a paid plan**: Upgrade to {% data variables.copilot.copilot_pro_short %} or {% data variables.copilot.copilot_pro_plus_short %} for full access to premium features and more generous usage limits. You can try {% data variables.copilot.copilot_pro_short %} for free with a one-time 30-day trial. -* **Eligible for free {% data variables.copilot.copilot_pro_short %} access?** Students, teachers, and open source maintainers may qualify for {% data variables.copilot.copilot_pro_short %} at no cost. See [AUTOTITLE](/copilot/managing-copilot/managing-copilot-as-an-individual-subscriber/getting-free-access-to-copilot-as-a-student-teacher-or-maintainer). -* **Organization members**: If your organization or enterprise has a {% data variables.product.prodname_copilot %} plan, you can request access to {% data variables.product.prodname_copilot_short %} by going to [https://github.com/settings/copilot](https://github.com/settings/copilot) and requesting access under "Get {% data variables.product.prodname_copilot_short %} from an organization." +* **Try {% data variables.product.prodname_copilot_short %} for free.** Use {% data variables.copilot.copilot_free_short %} to explore core features with no paid plan required. +* **Subscribe to a paid plan.** Upgrade to {% data variables.copilot.copilot_pro_short %} or {% data variables.copilot.copilot_pro_plus_short %} for full access to premium features and more generous usage limits. + * Try {% data variables.copilot.copilot_pro_short %} for free with a one-time 30-day trial. +* **Get free access if you're eligible.** Students, teachers, and open source maintainers may qualify for {% data variables.copilot.copilot_pro_short %} at no cost. See [AUTOTITLE](/copilot/managing-copilot/managing-copilot-as-an-individual-subscriber/getting-free-access-to-copilot-as-a-student-teacher-or-maintainer). +* **Request access from your organization.** If your organization or enterprise has a {% data variables.product.prodname_copilot %} plan, you can request access by going to [https://github.com/settings/copilot](https://github.com/settings/copilot) and request access under "Get {% data variables.product.prodname_copilot_short %} from an organization." See [AUTOTITLE](/copilot/managing-copilot/managing-copilot-as-an-individual-subscriber/getting-started-with-copilot-on-your-personal-account/getting-started-with-a-copilot-plan) for more information. -### For organizations and enterprises +### Organizations and enterprises -* **Organization owners**: Purchase {% data variables.copilot.copilot_business_short %} for your team. See [AUTOTITLE](/copilot/managing-copilot/managing-github-copilot-in-your-organization/subscribing-to-copilot-for-your-organization). If your organization is owned by an enterprise that has a {% data variables.product.prodname_copilot_short %} subscription, you can ask your enterprise owner to enable {% data variables.product.prodname_copilot_short %} for your organization by going to [https://github.com/settings/copilot](https://github.com/settings/copilot) and requesting access under "Get {% data variables.product.prodname_copilot_short %} from an organization." -* **Enterprise owners**: Purchase {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} for your enterprise. See [AUTOTITLE](/copilot/managing-copilot/managing-copilot-for-your-enterprise/subscribing-to-copilot-for-your-enterprise). +**Organization owners** can purchase {% data variables.copilot.copilot_business_short %} for their team. See [AUTOTITLE](/copilot/managing-copilot/managing-github-copilot-in-your-organization/subscribing-to-copilot-for-your-organization). -If you **don't need other {% data variables.product.github %} features**, you can create an enterprise account specifically for managing {% data variables.copilot.copilot_business_short %} licenses. This gives you enterprise-grade authentication options without charges for {% data variables.product.prodname_enterprise %} licenses. See [AUTOTITLE](/copilot/concepts/about-enterprise-accounts-for-copilot-business). +If your organization is owned by an enterprise that has a {% data variables.product.prodname_copilot_short %} subscription, you can ask your enterprise owner to enable {% data variables.product.prodname_copilot_short %} for your organization. Go to [https://github.com/settings/copilot](https://github.com/settings/copilot) and request access under "Get {% data variables.product.prodname_copilot_short %} from an organization." + +**Enterprise owners** can purchase {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} for your enterprise. See [AUTOTITLE](/copilot/managing-copilot/managing-copilot-for-your-enterprise/subscribing-to-copilot-for-your-enterprise). + +If you don't need other {% data variables.product.github %} features, you can create an enterprise account specifically for managing {% data variables.copilot.copilot_business_short %} licenses. This gives you enterprise-grade authentication without charges for {% data variables.product.prodname_enterprise %} licenses. See [AUTOTITLE](/copilot/concepts/about-enterprise-accounts-for-copilot-business). ## Next steps -* To learn more about the {% data variables.product.prodname_copilot_short %} features, see [AUTOTITLE](/copilot/about-github-copilot/github-copilot-features). -* To start using {% data variables.product.prodname_copilot_short %}, see [AUTOTITLE](/copilot/setting-up-github-copilot). +* Learn more about {% data variables.product.prodname_copilot_short %} features. See [AUTOTITLE](/copilot/about-github-copilot/github-copilot-features). +* Start using {% data variables.product.prodname_copilot_short %}. See [AUTOTITLE](/copilot/setting-up-github-copilot). ## Further reading diff --git a/content/copilot/tutorials/index.md b/content/copilot/tutorials/index.md index 8df868affd7f..09ed26940a01 100644 --- a/content/copilot/tutorials/index.md +++ b/content/copilot/tutorials/index.md @@ -39,10 +39,11 @@ layout: bespoke-landing sidebarLink: text: All tutorials href: /copilot/tutorials -recommended: - - /copilot/tutorials/copilot-chat-cookbook - - /copilot/tutorials/customization-library - - /copilot/tutorials/roll-out-at-scale +carousels: + recommended: + - /copilot/tutorials/copilot-chat-cookbook + - /copilot/tutorials/customization-library + - /copilot/tutorials/roll-out-at-scale includedCategories: - Accelerate PR velocity - Automate simple user stories diff --git a/data/reusables/contributing/content-linter-rules.md b/data/reusables/contributing/content-linter-rules.md index 1e7c8e0515f0..9486cf26ed00 100644 --- a/data/reusables/contributing/content-linter-rules.md +++ b/data/reusables/contributing/content-linter-rules.md @@ -57,7 +57,7 @@ | GHD047 | table-column-integrity | Tables must have consistent column counts across all rows | error | tables, accessibility, formatting | | GHD051 | frontmatter-versions-whitespace | Versions frontmatter should not contain unnecessary whitespace | error | frontmatter, versions | | GHD054 | third-party-actions-reusable | Code examples with third-party actions must include disclaimer reusable | error | actions, reusable, third-party | -| GHD056 | frontmatter-landing-recommended | Only landing pages can have recommended articles, there should be no duplicate recommended articles, and all recommended articles must exist | error | frontmatter, landing, recommended | +| GHD056 | frontmatter-landing-carousels | Only landing pages can have carousels, there should be no duplicate articles, and all articles must exist | error | frontmatter, landing, carousels | | GHD057 | ctas-schema | CTA URLs must conform to the schema | error | ctas, schema, urls | | GHD058 | journey-tracks-liquid | Journey track properties must use valid Liquid syntax | error | frontmatter, journey-tracks, liquid | | GHD059 | journey-tracks-guide-path-exists | Journey track guide paths must reference existing content files | error | frontmatter, journey-tracks | diff --git a/data/ui.yml b/data/ui.yml index 32fd466830b1..1f6257f545fe 100644 --- a/data/ui.yml +++ b/data/ui.yml @@ -346,6 +346,9 @@ cookbook_landing: category: Category complexity: Complexity +carousels: + recommended: Recommended + not_found: title: Ooops! message: It looks like this page doesn't exist. diff --git a/package.json b/package.json index ef3cd150ece9..aabadb835361 100644 --- a/package.json +++ b/package.json @@ -80,8 +80,9 @@ "repo-sync": "./src/workflows/local-repo-sync.sh", "reusables": "tsx src/content-render/scripts/reusables-cli.ts", "liquid-tags": "tsx src/content-render/scripts/liquid-tags.ts", - "rendered-content-link-checker": "tsx src/links/scripts/rendered-content-link-checker.ts", - "rendered-content-link-checker-cli": "tsx src/links/scripts/rendered-content-link-checker-cli.ts", + "check-links-pr": "tsx src/links/scripts/check-links-pr.ts", + "check-links-internal": "tsx src/links/scripts/check-links-internal.ts", + "check-links-external": "tsx src/links/scripts/check-links-external.ts", "rest-dev": "tsx src/rest/scripts/update-files.ts", "show-action-deps": "echo 'Action Dependencies:' && rg '^[\\s|-]*(uses:.*)$' .github -I -N --no-heading -r '$1$2' | sort | uniq | cut -c 7-", "start": "cross-env NODE_ENV=development ENABLED_LANGUAGES=en nodemon src/frame/server.ts", diff --git a/src/article-api/transformers/bespoke-landing-transformer.ts b/src/article-api/transformers/bespoke-landing-transformer.ts index e3c52ed3a35e..1dacd240a3f4 100644 --- a/src/article-api/transformers/bespoke-landing-transformer.ts +++ b/src/article-api/transformers/bespoke-landing-transformer.ts @@ -1,26 +1,20 @@ -import type { Context, Page } from '@/types' +import type { Context, Page, ResolvedArticle } from '@/types' import type { PageTransformer, TemplateData, Section, LinkData } from './types' import { renderContent } from '@/content-render/index' import { loadTemplate } from '@/article-api/lib/load-template' import { getAllTocItems, flattenTocItems } from '@/article-api/lib/get-all-toc-items' -interface RecommendedItem { - href: string - title?: string - intro?: string -} - interface BespokeLandingPage extends Omit { featuredLinks?: Record> children?: string[] - recommended?: RecommendedItem[] - rawRecommended?: string[] + carousels?: Record + rawCarousels?: Record includedCategories?: string[] } /** * Transforms bespoke-landing pages into markdown format. - * Handles recommended carousel and full article listings. + * Handles carousels and full article listings. * Note: Unlike discovery-landing, bespoke-landing shows ALL articles * regardless of includedCategories. */ @@ -53,38 +47,48 @@ export class BespokeLandingTransformer implements PageTransformer { const bespokePage = page as BespokeLandingPage const sections: Section[] = [] - // Recommended carousel - const recommended = bespokePage.recommended ?? bespokePage.rawRecommended - if (recommended && recommended.length > 0) { + // Process carousels (each carousel becomes a section) + const carousels = bespokePage.carousels ?? bespokePage.rawCarousels + if (carousels && typeof carousels === 'object') { const { default: getLearningTrackLinkData } = await import( '@/learning-track/lib/get-link-data' ) - let links: LinkData[] - if (typeof recommended[0] === 'object' && 'title' in recommended[0]) { - links = recommended.map((item) => ({ - href: typeof item === 'string' ? item : item.href, - title: (typeof item === 'object' && item.title) || '', - intro: (typeof item === 'object' && item.intro) || '', - })) - } else { - const linkData = await getLearningTrackLinkData(recommended as string[], context, { - title: true, - intro: true, - }) - links = (linkData || []).map((item: { href: string; title?: string; intro?: string }) => ({ - href: item.href, - title: item.title || '', - intro: item.intro || '', - })) - } - - const validLinks = links.filter((l) => l.href && l.title) - if (validLinks.length > 0) { - sections.push({ - title: 'Recommended', - groups: [{ title: null, links: validLinks }], - }) + for (const [carouselKey, articles] of Object.entries(carousels)) { + if (!Array.isArray(articles) || articles.length === 0) continue + + let links: LinkData[] + if (typeof articles[0] === 'object' && 'title' in articles[0]) { + // Already resolved articles + links = articles.map((item) => ({ + href: typeof item === 'string' ? item : item.href, + title: (typeof item === 'object' && item.title) || '', + intro: (typeof item === 'object' && item.intro) || '', + })) + } else { + // Raw paths that need resolution + const linkData = await getLearningTrackLinkData(articles as string[], context, { + title: true, + intro: true, + }) + links = (linkData || []).map( + (item: { href: string; title?: string; intro?: string }) => ({ + href: item.href, + title: item.title || '', + intro: item.intro || '', + }), + ) + } + + const validLinks = links.filter((l) => l.href && l.title) + if (validLinks.length > 0) { + // Use carousel key as title (capitalize first letter) + const sectionTitle = carouselKey.charAt(0).toUpperCase() + carouselKey.slice(1) + sections.push({ + title: sectionTitle, + groups: [{ title: null, links: validLinks }], + }) + } } } diff --git a/src/article-api/transformers/discovery-landing-transformer.ts b/src/article-api/transformers/discovery-landing-transformer.ts index 5a013fbc0fc6..e03dea7440e7 100644 --- a/src/article-api/transformers/discovery-landing-transformer.ts +++ b/src/article-api/transformers/discovery-landing-transformer.ts @@ -1,20 +1,14 @@ -import type { Context, Page } from '@/types' +import type { Context, Page, ResolvedArticle } from '@/types' import type { PageTransformer, TemplateData, Section, LinkData } from './types' import { renderContent } from '@/content-render/index' import { loadTemplate } from '@/article-api/lib/load-template' import { getAllTocItems, flattenTocItems } from '@/article-api/lib/get-all-toc-items' -interface RecommendedItem { - href: string - title?: string - intro?: string -} - interface DiscoveryPage extends Page { rawIntroLinks?: Record introLinks?: Record - recommended?: RecommendedItem[] - rawRecommended?: string[] + carousels?: Record + rawCarousels?: Record includedCategories?: string[] children?: string[] } @@ -53,38 +47,48 @@ export class DiscoveryLandingTransformer implements PageTransformer { const discoveryPage = page as DiscoveryPage const sections: Section[] = [] - // Recommended carousel - const recommended = discoveryPage.recommended ?? discoveryPage.rawRecommended - if (recommended && recommended.length > 0) { + // Process carousels (each carousel becomes a section) + const carousels = discoveryPage.carousels ?? discoveryPage.rawCarousels + if (carousels && typeof carousels === 'object') { const { default: getLearningTrackLinkData } = await import( '@/learning-track/lib/get-link-data' ) - let links: LinkData[] - if (typeof recommended[0] === 'object' && 'title' in recommended[0]) { - links = recommended.map((item) => ({ - href: typeof item === 'string' ? item : item.href, - title: (typeof item === 'object' && item.title) || '', - intro: (typeof item === 'object' && item.intro) || '', - })) - } else { - const linkData = await getLearningTrackLinkData(recommended as string[], context, { - title: true, - intro: true, - }) - links = (linkData || []).map((item: { href: string; title?: string; intro?: string }) => ({ - href: item.href, - title: item.title || '', - intro: item.intro || '', - })) - } + for (const [carouselKey, articles] of Object.entries(carousels)) { + if (!Array.isArray(articles) || articles.length === 0) continue + + let links: LinkData[] + if (typeof articles[0] === 'object' && 'title' in articles[0]) { + // Already resolved articles + links = articles.map((item) => ({ + href: typeof item === 'string' ? item : item.href, + title: (typeof item === 'object' && item.title) || '', + intro: (typeof item === 'object' && item.intro) || '', + })) + } else { + // Raw paths that need resolution + const linkData = await getLearningTrackLinkData(articles as string[], context, { + title: true, + intro: true, + }) + links = (linkData || []).map( + (item: { href: string; title?: string; intro?: string }) => ({ + href: item.href, + title: item.title || '', + intro: item.intro || '', + }), + ) + } - const validLinks = links.filter((l) => l.href && l.title) - if (validLinks.length > 0) { - sections.push({ - title: 'Recommended', - groups: [{ title: null, links: validLinks }], - }) + const validLinks = links.filter((l) => l.href && l.title) + if (validLinks.length > 0) { + // Use carousel key as title (capitalize first letter) + const sectionTitle = carouselKey.charAt(0).toUpperCase() + carouselKey.slice(1) + sections.push({ + title: sectionTitle, + groups: [{ title: null, links: validLinks }], + }) + } } } diff --git a/src/article-api/transformers/product-landing-transformer.ts b/src/article-api/transformers/product-landing-transformer.ts index 59b741d39db5..7c089d4d2dca 100644 --- a/src/article-api/transformers/product-landing-transformer.ts +++ b/src/article-api/transformers/product-landing-transformer.ts @@ -1,21 +1,15 @@ -import type { Context, Page } from '@/types' +import type { Context, Page, ResolvedArticle } from '@/types' import type { PageTransformer, TemplateData, Section, LinkGroup, LinkData } from './types' import { renderContent } from '@/content-render/index' import { loadTemplate } from '@/article-api/lib/load-template' import { resolvePath } from '@/article-api/lib/resolve-path' import { getLinkData } from '@/article-api/lib/get-link-data' -interface RecommendedItem { - href: string - title?: string - intro?: string -} - interface ProductPage extends Omit { featuredLinks?: Record> children?: string[] - recommended?: RecommendedItem[] - rawRecommended?: string[] + carousels?: Record + rawCarousels?: Record includedCategories?: string[] } @@ -59,38 +53,48 @@ export class ProductLandingTransformer implements PageTransformer { const languageCode = page.languageCode || 'en' const sections: Section[] = [] - // Recommended carousel - const recommended = productPage.recommended ?? productPage.rawRecommended - if (recommended && recommended.length > 0) { + // Process carousels (each carousel becomes a section) + const carousels = productPage.carousels ?? productPage.rawCarousels + if (carousels && typeof carousels === 'object') { const { default: getLearningTrackLinkData } = await import( '@/learning-track/lib/get-link-data' ) - let links: LinkData[] - if (typeof recommended[0] === 'object' && 'title' in recommended[0]) { - links = recommended.map((item) => ({ - href: typeof item === 'string' ? item : item.href, - title: (typeof item === 'object' && item.title) || '', - intro: (typeof item === 'object' && item.intro) || '', - })) - } else { - const linkData = await getLearningTrackLinkData(recommended as string[], context, { - title: true, - intro: true, - }) - links = (linkData || []).map((item: { href: string; title?: string; intro?: string }) => ({ - href: item.href, - title: item.title || '', - intro: item.intro || '', - })) - } + for (const [carouselKey, articles] of Object.entries(carousels)) { + if (!Array.isArray(articles) || articles.length === 0) continue - const validLinks = links.filter((l) => l.href && l.title) - if (validLinks.length > 0) { - sections.push({ - title: 'Recommended', - groups: [{ title: null, links: validLinks }], - }) + let links: LinkData[] + if (typeof articles[0] === 'object' && 'title' in articles[0]) { + // Already resolved articles + links = articles.map((item) => ({ + href: typeof item === 'string' ? item : item.href, + title: (typeof item === 'object' && item.title) || '', + intro: (typeof item === 'object' && item.intro) || '', + })) + } else { + // Raw paths that need resolution + const linkData = await getLearningTrackLinkData(articles as string[], context, { + title: true, + intro: true, + }) + links = (linkData || []).map( + (item: { href: string; title?: string; intro?: string }) => ({ + href: item.href, + title: item.title || '', + intro: item.intro || '', + }), + ) + } + + const validLinks = links.filter((l) => l.href && l.title) + if (validLinks.length > 0) { + // Use carousel key as title (capitalize first letter) + const sectionTitle = carouselKey.charAt(0).toUpperCase() + carouselKey.slice(1) + sections.push({ + title: sectionTitle, + groups: [{ title: null, links: validLinks }], + }) + } } } diff --git a/src/content-linter/lib/linting-rules/frontmatter-landing-recommended.ts b/src/content-linter/lib/linting-rules/frontmatter-landing-carousels.ts similarity index 68% rename from src/content-linter/lib/linting-rules/frontmatter-landing-recommended.ts rename to src/content-linter/lib/linting-rules/frontmatter-landing-carousels.ts index ac6b9894b3cd..cad6bd9f25c2 100644 --- a/src/content-linter/lib/linting-rules/frontmatter-landing-recommended.ts +++ b/src/content-linter/lib/linting-rules/frontmatter-landing-carousels.ts @@ -6,7 +6,7 @@ import { getFrontmatter } from '../helpers/utils' import type { RuleParams, RuleErrorCallback } from '@/content-linter/types' interface Frontmatter { - recommended?: string[] + carousels?: Record layout?: string [key: string]: unknown } @@ -52,42 +52,48 @@ function isValidArticlePath(articlePath: string, currentFilePath: string): boole } } -export const frontmatterLandingRecommended = { - names: ['GHD056', 'frontmatter-landing-recommended'], +export const frontmatterLandingCarousels = { + names: ['GHD056', 'frontmatter-landing-carousels'], description: - 'Only landing pages can have recommended articles, there should be no duplicate recommended articles, and all recommended articles must exist', - tags: ['frontmatter', 'landing', 'recommended'], + 'Only landing pages can have carousels, there should be no duplicate articles, and all articles must exist', + tags: ['frontmatter', 'landing', 'carousels'], function: (params: RuleParams, onError: RuleErrorCallback) => { // Using any for frontmatter as it's a dynamic YAML object with varying properties const fm = getFrontmatter(params.lines) as Frontmatter | null - if (!fm || !fm.recommended) return + if (!fm) return - const recommendedLine: string | undefined = params.lines.find((line) => - line.startsWith('recommended:'), + const hasCarousels = fm.carousels && typeof fm.carousels === 'object' + + if (!hasCarousels) return + + const carouselsLine: string | undefined = params.lines.find((line) => + line.startsWith('carousels:'), ) - if (!recommendedLine) return + if (!carouselsLine) return - const lineNumber: number = params.lines.indexOf(recommendedLine) + 1 + const lineNumber: number = params.lines.indexOf(carouselsLine) + 1 if (!fm.layout || !fm.layout.includes('landing')) { addError( onError, lineNumber, - 'recommended frontmatter key is only valid for landing pages', - recommendedLine, - [1, recommendedLine.length], + 'carousels frontmatter key is only valid for landing pages', + carouselsLine, + [1, carouselsLine.length], null, ) } - // Check for duplicate recommended items and invalid paths - if (Array.isArray(fm.recommended)) { + // Check each carousel for duplicates and invalid paths + for (const [carouselKey, articles] of Object.entries(fm.carousels!)) { + if (!Array.isArray(articles)) continue + const seen = new Set() const duplicates: string[] = [] const invalidPaths: string[] = [] - for (const item of fm.recommended) { + for (const item of articles) { if (seen.has(item)) { duplicates.push(item) } else { @@ -104,9 +110,9 @@ export const frontmatterLandingRecommended = { addError( onError, lineNumber, - `Found duplicate recommended articles: ${duplicates.join(', ')}`, - recommendedLine, - [1, recommendedLine.length], + `Found duplicate articles in carousel '${carouselKey}': ${duplicates.join(', ')}`, + carouselsLine, + [1, carouselsLine.length], null, ) } @@ -115,9 +121,9 @@ export const frontmatterLandingRecommended = { addError( onError, lineNumber, - `Found invalid recommended article paths: ${invalidPaths.join(', ')}`, - recommendedLine, - [1, recommendedLine.length], + `Found invalid article paths in carousel '${carouselKey}': ${invalidPaths.join(', ')}`, + carouselsLine, + [1, carouselsLine.length], null, ) } diff --git a/src/content-linter/lib/linting-rules/index.ts b/src/content-linter/lib/linting-rules/index.ts index e9afc32987f8..9248237a7e81 100644 --- a/src/content-linter/lib/linting-rules/index.ts +++ b/src/content-linter/lib/linting-rules/index.ts @@ -46,7 +46,7 @@ import { liquidIfversionVersions } from '@/content-linter/lib/linting-rules/liqu import { outdatedReleasePhaseTerminology } from '@/content-linter/lib/linting-rules/outdated-release-phase-terminology' import { frontmatterVersionsWhitespace } from '@/content-linter/lib/linting-rules/frontmatter-versions-whitespace' import { thirdPartyActionsReusable } from '@/content-linter/lib/linting-rules/third-party-actions-reusable' -import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended' +import { frontmatterLandingCarousels } from '@/content-linter/lib/linting-rules/frontmatter-landing-carousels' import { ctasSchema } from '@/content-linter/lib/linting-rules/ctas-schema' import { journeyTracksLiquid } from './journey-tracks-liquid' import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists' @@ -111,7 +111,7 @@ export const gitHubDocsMarkdownlint = { tableColumnIntegrity, // GHD047 frontmatterVersionsWhitespace, // GHD051 thirdPartyActionsReusable, // GHD054 - frontmatterLandingRecommended, // GHD056 + frontmatterLandingCarousels, // GHD056 ctasSchema, // GHD057 journeyTracksLiquid, // GHD058 journeyTracksGuidePathExists, // GHD059 diff --git a/src/content-linter/lib/linting-rules/journey-tracks-guide-path-exists.ts b/src/content-linter/lib/linting-rules/journey-tracks-guide-path-exists.ts index 5ded02cac234..d890b24c3f05 100644 --- a/src/content-linter/lib/linting-rules/journey-tracks-guide-path-exists.ts +++ b/src/content-linter/lib/linting-rules/journey-tracks-guide-path-exists.ts @@ -5,7 +5,7 @@ import { addError } from 'markdownlint-rule-helpers' import { getFrontmatter } from '../helpers/utils' import type { RuleParams, RuleErrorCallback } from '@/content-linter/types' -// Yoink path validation approach from frontmatter-landing-recommended +// Yoink path validation approach from frontmatter-landing-carousels function isValidGuidePath(guidePath: string, currentFilePath: string): boolean { const ROOT = process.env.ROOT || '.' diff --git a/src/content-linter/style/github-docs.ts b/src/content-linter/style/github-docs.ts index f1f1f9cf7461..551576bf18be 100644 --- a/src/content-linter/style/github-docs.ts +++ b/src/content-linter/style/github-docs.ts @@ -242,7 +242,7 @@ export const githubDocsFrontmatterConfig = { 'partial-markdown-files': false, 'yml-files': false, }, - 'frontmatter-landing-recommended': { + 'frontmatter-landing-carousels': { // GHD056 severity: 'error', 'partial-markdown-files': false, diff --git a/src/content-linter/tests/fixtures/landing-recommended/article-one.md b/src/content-linter/tests/fixtures/landing-carousels/article-one.md similarity index 100% rename from src/content-linter/tests/fixtures/landing-recommended/article-one.md rename to src/content-linter/tests/fixtures/landing-carousels/article-one.md diff --git a/src/content-linter/tests/fixtures/landing-recommended/article-two.md b/src/content-linter/tests/fixtures/landing-carousels/article-two.md similarity index 100% rename from src/content-linter/tests/fixtures/landing-recommended/article-two.md rename to src/content-linter/tests/fixtures/landing-carousels/article-two.md diff --git a/src/content-linter/tests/fixtures/landing-carousels/duplicate-carousels.md b/src/content-linter/tests/fixtures/landing-carousels/duplicate-carousels.md new file mode 100644 index 000000000000..34b43112e777 --- /dev/null +++ b/src/content-linter/tests/fixtures/landing-carousels/duplicate-carousels.md @@ -0,0 +1,20 @@ +--- +title: Landing with Duplicates +layout: product-landing +versions: + fpt: '*' + ghec: '*' + ghes: '*' +topics: + - Testing +carousels: + recommended: + - /article-one + - /article-two + - /article-one + - /subdir/article-three +--- + +# Landing with Duplicates + +This landing page has duplicate carousel articles. diff --git a/src/content-linter/tests/fixtures/landing-carousels/duplicate-recommended.md b/src/content-linter/tests/fixtures/landing-carousels/duplicate-recommended.md new file mode 100644 index 000000000000..34b43112e777 --- /dev/null +++ b/src/content-linter/tests/fixtures/landing-carousels/duplicate-recommended.md @@ -0,0 +1,20 @@ +--- +title: Landing with Duplicates +layout: product-landing +versions: + fpt: '*' + ghec: '*' + ghes: '*' +topics: + - Testing +carousels: + recommended: + - /article-one + - /article-two + - /article-one + - /subdir/article-three +--- + +# Landing with Duplicates + +This landing page has duplicate carousel articles. diff --git a/src/content-linter/tests/fixtures/landing-carousels/invalid-non-landing.md b/src/content-linter/tests/fixtures/landing-carousels/invalid-non-landing.md new file mode 100644 index 000000000000..c399479cb97a --- /dev/null +++ b/src/content-linter/tests/fixtures/landing-carousels/invalid-non-landing.md @@ -0,0 +1,19 @@ +--- +title: Not a Landing Page +layout: inline +versions: + fpt: '*' + ghec: '*' + ghes: '*' +topics: + - Testing +carousels: + recommended: + - /article-one + - /article-two + - /subdir/article-three +--- + +# Not a Landing Page + +This page has a carousels property but is not a landing page. diff --git a/src/content-linter/tests/fixtures/landing-recommended/invalid-paths.md b/src/content-linter/tests/fixtures/landing-carousels/invalid-paths.md similarity index 50% rename from src/content-linter/tests/fixtures/landing-recommended/invalid-paths.md rename to src/content-linter/tests/fixtures/landing-carousels/invalid-paths.md index fb76b85bcea2..4d33154a025b 100644 --- a/src/content-linter/tests/fixtures/landing-recommended/invalid-paths.md +++ b/src/content-linter/tests/fixtures/landing-carousels/invalid-paths.md @@ -7,12 +7,13 @@ versions: ghes: '*' topics: - Testing -recommended: - - /article-one - - /nonexistent/path - - /another/invalid/path +carousels: + recommended: + - /article-one + - /nonexistent/path + - /another/invalid/path --- # Landing with Invalid Paths -This landing page has some invalid recommended article paths. +This landing page has some invalid carousel article paths. diff --git a/src/content-linter/tests/fixtures/landing-carousels/no-carousels.md b/src/content-linter/tests/fixtures/landing-carousels/no-carousels.md new file mode 100644 index 000000000000..149684323fe9 --- /dev/null +++ b/src/content-linter/tests/fixtures/landing-carousels/no-carousels.md @@ -0,0 +1,14 @@ +--- +title: Landing without Carousels +layout: product-landing +versions: + fpt: '*' + ghec: '*' + ghes: '*' +topics: + - Testing +--- + +# Landing without Carousels + +This is a landing page without any carousels. diff --git a/src/content-linter/tests/fixtures/landing-carousels/no-recommended.md b/src/content-linter/tests/fixtures/landing-carousels/no-recommended.md new file mode 100644 index 000000000000..149684323fe9 --- /dev/null +++ b/src/content-linter/tests/fixtures/landing-carousels/no-recommended.md @@ -0,0 +1,14 @@ +--- +title: Landing without Carousels +layout: product-landing +versions: + fpt: '*' + ghec: '*' + ghes: '*' +topics: + - Testing +--- + +# Landing without Carousels + +This is a landing page without any carousels. diff --git a/src/content-linter/tests/fixtures/landing-recommended/subdir/article-three.md b/src/content-linter/tests/fixtures/landing-carousels/subdir/article-three.md similarity index 100% rename from src/content-linter/tests/fixtures/landing-recommended/subdir/article-three.md rename to src/content-linter/tests/fixtures/landing-carousels/subdir/article-three.md diff --git a/src/content-linter/tests/fixtures/landing-recommended/test-absolute-only.md b/src/content-linter/tests/fixtures/landing-carousels/test-absolute-only.md similarity index 85% rename from src/content-linter/tests/fixtures/landing-recommended/test-absolute-only.md rename to src/content-linter/tests/fixtures/landing-carousels/test-absolute-only.md index 964f85cf6a49..da9b8de286e9 100644 --- a/src/content-linter/tests/fixtures/landing-recommended/test-absolute-only.md +++ b/src/content-linter/tests/fixtures/landing-carousels/test-absolute-only.md @@ -3,8 +3,9 @@ title: Test Absolute Only Path layout: product-landing versions: fpt: '*' -recommended: - - /article-two +carousels: + recommended: + - /article-two --- # Test Absolute Only Path diff --git a/src/content-linter/tests/fixtures/landing-recommended/test-absolute-priority.md b/src/content-linter/tests/fixtures/landing-carousels/test-absolute-priority.md similarity index 71% rename from src/content-linter/tests/fixtures/landing-recommended/test-absolute-priority.md rename to src/content-linter/tests/fixtures/landing-carousels/test-absolute-priority.md index bc529b8d8b3e..ab8cd042fefe 100644 --- a/src/content-linter/tests/fixtures/landing-recommended/test-absolute-priority.md +++ b/src/content-linter/tests/fixtures/landing-carousels/test-absolute-priority.md @@ -3,9 +3,10 @@ title: Test Absolute Path Priority layout: product-landing versions: fpt: '*' -recommended: - - /article-one - - /subdir/article-three +carousels: + recommended: + - /article-one + - /subdir/article-three --- # Test Absolute Path Priority diff --git a/src/content-linter/tests/fixtures/landing-recommended/test-path-priority.md b/src/content-linter/tests/fixtures/landing-carousels/test-path-priority.md similarity index 74% rename from src/content-linter/tests/fixtures/landing-recommended/test-path-priority.md rename to src/content-linter/tests/fixtures/landing-carousels/test-path-priority.md index 226c7f956a91..30e078ff8b80 100644 --- a/src/content-linter/tests/fixtures/landing-recommended/test-path-priority.md +++ b/src/content-linter/tests/fixtures/landing-carousels/test-path-priority.md @@ -3,8 +3,9 @@ title: Test Path Priority Resolution layout: product-landing versions: fpt: '*' -recommended: - - /article-one +carousels: + recommended: + - /article-one --- # Test Path Priority Resolution @@ -12,6 +13,6 @@ recommended: This tests that /article-one resolves to the absolute path: tests/fixtures/fixtures/content/article-one.md (absolute from fixtures root) NOT the relative path: - tests/fixtures/landing-recommended/article-one.md (relative to this file) + tests/fixtures/landing-carousels/article-one.md (relative to this file) The absolute path should be prioritized over the relative path. diff --git a/src/content-linter/tests/fixtures/landing-recommended/test-priority-validation.md b/src/content-linter/tests/fixtures/landing-carousels/test-priority-validation.md similarity index 80% rename from src/content-linter/tests/fixtures/landing-recommended/test-priority-validation.md rename to src/content-linter/tests/fixtures/landing-carousels/test-priority-validation.md index 248f88d8433c..560b75a2ac78 100644 --- a/src/content-linter/tests/fixtures/landing-recommended/test-priority-validation.md +++ b/src/content-linter/tests/fixtures/landing-carousels/test-priority-validation.md @@ -3,9 +3,10 @@ title: Test Priority Validation layout: product-landing versions: fpt: '*' -recommended: - - /article-one - - /nonexistent-absolute +carousels: + recommended: + - /article-one + - /nonexistent-absolute --- # Test Priority Validation diff --git a/src/content-linter/tests/fixtures/landing-carousels/valid-landing.md b/src/content-linter/tests/fixtures/landing-carousels/valid-landing.md new file mode 100644 index 000000000000..76083ae222c2 --- /dev/null +++ b/src/content-linter/tests/fixtures/landing-carousels/valid-landing.md @@ -0,0 +1,20 @@ +--- +title: Valid Landing Page +layout: product-landing +versions: + fpt: '*' + ghec: '*' + ghes: '*' +topics: + - Testing +carousels: + recommended: + - /article-one + - /article-two + - /subdir/article-three + - /get-started +--- + +# Valid Landing Page + +This is a valid landing page with carousel articles and landing page paths. diff --git a/src/content-linter/tests/fixtures/landing-recommended/duplicate-recommended.md b/src/content-linter/tests/fixtures/landing-recommended/duplicate-recommended.md deleted file mode 100644 index 8a50baa93d43..000000000000 --- a/src/content-linter/tests/fixtures/landing-recommended/duplicate-recommended.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Landing with Duplicates -layout: product-landing -versions: - fpt: '*' - ghec: '*' - ghes: '*' -topics: - - Testing -recommended: - - /article-one - - /article-two - - /article-one - - /subdir/article-three ---- - -# Landing with Duplicates - -This landing page has duplicate recommended articles. diff --git a/src/content-linter/tests/fixtures/landing-recommended/invalid-non-landing.md b/src/content-linter/tests/fixtures/landing-recommended/invalid-non-landing.md deleted file mode 100644 index 81cdf1352056..000000000000 --- a/src/content-linter/tests/fixtures/landing-recommended/invalid-non-landing.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -title: Not a Landing Page -layout: inline -versions: - fpt: '*' - ghec: '*' - ghes: '*' -topics: - - Testing -recommended: - - /article-one - - /article-two - - /subdir/article-three ---- - -# Not a Landing Page - -This page has a recommended property but is not a landing page. diff --git a/src/content-linter/tests/fixtures/landing-recommended/no-recommended.md b/src/content-linter/tests/fixtures/landing-recommended/no-recommended.md deleted file mode 100644 index dcc452f24f61..000000000000 --- a/src/content-linter/tests/fixtures/landing-recommended/no-recommended.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -title: Landing without Recommended -layout: product-landing -versions: - fpt: '*' - ghec: '*' - ghes: '*' -topics: - - Testing ---- - -# Landing without Recommended - -This is a landing page without any recommended articles. diff --git a/src/content-linter/tests/fixtures/landing-recommended/valid-landing.md b/src/content-linter/tests/fixtures/landing-recommended/valid-landing.md deleted file mode 100644 index 09d5956c5ca0..000000000000 --- a/src/content-linter/tests/fixtures/landing-recommended/valid-landing.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -title: Valid Landing Page -layout: product-landing -versions: - fpt: '*' - ghec: '*' - ghes: '*' -topics: - - Testing -recommended: - - /article-one - - /article-two - - /subdir/article-three - - /get-started ---- - -# Valid Landing Page - -This is a valid landing page with recommended articles and landing page paths. diff --git a/src/content-linter/tests/unit/frontmatter-landing-recommended.ts b/src/content-linter/tests/unit/frontmatter-landing-carousels.ts similarity index 59% rename from src/content-linter/tests/unit/frontmatter-landing-recommended.ts rename to src/content-linter/tests/unit/frontmatter-landing-carousels.ts index 7dc86e0c197c..3238d9555f7e 100644 --- a/src/content-linter/tests/unit/frontmatter-landing-recommended.ts +++ b/src/content-linter/tests/unit/frontmatter-landing-carousels.ts @@ -1,23 +1,23 @@ import { describe, expect, test, beforeAll, afterAll } from 'vitest' import { runRule } from '@/content-linter/lib/init-test' -import { frontmatterLandingRecommended } from '@/content-linter/lib/linting-rules/frontmatter-landing-recommended' +import { frontmatterLandingCarousels } from '@/content-linter/lib/linting-rules/frontmatter-landing-carousels' -const VALID_LANDING = 'src/content-linter/tests/fixtures/landing-recommended/valid-landing.md' +const VALID_LANDING = 'src/content-linter/tests/fixtures/landing-carousels/valid-landing.md' const INVALID_NON_LANDING = - 'src/content-linter/tests/fixtures/landing-recommended/invalid-non-landing.md' -const DUPLICATE_RECOMMENDED = - 'src/content-linter/tests/fixtures/landing-recommended/duplicate-recommended.md' -const INVALID_PATHS = 'src/content-linter/tests/fixtures/landing-recommended/invalid-paths.md' -const NO_RECOMMENDED = 'src/content-linter/tests/fixtures/landing-recommended/no-recommended.md' + 'src/content-linter/tests/fixtures/landing-carousels/invalid-non-landing.md' +const DUPLICATE_CAROUSELS = + 'src/content-linter/tests/fixtures/landing-carousels/duplicate-carousels.md' +const INVALID_PATHS = 'src/content-linter/tests/fixtures/landing-carousels/invalid-paths.md' +const NO_CAROUSELS = 'src/content-linter/tests/fixtures/landing-carousels/no-carousels.md' const ABSOLUTE_PRIORITY = - 'src/content-linter/tests/fixtures/landing-recommended/test-absolute-priority.md' -const PATH_PRIORITY = 'src/content-linter/tests/fixtures/landing-recommended/test-path-priority.md' -const ABSOLUTE_ONLY = 'src/content-linter/tests/fixtures/landing-recommended/test-absolute-only.md' + 'src/content-linter/tests/fixtures/landing-carousels/test-absolute-priority.md' +const PATH_PRIORITY = 'src/content-linter/tests/fixtures/landing-carousels/test-path-priority.md' +const ABSOLUTE_ONLY = 'src/content-linter/tests/fixtures/landing-carousels/test-absolute-only.md' const PRIORITY_VALIDATION = - 'src/content-linter/tests/fixtures/landing-recommended/test-priority-validation.md' + 'src/content-linter/tests/fixtures/landing-carousels/test-priority-validation.md' -const ruleName = frontmatterLandingRecommended.names[1] +const ruleName = frontmatterLandingCarousels.names[1] // Configure the test fixture to not split frontmatter and content const fmOptions = { markdownlintOptions: { frontMatter: null } } @@ -32,59 +32,59 @@ describe(ruleName, () => { afterAll(() => { process.env.ROOT = envVarValueBefore }) - test('landing page with recommended articles passes', async () => { - const result = await runRule(frontmatterLandingRecommended, { + test('landing page with carousel articles passes', async () => { + const result = await runRule(frontmatterLandingCarousels, { files: [VALID_LANDING], ...fmOptions, }) expect(result[VALID_LANDING]).toEqual([]) }) - test('non-landing page with recommended property fails', async () => { - const result = await runRule(frontmatterLandingRecommended, { + test('non-landing page with carousels property fails', async () => { + const result = await runRule(frontmatterLandingCarousels, { files: [INVALID_NON_LANDING], ...fmOptions, }) expect(result[INVALID_NON_LANDING]).toHaveLength(1) expect(result[INVALID_NON_LANDING][0].errorDetail).toContain( - 'recommended frontmatter key is only valid for landing pages', + 'carousels frontmatter key is only valid for landing pages', ) }) - test('pages without recommended property pass', async () => { - const result = await runRule(frontmatterLandingRecommended, { - files: [NO_RECOMMENDED], + test('pages without carousels property pass', async () => { + const result = await runRule(frontmatterLandingCarousels, { + files: [NO_CAROUSELS], ...fmOptions, }) - expect(result[NO_RECOMMENDED]).toEqual([]) + expect(result[NO_CAROUSELS]).toEqual([]) }) - test('page with duplicate recommended articles fails', async () => { - const result = await runRule(frontmatterLandingRecommended, { - files: [DUPLICATE_RECOMMENDED], + test('page with duplicate carousel articles fails', async () => { + const result = await runRule(frontmatterLandingCarousels, { + files: [DUPLICATE_CAROUSELS], ...fmOptions, }) - expect(result[DUPLICATE_RECOMMENDED]).toHaveLength(1) // Only duplicate error since all paths are valid - expect(result[DUPLICATE_RECOMMENDED][0].errorDetail).toContain( - 'Found duplicate recommended articles: /article-one', + expect(result[DUPLICATE_CAROUSELS]).toHaveLength(1) // Only duplicate error since all paths are valid + expect(result[DUPLICATE_CAROUSELS][0].errorDetail).toContain( + "Found duplicate articles in carousel 'recommended': /article-one", ) }) - test('page with invalid recommended article paths fails', async () => { - const result = await runRule(frontmatterLandingRecommended, { + test('page with invalid carousel article paths fails', async () => { + const result = await runRule(frontmatterLandingCarousels, { files: [INVALID_PATHS], ...fmOptions, }) expect(result[INVALID_PATHS]).toHaveLength(1) expect(result[INVALID_PATHS][0].errorDetail).toContain( - 'Found invalid recommended article paths:', + "Found invalid article paths in carousel 'recommended':", ) expect(result[INVALID_PATHS][0].errorDetail).toContain('/nonexistent/path') expect(result[INVALID_PATHS][0].errorDetail).toContain('/another/invalid/path') }) - test('page with valid recommended articles passes', async () => { - const result = await runRule(frontmatterLandingRecommended, { + test('page with valid carousel articles passes', async () => { + const result = await runRule(frontmatterLandingCarousels, { files: [VALID_LANDING], ...fmOptions, }) @@ -97,10 +97,10 @@ describe(ruleName, () => { // // Setup: // - /article-one should resolve to src/fixtures/fixtures/content/article-one.md (absolute) - // - article-one (relative) would resolve to src/content-linter/tests/fixtures/landing-recommended/article-one.md + // - article-one (relative) would resolve to src/content-linter/tests/fixtures/landing-carousels/article-one.md // // The test passes because our logic prioritizes the absolute path resolution first - const result = await runRule(frontmatterLandingRecommended, { + const result = await runRule(frontmatterLandingCarousels, { files: [ABSOLUTE_PRIORITY], ...fmOptions, }) @@ -114,11 +114,11 @@ describe(ruleName, () => { // Setup: // - /article-one could resolve to EITHER: // 1. src/fixtures/fixtures/content/article-one.md (absolute - should be chosen) - // 2. src/content-linter/tests/fixtures/landing-recommended/article-one.md (relative - should be ignored) + // 2. src/content-linter/tests/fixtures/landing-carousels/article-one.md (relative - should be ignored) // // Our prioritization logic should choose #1 (absolute) over #2 (relative) // This test passes because the absolute path exists and is found first - const result = await runRule(frontmatterLandingRecommended, { + const result = await runRule(frontmatterLandingCarousels, { files: [PATH_PRIORITY], ...fmOptions, }) @@ -128,9 +128,9 @@ describe(ruleName, () => { test('absolute-only paths work when no relative path exists', async () => { // This test verifies that absolute path resolution works when no relative path exists // /article-two exists in src/fixtures/fixtures/content/article-two.md - // but NOT in src/content-linter/tests/fixtures/landing-recommended/article-two.md + // but NOT in src/content-linter/tests/fixtures/landing-carousels/article-two.md // This test would fail if we didn't prioritize absolute paths properly - const result = await runRule(frontmatterLandingRecommended, { + const result = await runRule(frontmatterLandingCarousels, { files: [ABSOLUTE_ONLY], ...fmOptions, }) @@ -140,7 +140,7 @@ describe(ruleName, () => { test('mixed valid and invalid absolute paths are handled correctly', async () => { // This test has both a valid absolute path (/article-one) and an invalid one (/nonexistent-absolute) // It should fail because of the invalid path, proving our absolute path resolution is working - const result = await runRule(frontmatterLandingRecommended, { + const result = await runRule(frontmatterLandingCarousels, { files: [PRIORITY_VALIDATION], ...fmOptions, }) diff --git a/src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md b/src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md index 939c6507c921..43376ac26bb5 100644 --- a/src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md +++ b/src/fixtures/fixtures/content/get-started/article-grid-bespoke/index.md @@ -6,10 +6,11 @@ versions: ghes: "*" ghec: "*" layout: bespoke-landing -recommended: - - /grid-category-one/grid-article-one - - /grid-category-one/grid-article-two - - /grid-category-two/grid-article-three +carousels: + recommended: + - /grid-category-one/grid-article-one + - /grid-category-one/grid-article-two + - /grid-category-two/grid-article-three spotlight: - article: /grid-category-one/grid-article-one image: /assets/images/placeholder.png diff --git a/src/fixtures/fixtures/content/get-started/article-grid-discovery/index.md b/src/fixtures/fixtures/content/get-started/article-grid-discovery/index.md index 8944b2da5cea..0093ed207121 100644 --- a/src/fixtures/fixtures/content/get-started/article-grid-discovery/index.md +++ b/src/fixtures/fixtures/content/get-started/article-grid-discovery/index.md @@ -6,10 +6,11 @@ versions: ghes: "*" ghec: "*" layout: discovery-landing -recommended: - - /grid-category-one/grid-article-one - - /grid-category-one/grid-article-two - - /grid-category-two/grid-article-three +carousels: + recommended: + - /grid-category-one/grid-article-one + - /grid-category-one/grid-article-two + - /grid-category-two/grid-article-three spotlight: - article: /grid-category-one/grid-article-one image: /assets/images/placeholder.png diff --git a/src/fixtures/fixtures/content/get-started/carousel/index.md b/src/fixtures/fixtures/content/get-started/carousel/index.md index e31cabbfba67..fe29f6d81d0e 100644 --- a/src/fixtures/fixtures/content/get-started/carousel/index.md +++ b/src/fixtures/fixtures/content/get-started/carousel/index.md @@ -6,10 +6,11 @@ versions: ghes: '*' ghec: '*' layout: discovery-landing -recommended: - - /category-one/article-one - - /category-one/article-two - - /category-two/article-three +carousels: + recommended: + - /category-one/article-one + - /category-one/article-two + - /category-two/article-three spotlight: - article: /category-one/article-one image: /assets/images/placeholder.png diff --git a/src/fixtures/fixtures/content/get-started/index.md b/src/fixtures/fixtures/content/get-started/index.md index 742039a9df50..e64ae2eced8b 100644 --- a/src/fixtures/fixtures/content/get-started/index.md +++ b/src/fixtures/fixtures/content/get-started/index.md @@ -45,6 +45,7 @@ children: - /carousel - /article-grid-discovery - /article-grid-bespoke + - /multi-carousel - /non-child-resolution communityRedirect: name: Provide HubGit Feedback diff --git a/src/fixtures/fixtures/content/get-started/multi-carousel/index.md b/src/fixtures/fixtures/content/get-started/multi-carousel/index.md new file mode 100644 index 000000000000..c3b815525019 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/multi-carousel/index.md @@ -0,0 +1,25 @@ +--- +title: Multi-Carousel Test Page +intro: "A test page for testing multiple carousels with articles." +versions: + fpt: "*" + ghes: "*" + ghec: "*" +layout: discovery-landing +carousels: + # This has a matching key in ui.yml -> should display "Recommended" title + recommended: + - /get-started/foo/bar + - /get-started/foo/autotitling + - /get-started/foo/for-playwright + # This has NO matching key in ui.yml -> should display no title + titleTwoNoMatchingUiYml: + - /get-started/foo/for-playwright + - /get-started/foo/autotitling + - /get-started/foo/bar +children: + - /placeholder +--- + +This page tests multiple carousels. +Each carousel can have a different set of articles, and the title comes from ui.yml if the key matches. diff --git a/src/fixtures/fixtures/content/get-started/multi-carousel/placeholder/index.md b/src/fixtures/fixtures/content/get-started/multi-carousel/placeholder/index.md new file mode 100644 index 000000000000..afcda2c62812 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/multi-carousel/placeholder/index.md @@ -0,0 +1,11 @@ +--- +title: Placeholder +intro: 'Placeholder child for multi-carousel fixture' +versions: + fpt: '*' + ghes: '*' + ghec: '*' +children: [] +--- + +Placeholder content. diff --git a/src/fixtures/fixtures/data/ui.yml b/src/fixtures/fixtures/data/ui.yml index 32fd466830b1..1f6257f545fe 100644 --- a/src/fixtures/fixtures/data/ui.yml +++ b/src/fixtures/fixtures/data/ui.yml @@ -346,6 +346,9 @@ cookbook_landing: category: Category complexity: Complexity +carousels: + recommended: Recommended + not_found: title: Ooops! message: It looks like this page doesn't exist. diff --git a/src/fixtures/fixtures/test-discovery-landing.md b/src/fixtures/fixtures/test-discovery-landing.md index 5eb46113b294..454c0f99d71d 100644 --- a/src/fixtures/fixtures/test-discovery-landing.md +++ b/src/fixtures/fixtures/test-discovery-landing.md @@ -1,9 +1,10 @@ --- title: Test Discovery Landing Page intro: This is a test discovery landing page -recommended: - - /get-started/quickstart - - /actions/learn-github-actions +carousels: + recommended: + - /get-started/quickstart + - /actions/learn-github-actions --- # Test Discovery Landing Page diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 1b1ab529427e..bcade919dee8 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1063,6 +1063,88 @@ test.describe('LandingCarousel component', () => { }) }) +test.describe('Multi-carousel support', () => { + test('displays multiple carousels from carousels frontmatter', async ({ page }) => { + await page.goto('/get-started/multi-carousel') + + // Should have multiple carousels rendered + const carousels = page.locator('[data-testid="landing-carousel"]') + const carouselCount = await carousels.count() + + // We defined exactly 2 carousels in the frontmatter + expect(carouselCount).toBe(2) + }) + + test('carousel with matching ui.yml key displays translated title', async ({ page }) => { + await page.goto('/get-started/multi-carousel') + + // The "recommended" carousel should show "Recommended" title from ui.yml + const carouselHeadings = page.locator('[data-testid="landing-carousel"] h2') + + const headingTexts = await carouselHeadings.allTextContents() + + // Check that at least one heading has "Recommended" + expect(headingTexts.some((text) => text.includes('Recommended'))).toBe(true) + }) + + test('carousel without matching ui.yml key renders without title', async ({ page }) => { + await page.goto('/get-started/multi-carousel') + + // The "titleTwoNoMatchingUiYml" carousel should not have a visible heading + // or the heading element should be empty/not exist for that carousel + const carouselHeadings = page.locator('[data-testid="landing-carousel"] h2') + const headingTexts = await carouselHeadings.allTextContents() + + // The raw key "titleTwoNoMatchingUiYml" should NOT appear as a heading + // (the component should not show the key as fallback) + expect(headingTexts.some((text) => text === 'titleTwoNoMatchingUiYml')).toBe(false) + }) + + test('heading h2 element is only present when ui.yml translation exists', async ({ page }) => { + await page.goto('/get-started/multi-carousel') + + const carousels = page.locator('[data-testid="landing-carousel"]') + const count = await carousels.count() + + // We have 2 carousels: "recommended" and "titleTwoNoMatchingUiYml" + expect(count).toBe(2) + + // Count carousels that have h2 elements + let carouselsWithHeadings = 0 + for (let i = 0; i < count; i++) { + const carousel = carousels.nth(i) + const h2Count = await carousel.locator('h2').count() + if (h2Count > 0) { + carouselsWithHeadings++ + } + } + + // Only 1 carousel should have a heading (recommended has ui.yml entry) + // titleTwoNoMatchingUiYml should NOT have an h2 element at all + expect(carouselsWithHeadings).toBe(1) + + // Verify the specific titles that should be visible + const visibleHeadings = await carousels.locator('h2').allTextContents() + expect(visibleHeadings).toContain('Recommended') + expect(visibleHeadings).not.toContain('titleTwoNoMatchingUiYml') + }) + + test('each carousel has articles based on frontmatter paths', async ({ page }) => { + await page.goto('/get-started/multi-carousel') + + const carousels = page.locator('[data-testid="landing-carousel"]') + const count = await carousels.count() + + // Each carousel should have at least one article + for (let i = 0; i < count; i++) { + const carousel = carousels.nth(i) + const articles = carousel.locator('[data-testid="carousel-items"] a') + const articleCount = await articles.count() + expect(articleCount).toBeGreaterThan(0) + } + }) +}) + test.describe('Journey Tracks', () => { test('displays all journey tracks on landing pages', async ({ page }) => { await page.goto('/get-started/test-journey') diff --git a/src/frame/lib/frontmatter.ts b/src/frame/lib/frontmatter.ts index e914da6f709d..0d4dc7ee5da6 100644 --- a/src/frame/lib/frontmatter.ts +++ b/src/frame/lib/frontmatter.ts @@ -19,6 +19,7 @@ interface SchemaProperty { properties?: Record required?: string[] additionalProperties?: boolean + patternProperties?: Record format?: string description?: string minItems?: number @@ -422,12 +423,20 @@ category: }, description: 'Array of articles to feature in the spotlight section', }, - // Recommended configuration for category landing pages - recommended: { - type: 'array', - minItems: 3, - maxItems: 9, - description: 'Array of articles to feature in the carousel section', + // Carousels configuration for category landing pages (supports multiple carousels) + carousels: { + type: 'object', + description: 'Multiple named carousels with articles to feature', + patternProperties: { + '^[a-zA-Z_][a-zA-Z0-9_]*$': { + type: 'array', + minItems: 3, + maxItems: 9, + items: { + type: 'string', + }, + }, + }, }, // Included categories for article grid filtering includedCategories: { diff --git a/src/frame/lib/page.ts b/src/frame/lib/page.ts index 05de3fef941e..5f3c619cbd8c 100644 --- a/src/frame/lib/page.ts +++ b/src/frame/lib/page.ts @@ -95,8 +95,8 @@ class Page { public rawIncludeGuides?: string[] public introLinks?: Record public rawIntroLinks?: Record - public recommended?: string[] - public rawRecommended?: string[] + public carousels?: Record + public rawCarousels?: Record public autogenerated?: string public featuredLinks?: FeaturedLinksExpanded public children?: string[] @@ -224,7 +224,7 @@ class Page { this.rawLearningTracks = this.learningTracks this.rawIncludeGuides = this.includeGuides as any this.rawIntroLinks = this.introLinks - this.rawRecommended = this.recommended + this.rawCarousels = this.carousels // Is this the Homepage or a Product, Category, Topic, or Article? this.documentType = getDocumentType(this.relativePath) diff --git a/src/frame/middleware/index.ts b/src/frame/middleware/index.ts index eb69e7acffac..d78637d6030a 100644 --- a/src/frame/middleware/index.ts +++ b/src/frame/middleware/index.ts @@ -44,7 +44,7 @@ import currentProductTree from './context/current-product-tree' import genericToc from './context/generic-toc' import breadcrumbs from './context/breadcrumbs' import glossaries from './context/glossaries' -import resolveRecommended from './resolve-recommended' +import resolveCarousels from './resolve-carousels' import renderProductName from './context/render-product-name' import features from '@/versions/middleware/features' import productExamples from './context/product-examples' @@ -279,7 +279,7 @@ export default function index(app: Express) { app.use(asyncMiddleware(glossaries)) app.use(asyncMiddleware(generalSearchMiddleware)) app.use(asyncMiddleware(featuredLinks)) - app.use(asyncMiddleware(resolveRecommended)) + app.use(asyncMiddleware(resolveCarousels)) app.use(asyncMiddleware(learningTrack)) app.use(asyncMiddleware(journeyTrack)) diff --git a/src/frame/middleware/resolve-recommended.ts b/src/frame/middleware/resolve-carousels.ts similarity index 57% rename from src/frame/middleware/resolve-recommended.ts rename to src/frame/middleware/resolve-carousels.ts index 12d81c9fb864..ecd80e156a4e 100644 --- a/src/frame/middleware/resolve-recommended.ts +++ b/src/frame/middleware/resolve-carousels.ts @@ -6,7 +6,7 @@ import Permalink from '@/frame/lib/permalink' import { createLogger } from '@/observability/logger/index' -const logger = createLogger('middleware:resolve-recommended') +const logger = createLogger('middleware:resolve-carousels') /** * Build an article path by combining language, optional base path, and article path @@ -94,77 +94,78 @@ function getPageHref(page: any): string { } /** - * Middleware to resolve recommended articles from rawRecommended paths and legacy spotlight field + * Middleware to resolve carousel articles from rawCarousels object */ -async function resolveRecommended( +async function resolveCarousels( req: ExtendedRequest, res: Response, next: NextFunction, ): Promise { try { const page = req.context?.page - const rawRecommended = (page as any)?.rawRecommended - const spotlight = (page as any)?.spotlight - // Collect article paths from rawRecommended or spotlight if there are no - // recommended articles - let articlePaths: string[] = [] - - // Add paths from rawRecommended - if (rawRecommended && Array.isArray(rawRecommended)) { - articlePaths.push(...rawRecommended) - } + const rawCarousels = (page as any)?.rawCarousels - // Add paths from spotlight (legacy field) if no recommended articles - if (articlePaths.length === 0 && spotlight && Array.isArray(spotlight)) { - const spotlightPaths = spotlight - .filter((item: any) => item && typeof item.article === 'string') - .map((item: any) => item.article) - articlePaths.push(...spotlightPaths) - } + // Handle carousels format + if (rawCarousels && typeof rawCarousels === 'object') { + const resolvedCarousels: Record = {} - if (articlePaths.length === 0) { - return next() - } + for (const [carouselKey, articlePaths] of Object.entries(rawCarousels)) { + if (!Array.isArray(articlePaths) || articlePaths.length === 0) { + continue + } - // remove duplicate articles - articlePaths = [...new Set(articlePaths)] - const resolved: ResolvedArticle[] = [] - - for (const rawPath of articlePaths) { - try { - const foundPage = tryResolveArticlePath(rawPath, page?.relativePath, req) - - if ( - foundPage && - (!req.context?.currentVersion || - foundPage.applicableVersions.includes(req.context.currentVersion)) - ) { - const href = getPageHref(foundPage) - const category = foundPage.relativePath - ? foundPage.relativePath.split('/').slice(0, -1).filter(Boolean) - : [] - - resolved.push({ - title: foundPage.title, - intro: await renderContent(foundPage.intro, req.context), - href, - category, - }) + // Remove duplicate articles + const uniquePaths = [...new Set(articlePaths)] + const resolved: ResolvedArticle[] = [] + + for (const rawPath of uniquePaths) { + try { + const foundPage = tryResolveArticlePath(rawPath, page?.relativePath, req) + + if ( + foundPage && + (!req.context?.currentVersion || + foundPage.applicableVersions.includes(req.context.currentVersion)) + ) { + const href = getPageHref(foundPage) + const category = foundPage.relativePath + ? foundPage.relativePath.split('/').slice(0, -1).filter(Boolean) + : [] + + resolved.push({ + title: await renderContent(foundPage.title, req.context, { textOnly: true }), + intro: await renderContent(foundPage.intro, req.context, { textOnly: true }), + href, + category, + }) + } + } catch (error) { + logger.warn(`Failed to resolve carousel article: ${rawPath}`, { error }) + } + } + + if (resolved.length > 0) { + // Prevent prototype pollution by rejecting __proto__ keys + if ( + carouselKey !== '__proto__' && + carouselKey !== 'constructor' && + carouselKey !== 'prototype' + ) { + resolvedCarousels[carouselKey] = resolved + } } - } catch (error) { - logger.warn(`Failed to resolve recommended article: ${rawPath}`, { error }) } - } - // Replace the rawRecommended with resolved articles - if (page) { - ;(page as any).recommended = resolved + // Store resolved carousels on the page + if (page && Object.keys(resolvedCarousels).length > 0) { + ;(page as any).carousels = resolvedCarousels + } } } catch (error) { - logger.error('Error in resolveRecommended middleware:', { error }) + logger.error('Error in resolveCarousels middleware:', { error }) } next() } -export default resolveRecommended +export default resolveCarousels diff --git a/src/frame/tests/resolve-recommended.test.ts b/src/frame/tests/resolve-carousels.test.ts similarity index 51% rename from src/frame/tests/resolve-recommended.test.ts rename to src/frame/tests/resolve-carousels.test.ts index 4eacb50767b3..683b2ba3fac0 100644 --- a/src/frame/tests/resolve-recommended.test.ts +++ b/src/frame/tests/resolve-carousels.test.ts @@ -2,7 +2,7 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' import type { Response, NextFunction } from 'express' import type { ExtendedRequest, Page, ResolvedArticle } from '@/types' import findPage from '@/frame/lib/find-page' -import resolveRecommended from '../middleware/resolve-recommended' +import resolveCarousels from '../middleware/resolve-carousels' // Mock the findPage function vi.mock('@/frame/lib/find-page', () => ({ @@ -11,15 +11,20 @@ vi.mock('@/frame/lib/find-page', () => ({ // Mock the renderContent function vi.mock('@/content-render/index', () => ({ - renderContent: vi.fn((content) => `

${content}

`), + renderContent: vi.fn((content, _context, options) => { + // When textOnly is true, return plain text (no HTML wrapper) + if (options?.textOnly) { + return content + } + return `

${content}

` + }), })) -describe('resolveRecommended middleware', () => { +describe('resolveCarousels middleware', () => { const mockFindPage = vi.mocked(findPage) type TestPage = Partial & { - rawRecommended?: string[] - spotlight?: Array<{ article: string }> + rawCarousels?: Record } const createMockRequest = ( @@ -60,7 +65,7 @@ describe('resolveRecommended middleware', () => { test('should call next when no context', async () => { const req = {} as ExtendedRequest - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) expect(mockNext).toHaveBeenCalled() expect(mockFindPage).not.toHaveBeenCalled() @@ -69,31 +74,34 @@ describe('resolveRecommended middleware', () => { test('should call next when no page', async () => { const req = { context: {} } as ExtendedRequest - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) expect(mockNext).toHaveBeenCalled() expect(mockFindPage).not.toHaveBeenCalled() }) test('should call next when no pages collection', async () => { - const req = createMockRequest({ rawRecommended: ['/test-article'] }, { pages: undefined }) + const req = createMockRequest( + { rawCarousels: { recommended: ['/test-article'] } }, + { pages: undefined }, + ) - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) expect(mockNext).toHaveBeenCalled() expect(mockFindPage).not.toHaveBeenCalled() }) - test('should call next when no rawRecommended', async () => { + test('should call next when no rawCarousels', async () => { const req = createMockRequest() - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) expect(mockNext).toHaveBeenCalled() expect(mockFindPage).not.toHaveBeenCalled() }) - test('should resolve recommended articles when they exist', async () => { + test('should resolve carousel articles when they exist', async () => { const testPage: Partial = { title: 'Test Article', intro: 'Test intro', @@ -103,75 +111,100 @@ describe('resolveRecommended middleware', () => { mockFindPage.mockReturnValue(testPage as unknown as Page) - const req = createMockRequest({ rawRecommended: ['/copilot/tutorials/article'] }) + const req = createMockRequest({ + rawCarousels: { recommended: ['/copilot/tutorials/article'] }, + }) - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) expect(mockFindPage).toHaveBeenCalledWith( '/en/copilot/tutorials/article', req.context!.pages, req.context!.redirects, ) - expect((req.context!.page as Page & { recommended?: ResolvedArticle[] }).recommended).toEqual([ - { - title: 'Test Article', - intro: '

Test intro

', - href: '/copilot/tutorials/article', - category: ['copilot', 'tutorials'], - }, - ]) + expect( + (req.context!.page as Page & { carousels?: Record }).carousels, + ).toEqual({ + recommended: [ + { + title: 'Test Article', + intro: 'Test intro', + href: '/copilot/tutorials/article', + category: ['copilot', 'tutorials'], + }, + ], + }) expect(mockNext).toHaveBeenCalled() }) - test('should not resolve spotlight articles when there are recommended articles', async () => { - const testPage: Partial = { - title: 'Test Article', - intro: 'Test intro', - relativePath: 'copilot/tutorials/article.md', + test('should resolve multiple carousels', async () => { + const testPage1: Partial = { + title: 'Article One', + intro: 'Intro one', + relativePath: 'test/article-one.md', + applicableVersions: ['free-pro-team@latest'], + } + const testPage2: Partial = { + title: 'Article Two', + intro: 'Intro two', + relativePath: 'test/article-two.md', applicableVersions: ['free-pro-team@latest'], } - mockFindPage.mockReturnValueOnce(testPage as unknown as Page) + mockFindPage + .mockReturnValueOnce(testPage1 as unknown as Page) + .mockReturnValueOnce(testPage2 as unknown as Page) const req = createMockRequest({ - rawRecommended: ['/copilot/tutorials/article'], - spotlight: [{ article: '/copilot/tutorials/spotlight-article' }], + rawCarousels: { + recommended: ['/test/article-one'], + featured: ['/test/article-two'], + }, }) - await resolveRecommended(req, mockRes, mockNext) - - expect(mockFindPage).toHaveBeenCalledTimes(1) - expect(mockFindPage).toHaveBeenCalledWith( - '/en/copilot/tutorials/article', - req.context!.pages, - req.context!.redirects, - ) - expect((req.context!.page as Page & { recommended?: ResolvedArticle[] }).recommended).toEqual([ - { - title: 'Test Article', - intro: '

Test intro

', - href: '/copilot/tutorials/article', - category: ['copilot', 'tutorials'], - }, - ]) + await resolveCarousels(req, mockRes, mockNext) + + expect( + (req.context!.page as Page & { carousels?: Record }).carousels, + ).toEqual({ + recommended: [ + { + title: 'Article One', + intro: 'Intro one', + href: '/test/article-one', + category: ['test'], + }, + ], + featured: [ + { + title: 'Article Two', + intro: 'Intro two', + href: '/test/article-two', + category: ['test'], + }, + ], + }) expect(mockNext).toHaveBeenCalled() }) test('should handle articles not found', async () => { mockFindPage.mockReturnValue(undefined) - const req = createMockRequest({ rawRecommended: ['/nonexistent-article'] }) + const req = createMockRequest({ + rawCarousels: { recommended: ['/nonexistent-article'] }, + }) - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) expect(mockFindPage).toHaveBeenCalledWith( '/en/nonexistent-article', req.context!.pages, req.context!.redirects, ) - expect((req.context!.page as Page & { recommended?: ResolvedArticle[] }).recommended).toEqual( - [], - ) + // Carousel should not be added if all articles are not found + expect( + (req.context!.page as Page & { carousels?: Record }).carousels, + ).toBeUndefined() expect(mockNext).toHaveBeenCalled() }) @@ -180,13 +213,13 @@ describe('resolveRecommended middleware', () => { throw new Error('Test error') }) - const req = createMockRequest({ rawRecommended: ['/error-article'] as string[] }) + const req = createMockRequest({ + rawCarousels: { recommended: ['/error-article'] }, + }) - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) - expect((req.context!.page as Page & { recommended?: ResolvedArticle[] }).recommended).toEqual( - [], - ) + // Should still call next even on error expect(mockNext).toHaveBeenCalled() }) @@ -200,18 +233,24 @@ describe('resolveRecommended middleware', () => { mockFindPage.mockReturnValueOnce(testPage as unknown as Page).mockReturnValueOnce(undefined) - const req = createMockRequest({ rawRecommended: ['/valid-article', '/invalid-article'] }) - - await resolveRecommended(req, mockRes, mockNext) + const req = createMockRequest({ + rawCarousels: { recommended: ['/valid-article', '/invalid-article'] }, + }) - expect((req.context!.page as Page & { recommended?: ResolvedArticle[] }).recommended).toEqual([ - { - title: 'Valid Article', - intro: '

Valid intro

', - href: '/test/valid', - category: ['test'], - }, - ]) + await resolveCarousels(req, mockRes, mockNext) + + expect( + (req.context!.page as Page & { carousels?: Record }).carousels, + ).toEqual({ + recommended: [ + { + title: 'Valid Article', + intro: 'Valid intro', + href: '/test/valid', + category: ['test'], + }, + ], + }) expect(mockNext).toHaveBeenCalled() }) @@ -227,11 +266,11 @@ describe('resolveRecommended middleware', () => { mockFindPage.mockReturnValueOnce(undefined).mockReturnValueOnce(testPage as unknown as Page) const req = createMockRequest({ - rawRecommended: ['relative-article'], + rawCarousels: { recommended: ['relative-article'] }, relativePath: 'copilot/index.md', }) - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) expect(mockFindPage).toHaveBeenCalledTimes(2) expect(mockFindPage).toHaveBeenCalledWith( @@ -244,14 +283,18 @@ describe('resolveRecommended middleware', () => { req.context!.pages, req.context!.redirects, ) - expect((req.context!.page as Page & { recommended?: ResolvedArticle[] }).recommended).toEqual([ - { - title: 'Relative Article', - intro: '

Relative intro

', - href: '/copilot/relative-article', // Updated to clean path - category: ['copilot'], - }, - ]) + expect( + (req.context!.page as Page & { carousels?: Record }).carousels, + ).toEqual({ + recommended: [ + { + title: 'Relative Article', + intro: 'Relative intro', + href: '/copilot/relative-article', + category: ['copilot'], + }, + ], + }) expect(mockNext).toHaveBeenCalled() }) @@ -265,9 +308,11 @@ describe('resolveRecommended middleware', () => { mockFindPage.mockReturnValue(testPage as unknown as Page) - const req = createMockRequest({ rawRecommended: ['/copilot/tutorials/tutorial-page'] }) + const req = createMockRequest({ + rawCarousels: { recommended: ['/copilot/tutorials/tutorial-page'] }, + }) - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) expect(mockFindPage).toHaveBeenCalledWith( '/en/copilot/tutorials/tutorial-page', @@ -275,16 +320,19 @@ describe('resolveRecommended middleware', () => { req.context!.redirects, ) - // Verify that the href is a clean path without language/version, that gets - // added on the React side. - expect((req.context!.page as Page & { recommended?: ResolvedArticle[] }).recommended).toEqual([ - { - title: 'Tutorial Page', - intro: '

Tutorial intro

', - href: '/copilot/tutorials/tutorial-page', - category: ['copilot', 'tutorials', 'tutorial-page'], - }, - ]) + // Verify that the href is a clean path without language/version + expect( + (req.context!.page as Page & { carousels?: Record }).carousels, + ).toEqual({ + recommended: [ + { + title: 'Tutorial Page', + intro: 'Tutorial intro', + href: '/copilot/tutorials/tutorial-page', + category: ['copilot', 'tutorials', 'tutorial-page'], + }, + ], + }) expect(mockNext).toHaveBeenCalled() }) @@ -301,19 +349,51 @@ describe('resolveRecommended middleware', () => { // Create a request context where we're viewing the GHEC version const req = createMockRequest( - { rawRecommended: ['/test/fpt-only'] }, + { rawCarousels: { recommended: ['/test/fpt-only'] } }, { currentVersion: 'enterprise-cloud@latest', // Current context is GHEC, not FPT currentLanguage: 'en', }, ) - await resolveRecommended(req, mockRes, mockNext) + await resolveCarousels(req, mockRes, mockNext) - // The recommended array should be empty since the article isn't available in enterprise-cloud - expect((req.context!.page as Page & { recommended?: ResolvedArticle[] }).recommended).toEqual( - [], - ) + // The carousels should not be added since the article isn't available in enterprise-cloud + expect( + (req.context!.page as Page & { carousels?: Record }).carousels, + ).toBeUndefined() + expect(mockNext).toHaveBeenCalled() + }) + + test('should deduplicate articles within a carousel', async () => { + const testPage: Partial = { + title: 'Duplicate Article', + intro: 'Duplicate intro', + relativePath: 'test/duplicate.md', + applicableVersions: ['free-pro-team@latest'], + } + + mockFindPage.mockReturnValue(testPage as unknown as Page) + + const req = createMockRequest({ + rawCarousels: { recommended: ['/test/duplicate', '/test/duplicate', '/test/duplicate'] }, + }) + + await resolveCarousels(req, mockRes, mockNext) + + // Should only have one article, not three duplicates + expect( + (req.context!.page as Page & { carousels?: Record }).carousels, + ).toEqual({ + recommended: [ + { + title: 'Duplicate Article', + intro: 'Duplicate intro', + href: '/test/duplicate', + category: ['test'], + }, + ], + }) expect(mockNext).toHaveBeenCalled() }) }) diff --git a/src/landings/components/bespoke/BespokeLanding.tsx b/src/landings/components/bespoke/BespokeLanding.tsx index 7b4b1f85da9e..b1d715c528a2 100644 --- a/src/landings/components/bespoke/BespokeLanding.tsx +++ b/src/landings/components/bespoke/BespokeLanding.tsx @@ -13,7 +13,7 @@ export const BespokeLanding = () => { heroImage, introLinks, tocItems, - recommended, + carousels, includedCategories, landingType, } = useLandingContext() @@ -29,7 +29,16 @@ export const BespokeLanding = () => {
- + {/* Render carousels */} + {carousels && + Object.entries(carousels).map(([carouselKey, articles]) => ( + + ))} + { heroImage, introLinks, tocItems, - recommended, + carousels, includedCategories, landingType, } = useLandingContext() @@ -28,7 +28,16 @@ export const DiscoveryLanding = () => {
- + {/* Render carousels */} + {carousels && + Object.entries(carousels).map(([carouselKey, articles]) => ( + + ))} + { return itemsPerView } -export const LandingCarousel = ({ heading = '', recommended }: LandingCarouselProps) => { +export const LandingCarousel = ({ + heading = '', + carouselKey, + carouselArticles, +}: LandingCarouselProps) => { const [currentPage, setCurrentPage] = useState(0) const [isAnimating, setIsAnimating] = useState(false) const itemsPerView = useResponsiveItemsPerView() - const { t } = useTranslation('product_landing') + const { t } = useTranslation('carousels') const router = useRouter() const { currentVersion } = useVersion() - const headingText = heading || t('carousel.recommended') + + // Determine heading text + let headingText = heading + if (!headingText && carouselKey) { + // Try to get translation for the carousel key + const translated = t(carouselKey) + + // Check if we got a real translation or a fallback + const looksLikeFallback = !translated || translated === carouselKey + + if (!looksLikeFallback) { + headingText = translated + } + } + // Ref to store timeout IDs for cleanup const animationTimeoutRef = useRef(null) @@ -55,7 +74,7 @@ export const LandingCarousel = ({ heading = '', recommended }: LandingCarouselPr setCurrentPage(0) }, [itemsPerView]) - const processedItems: ResolvedArticle[] = recommended || [] + const processedItems: ResolvedArticle[] = carouselArticles || [] // Cleanup timeout on unmount useEffect(() => { @@ -116,9 +135,12 @@ export const LandingCarousel = ({ heading = '', recommended }: LandingCarouselPr } return ( -
+
-

{headingText}

+ {headingText &&

{headingText}

} {totalItems > itemsPerView && (