diff --git a/.dockerignore b/.dockerignore index 087716df1f38..8be236839d86 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,3 +8,7 @@ docs-early-access/ node_modules/ README.md tests/ +# Local symlinks to docs-early-access. merge-early-access.sh creates these during the build. +assets/images/early-access +content/early-access +data/early-access diff --git a/.github/workflows/check-for-spammy-prs.yml b/.github/workflows/check-for-spammy-prs.yml index c2f6021a22bc..bb17e6836478 100644 --- a/.github/workflows/check-for-spammy-prs.yml +++ b/.github/workflows/check-for-spammy-prs.yml @@ -8,6 +8,7 @@ on: permissions: contents: read + issues: read pull-requests: write jobs: @@ -52,10 +53,49 @@ jobs: ) const onlyIndexMd = files.length === 1 && files[0].filename.split('/').pop() === 'index.md' + // Content removals are allowed once the Docs team marks the linked issue `help wanted`. + let hasApprovedIssue = false + if (onlyDeletesLines || onlyDeletes) { + try { + const { repository } = await github.graphql( + `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + closingIssuesReferences(first: 100) { + nodes { + repository { + nameWithOwner + } + labels(first: 100) { + nodes { + name + } + } + } + } + } + } + }`, + { owner, repo, number: pull_number }, + ) + const closingIssues = repository?.pullRequest?.closingIssuesReferences?.nodes || [] + // Issues in other repos don't count, since their owners can add the label themselves. + hasApprovedIssue = closingIssues.some(issue => + issue.repository?.nameWithOwner?.toLowerCase() === `${owner}/${repo}` && + issue.labels.nodes.some(label => label.name.toLowerCase() === 'help wanted'), + ) + } catch (error) { + // Without the lookup, treat the removal as unapproved, as before. + core.warning(`Could not check linked issues: ${error.message}`) + } + } + const unapprovedLineDeletion = onlyDeletesLines && !hasApprovedIssue + const unapprovedFileDeletion = onlyDeletes && !hasApprovedIssue + if ( targetsNonMain || - onlyDeletesLines || - onlyDeletes || + unapprovedLineDeletion || + unapprovedFileDeletion || isEmptyCommit || touchesTooMany || isBlankLineEdit || @@ -75,8 +115,8 @@ jobs: issue_number: pull_number, body: targetsNonMain ? 'Pull requests must target the `main` branch. This pull request has been closed as invalid. If you think this is incorrect, please mention this in a corresponding issue.' - : onlyDeletesLines - ? 'This pull request only removes existing content. Before submitting a content-removal pull request, please [open an issue](https://github.com/github/docs/issues/new/choose) explaining the proposed removal and wait for approval from the GitHub Docs team. Once the change has been approved, you can open a new pull request and link it to the issue.' + : unapprovedLineDeletion + ? 'This pull request only removes existing content. Before submitting a content-removal pull request, please [open an issue](https://github.com/github/docs/issues/new/choose) explaining the proposed removal and wait for the GitHub Docs team to add the `help wanted` label to it. Once the issue has the `help wanted` label, you can open a new pull request that links the issue with `Closes: #` in the description.' : "This pull request may have been opened accidentally. I'm going to close it now, but feel free to check out our [contribution guidelines](https://docs.github.com/en/contributing), or raise an issue.", }) @@ -89,7 +129,7 @@ jobs: }) } - if (onlyDeletesLines) { + if (unapprovedLineDeletion) { core.setFailed( 'This pull request only deletes lines. An approved issue is required first.', ) diff --git a/.github/workflows/content-pipelines.yml b/.github/workflows/content-pipelines.yml index 16f6cf0b27d8..70c590077b2a 100644 --- a/.github/workflows/content-pipelines.yml +++ b/.github/workflows/content-pipelines.yml @@ -49,7 +49,10 @@ jobs: GH_TOKEN: ${{ github.token }} UPDATE_BRANCH: ${{ steps.branch.outputs.update_branch }} run: | - PR_NUMBER=$(gh pr list --head "$UPDATE_BRANCH" --state open --json number --jq '.[0].number // empty' 2>/dev/null || echo "") + PR_NUMBER=$(gh pr list --head "$UPDATE_BRANCH" --state open --json number --jq '.[0].number // empty') || { + echo "::error::Could not look up the PR for ${UPDATE_BRANCH}." + exit 1 + } echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - name: Setup branch @@ -137,23 +140,22 @@ jobs: echo "source_path=$SOURCE_PATH" >> "$GITHUB_OUTPUT" - name: Create or update PR - if: steps.commit.outputs.has_changes == 'true' + if: steps.commit.outputs.has_changes == 'true' || steps.check-pr.outputs.pr_number != '' env: GH_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} UPDATE_BRANCH: ${{ steps.branch.outputs.update_branch }} PIPELINE_ID: ${{ matrix.id }} SOURCE_REPO: ${{ steps.source-info.outputs.source_repo }} SOURCE_PATH: ${{ steps.source-info.outputs.source_path }} + PR_REF: ${{ steps.check-pr.outputs.pr_number }} run: | - PR_NUMBER="${{ steps.check-pr.outputs.pr_number }}" + set -euo pipefail + PR_TITLE="docs: update ${PIPELINE_ID} content from source docs" SOURCE_LINK="See the [upstream repo](https://github.com/${SOURCE_REPO}/tree/main/${SOURCE_PATH}) for changes that triggered this update." - if [ -n "$PR_NUMBER" ]; then - echo "PR #$PR_NUMBER already exists" - - echo "Ensuring PR #$PR_NUMBER is marked ready for review" - gh pr ready "$PR_NUMBER" || echo "Unable to mark PR #$PR_NUMBER as ready (it may already be ready)" + if [ -n "$PR_REF" ]; then + echo "PR #$PR_REF already exists" else echo "Creating new PR" @@ -178,12 +180,30 @@ jobs: PR_BODY+="* Once satisfied, merge to keep docs up to date"$'\n' PR_BODY+="* A new PR will be created on the next run if there are further changes" - gh pr create \ + # The opened event must describe a draft before any labels are applied. + PR_REF=$(gh pr create --draft \ --title "$PR_TITLE" \ --body "$PR_BODY" \ --base main \ - --head "$UPDATE_BRANCH" \ - --label "workflow-generated,content-pipeline-update,ready-for-doc-review,skip FR board" + --head "$UPDATE_BRANCH") + fi + + # Retry publication for existing PRs, including runs without content changes. + gh pr edit "$PR_REF" --add-label "skip FR board" || { + echo "::error::Could not apply skip FR board. The PR will not be marked ready." + exit 1 + } + for LABEL in "workflow-generated" "content-pipeline-update" "ready-for-doc-review"; do + gh pr edit "$PR_REF" --add-label "$LABEL" \ + || echo "::warning::Could not apply the \"$LABEL\" label." + done + + IS_DRAFT=$(gh pr view "$PR_REF" --json isDraft --jq '.isDraft') + if [ "$IS_DRAFT" = "true" ]; then + gh pr ready "$PR_REF" || { + echo "::error::Could not mark the labeled PR ready for review." + exit 1 + } fi - uses: ./.github/actions/create-workflow-failure-issue diff --git a/.github/workflows/index-general-search.yml b/.github/workflows/index-general-search.yml index 6ec3b423bf42..50ef546ed3ba 100644 --- a/.github/workflows/index-general-search.yml +++ b/.github/workflows/index-general-search.yml @@ -7,7 +7,7 @@ on: workflow_dispatch: inputs: version: - description: "Version to exclusively generate the search index for. E.g. 'dotcom', 'ghes-3.12'" + description: "Version to exclusively generate the search index for: 'fpt', 'ghec', or a supported 'ghes-X.Y'. Leave empty for all." required: false default: '' languages: diff --git a/.github/workflows/move-content.yml b/.github/workflows/move-content.yml index b0a817c3b0c7..d1ae254ca5a6 100644 --- a/.github/workflows/move-content.yml +++ b/.github/workflows/move-content.yml @@ -46,6 +46,31 @@ jobs: src/fixtures/fixtures/content/get-started/start-your-journey/hello-wurld.md git log | head -n 100 + - name: Undo moving hello-world.md to hello-wurld.md + env: + ROOT: src/fixtures/fixtures + run: | + # The fixtures start with hello-world, so confirm the move rewrote them before testing undo. + for file in src/fixtures/fixtures/content/index.md src/fixtures/fixtures/content/get-started/index.md; do + grep -q -- '- /get-started/start-your-journey/hello-wurld$' "$file" + done + + npm run move-content -- --undo \ + src/fixtures/fixtures/content/get-started/start-your-journey/hello-world.md \ + src/fixtures/fixtures/content/get-started/start-your-journey/hello-wurld.md + + test -f src/fixtures/fixtures/content/get-started/start-your-journey/hello-world.md + test ! -e src/fixtures/fixtures/content/get-started/start-your-journey/hello-wurld.md + # Undo must revert featuredLinks too, or they point at a page that no longer exists. + for file in src/fixtures/fixtures/content/index.md src/fixtures/fixtures/content/get-started/index.md; do + grep -q -- '- /get-started/start-your-journey/hello-world$' "$file" + if grep -q hello-wurld "$file"; then + echo "$file still links to hello-wurld" + exit 1 + fi + done + git log | head -n 100 + - name: Move code-security/getting-started to code-security/got-started env: ROOT: src/fixtures/fixtures diff --git a/.github/workflows/purge-fastly.yml b/.github/workflows/purge-fastly.yml index 1e350232c7a5..4d3422023e28 100644 --- a/.github/workflows/purge-fastly.yml +++ b/.github/workflows/purge-fastly.yml @@ -65,6 +65,8 @@ jobs: - name: Wait for production to serve this build if: ${{ github.event_name == 'deployment_status' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: npm run wait-for-build - name: Purge Fastly (manual) diff --git a/.github/workflows/site-policy-sync.yml b/.github/workflows/site-policy-sync.yml index 7cc23c8e72b1..685ce239c93d 100644 --- a/.github/workflows/site-policy-sync.yml +++ b/.github/workflows/site-policy-sync.yml @@ -48,19 +48,21 @@ jobs: echo PR_TITLE: $PR_TITLE [[ ! -z $PR_TITLE ]] && DESCRIPTION="${PR_TITLE}" || DESCRIPTION="Update manually triggered by workflow" echo "DESCRIPTION=$DESCRIPTION" >> $GITHUB_ENV - git commit -m "$(echo $DESCRIPTION)" + # git commit exits 1 when the public repo already matches, which would fail the job. + if git diff --cached --quiet; then + echo "No updates to push to the public repo" + echo "HAS_CHANGES=false" >> "$GITHUB_ENV" + else + git commit -m "$(echo $DESCRIPTION)" + echo "HAS_CHANGES=true" >> "$GITHUB_ENV" + fi - name: If there are changes to push, create a branch in the public repo and push changes + if: env.HAS_CHANGES == 'true' env: GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} run: | cd public-repo git config --local user.name 'site-policy-bot' git config --local user.email 'site-policy-bot@github.com' - NUM_FILES_CHANGED=$(git diff --name-only HEAD^..HEAD | wc -l) - if [[ $NUM_FILES_CHANGED -ge 1 ]] - then - git push --set-upstream origin automated-sync-$GITHUB_RUN_ID - else - echo "No updates to push to the public repo" - fi + git push --set-upstream origin automated-sync-$GITHUB_RUN_ID diff --git a/.github/workflows/validate-asset-images.yml b/.github/workflows/validate-asset-images.yml index f2e9a770c31d..495460ddc34d 100644 --- a/.github/workflows/validate-asset-images.yml +++ b/.github/workflows/validate-asset-images.yml @@ -8,6 +8,10 @@ on: paths: - 'assets/**' - '.github/workflows/validate-asset-images.yml' + # Dependency changes can break the validator. + - 'package*.json' + - 'src/assets/scripts/validate-asset-images.ts' + - '.github/actions/node-npm-setup/action.yml' permissions: contents: read diff --git a/content/actions/reference/runners/self-hosted-runners.md b/content/actions/reference/runners/self-hosted-runners.md index 66f804626618..a82b03bde608 100644 --- a/content/actions/reference/runners/self-hosted-runners.md +++ b/content/actions/reference/runners/self-hosted-runners.md @@ -59,7 +59,6 @@ You can use a machine as a self-hosted runner as long as it meets these requirem * `x64` - Linux, macOS, Windows. * `ARM64` - Linux, macOS{% ifversion actions-windows-arm %}, Windows (currently in {% data variables.release-phases.public_preview %}){% endif %}. -* `ARM32` - Linux. ## Routing precedence for self-hosted runners diff --git a/content/actions/tutorials/develop-agentic-workflows-in-github-actions.md b/content/actions/tutorials/develop-agentic-workflows-in-github-actions.md index 8d31eedcf10f..365bd75775e3 100644 --- a/content/actions/tutorials/develop-agentic-workflows-in-github-actions.md +++ b/content/actions/tutorials/develop-agentic-workflows-in-github-actions.md @@ -51,7 +51,7 @@ This article walks through a simple setup that adds the agent's credential as a | --- | --- | --- | | Claude Code | `claude` | `ANTHROPIC_API_KEY` containing an Anthropic API key | | {% data variables.product.prodname_openai_codex %} | `codex` | `OPENAI_API_KEY` containing an OpenAI API key | -| Google {% data variables.copilot.copilot_gemini %} CLI | `gemini` | `GEMINI_API_KEY` containing a Google AI Studio API key | +| Google {% data variables.copilot.gemini %} CLI | `gemini` | `GEMINI_API_KEY` containing a Google AI Studio API key | | {% data variables.copilot.copilot_cli_short %} (default) | `copilot` | No secret needed for organization repositories (see below). For personal repositories, `COPILOT_GITHUB_TOKEN` containing a fine-grained {% data variables.product.pat_generic %} with **Copilot Requests** set to **Read**. | Other engines such as Pi (experimental) are also supported. For the full list, see the [{% data variables.copilot.github_agentic_workflows %} authentication reference](https://github.github.com/gh-aw/reference/auth/). diff --git a/content/admin/data-residency/github-copilot-with-data-residency.md b/content/admin/data-residency/github-copilot-with-data-residency.md index 1943ac71bda5..6e4b1c8b49e5 100644 --- a/content/admin/data-residency/github-copilot-with-data-residency.md +++ b/content/admin/data-residency/github-copilot-with-data-residency.md @@ -52,25 +52,25 @@ The models available for {% data variables.product.prodname_copilot_short %} var ### European Union -* {% data variables.copilot.copilot_gpt_4o_mini %} -* {% data variables.copilot.copilot_gpt_41 %} -* {% data variables.copilot.copilot_gpt_52 %} -* {% data variables.copilot.copilot_gpt_53_codex %} -* {% data variables.copilot.copilot_gpt_54_nano %} -* {% data variables.copilot.copilot_gpt_56_luna %} -* {% data variables.copilot.copilot_gpt_56_sol %} -* {% data variables.copilot.copilot_gpt_56_terra %} -* {% data variables.copilot.copilot_gpt_6_astra %} -* {% data variables.copilot.copilot_gpt_6_luna %} -* {% data variables.copilot.copilot_gpt_6_sol %} -* {% data variables.copilot.copilot_gpt_61_sol %} -* {% data variables.copilot.copilot_claude_haiku_45 %} -* {% data variables.copilot.copilot_claude_haiku_55 %} -* {% data variables.copilot.copilot_claude_opus_48 %} -* {% data variables.copilot.copilot_claude_opus_5 %} -* {% data variables.copilot.copilot_claude_opus_55 %} -* {% data variables.copilot.copilot_claude_sonnet_5 %} -* {% data variables.copilot.copilot_claude_sonnet_55 %} +* {% data variables.copilot.gpt_4o_mini %} +* {% data variables.copilot.gpt_41 %} +* {% data variables.copilot.gpt_52 %} +* {% data variables.copilot.gpt_53_codex %} +* {% data variables.copilot.gpt_54_nano %} +* {% data variables.copilot.gpt_56_luna %} +* {% data variables.copilot.gpt_56_sol %} +* {% data variables.copilot.gpt_56_terra %} +* {% data variables.copilot.gpt_6_astra %} +* {% data variables.copilot.gpt_6_luna %} +* {% data variables.copilot.gpt_6_sol %} +* {% data variables.copilot.gpt_61_sol %} +* {% data variables.copilot.claude_haiku_45 %} +* {% data variables.copilot.claude_haiku_55 %} +* {% data variables.copilot.claude_opus_48 %} +* {% data variables.copilot.claude_opus_5 %} +* {% data variables.copilot.claude_opus_55 %} +* {% data variables.copilot.claude_sonnet_5 %} +* {% data variables.copilot.claude_sonnet_55 %} ## Pricing changes diff --git a/content/admin/monitoring-and-managing-your-instance/monitoring-your-instance/troubleshooting-resource-allocation-problems.md b/content/admin/monitoring-and-managing-your-instance/monitoring-your-instance/troubleshooting-resource-allocation-problems.md index 75a63202ba2a..2a3c8d9f2fc2 100644 --- a/content/admin/monitoring-and-managing-your-instance/monitoring-your-instance/troubleshooting-resource-allocation-problems.md +++ b/content/admin/monitoring-and-managing-your-instance/monitoring-your-instance/troubleshooting-resource-allocation-problems.md @@ -22,7 +22,7 @@ category: We recommend using the monitor dashboard to stay informed on your appliance's resource health and make decisions on how to fix high usage issues, such as the ones outlined on this page. -For system-critical issues, and prior to making modifications to your appliance, we highly recommend contacting us by visiting {% data variables.contact.contact_ent_support %} and including your support bundle. For more information, see [AUTOTITLE](/enterprise/{{ currentVersion}}/support/contacting-github-support/providing-data-to-github-support#creating-and-sharing-support-bundles). +For system-critical issues, and prior to making modifications to your appliance, we highly recommend contacting us by visiting {% data variables.contact.contact_ent_support %} and including your support bundle. For more information, see [AUTOTITLE](/support/contacting-github-support/providing-data-to-github-support#creating-and-sharing-support-bundles). ## High CPU usage diff --git a/content/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/about-github-agentic-workflows.md b/content/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/about-github-agentic-workflows.md index e7b93f90cfda..8022260619e3 100644 --- a/content/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/about-github-agentic-workflows.md +++ b/content/copilot/concepts/agent-capabilities/agent-powered-workflows-and-operations/about-github-agentic-workflows.md @@ -16,7 +16,7 @@ category: ## About {% data variables.copilot.agentic_workflows_short %} -{% data variables.copilot.github_agentic_workflows %} are AI-powered repository automations that you define in markdown and run as {% data variables.product.prodname_actions %} workflows. Unlike traditional automation with fixed if-then rules, {% data variables.copilot.agentic_workflows_short %} use coding agents to understand context, make decisions, and take meaningful actions—all from natural language instructions. +{% data variables.copilot.github_agentic_workflows %} are AI-powered repository automations that you define in markdown and run as {% data variables.product.prodname_actions %} workflows. Unlike traditional automation with fixed if-then rules, {% data variables.copilot.agentic_workflows_short %} use coding agents to understand context, evaluate decisions, and propose meaningful actions—all from natural language instructions. In practice, compared with traditional workflows that execute predefined steps: @@ -35,7 +35,7 @@ With {% data variables.copilot.agentic_workflows_short %}, you can automate task * **Automate repetitive repository work**. Define issue triage, CI investigation, documentation updates, and reporting in natural language. * **Reduce workflow complexity**. Write markdown instructions instead of building complex procedural scripts for every scenario. -* **Keep human review in the loop**. {% data variables.copilot.agentic_workflows_short_cap %} can generate ready-to-review outputs, such as issues, comments, and pull requests, while you control approvals and merges. +* **Keep human review in the loop**. {% data variables.copilot.agentic_workflows_short_cap %} can generate reviewable outputs, such as issues, comments, and pull requests, while you control approvals and merges. * **Run agents with layered security**. Agents run in firewalled containers with read-only tokens by default. Write actions are limited to declared "safe outputs" that you have defined, and checked by agentic threat detection. ## Requirements @@ -56,7 +56,7 @@ Each workflow markdown file has two parts: At a high level, the process to create and use {% data variables.copilot.agentic_workflows_short %} is: 1. Define the agentic workflow `.md` file, including YAML frontmatter and markdown instructions. -1. Compile the markdown workflow file into a hardened `.lock.yml` {% data variables.product.prodname_actions %} workflow file. +1. Compile the markdown workflow file into a `.lock.yml` {% data variables.product.prodname_actions %} workflow file. 1. Commit and push both files to the default branch of your repository. 1. Run the workflow like any other {% data variables.product.prodname_actions %} workflow, on a trigger or in the {% data variables.product.github %} web interface for your repository. You can also run it from the {% data variables.product.prodname_cli %}. diff --git a/content/copilot/concepts/models/fallback-and-lts-models.md b/content/copilot/concepts/models/fallback-and-lts-models.md index cbac874b20b5..55e010edffbc 100644 --- a/content/copilot/concepts/models/fallback-and-lts-models.md +++ b/content/copilot/concepts/models/fallback-and-lts-models.md @@ -15,7 +15,7 @@ redirect_from: ## About base models > [!IMPORTANT] -> * On March 18, 2026, {% data variables.product.github %} designated {% data variables.copilot.copilot_gpt_53_codex %} as the base model. +> * On March 18, 2026, {% data variables.product.github %} designated {% data variables.copilot.gpt_53_codex %} as the base model. > * Base models apply only to {% data variables.copilot.copilot_business_short %} and {% data variables.copilot.copilot_enterprise_short %}. A base model is the default AI model that {% data variables.product.prodname_copilot %} uses when no other models are enabled. The base model is automatically enabled for all {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} accounts within 60 days after the model is designated as a base model. @@ -31,7 +31,7 @@ When a new model is designated a base model, the following timeline applies: ## About long-term support (LTS) models > [!IMPORTANT] -> * On March 18, 2026, {% data variables.product.github %} designated {% data variables.copilot.copilot_gpt_53_codex %} as the LTS model. +> * On March 18, 2026, {% data variables.product.github %} designated {% data variables.copilot.gpt_53_codex %} as the LTS model. > * LTS models apply only to {% data variables.copilot.copilot_business_short %} and {% data variables.copilot.copilot_enterprise_short %}. An LTS model is an AI model that {% data variables.product.github %} commits to supporting for an extended period of one year from its designation date. During that period, the model remains available, which allows users to build around the model without concern that it will be {% data variables.release-phases.closing_down %} unexpectedly. diff --git a/content/copilot/get-started/plans.md b/content/copilot/get-started/plans.md index d5b082eb1fe5..dcf1a7b777f4 100644 --- a/content/copilot/get-started/plans.md +++ b/content/copilot/get-started/plans.md @@ -271,4 +271,4 @@ For a full explanation of how {% data variables.product.prodname_ai_credits_shor * To compare models supported across plans, features, and IDEs, see [AUTOTITLE](/copilot/reference/ai-models/supported-models). [^1]: {% data variables.copilot.copilot_chat_short %} skills in IDEs is available in {% data variables.product.prodname_vscode %} and {% data variables.product.prodname_vs %}. -[^claude-fable-5]: When {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %} is used, Anthropic retains data, including prompts and outputs, by default to operate safety classifiers that detect harmful use. Customers can request to use {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %} with zero data retention (ZDR) through the end of 2026 under a time-bound exemption while Anthropic rolls out Enterprise Frontier Safeguards (EFS). After that point, continued use of these models would require EFS, which will enable eligible customers to keep their data under their own control while also enabling automated safety monitoring. For an enterprise that has been approved and configured for this type of access, {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %} requests will use the ZDR endpoint through the end of 2026 when the models are enabled. To learn whether your enterprise is eligible and request access, contact your {% data variables.product.github %} account team. Approval for access does not automatically enable {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %}. An enterprise or organization administrator must still enable each model before users can access it. Other Claude models, except for {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %}, continue to operate under ZDR. Customers who enable a Fable model with ZDR agree to use this model only for internal operations, including to develop and evaluate products for their own customers. They may not make the model endpoints or outputs available externally. To enable these models, see [AUTOTITLE](/copilot/how-tos/copilot-on-github/set-up-copilot/configure-access-to-ai-models). +[^claude-fable-5]: When {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %} is used, Anthropic retains data, including prompts and outputs, by default to operate safety classifiers that detect harmful use. Customers can request to use {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %} with zero data retention (ZDR) through the end of 2026 under a time-bound exemption while Anthropic rolls out Enterprise Frontier Safeguards (EFS). After that point, continued use of these models would require EFS, which will enable eligible customers to keep their data under their own control while also enabling automated safety monitoring. For an enterprise that has been approved and configured for this type of access, {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %} requests will use the ZDR endpoint through the end of 2026 when the models are enabled. To learn whether your enterprise is eligible and request access, contact your {% data variables.product.github %} account team. Approval for access does not automatically enable {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %}. An enterprise or organization administrator must still enable each model before users can access it. Other Claude models, except for {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %}, continue to operate under ZDR. Customers who enable a Fable model with ZDR agree to use this model only for internal operations, including to develop and evaluate products for their own customers. They may not make the model endpoints or outputs available externally. To enable these models, see [AUTOTITLE](/copilot/how-tos/copilot-on-github/set-up-copilot/configure-access-to-ai-models). diff --git a/content/copilot/how-tos/copilot-cli/cli-best-practices.md b/content/copilot/how-tos/copilot-cli/cli-best-practices.md index d99161f44fd3..42f15f2f69e5 100644 --- a/content/copilot/how-tos/copilot-cli/cli-best-practices.md +++ b/content/copilot/how-tos/copilot-cli/cli-best-practices.md @@ -87,15 +87,15 @@ Use `/model` to choose from available models based on your task complexity: | Model | Best For | Tradeoffs | | ----- | -------- | --------- | | **Auto** | Reduced rate limiting and lower latency and errors | See [AUTOTITLE](/copilot/concepts/models/auto-model-selection) | -| **{% data variables.copilot.copilot_claude_opus_5 %}** | Complex architecture, difficult debugging, nuanced refactoring | Most capable but more costly | -| **{% data variables.copilot.copilot_claude_sonnet_5 %}** | Day-to-day coding, most routine tasks | Fast, cost-effective, handles most work well | -| **{% data variables.copilot.copilot_gpt_53_codex %}** | Code generation, code review, straightforward implementations | Excellent for reviewing code produced by other models | +| **{% data variables.copilot.claude_opus_5 %}** | Complex architecture, difficult debugging, nuanced refactoring | Most capable but more costly | +| **{% data variables.copilot.claude_sonnet_5 %}** | Day-to-day coding, most routine tasks | Fast, cost-effective, handles most work well | +| **{% data variables.copilot.gpt_53_codex %}** | Code generation, code review, straightforward implementations | Excellent for reviewing code produced by other models | **Recommendations:** * **Auto** intelligently chooses models based on real-time system health and model performance (reducing rate limiting and providing lower latency and errors), and the complexity of the task you have given {% data variables.product.prodname_copilot_short %}. -* **{% data variables.copilot.copilot_claude_opus_5 %}** is ideal for tasks requiring deep reasoning, complex system design, subtle bug investigation, or extensive context understanding. -* **Switch to {% data variables.copilot.copilot_claude_sonnet_5 %}** for routine tasks where speed and cost efficiency matter—it handles the majority of everyday coding effectively. +* **{% data variables.copilot.claude_opus_5 %}** is ideal for tasks requiring deep reasoning, complex system design, subtle bug investigation, or extensive context understanding. +* **Switch to {% data variables.copilot.claude_sonnet_5 %}** for routine tasks where speed and cost efficiency matter—it handles the majority of everyday coding effectively. * **Use Codex** for high-volume code generation and as a second opinion for reviewing code produced by other models. You can switch models mid-session with `/model` as task complexity changes. @@ -336,7 +336,7 @@ Pair with {% data variables.copilot.copilot_cli_short %} to develop tests. ### Code review assistance * ``/security-review Review my current local changes for security issues. Prioritize high-severity findings and suggest remediations I can apply before opening a pull request.`` -* ``/review Use {% data variables.copilot.copilot_claude_opus_5 %} and {% data variables.copilot.copilot_gpt_53_codex %} to review the changes in my current branch against `main`. Focus on potential bugs and security issues.`` +* ``/review Use {% data variables.copilot.claude_opus_5 %} and {% data variables.copilot.gpt_53_codex %} to review the changes in my current branch against `main`. Focus on potential bugs and security issues.`` * Triage high-severity findings first, validate your fixes, then continue through your normal pull request review workflow. ### Git operations diff --git a/content/copilot/reference/ai-models/model-comparison.md b/content/copilot/reference/ai-models/model-comparison.md index b32efb96c733..97a7c8fafc19 100644 --- a/content/copilot/reference/ai-models/model-comparison.md +++ b/content/copilot/reference/ai-models/model-comparison.md @@ -41,8 +41,8 @@ Use these models for common development tasks that require a balance of quality, | Model | Why it's a good fit | |---------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------| -| {% data variables.copilot.copilot_gpt_53_codex %} | Delivers higher-quality code on complex engineering tasks like features, tests, debugging, refactors, and reviews without lengthy instructions. | -| {% data variables.copilot.copilot_gpt_56_terra %} | Balanced all-round choice for everyday interactive and agentic coding. | +| {% data variables.copilot.gpt_53_codex %} | Delivers higher-quality code on complex engineering tasks like features, tests, debugging, refactors, and reviews without lengthy instructions. | +| {% data variables.copilot.gpt_56_terra %} | Balanced all-round choice for everyday interactive and agentic coding. | ### When to use these models @@ -65,8 +65,8 @@ These models are optimized for speed and responsiveness. They’re ideal for qui | Model | Why it's a good fit | |-------------------------------------------------------|------------------------------------------------------------------------------------------------------------| -| {% data variables.copilot.copilot_gpt_56_luna %} | Lightweight, cost-efficient option for smaller, faster tasks. The lowest-cost model in the GPT-5.6 family. | -| {% data variables.copilot.copilot_claude_haiku_45 %} | Balances fast responses with quality output. Ideal for small tasks and lightweight code explanations. | +| {% data variables.copilot.gpt_56_luna %} | Lightweight, cost-efficient option for smaller, faster tasks. The lowest-cost model in the GPT-5.6 family. | +| {% data variables.copilot.claude_haiku_45 %} | Balances fast responses with quality output. Ideal for small tasks and lightweight code explanations. | ### When to use these models @@ -90,8 +90,8 @@ These models are designed for tasks that require step-by-step reasoning, complex | Model | Why it's a good fit | |-------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| {% data variables.copilot.copilot_gpt_56_sol %} | The highest reasoning ceiling in the GPT-5.6 family. Best for complex reasoning over large codebases and demanding, long-running agentic work. | -| {% data variables.copilot.copilot_claude_sonnet_46 %} | Reliable completions and smarter reasoning under pressure. | +| {% data variables.copilot.gpt_56_sol %} | The highest reasoning ceiling in the GPT-5.6 family. Best for complex reasoning over large codebases and demanding, long-running agentic work. | +| {% data variables.copilot.claude_sonnet_46 %} | Reliable completions and smarter reasoning under pressure. | ### When to use these models @@ -114,8 +114,8 @@ Use these models when you want to ask questions about screenshots, diagrams, UI | Model | Why it's a good fit | |-------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| {% data variables.copilot.copilot_gpt_56_luna %} | Quick, cost-efficient responses for smaller coding tasks, and supports multimodal input for visual reasoning tasks. | -| {% data variables.copilot.copilot_claude_sonnet_46 %} | Reliable completions and smarter reasoning under pressure. | +| {% data variables.copilot.gpt_56_luna %} | Quick, cost-efficient responses for smaller coding tasks, and supports multimodal input for visual reasoning tasks. | +| {% data variables.copilot.claude_sonnet_46 %} | Reliable completions and smarter reasoning under pressure. | ### When to use these models @@ -136,17 +136,17 @@ If your task involves deep reasoning or large-scale refactoring, consider a mode Some models have behaviors, limitations, or safeguards that are useful to understand before you choose them for a task. -### {% data variables.copilot.copilot_kimi_k3 %} +### {% data variables.copilot.kimi_k3 %} -{% data variables.copilot.copilot_kimi_k3 %} is designed for long-context, multi-step coding and agentic workflows. Fine-tuned model variants may be included as part of the {% data variables.copilot.copilot_kimi_k3 %} ({% data variables.product.github %}) offering on individual plans only. Fine-tuned variants are not included for {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} plans. In pre-release testing, the model exhibited elevated risk on certain higher-risk prompts and was less consistent than some other models in refusing requests involving sensitive topics. These behaviors may reflect differences in the model's safety post-training and alignment. We have deployed additional safeguards in {% data variables.product.prodname_copilot %} to help mitigate the identified risks. As with any model, enterprises should evaluate model capabilities, limitations, and safeguards in light of their particular use cases and requirements. +{% data variables.copilot.kimi_k3 %} is designed for long-context, multi-step coding and agentic workflows. Fine-tuned model variants may be included as part of the {% data variables.copilot.kimi_k3 %} ({% data variables.product.github %}) offering on individual plans only. Fine-tuned variants are not included for {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} plans. In pre-release testing, the model exhibited elevated risk on certain higher-risk prompts and was less consistent than some other models in refusing requests involving sensitive topics. These behaviors may reflect differences in the model's safety post-training and alignment. We have deployed additional safeguards in {% data variables.product.prodname_copilot %} to help mitigate the identified risks. As with any model, enterprises should evaluate model capabilities, limitations, and safeguards in light of their particular use cases and requirements. ## Next steps [^mai-models]: MAI models are continuously improving models. Performance and behavior may evolve over time as new checkpoints are released. -[^kimi-k3]: For important information about {% data variables.copilot.copilot_kimi_k3 %} behavior and safeguards, see [Model-specific considerations](#kimi-k3). +[^kimi-k3]: For important information about {% data variables.copilot.kimi_k3 %} behavior and safeguards, see [Model-specific considerations](#kimi-k3). -Choosing the right model helps you get the most out of {% data variables.product.prodname_copilot_short %}. If you're not sure which model to use, start with a general-purpose option like {% data variables.copilot.copilot_gpt_56_terra %}, then adjust based on your needs. +Choosing the right model helps you get the most out of {% data variables.product.prodname_copilot_short %}. If you're not sure which model to use, start with a general-purpose option like {% data variables.copilot.gpt_56_terra %}, then adjust based on your needs. * For detailed model specs and pricing, see [AUTOTITLE](/copilot/reference/ai-models/supported-models). * To switch between models, refer to [AUTOTITLE](/copilot/how-tos/copilot-in-your-ide/chat-with-copilot/change-the-chat-model) or [AUTOTITLE](/copilot/how-tos/copilot-in-your-ide/copilot-for-common-tasks/change-the-completion-model). diff --git a/content/copilot/reference/ai-models/model-hosting.md b/content/copilot/reference/ai-models/model-hosting.md index 48c7590e495f..f726fadf3e56 100644 --- a/content/copilot/reference/ai-models/model-hosting.md +++ b/content/copilot/reference/ai-models/model-hosting.md @@ -18,15 +18,15 @@ contentType: reference Used for: -* {% data variables.copilot.copilot_gpt_53_codex %} -* {% data variables.copilot.copilot_gpt_54_nano %} -* {% data variables.copilot.copilot_gpt_56_luna %} -* {% data variables.copilot.copilot_gpt_56_sol %} -* {% data variables.copilot.copilot_gpt_56_terra %} -* {% data variables.copilot.copilot_gpt_6_astra %} -* {% data variables.copilot.copilot_gpt_6_luna %} -* {% data variables.copilot.copilot_gpt_6_sol %} -* {% data variables.copilot.copilot_gpt_61_sol %} +* {% data variables.copilot.gpt_53_codex %} +* {% data variables.copilot.gpt_54_nano %} +* {% data variables.copilot.gpt_56_luna %} +* {% data variables.copilot.gpt_56_sol %} +* {% data variables.copilot.gpt_56_terra %} +* {% data variables.copilot.gpt_6_astra %} +* {% data variables.copilot.gpt_6_luna %} +* {% data variables.copilot.gpt_6_sol %} +* {% data variables.copilot.gpt_61_sol %} These models are hosted by OpenAI and {% data variables.product.github %}'s Azure infrastructure. @@ -40,26 +40,26 @@ All input requests and output responses processed by {% data variables.product.p Used for: -* {% data variables.copilot.copilot_claude_haiku_45 %} -* {% data variables.copilot.copilot_claude_haiku_55 %} -* {% data variables.copilot.copilot_claude_sonnet_46 %} -* {% data variables.copilot.copilot_claude_sonnet_5 %} -* {% data variables.copilot.copilot_claude_sonnet_55 %} -* {% data variables.copilot.copilot_claude_opus_48 %} -* {% data variables.copilot.copilot_claude_opus_48_fast %} -* {% data variables.copilot.copilot_claude_opus_5 %} -* {% data variables.copilot.copilot_claude_opus_55 %} -* {% data variables.copilot.copilot_claude_fable_5 %} -* {% data variables.copilot.copilot_claude_fable_51 %} +* {% data variables.copilot.claude_haiku_45 %} +* {% data variables.copilot.claude_haiku_55 %} +* {% data variables.copilot.claude_sonnet_46 %} +* {% data variables.copilot.claude_sonnet_5 %} +* {% data variables.copilot.claude_sonnet_55 %} +* {% data variables.copilot.claude_opus_48 %} +* {% data variables.copilot.claude_opus_48_fast %} +* {% data variables.copilot.claude_opus_5 %} +* {% data variables.copilot.claude_opus_55 %} +* {% data variables.copilot.claude_fable_5 %} +* {% data variables.copilot.claude_fable_51 %} > [!WARNING] -> When {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %} is used, Anthropic retains data, including prompts and outputs, by default to operate safety classifiers that detect harmful use. +> When {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %} is used, Anthropic retains data, including prompts and outputs, by default to operate safety classifiers that detect harmful use. > -> Customers can request to use {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %} with zero data retention (ZDR) through the end of 2026 under a time-bound exemption while Anthropic rolls out Enterprise Frontier Safeguards (EFS). After that point, continued use of these models would require EFS, which will enable eligible customers to keep their data under their own control while also enabling automated safety monitoring. For an enterprise that has been approved and configured for this type of access, {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %} requests will use the ZDR endpoint through the end of 2026 when the models are enabled. +> Customers can request to use {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %} with zero data retention (ZDR) through the end of 2026 under a time-bound exemption while Anthropic rolls out Enterprise Frontier Safeguards (EFS). After that point, continued use of these models would require EFS, which will enable eligible customers to keep their data under their own control while also enabling automated safety monitoring. For an enterprise that has been approved and configured for this type of access, {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %} requests will use the ZDR endpoint through the end of 2026 when the models are enabled. > > To learn whether your enterprise is eligible and request access, contact your {% data variables.product.github %} account team. > -> Approval for access does not automatically enable {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %}. An enterprise or organization administrator must still enable each model before users can access it. Other Claude models, except for {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %}, continue to operate under ZDR. +> Approval for access does not automatically enable {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %}. An enterprise or organization administrator must still enable each model before users can access it. Other Claude models, except for {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %}, continue to operate under ZDR. > > Customers who enable a Fable model with ZDR agree to use this model only for internal operations, including to develop and evaluate products for their own customers. They may not make the model endpoints or outputs available externally. @@ -67,34 +67,34 @@ These models are hosted by Amazon Web Services, Anthropic PBC, and Google Cloud * Amazon Bedrock: Amazon makes the [following data commitments](https://docs.aws.amazon.com/bedrock/latest/userguide/data-protection.html): _Amazon Bedrock doesn't store or log your prompts and completions. Amazon Bedrock doesn't use your prompts and completions to train any AWS models and doesn't distribute them to third parties_. -* Anthropic PBC: {% data variables.product.github %} maintains a [zero data retention agreement](https://privacy.anthropic.com/en/articles/8956058-i-have-a-zero-retention-agreement-with-anthropic-what-products-does-it-apply-to) with Anthropic for generally available Anthropic features in {% data variables.product.prodname_copilot %}. {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %} are not covered by this agreement by default. Enterprises that meet eligibility requirements can use {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %} with zero data retention through the end of 2026 under a separate time-bound exemption; contact your account manager to learn more. Some Anthropic features in beta or {% data variables.release-phases.public_preview %}—including tool search via the Messages API—are also not covered by this agreement. For these features, data may be retained by Anthropic in accordance with [Anthropic's ZDR documentation](https://platform.claude.com/docs/en/build-with-claude/zero-data-retention). {% data variables.product.github %} will update this page as ZDR coverage changes. +* Anthropic PBC: {% data variables.product.github %} maintains a [zero data retention agreement](https://privacy.anthropic.com/en/articles/8956058-i-have-a-zero-retention-agreement-with-anthropic-what-products-does-it-apply-to) with Anthropic for generally available Anthropic features in {% data variables.product.prodname_copilot %}. {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %} are not covered by this agreement by default. Enterprises that meet eligibility requirements can use {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %} with zero data retention through the end of 2026 under a separate time-bound exemption; contact your account manager to learn more. Some Anthropic features in beta or {% data variables.release-phases.public_preview %}—including tool search via the Messages API—are also not covered by this agreement. For these features, data may be retained by Anthropic in accordance with [Anthropic's ZDR documentation](https://platform.claude.com/docs/en/build-with-claude/zero-data-retention). {% data variables.product.github %} will update this page as ZDR coverage changes. * Google Cloud: [Google commits to not training on {% data variables.product.github %} data as part of their service terms](https://cloud.google.com/vertex-ai/generative-ai/docs/data-governance). {% data variables.product.github %} is additionally not subject to prompt logging for abuse monitoring. To provide better service quality and reduce latency, {% data variables.product.github %} uses [prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching). You can read more about prompt caching on [Anthropic PBC](https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching), [Amazon Bedrock](https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html), and [Google Cloud](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude-prompt-caching). -When using {% data variables.copilot.copilot_claude %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. +When using {% data variables.copilot.claude %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. ## Google models Used for: -* {% data variables.copilot.copilot_gemini_38_flash %} -* {% data variables.copilot.copilot_gemini_4_argon %} +* {% data variables.copilot.gemini_38_flash %} +* {% data variables.copilot.gemini_4_argon %} -{% data variables.product.prodname_copilot %} uses {% data variables.copilot.copilot_gemini %} models hosted on Google Cloud Platform (GCP). When using {% data variables.copilot.copilot_gemini %} models, prompts and metadata are sent to GCP, which makes the [following data commitment](https://cloud.google.com/vertex-ai/generative-ai/docs/data-governance): _{% data variables.copilot.copilot_gemini %} doesn't use your prompts, or its responses, as data to train its models._ +{% data variables.product.prodname_copilot %} uses {% data variables.copilot.gemini %} models hosted on Google Cloud Platform (GCP). When using {% data variables.copilot.gemini %} models, prompts and metadata are sent to GCP, which makes the [following data commitment](https://cloud.google.com/vertex-ai/generative-ai/docs/data-governance): _{% data variables.copilot.gemini %} doesn't use your prompts, or its responses, as data to train its models._ To provide better service quality and reduce latency, {% data variables.product.github %} uses [Google's Durable Caching](https://docs.cloud.google.com/gemini-enterprise-agent-platform/resources/zero-data-retention#durable-data-caching). Cached data is encrypted and held for up to 24 hours. Cached data is never used to train or fine-tune models. -When using {% data variables.copilot.copilot_gemini %} models, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. +When using {% data variables.copilot.gemini %} models, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. ## xAI models Used for: -* {% data variables.copilot.copilot_grok_45 %} -* {% data variables.copilot.copilot_grok_46 %} -* {% data variables.copilot.copilot_grok_47 %} +* {% data variables.copilot.grok_45 %} +* {% data variables.copilot.grok_46 %} +* {% data variables.copilot.grok_47 %} These models are hosted on xAI. xAI operates these models in {% data variables.product.prodname_copilot %} under a zero data retention API policy. This means xAI commits that user content (both inputs sent to the model and outputs generated by the model): @@ -114,15 +114,15 @@ When using xAI, input prompts and output completions continue to run through {% Used for: -* {% data variables.copilot.copilot_mai_code_1_1_flash %} +* {% data variables.copilot.mai_code_1_1_flash %} -{% data variables.copilot.copilot_mai_code_1_1_flash %} is a first-party Microsoft model hosted on Azure in {% data variables.product.github %}'s tenant. +{% data variables.copilot.mai_code_1_1_flash %} is a first-party Microsoft model hosted on Azure in {% data variables.product.github %}'s tenant. {% data variables.product.github %} does not use {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} customer data to train AI models. For individual subscribers—{% data variables.copilot.copilot_free_short %}, {% data variables.copilot.copilot_pro_short %}, {% data variables.copilot.copilot_pro_plus_short %}, and {% data variables.copilot.copilot_max_short %} users—{% data variables.product.github %} may use {% data variables.product.prodname_copilot_short %} interaction data, including prompts (inputs), suggestions (outputs), and code snippets generated during {% data variables.product.prodname_copilot_short %} sessions to train and improve AI models, in accordance with our [AUTOTITLE](/free-pro-team@latest/site-policy/privacy-policies/github-general-privacy-statement) and applicable user settings. Individual subscribers can opt out of having their data used for AI model training. To manage this setting, see [AUTOTITLE](/copilot/how-tos/manage-your-account/manage-policies#model-training-and-improvements). -{% data variables.copilot.copilot_mai_code_1_1_flash %} is served on Microsoft Azure AI Foundry within {% data variables.product.github %}'s tenant and is subject to {% data variables.product.github %}'s data handling configuration for that deployment. For details about how data is processed, retained, and secured for models served on Azure AI Foundry, see [Data, privacy, and security for Foundry Models sold by Azure](https://learn.microsoft.com/en-us/azure/foundry/responsible-ai/openai/data-privacy?tabs=azure-portal) in the Microsoft documentation. +{% data variables.copilot.mai_code_1_1_flash %} is served on Microsoft Azure AI Foundry within {% data variables.product.github %}'s tenant and is subject to {% data variables.product.github %}'s data handling configuration for that deployment. For details about how data is processed, retained, and secured for models served on Azure AI Foundry, see [Data, privacy, and security for Foundry Models sold by Azure](https://learn.microsoft.com/en-us/azure/foundry/responsible-ai/openai/data-privacy?tabs=azure-portal) in the Microsoft documentation. -When using {% data variables.copilot.copilot_mai_code_1_1_flash %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. +When using {% data variables.copilot.mai_code_1_1_flash %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters for public code matching, when applied, along with those for harmful or offensive content. ## Open-weight models @@ -132,17 +132,17 @@ Open-weight models have publicly available weights. {% data reusables.copilot.op Used for: -* {% data variables.copilot.copilot_kimi_k3 %} +* {% data variables.copilot.kimi_k3 %} -{% data variables.copilot.copilot_kimi_k3 %} was developed by Moonshot AI. It is an open-weight model that may be less aligned than other {% data variables.product.prodname_copilot_short %} models, with an elevated risk of geographic bias. Before you enable the model, review the [{% data variables.copilot.copilot_kimi_k3 %} model card](https://huggingface.co/moonshotai/Kimi-K3) and run your own evaluations. +{% data variables.copilot.kimi_k3 %} was developed by Moonshot AI. It is an open-weight model that may be less aligned than other {% data variables.product.prodname_copilot_short %} models, with an elevated risk of geographic bias. Before you enable the model, review the [{% data variables.copilot.kimi_k3 %} model card](https://huggingface.co/moonshotai/Kimi-K3) and run your own evaluations. -{% data variables.copilot.copilot_kimi_k3 %} is hosted by {% data variables.product.github %} on Fireworks AI. +{% data variables.copilot.kimi_k3 %} is hosted by {% data variables.product.github %} on Fireworks AI. -{% data variables.copilot.copilot_kimi_k3 %} is covered by a zero data retention agreement with the hosting provider. Fireworks AI does not use prompts or completions to train models, and prompts and completions are not sent to Moonshot AI. +{% data variables.copilot.kimi_k3 %} is covered by a zero data retention agreement with the hosting provider. Fireworks AI does not use prompts or completions to train models, and prompts and completions are not sent to Moonshot AI. {% data variables.product.github %} does not use {% data variables.copilot.copilot_business_short %} or {% data variables.copilot.copilot_enterprise_short %} customer data to train AI models. For individual subscribers—{% data variables.copilot.copilot_free_short %}, {% data variables.copilot.copilot_pro_short %}, {% data variables.copilot.copilot_pro_plus_short %}, and {% data variables.copilot.copilot_max_short %} users—{% data variables.product.github %} may use {% data variables.product.prodname_copilot_short %} interaction data, including prompts (inputs), suggestions (outputs), and code snippets generated during {% data variables.product.prodname_copilot_short %} sessions to train and improve AI models, in accordance with our [AUTOTITLE](/free-pro-team@latest/site-policy/privacy-policies/github-general-privacy-statement) and applicable user settings. Individual subscribers can opt out of having their data used for AI model training. To manage this setting, see [AUTOTITLE](/copilot/how-tos/manage-your-account/manage-policies#model-training-and-improvements). -When using {% data variables.copilot.copilot_kimi_k3 %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters. +When using {% data variables.copilot.kimi_k3 %}, input prompts and output completions continue to run through {% data variables.product.prodname_copilot %}'s content filters. ## Inline suggestions diff --git a/content/copilot/reference/ai-models/supported-models.md b/content/copilot/reference/ai-models/supported-models.md index a31ebb1d5520..bb40b4be56f2 100644 --- a/content/copilot/reference/ai-models/supported-models.md +++ b/content/copilot/reference/ai-models/supported-models.md @@ -44,11 +44,11 @@ This table lists the AI models available in {% data variables.product.prodname_c {% endrowheaders %} -[^claude-fable-5]: When {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %} is used, Anthropic retains data, including prompts and outputs, by default to operate safety classifiers that detect harmful use. Customers can request to use {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %} with zero data retention (ZDR) through the end of 2026 under a time-bound exemption while Anthropic rolls out Enterprise Frontier Safeguards (EFS). After that point, continued use of these models would require EFS, which will enable eligible customers to keep their data under their own control while also enabling automated safety monitoring. For an enterprise that has been approved and configured for this type of access, {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %} requests will use the ZDR endpoint through the end of 2026 when the models are enabled. To learn whether your enterprise is eligible and request access, contact your {% data variables.product.github %} account team. Approval for access does not automatically enable {% data variables.copilot.copilot_claude_fable_5 %} or {% data variables.copilot.copilot_claude_fable_51 %}. An enterprise or organization administrator must still enable each model before users can access it. Other Claude models, except for {% data variables.copilot.copilot_claude_fable_5 %} and {% data variables.copilot.copilot_claude_fable_51 %}, continue to operate under ZDR. Customers who enable a Fable model with ZDR agree to use this model only for internal operations, including to develop and evaluate products for their own customers. They may not make the model endpoints or outputs available externally. To enable these models, see [AUTOTITLE](/copilot/how-tos/copilot-on-github/set-up-copilot/configure-access-to-ai-models). +[^claude-fable-5]: When {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %} is used, Anthropic retains data, including prompts and outputs, by default to operate safety classifiers that detect harmful use. Customers can request to use {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %} with zero data retention (ZDR) through the end of 2026 under a time-bound exemption while Anthropic rolls out Enterprise Frontier Safeguards (EFS). After that point, continued use of these models would require EFS, which will enable eligible customers to keep their data under their own control while also enabling automated safety monitoring. For an enterprise that has been approved and configured for this type of access, {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %} requests will use the ZDR endpoint through the end of 2026 when the models are enabled. To learn whether your enterprise is eligible and request access, contact your {% data variables.product.github %} account team. Approval for access does not automatically enable {% data variables.copilot.claude_fable_5 %} or {% data variables.copilot.claude_fable_51 %}. An enterprise or organization administrator must still enable each model before users can access it. Other Claude models, except for {% data variables.copilot.claude_fable_5 %} and {% data variables.copilot.claude_fable_51 %}, continue to operate under ZDR. Customers who enable a Fable model with ZDR agree to use this model only for internal operations, including to develop and evaluate products for their own customers. They may not make the model endpoints or outputs available externally. To enable these models, see [AUTOTITLE](/copilot/how-tos/copilot-on-github/set-up-copilot/configure-access-to-ai-models). ## Supported AI models in {% data variables.copilot.copilot_auto_model_selection_short_cap_a %} -This table lists the supported AI models for {% data variables.copilot.copilot_auto_model_selection_short_cap_a %}. Available models may be limited by model policies. For example, as the long term support model, {% data variables.copilot.copilot_gpt_53_codex %} will be used in the event no other models are available. See [AUTOTITLE](/copilot/concepts/models/fallback-and-lts-models) and [AUTOTITLE](/copilot/concepts/models/auto-model-selection). +This table lists the supported AI models for {% data variables.copilot.copilot_auto_model_selection_short_cap_a %}. Available models may be limited by model policies. For example, as the long term support model, {% data variables.copilot.gpt_53_codex %} will be used in the event no other models are available. See [AUTOTITLE](/copilot/concepts/models/fallback-and-lts-models) and [AUTOTITLE](/copilot/concepts/models/auto-model-selection). {% rowheaders %} @@ -78,25 +78,25 @@ Choosing a larger context window or higher reasoning will impact {% data variabl | Model | 1 million token context window | Configurable reasoning | | --- | --- | --- | -| {% data variables.copilot.copilot_claude_sonnet_46 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_opus_48 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_opus_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_opus_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_sonnet_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_sonnet_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_haiku_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_opus_48_fast %} | {% octicon "x" aria-label="Not supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_fable_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_claude_fable_51 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_53_codex %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_56_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_56_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_56_terra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_6_astra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_6_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_6_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_gpt_61_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | -| {% data variables.copilot.copilot_kimi_k3 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_sonnet_46 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_opus_48 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_opus_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_opus_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_sonnet_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_sonnet_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_haiku_55 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_opus_48_fast %} | {% octicon "x" aria-label="Not supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_fable_5 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.claude_fable_51 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_53_codex %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_56_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_56_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_56_terra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_6_astra %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_6_luna %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_6_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.gpt_61_sol %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | +| {% data variables.copilot.kimi_k3 %} | {% octicon "check" aria-label="Supported" %} | {% octicon "check" aria-label="Supported" %} | {% endrowheaders %} @@ -125,35 +125,35 @@ Some {% data variables.product.prodname_copilot_short %} models require minimum | Model | {% data variables.product.prodname_vscode %} | {% data variables.product.prodname_vs %} | JetBrains IDEs | Xcode | Eclipse | |----------------------------------------------------------|----------------------------------------------|------------------------------------------|----------------|-------|---------| -| {% data variables.copilot.copilot_gemini_38_flash %} | TBD | `17.14.22` or `18.1.0` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gemini_4_argon %} | TBD | `17.14.22` or `18.1.0` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_53_codex %} | `v1.104.1` | `17.14.19` | `1.5.61` | `0.45.0` | `0.13.0` | -| {% data variables.copilot.copilot_gpt_56_luna %} | `1.128.0` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_56_sol %} | `1.128.0` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_56_terra %} | `1.128.0` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_6_astra %} | `1.136.1` | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_6_luna %} | TBD | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_6_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_gpt_61_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_opus_48 %} | `v1.118` | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_opus_5 %} | `v1.128.0` | `17.14.22` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_opus_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_sonnet_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_sonnet_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_haiku_55 %} | TBD | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_fable_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_claude_fable_51 %} | TBD | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_kimi_k3 %} | `v1.131` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_mai_code_1_1_flash %} | `v1.121` | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_grok_45 %} | TBD | `17.14.19` | TBD | TBD | TBD | -| {% data variables.copilot.copilot_grok_46 %} | TBD | TBD | TBD | TBD | TBD | -| {% data variables.copilot.copilot_grok_47 %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.gemini_38_flash %} | TBD | `17.14.22` or `18.1.0` | TBD | TBD | TBD | +| {% data variables.copilot.gemini_4_argon %} | TBD | `17.14.22` or `18.1.0` | TBD | TBD | TBD | +| {% data variables.copilot.gpt_53_codex %} | `v1.104.1` | `17.14.19` | `1.5.61` | `0.45.0` | `0.13.0` | +| {% data variables.copilot.gpt_56_luna %} | `1.128.0` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.gpt_56_sol %} | `1.128.0` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.gpt_56_terra %} | `1.128.0` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.gpt_6_astra %} | `1.136.1` | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.gpt_6_luna %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.gpt_6_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.gpt_61_sol %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.claude_opus_48 %} | `v1.118` | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_opus_5 %} | `v1.128.0` | `17.14.22` | TBD | TBD | TBD | +| {% data variables.copilot.claude_opus_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_sonnet_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_sonnet_55 %} | TBD | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_haiku_55 %} | TBD | TBD | TBD | TBD | TBD | +| {% data variables.copilot.claude_fable_5 %} | `v1.124` | `17.14.6` | TBD | TBD | TBD | +| {% data variables.copilot.claude_fable_51 %} | TBD | TBD | TBD | TBD | TBD | +| {% data variables.copilot.kimi_k3 %} | `v1.131` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.mai_code_1_1_flash %} | `v1.121` | TBD | TBD | TBD | TBD | +| {% data variables.copilot.grok_45 %} | TBD | `17.14.19` | TBD | TBD | TBD | +| {% data variables.copilot.grok_46 %} | TBD | TBD | TBD | TBD | TBD | +| {% data variables.copilot.grok_47 %} | TBD | `17.14.19` | TBD | TBD | TBD | {% endrowheaders %} > [!NOTE] > -> * For {% data variables.copilot.copilot_gpt_53_codex %} in {% data variables.product.prodname_vscode %}, `v1.108` and later provide improved prompting and response quality. +> * For {% data variables.copilot.gpt_53_codex %} in {% data variables.product.prodname_vscode %}, `v1.108` and later provide improved prompting and response quality. > * "No minimum listed" means the reviewed changelog or release guidance did not specify a minimum version, not that all older versions are supported. > * Even when a model appears in the model picker on older supported versions, prompting and model behavior may work best with the latest IDE and {% data variables.product.prodname_copilot_short %} extension or plugin versions. @@ -218,7 +218,7 @@ The following table lists AI models that are retired or scheduled for retirement {% endrowheaders %} -[^claude-sonnet-46-annual]: {% data variables.copilot.copilot_claude_sonnet_46 %} remains available to individual {% data variables.product.prodname_copilot_short %} subscribers on annual plans. The retirement of {% data variables.copilot.copilot_claude_sonnet_46 %} does not apply to these subscribers. +[^claude-sonnet-46-annual]: {% data variables.copilot.claude_sonnet_46 %} remains available to individual {% data variables.product.prodname_copilot_short %} subscribers on annual plans. The retirement of {% data variables.copilot.claude_sonnet_46 %} does not apply to these subscribers. ## Next steps diff --git a/content/copilot/reference/copilot-billing/models-and-pricing.md b/content/copilot/reference/copilot-billing/models-and-pricing.md index 0d193afb7f01..c35f81c2b76f 100644 --- a/content/copilot/reference/copilot-billing/models-and-pricing.md +++ b/content/copilot/reference/copilot-billing/models-and-pricing.md @@ -33,7 +33,7 @@ All prices are **per 1 million tokens**. {% data reusables.copilot.extended-context-pricing %} -{% data variables.copilot.copilot_gpt_56_sol %}, {% data variables.copilot.copilot_gpt_56_terra %}, {% data variables.copilot.copilot_gpt_56_luna %}, {% data variables.copilot.copilot_gpt_6_astra %}, {% data variables.copilot.copilot_gpt_6_luna %}, {% data variables.copilot.copilot_gpt_6_sol %}, and {% data variables.copilot.copilot_gpt_61_sol %} include a cache write cost in addition to cached input. Earlier OpenAI models have no cache write cost. +{% data variables.copilot.gpt_56_sol %}, {% data variables.copilot.gpt_56_terra %}, {% data variables.copilot.gpt_56_luna %}, {% data variables.copilot.gpt_6_astra %}, {% data variables.copilot.gpt_6_luna %}, {% data variables.copilot.gpt_6_sol %}, and {% data variables.copilot.gpt_61_sol %} include a cache write cost in addition to cached input. Earlier OpenAI models have no cache write cost. | Model | Release status | Category | Tier | Threshold (input tokens) | Input | Cached input | Cache write | Output | | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | @@ -120,10 +120,10 @@ You can view your current {% data variables.product.prodname_actions %} usage fo {% data variables.copilot.copilot_pro_short %} and {% data variables.copilot.copilot_pro_plus_short %} subscribers on **existing annual billing plans** using the **request-based billing** model have different model multipliers. See [AUTOTITLE](/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans). -[^gemini-flash-promo]: {% data variables.copilot.copilot_gemini_38_flash %} is available at the promotional pricing of $0.75 per 1M input tokens, $0.075 per 1M cached input tokens, and $3.75 per 1M output tokens through December 31, 2026. +[^gemini-flash-promo]: {% data variables.copilot.gemini_38_flash %} is available at the promotional pricing of $0.75 per 1M input tokens, $0.075 per 1M cached input tokens, and $3.75 per 1M output tokens through December 31, 2026. -[^gemini-4-argon-promo]: {% data variables.copilot.copilot_gemini_4_argon %} is available at the promotional pricing of $2.00 per 1M input tokens, $0.10 per 1M cached input tokens, and $10.00 per 1M output tokens until December 1, 2026. +[^gemini-4-argon-promo]: {% data variables.copilot.gemini_4_argon %} is available at the promotional pricing of $2.00 per 1M input tokens, $0.10 per 1M cached input tokens, and $10.00 per 1M output tokens until December 1, 2026. diff --git a/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md b/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md index 81098d27e996..20e4e5a183d2 100644 --- a/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md +++ b/content/copilot/reference/copilot-billing/request-based-billing-legacy/model-multipliers-for-annual-plans.md @@ -33,7 +33,7 @@ The following table shows the model multipliers per supported model. > [!NOTE] > The multiplier for these models are subject to change. > -> * {% data variables.copilot.copilot_claude_sonnet_46 %} +> * {% data variables.copilot.claude_sonnet_46 %} > > If you use {% data variables.copilot.copilot_auto_model_selection_short %} in {% data variables.copilot.copilot_chat_short %}, {% data variables.copilot.copilot_cli_short %}, the {% data variables.copilot.github_copilot_app %}, or {% data variables.copilot.copilot_cloud_agent %}, you qualify for a 10% discount. For example, if a model has a multiplier of 1x you'll be billed at 0.9x instead. diff --git a/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md b/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md index c25221f5ef1f..aff8fb5c8170 100644 --- a/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md +++ b/content/copilot/reference/copilot-cli-reference/cli-config-dir-reference.md @@ -510,6 +510,7 @@ These settings apply across all your sessions and repositories. You can use the | `screenReader` | `boolean` | `false` | Enable screen reader optimizations. | | `scrollbar` | `boolean` | `true` | Show the scrollbar in scrollable views. Set to `false` to hide it and use the full terminal width. | | `shellShortcut` | `boolean` | `true` | Let a lone `$` at the prompt, followed by Enter, open an interactive shell rooted at the session's working directory (activates only for a local, trusted, idle session on a real TTY). User- or managed-scoped only—not repo-overridable. | +| `showReasoning` | `boolean` | None | Show model reasoning in the timeline. You can set this with `/config` or `/settings`, or toggle it with Ctrl+T. Your preference is saved across sessions. | | `showTimestamps` | `boolean` | `true` | Show dim `HH:mm` timestamps next to user messages in the timeline. | | `showTipsOnStartup` | `boolean` | `true` | Show a random command tip when the CLI starts. | | `sidebar` | `boolean` | `true` | Enable the current-session sidebar. Set to `false` to disable it entirely, hiding the composer hint and the ← open gesture. See [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-command-reference#sidebar-and-sessions-tab-shortcuts). | diff --git a/content/copilot/tutorials/vibe-coding.md b/content/copilot/tutorials/vibe-coding.md index 210331f0ae21..b422e4a32714 100644 --- a/content/copilot/tutorials/vibe-coding.md +++ b/content/copilot/tutorials/vibe-coding.md @@ -71,7 +71,7 @@ We'll create a time-tracking app in a new {% data variables.product.github %} re 1. Choose a model from the models dropdown. > [!NOTE] - > The responses referred to in this tutorial were received while using {% data variables.copilot.copilot_claude_sonnet_45 %}, which has since been retired. Other models will respond differently, but you should get roughly similar results. + > The responses referred to in this tutorial were received while using {% data variables.copilot.claude_sonnet_45 %}, which has since been retired. Other models will respond differently, but you should get roughly similar results. > > Set the model to **Auto**, or select a model of your choice. diff --git a/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/managing-rulesets-for-a-repository.md b/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/managing-rulesets-for-a-repository.md index 80ab6a4de568..b6180455cc92 100644 --- a/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/managing-rulesets-for-a-repository.md +++ b/content/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/managing-rulesets-for-a-repository.md @@ -115,9 +115,13 @@ You can view insights for rulesets to see how rulesets are affecting a repositor ### Viewing the rule insights dashboard +{% ifversion fpt or ghec %} + > [!NOTE] > The rule insights dashboard is available for {% data variables.product.prodname_team %} and {% data variables.product.prodname_ghe_cloud %} plans. +{% endif %} + The rule insights dashboard gives you a visual, high-level summary of rule evaluation activity for your repository, including: * **Successes, failures, and bypasses over time**: A chart showing trends in rule evaluation results, helping you spot spikes in blocked pushes or unusual patterns. @@ -132,12 +136,16 @@ To view the dashboard: 1. In the left sidebar, under "Code and automation," click **Rules**, then click **Insights**. 1. At the top of the "Rule Insights" page, view the dashboard charts for an overview of rule evaluation activity. +{% ifversion rule-insights-dashboard-org-level %} + At the organization level, you can view the rule insights dashboard to review the evaluation activity of rulesets across your organization. See [AUTOTITLE](/organizations/managing-organization-settings/managing-rulesets-for-repositories-in-your-organization#viewing-the-rule-insights-dashboard). {% endif %} {% endif %} +{% endif %} + {% ifversion push-rule-delegated-bypass %} {% data reusables.repositories.managing-delegated-bypass %} diff --git a/data/features/rule-insights-dashboard-org-level.yml b/data/features/rule-insights-dashboard-org-level.yml index 33e16d808761..3a4030c563e1 100644 --- a/data/features/rule-insights-dashboard-org-level.yml +++ b/data/features/rule-insights-dashboard-org-level.yml @@ -3,4 +3,3 @@ versions: fpt: '*' ghec: '*' - ghes: '>=3.23' diff --git a/data/features/rule-insights-dashboard.yml b/data/features/rule-insights-dashboard.yml index 14ba8f5e2d5b..d14197926f73 100644 --- a/data/features/rule-insights-dashboard.yml +++ b/data/features/rule-insights-dashboard.yml @@ -1,3 +1,4 @@ versions: fpt: '*' ghec: '*' + ghes: '>=3.24' diff --git a/data/reusables/code-scanning/codeql-language-identifiers-table.md b/data/reusables/code-scanning/codeql-language-identifiers-table.md index e6a90bff99e9..66f801a1287d 100644 --- a/data/reusables/code-scanning/codeql-language-identifiers-table.md +++ b/data/reusables/code-scanning/codeql-language-identifiers-table.md @@ -2,13 +2,13 @@ | Language | Identifier | Optional alternative identifiers (if any) |------------------|------------------- | --------------- | C/C++ | `c-cpp` | `c` or `cpp` | -| C# | `csharp` | -| {% data variables.product.prodname_actions %} workflows | `actions` | -| Go | `go` | +| C# | `csharp` | | +| {% data variables.product.prodname_actions %} workflows | `actions` | | +| Go | `go` | | | Java/Kotlin | `java-kotlin` | `java` or `kotlin` | | JavaScript/TypeScript | `javascript-typescript` | `javascript` or `typescript` | -| Python | `python` | -| Ruby | `ruby` | +| Python | `python` | | +| Ruby | `ruby` | | | {% ifversion codeql-rust-available %} | Rust | `rust` | {% else ifversion codeql-rust-public-preview %} diff --git a/data/reusables/copilot/agentic-workflows-third-party-agent-clis.md b/data/reusables/copilot/agentic-workflows-third-party-agent-clis.md index f33a0a931eda..0345bca19b50 100644 --- a/data/reusables/copilot/agentic-workflows-third-party-agent-clis.md +++ b/data/reusables/copilot/agentic-workflows-third-party-agent-clis.md @@ -1 +1 @@ -Claude Code, {% data variables.product.prodname_openai_codex %}, or Google {% data variables.copilot.copilot_gemini %} CLI +Claude Code, {% data variables.product.prodname_openai_codex %}, or Google {% data variables.copilot.gemini %} CLI diff --git a/data/reusables/copilot/anthropic-claude-agent-models.md b/data/reusables/copilot/anthropic-claude-agent-models.md index 132cd68a834c..e52cccd31b2a 100644 --- a/data/reusables/copilot/anthropic-claude-agent-models.md +++ b/data/reusables/copilot/anthropic-claude-agent-models.md @@ -1,6 +1,6 @@ -* {% data variables.copilot.copilot_claude_sonnet_46 %} -* {% data variables.copilot.copilot_claude_sonnet_5 %} -* {% data variables.copilot.copilot_claude_opus_48 %} -* {% data variables.copilot.copilot_claude_opus_48_fast %} -* {% data variables.copilot.copilot_claude_opus_5 %} -* {% data variables.copilot.copilot_claude_fable_51 %} +* {% data variables.copilot.claude_sonnet_46 %} +* {% data variables.copilot.claude_sonnet_5 %} +* {% data variables.copilot.claude_opus_48 %} +* {% data variables.copilot.claude_opus_48_fast %} +* {% data variables.copilot.claude_opus_5 %} +* {% data variables.copilot.claude_fable_51 %} diff --git a/data/reusables/copilot/available-models-per-plan.md b/data/reusables/copilot/available-models-per-plan.md index 6efe53aa7921..323d33a090c9 100644 --- a/data/reusables/copilot/available-models-per-plan.md +++ b/data/reusables/copilot/available-models-per-plan.md @@ -12,4 +12,4 @@ [^gpt54nano]: GPT-5.4 nano is currently only available in the Codex {% data variables.product.prodname_vscode %} extension ({% data variables.copilot.copilot_pro_plus_short %} only) and is not available in {% data variables.copilot.copilot_chat_short %}. -[^claude-sonnet-46-plans]: {% data variables.copilot.copilot_claude_sonnet_46 %} was retired on September 1, 2026, but remains available to individual {% data variables.product.prodname_copilot_short %} subscribers on annual {% data variables.copilot.copilot_pro_short %} and {% data variables.copilot.copilot_pro_plus_short %} plans. It is not available to subscribers on monthly plans. +[^claude-sonnet-46-plans]: {% data variables.copilot.claude_sonnet_46 %} was retired on September 1, 2026, but remains available to individual {% data variables.product.prodname_copilot_short %} subscribers on annual {% data variables.copilot.copilot_pro_short %} and {% data variables.copilot.copilot_pro_plus_short %} plans. It is not available to subscribers on monthly plans. diff --git a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md index 6da4ff46e703..df58864e20f6 100644 --- a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md +++ b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md @@ -1,18 +1,18 @@ -* {% data variables.copilot.copilot_claude_opus_5 %} -* {% data variables.copilot.copilot_claude_opus_55 %} -* {% data variables.copilot.copilot_claude_sonnet_55 %} -* {% data variables.copilot.copilot_claude_haiku_45 %} -* {% data variables.copilot.copilot_claude_haiku_55 %} -* {% data variables.copilot.copilot_gemini_38_flash %} -* {% data variables.copilot.copilot_gemini_4_argon %} -* {% data variables.copilot.copilot_gpt_56_luna %} -* {% data variables.copilot.copilot_gpt_56_sol %} -* {% data variables.copilot.copilot_gpt_56_terra %} -* {% data variables.copilot.copilot_gpt_6_astra %} -* {% data variables.copilot.copilot_gpt_6_luna %} -* {% data variables.copilot.copilot_gpt_6_sol %} -* {% data variables.copilot.copilot_gpt_61_sol %} -* {% data variables.copilot.copilot_grok_45 %} -* {% data variables.copilot.copilot_grok_46 %} -* {% data variables.copilot.copilot_grok_47 %} -* {% data variables.copilot.copilot_mai_code_1_1_flash %} +* {% data variables.copilot.claude_opus_5 %} +* {% data variables.copilot.claude_opus_55 %} +* {% data variables.copilot.claude_sonnet_55 %} +* {% data variables.copilot.claude_haiku_45 %} +* {% data variables.copilot.claude_haiku_55 %} +* {% data variables.copilot.gemini_38_flash %} +* {% data variables.copilot.gemini_4_argon %} +* {% data variables.copilot.gpt_56_luna %} +* {% data variables.copilot.gpt_56_sol %} +* {% data variables.copilot.gpt_56_terra %} +* {% data variables.copilot.gpt_6_astra %} +* {% data variables.copilot.gpt_6_luna %} +* {% data variables.copilot.gpt_6_sol %} +* {% data variables.copilot.gpt_61_sol %} +* {% data variables.copilot.grok_45 %} +* {% data variables.copilot.grok_46 %} +* {% data variables.copilot.grok_47 %} +* {% data variables.copilot.mai_code_1_1_flash %} diff --git a/data/reusables/copilot/model-autoenablement-excluded-models.md b/data/reusables/copilot/model-autoenablement-excluded-models.md index 37fe8dd99409..3ae1f43176f3 100644 --- a/data/reusables/copilot/model-autoenablement-excluded-models.md +++ b/data/reusables/copilot/model-autoenablement-excluded-models.md @@ -3,6 +3,6 @@ The default policy applies to models that you have not explicitly configured. Th The following models are **not** in scope. They are disabled by default, regardless of your "Default availability" policy setting. * Pre-GA models -* Open weight models (DeepSeek, {% data variables.copilot.copilot_kimi_k3 %}) -* Models that are not covered by {% data variables.product.company_short %}'s data retention agreement ({% data variables.copilot.copilot_claude_fable_5 %}, {% data variables.copilot.copilot_claude_fable_51 %}) +* Open weight models (DeepSeek, {% data variables.copilot.kimi_k3 %}) +* Models that are not covered by {% data variables.product.company_short %}'s data retention agreement ({% data variables.copilot.claude_fable_5 %}, {% data variables.copilot.claude_fable_51 %}) * For enterprises that have restricted models to data-resident or FedRAMP-compliant models, any models that do not respect these policies diff --git a/data/reusables/copilot/openai-codex-agent-models.md b/data/reusables/copilot/openai-codex-agent-models.md index 3ff97c9ed39a..b42391ed1fb1 100644 --- a/data/reusables/copilot/openai-codex-agent-models.md +++ b/data/reusables/copilot/openai-codex-agent-models.md @@ -1,2 +1,2 @@ -* {% data variables.copilot.copilot_gpt_53_codex %} -* {% data variables.copilot.copilot_gpt_54_nano %} +* {% data variables.copilot.gpt_53_codex %} +* {% data variables.copilot.gpt_54_nano %} diff --git a/data/reusables/dependabot/dependabot-alerts-filters.md b/data/reusables/dependabot/dependabot-alerts-filters.md index 064a1b09dd7f..fb44f5029225 100644 --- a/data/reusables/dependabot/dependabot-alerts-filters.md +++ b/data/reusables/dependabot/dependabot-alerts-filters.md @@ -17,7 +17,7 @@ You can sort and filter {% data variables.product.prodname_dependabot_alerts %} | `manifest` | Displays alerts for the selected manifest | Use `manifest:webwolf/pom.xml` to show alerts on the pom.xml file of the webwolf application | | `package` | Displays alerts for the selected package | Use `package:django` to show alerts for django | | {% ifversion transitive-dependency-labeling-npm %} | -| `relationship` | Displays alerts of the selected relationship status
Note that this filter is only available for ecosystems with transitive support. | Use `relationship:direct` to show alerts for direct dependencies (marked with the `Direct` label). | +| `relationship` | Displays alerts of the selected relationship status
Note that this filter is only available for ecosystems with transitive support. | Use `relationship:direct` to show alerts for direct dependencies (marked with the `Direct` label), or `relationship:transitive` to show alerts for transitive dependencies (marked with the `Transitive` label). | | {% endif %} | | `resolution` | Displays alerts of the selected resolution status | Use `resolution:no-bandwidth` to show alerts previously parked due to lack of resources or time to fix them | | `repo` | Displays alerts based on the repository they relate to
Note that this filter is only available for security overview. For more information, see [AUTOTITLE](/code-security/concepts/security-at-scale/security-overview) | Use `repo:octocat-repo` to show alerts in the repository called `octocat-repo` | diff --git a/data/variables/copilot.yml b/data/variables/copilot.yml index 49704a34dc1d..67221b29b1e7 100644 --- a/data/variables/copilot.yml +++ b/data/variables/copilot.yml @@ -182,74 +182,74 @@ max-chat-indexed-repos: '50' ## LLM models for Copilot # Claude: -copilot_claude: 'Claude' -copilot_claude_haiku_45: 'Claude Haiku 4.5' -copilot_claude_haiku_55: 'Claude Haiku 5.5' -copilot_claude_opus: 'Claude Opus 4' -copilot_claude_opus_41: 'Claude Opus 4.1' -copilot_claude_opus_45: 'Claude Opus 4.5' -copilot_claude_opus_46: 'Claude Opus 4.6' -copilot_claude_opus_46_fast: 'Claude Opus 4.6 (fast mode) (preview)' -copilot_claude_opus_48: 'Claude Opus 4.8' -copilot_claude_opus_48_fast: 'Claude Opus 4.8 (fast mode) (preview)' -copilot_claude_opus_5: 'Claude Opus 5' -copilot_claude_opus_55: 'Claude Opus 5.5' -copilot_claude_fable_5: 'Claude Fable 5' -copilot_claude_fable_51: 'Claude Fable 5.1' -copilot_claude_sonnet: 'Claude Sonnet' -copilot_claude_sonnet_35: 'Claude Sonnet 3.5' -copilot_claude_sonnet_37: 'Claude Sonnet 3.7' -copilot_claude_sonnet_40: 'Claude Sonnet 4' -copilot_claude_sonnet_45: 'Claude Sonnet 4.5' -copilot_claude_sonnet_46: 'Claude Sonnet 4.6' -copilot_claude_sonnet_5: 'Claude Sonnet 5' -copilot_claude_sonnet_55: 'Claude Sonnet 5.5' +claude: 'Claude' +claude_haiku_45: 'Claude Haiku 4.5' +claude_haiku_55: 'Claude Haiku 5.5' +claude_opus: 'Claude Opus 4' +claude_opus_41: 'Claude Opus 4.1' +claude_opus_45: 'Claude Opus 4.5' +claude_opus_46: 'Claude Opus 4.6' +claude_opus_46_fast: 'Claude Opus 4.6 (fast mode) (preview)' +claude_opus_48: 'Claude Opus 4.8' +claude_opus_48_fast: 'Claude Opus 4.8 (fast mode) (preview)' +claude_opus_5: 'Claude Opus 5' +claude_opus_55: 'Claude Opus 5.5' +claude_fable_5: 'Claude Fable 5' +claude_fable_51: 'Claude Fable 5.1' +claude_sonnet: 'Claude Sonnet' +claude_sonnet_35: 'Claude Sonnet 3.5' +claude_sonnet_37: 'Claude Sonnet 3.7' +claude_sonnet_40: 'Claude Sonnet 4' +claude_sonnet_45: 'Claude Sonnet 4.5' +claude_sonnet_46: 'Claude Sonnet 4.6' +claude_sonnet_5: 'Claude Sonnet 5' +claude_sonnet_55: 'Claude Sonnet 5.5' # Gemini: -copilot_gemini: 'Gemini' -copilot_gemini_flash: 'Gemini 2.0 Flash' -copilot_gemini_3_flash: 'Gemini 3 Flash' -copilot_gemini_25_pro: 'Gemini 2.5 Pro' -copilot_gemini_3_pro: 'Gemini 3 Pro' -copilot_gemini_31_pro: 'Gemini 3.1 Pro' -copilot_gemini_38_flash: 'Gemini 3.8 Flash' -copilot_gemini_4_argon: 'Gemini 4 Argon' +gemini: 'Gemini' +gemini_flash: 'Gemini 2.0 Flash' +gemini_3_flash: 'Gemini 3 Flash' +gemini_25_pro: 'Gemini 2.5 Pro' +gemini_3_pro: 'Gemini 3 Pro' +gemini_31_pro: 'Gemini 3.1 Pro' +gemini_38_flash: 'Gemini 3.8 Flash' +gemini_4_argon: 'Gemini 4 Argon' # OpenAI GPT series: -copilot_gpt_4o: 'GPT-4o' -copilot_gpt_4o_mini: 'GPT-4o mini' -copilot_gpt_41: 'GPT-4.1' -copilot_gpt_5: 'GPT-5' -copilot_gpt_5_codex: 'GPT-5-Codex' -copilot_gpt_51: 'GPT-5.1' -copilot_gpt_51_codex: 'GPT-5.1-Codex' -copilot_gpt_51_codex_mini: 'GPT-5.1-Codex-Mini' -copilot_gpt_51_codex_max: 'GPT-5.1-Codex-Max' -copilot_gpt_52: 'GPT-5.2' -copilot_gpt_52_codex: 'GPT-5.2-Codex' -copilot_gpt_53_codex: 'GPT-5.3-Codex' -copilot_gpt_54_nano: 'GPT-5.4 nano' -copilot_gpt_56_luna: 'GPT-5.6 Luna' -copilot_gpt_56_sol: 'GPT-5.6 Sol' -copilot_gpt_56_terra: 'GPT-5.6 Terra' -copilot_gpt_6_astra: 'GPT-6 Astra' -copilot_gpt_6_luna: 'GPT-6 Luna' -copilot_gpt_6_sol: 'GPT-6 Sol' -copilot_gpt_61_sol: 'GPT-6.1 Sol' +gpt_4o: 'GPT-4o' +gpt_4o_mini: 'GPT-4o mini' +gpt_41: 'GPT-4.1' +gpt_5: 'GPT-5' +gpt_5_codex: 'GPT-5-Codex' +gpt_51: 'GPT-5.1' +gpt_51_codex: 'GPT-5.1-Codex' +gpt_51_codex_mini: 'GPT-5.1-Codex-Mini' +gpt_51_codex_max: 'GPT-5.1-Codex-Max' +gpt_52: 'GPT-5.2' +gpt_52_codex: 'GPT-5.2-Codex' +gpt_53_codex: 'GPT-5.3-Codex' +gpt_54_nano: 'GPT-5.4 nano' +gpt_56_luna: 'GPT-5.6 Luna' +gpt_56_sol: 'GPT-5.6 Sol' +gpt_56_terra: 'GPT-5.6 Terra' +gpt_6_astra: 'GPT-6 Astra' +gpt_6_luna: 'GPT-6 Luna' +gpt_6_sol: 'GPT-6 Sol' +gpt_61_sol: 'GPT-6.1 Sol' # OpenAI 'o' series: -copilot_o3: 'o3' -copilot_o4_mini: 'o4-mini' +o3: 'o3' +o4_mini: 'o4-mini' ## xAI: -copilot_grok_code: 'Grok Code Fast 1' -copilot_grok_45: 'Grok 4.5' -copilot_grok_46: 'Grok 4.6' -copilot_grok_47: 'Grok 4.7' +grok_code: 'Grok Code Fast 1' +grok_45: 'Grok 4.5' +grok_46: 'Grok 4.6' +grok_47: 'Grok 4.7' # Qwen: -copilot_qwen_25: 'Qwen2.5' +qwen_25: 'Qwen2.5' # Microsoft: -copilot_mai_code_1_1_flash: 'MAI-Code-1.1-Flash' +mai_code_1_1_flash: 'MAI-Code-1.1-Flash' # Microsoft fine-tuned: -copilot_raptor_mini: 'Raptor mini' +raptor_mini: 'Raptor mini' # Open-weight models: -copilot_kimi_k3: 'Kimi K3' +kimi_k3: 'Kimi K3' ## Next edit suggestions next_edit_suggestions: 'next edit suggestions' diff --git a/src/archives/middleware/archived-enterprise-versions-assets.ts b/src/archives/middleware/archived-enterprise-versions-assets.ts index 3c8c213b6bec..e5bb2cd81b95 100644 --- a/src/archives/middleware/archived-enterprise-versions-assets.ts +++ b/src/archives/middleware/archived-enterprise-versions-assets.ts @@ -13,6 +13,31 @@ const logger = createLogger(import.meta.url) // Proxies archived CSS and JS assets from docs-ghes- repositories. // archived-enterprise-versions.ts handles non-asset paths. +// req.path keeps percent-encoding, and fetch resolves encoded dot segments such as +// %2e%2e, so the parsed URL must stay inside the docs-ghes repository for the version. +export function getArchivedAssetProxyUrl(assetPath: string, version: string): string | null { + if ( + assetPath.includes('../') || + assetPath.includes('://') || + (assetPath.includes(':') && assetPath.includes('@')) || + /%2f|%5c|\\/i.test(assetPath) + ) { + return null + } + + const repoPrefix = `/docs-ghes-${version}/` + let url: URL + try { + url = new URL(`https://github.github.com/docs-ghes-${version}${assetPath}`) + } catch { + return null + } + if (url.origin !== 'https://github.github.com' || !url.pathname.startsWith(repoPrefix)) { + return null + } + return url.href +} + export default async function archivedEnterpriseVersionsAssets( req: ExtendedRequest, res: Response, @@ -52,17 +77,12 @@ export default async function archivedEnterpriseVersionsAssets( const legacyEnterprisePrefix = `/enterprise/${requestedVersion}` const assetPath = req.path.replace(newEnterprisePrefix, '').replace(legacyEnterprisePrefix, '') - // Reject traversal and embedded URL syntax before constructing the proxy path. - if ( - assetPath.includes('../') || - assetPath.includes('://') || - (assetPath.includes(':') && assetPath.includes('@')) - ) { + const proxyPath = getArchivedAssetProxyUrl(assetPath, requestedVersion) + if (!proxyPath) { defaultCacheControl(res) return res.status(404).type('text/plain').send('Asset path not valid') } - const proxyPath = `https://github.github.com/docs-ghes-${requestedVersion}${assetPath}` try { const r = await fetchWithRetry( proxyPath, diff --git a/src/archives/tests/archived-enterprise-versions-assets.ts b/src/archives/tests/archived-enterprise-versions-assets.ts new file mode 100644 index 000000000000..aadfaaa3567f --- /dev/null +++ b/src/archives/tests/archived-enterprise-versions-assets.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'vitest' + +import { getArchivedAssetProxyUrl } from '@/archives/middleware/archived-enterprise-versions-assets' + +describe('getArchivedAssetProxyUrl', () => { + test('builds the proxy URL for a normal asset path', () => { + expect(getArchivedAssetProxyUrl('/_next/static/css/a.css', '3.5')).toBe( + 'https://github.github.com/docs-ghes-3.5/_next/static/css/a.css', + ) + expect(getArchivedAssetProxyUrl('/assets/images/site/favicon.png', '2.18')).toBe( + 'https://github.github.com/docs-ghes-2.18/assets/images/site/favicon.png', + ) + }) + + test('rejects literal dot segments', () => { + expect(getArchivedAssetProxyUrl('/_next/../../x', '3.5')).toBeNull() + expect(getArchivedAssetProxyUrl('/dist/index../css', '2.18')).toBeNull() + }) + + test('rejects percent-encoded dot segments that escape the archive', () => { + expect(getArchivedAssetProxyUrl('/_next/%2e%2e/%2e%2e/x/index.html', '3.5')).toBeNull() + expect(getArchivedAssetProxyUrl('/assets/%2E%2e/.%2e/x/', '3.5')).toBeNull() + expect(getArchivedAssetProxyUrl('/_next/%2e./%2E./x/index.html', '3.5')).toBeNull() + }) + + test('rejects backslashes and encoded slashes', () => { + expect(getArchivedAssetProxyUrl('/_next/..%2f..%2fx', '3.5')).toBeNull() + expect(getArchivedAssetProxyUrl('/_next/..%5C..%5Cx', '3.5')).toBeNull() + expect(getArchivedAssetProxyUrl('/_next/..\\..\\x', '3.5')).toBeNull() + }) + + test('rejects embedded URL syntax', () => { + expect(getArchivedAssetProxyUrl('/dist/index.csshttp://example.com', '2.18')).toBeNull() + expect(getArchivedAssetProxyUrl('/dist/index.css:password@example.com', '2.18')).toBeNull() + expect(getArchivedAssetProxyUrl('a:b@c', '3.5')).toBeNull() + }) +}) diff --git a/src/article-api/lib/get-all-toc-items.ts b/src/article-api/lib/get-all-toc-items.ts index d4e8ca5bc7db..591880f387e1 100644 --- a/src/article-api/lib/get-all-toc-items.ts +++ b/src/article-api/lib/get-all-toc-items.ts @@ -16,6 +16,7 @@ interface TocItem extends LinkData { } // Mirrors getTocItems() in src/frame/middleware/context/generic-toc.ts for frontmatter children. +// The site shows hidden children only in early access TOCs, so skip them here. export async function getAllTocItems( page: Page, context: Context, @@ -40,27 +41,28 @@ export async function getAllTocItems( const basePath = options.basePath ?? pathname const resolvedChildren = pageWithChildren.children - .map((childHref) => ({ - childHref, - childPage: resolvePath(childHref, languageCode, pathname, context) as + .map((childHref) => { + const childPage = resolvePath(childHref, languageCode, pathname, context) as | PageWithChildren - | undefined, - })) + | undefined + const childPermalink = childPage?.permalinks.find( + (p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion, + ) + return { href: childPermalink?.href, childPage } + }) + // Frontmatter children live under the parent, so other matches are wrong-product fallbacks. .filter( - (entry): entry is { childHref: string; childPage: PageWithChildren } => + (entry): entry is { href: string; childPage: PageWithChildren } => entry.childPage !== undefined && + !!entry.href?.startsWith(`${pathname}/`) && + !entry.childPage.hidden && // resolvePath can return a page from another version, which the website hides. (!context.currentVersion || entry.childPage.applicableVersions.includes(context.currentVersion)), ) const items = await Promise.all( - resolvedChildren.map(async ({ childHref, childPage }) => { - const childPermalink = childPage.permalinks.find( - (p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion, - ) - const href = childPermalink ? childPermalink.href : childHref - + resolvedChildren.map(async ({ href, childPage }) => { const title = await renderPropFast(childPage, 'title', context) const intro = await renderPropFast(childPage, 'intro', context) diff --git a/src/article-api/lib/get-intro-links-section.ts b/src/article-api/lib/get-intro-links-section.ts new file mode 100644 index 000000000000..4311a73d01b4 --- /dev/null +++ b/src/article-api/lib/get-intro-links-section.ts @@ -0,0 +1,25 @@ +import type { Context, Page } from '@/types' +import type { LinkData, Section } from '@/article-api/transformers/types' + +interface PageWithIntroLinks extends Page { + rawIntroLinks?: Record +} + +// Reads rawIntroLinks because Page.render overwrites introLinks on the Page that every version shares. +export async function getIntroLinksSection(page: Page, context: Context): Promise
{ + const { rawIntroLinks } = page as PageWithIntroLinks + if (!rawIntroLinks) return null + + const { default: getPageLinkData } = await import('@/frame/lib/get-link-data') + const links: LinkData[] = [] + for (const rawHref of Object.values(rawIntroLinks)) { + if (typeof rawHref !== 'string') continue + const [item] = (await getPageLinkData(rawHref, context)) ?? [] + if (item?.href) { + links.push({ href: item.href, title: item.title || '', intro: item.intro || '' }) + } + } + + if (links.length === 0) return null + return { title: 'Links', groups: [{ title: 'Getting started', links }] } +} diff --git a/src/article-api/liquid-renderers/rest-tags.ts b/src/article-api/liquid-renderers/rest-tags.ts index b7fc29a222c2..38ca91d86af6 100644 --- a/src/article-api/liquid-renderers/rest-tags.ts +++ b/src/article-api/liquid-renderers/rest-tags.ts @@ -31,13 +31,15 @@ export class RestParameter { const lines: string[] = [] const required = param.required ? ' (required)' : '' - const type = param.schema?.type || 'string' + const type = Array.isArray(param.schema?.type) + ? param.schema.type.join(' or ') + : param.schema?.type || 'string' lines.push(`- **\`${param.name}\`** (${type})${required}`) if (param.description) { const description = await htmlToMarkdown(param.description, context) - lines.push(` ${description}`) + lines.push(indentLines(description, ' ')) } if (param.schema?.default !== undefined) { @@ -87,7 +89,7 @@ export class RestBodyParameter { if (param.description) { const description = await htmlToMarkdown(param.description, context) - lines.push(`${prefix} ${description}`) + lines.push(indentLines(description, `${prefix} `)) } if (param.default !== undefined) { @@ -160,7 +162,7 @@ async function renderChildParameter( if (param.description) { const description = await htmlToMarkdown(param.description, context) - lines.push(`${prefix} ${description}`) + lines.push(indentLines(description, `${prefix} `)) } if (param.default !== undefined) { @@ -180,6 +182,14 @@ async function renderChildParameter( return lines.join('\n') } +// Description text keeps newlines between blocks, and a column-0 line after a blank line ends the list item. +function indentLines(text: string, indent: string): string { + return text + .split('\n') + .map((line) => (line.trim() ? `${indent}${line}` : '')) + .join('\n') +} + async function htmlToMarkdown(html: string, context: Context): Promise { if (!html) return '' diff --git a/src/article-api/middleware/validation.ts b/src/article-api/middleware/validation.ts index cb89a7be74c5..7fd07caab15e 100644 --- a/src/article-api/middleware/validation.ts +++ b/src/article-api/middleware/validation.ts @@ -4,6 +4,7 @@ import type { NextFunction, Response } from 'express' import { ExtendedRequest, Page } from '@/types' import { isArchivedVersionByPath } from '@/archives/lib/is-archived-version' import getRedirect from '@/redirects/lib/get-redirect' +import { applyGraphqlCategoryRedirect } from '@/redirects/lib/graphql-category-redirect' import { pathLanguagePrefixed } from '@/languages/lib/languages-server' import { getVersionStringFromPath, getLangFromPath } from '@/frame/lib/path-utils' import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' @@ -64,6 +65,7 @@ export const pathValidationMiddleware = ( // pageValidationMiddleware calls getRedirect directly because findPage hides pathname changes. // Redirected pathnames must match page.permalinks for translated-page fallback. // Archived enterprise paths skip redirect lookup, even if they also match a redirect. +// Redirect destinations get the legacy GraphQL kind rewrite and lose their #fragment. // This matches the site middleware order. export const pageValidationMiddleware = ( req: ExtendedRequestWithPageInfo, @@ -96,7 +98,10 @@ export const pageValidationMiddleware = ( const redirect = getRedirect(pathname, redirectsContext) if (redirect) { req.pageinfo.redirectedFrom = pathname - pathname = redirect + const rewritten = + applyGraphqlCategoryRedirect(redirect, req.context.currentLanguage) || redirect + // Pages are keyed without fragments, so drop the #anchor from internal destinations. + pathname = rewritten.includes('://') ? rewritten : rewritten.split('#')[0] } else if (!pathLanguagePrefixed(pathname)) { // The redirect table omits live permalinks, so add the language prefix as the site does. const prefixedPathname = `/${req.context.currentLanguage}${pathname}` diff --git a/src/article-api/templates/graphql-reference.template.md b/src/article-api/templates/graphql-reference.template.md index 1f054b5d8ff8..8413cecfc6d2 100644 --- a/src/article-api/templates/graphql-reference.template.md +++ b/src/article-api/templates/graphql-reference.template.md @@ -84,7 +84,7 @@ ### Values for `{{ item.name }}` -{% for value in item.values %}* `{{ value.name }}`: {{ value.description }} +{% for value in item.values %}* `{{ value.name }}`: {{ value.description }}{% if value.isDeprecated %} **Deprecated:** {{ value.deprecationReason }}{% endif %} {% endfor %} {% endif %} diff --git a/src/article-api/templates/rest-page.template.md b/src/article-api/templates/rest-page.template.md index 754c7c2598b5..da5372bc577f 100644 --- a/src/article-api/templates/rest-page.template.md +++ b/src/article-api/templates/rest-page.template.md @@ -17,6 +17,13 @@ {{ operation.description }} +{% if operation.basicAuth %} + +> [!NOTE] +> This endpoint requires [Basic authentication](/rest/authentication/authenticating-to-the-rest-api#using-basic-authentication). Use the application's `client_id` as the username and the `client_secret` as the password. Do not send an `Authorization: Bearer` header with this request, because it replaces the Basic credentials. + +{% endif %} + {% if operation.hasParameters %} ### Parameters @@ -84,7 +91,8 @@ ```curl curl -L \ -X {{ operation.verb | upcase }} \ - {{ example.request.url }}{% if example.request.acceptHeader and example.request.acceptHeader != 'application/vnd.github+json' and example.request.acceptHeader != 'application/vnd.github.v3+json' %} \ + {{ example.request.url }}{% if operation.basicAuth %} \ + -u ":"{% endif %}{% if example.request.acceptHeader and example.request.acceptHeader != 'application/vnd.github+json' and example.request.acceptHeader != 'application/vnd.github.v3+json' %} \ -H "Accept: {{ example.request.acceptHeader }}"{% endif %}{% if example.request.needsContentTypeHeader %} \ -H "Content-Type: {{ example.request.contentType }}"{% endif %}{% if example.request.bodyParameters.size > 0 %}{% for bodyParameter in example.request.bodyParameters %} \ {{ bodyParameter }}{% endfor %}{% endif %} diff --git a/src/article-api/tests/bespoke-landing-transformer.ts b/src/article-api/tests/bespoke-landing-transformer.ts index d6f2827ee5a3..f3c0edad7078 100644 --- a/src/article-api/tests/bespoke-landing-transformer.ts +++ b/src/article-api/tests/bespoke-landing-transformer.ts @@ -26,4 +26,22 @@ describe('bespoke landing transformer', () => { expect(res.body).toContain('[Grid Article Three]') expect(res.body).toContain('[Grid Article Four]') }) + + test('renders intro links as a getting started section', async () => { + const res = await get(makeURL('/en/get-started/article-grid-bespoke')) + expect(res.statusCode).toBe(200) + + const start = res.body.indexOf('## Links') + expect(start).toBeGreaterThan(-1) + const end = res.body.indexOf('\n## ', start) + const linksSection = res.body.slice(start, end === -1 ? undefined : end) + expect(linksSection).toContain('### Getting started') + expect(linksSection).toContain( + '[Grid Article One](/en/get-started/article-grid-bespoke/grid-category-one/grid-article-one)', + ) + expect(linksSection).toContain( + '[Grid Article Three](/en/get-started/article-grid-bespoke/grid-category-two/grid-article-three)', + ) + expect(linksSection).not.toContain('[Grid Article Two]') + }) }) diff --git a/src/article-api/tests/get-all-toc-items.ts b/src/article-api/tests/get-all-toc-items.ts index de877a40154e..b1021f1720be 100644 --- a/src/article-api/tests/get-all-toc-items.ts +++ b/src/article-api/tests/get-all-toc-items.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from 'vitest' -import { getAllTocItems } from '@/article-api/lib/get-all-toc-items' +import { getAllTocItems, flattenTocItems } from '@/article-api/lib/get-all-toc-items' import type { Context, Page, Permalink } from '@/types' function createMockPage(options: { @@ -7,6 +7,7 @@ function createMockPage(options: { applicableVersions: string[] permalinks: Partial[] children?: string[] + hidden?: boolean }): Page { return { languageCode: 'en', @@ -14,6 +15,7 @@ function createMockPage(options: { applicableVersions: options.applicableVersions, permalinks: options.permalinks as Permalink[], children: options.children, + hidden: options.hidden, renderProp: vi.fn().mockResolvedValue(''), } as unknown as Page } @@ -61,4 +63,86 @@ describe('getAllTocItems', () => { expect(items.map((item) => item.href)).toEqual(['/en/rest/quickstart']) expect(items[0].title).toBe('Quickstart') }) + + test('skips hidden children and does not recurse into hidden categories', async () => { + const VERSION = 'free-pro-team@latest' + const page = ( + href: string, + rawTitle: string, + extra: { children?: string[]; hidden?: boolean } = {}, + ) => + createMockPage({ + rawTitle, + applicableVersions: [VERSION], + permalinks: [{ languageCode: 'en', pageVersion: VERSION, href }], + ...extra, + }) + const landing = page('/en/policy', 'Policy', { + children: ['/visible-article', '/hidden-article', '/hidden-category'], + }) + const pages = [ + landing, + page('/en/policy/visible-article', 'Visible Article'), + page('/en/policy/hidden-article', 'Hidden Article', { hidden: true }), + page('/en/policy/hidden-category', 'Hidden Category', { + children: ['/visible-leaf'], + hidden: true, + }), + // A visible leaf proves a hidden category drops its whole subtree. + page('/en/policy/hidden-category/visible-leaf', 'Visible Leaf'), + ] + const byHref: Record = {} + for (const p of pages) byHref[p.permalinks[0].href] = p + const context = { pages: byHref, redirects: {}, currentVersion: VERSION } as unknown as Context + + const tocItems = await getAllTocItems(landing, context) + const titles = flattenTocItems(tocItems).map((item) => item.title) + + expect(titles).toEqual(['Visible Article']) + }) + + test('drops children that resolve to another product', async () => { + const ghes = 'enterprise-server@3.19' + const parent = createMockPage({ + rawTitle: 'REST API', + applicableVersions: [ghes], + permalinks: [{ languageCode: 'en', pageVersion: ghes, href: `/en/${ghes}/rest` }], + children: ['/actions', '/copilot', '/codespaces'], + }) + const actions = createMockPage({ + rawTitle: 'Actions', + applicableVersions: [ghes], + permalinks: [{ languageCode: 'en', pageVersion: ghes, href: `/en/${ghes}/rest/actions` }], + }) + const copilot = createMockPage({ + rawTitle: 'GitHub Copilot documentation', + applicableVersions: ['free-pro-team@latest', ghes], + permalinks: [ + { languageCode: 'en', pageVersion: 'free-pro-team@latest', href: '/en/copilot' }, + { languageCode: 'en', pageVersion: ghes, href: `/en/${ghes}/copilot` }, + ], + }) + const codespaces = createMockPage({ + rawTitle: 'GitHub Codespaces documentation', + applicableVersions: ['free-pro-team@latest'], + permalinks: [ + { languageCode: 'en', pageVersion: 'free-pro-team@latest', href: '/en/codespaces' }, + ], + }) + const context = { + currentVersion: ghes, + pages: { + [`/en/${ghes}/rest`]: parent, + [`/en/${ghes}/rest/actions`]: actions, + '/en/copilot': copilot, + '/en/codespaces': codespaces, + }, + redirects: {}, + } as unknown as Context + + const items = await getAllTocItems(parent, context) + + expect(items.map((item) => item.href)).toEqual([`/en/${ghes}/rest/actions`]) + expect(items[0].title).toBe('Actions') + }) }) diff --git a/src/article-api/tests/landing-intro-links.test.ts b/src/article-api/tests/landing-intro-links.test.ts new file mode 100644 index 000000000000..4b2b1dfd9b32 --- /dev/null +++ b/src/article-api/tests/landing-intro-links.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from 'vitest' + +import { BespokeLandingTransformer } from '@/article-api/transformers/bespoke-landing-transformer' +import { DiscoveryLandingTransformer } from '@/article-api/transformers/discovery-landing-transformer' +import shortVersionsMiddleware from '@/versions/middleware/short-versions' +import { allVersions } from '@/versions/lib/all-versions' +import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases' +import type { Context, ExtendedRequest, Page } from '@/types' + +const ghesVersion = `enterprise-server@${enterpriseServerReleases.latest}` +const overviewPath = '/billing/get-started/how-billing-works' +const ghesOverviewPath = '/billing/concepts/enterprise-billing/billing-for-enterprises' +const rawOverview = `{% ifversion fpt or ghec %}${overviewPath} {% elsif ghes %}${ghesOverviewPath}{% endif %}` + +const makeLinkedPage = (title: string) => + ({ + title, + intro: '', + // resolveCarouselLinks drops links whose page does not apply to the requested version. + applicableVersions: Object.keys(allVersions), + renderTitle: async () => title, + renderProp: async () => '', + }) as unknown as Page + +const pages: Record = { + [`/en${overviewPath}`]: makeLinkedPage('How billing works'), + [`/en${ghesOverviewPath}`]: makeLinkedPage('Billing for enterprises'), + [`/en/${ghesVersion}${ghesOverviewPath}`]: makeLinkedPage('Billing for enterprises'), +} + +// staleOverview stands in for the introLinks value that Page.render leaves on the shared Page. +const makeLandingPage = (layout: string, staleOverview: string) => + ({ + layout, + title: 'Billing', + intro: '', + rawIntroLinks: { overview: rawOverview }, + introLinks: { overview: staleOverview }, + rawCarousels: { recommended: [ghesOverviewPath] }, + renderTitle: async () => 'Billing', + renderProp: async () => '', + }) as unknown as Page + +const buildContext = async (currentVersion: string): Promise => { + const req = { language: 'en', query: {} } as ExtendedRequest + req.context = { + currentLanguage: 'en', + currentVersion, + currentVersionObj: allVersions[currentVersion], + allVersions, + enterpriseServerReleases, + pages, + redirects: {}, + } as Context + await shortVersionsMiddleware(req, null, () => {}) + return req.context +} + +const getLinksSection = (body: string) => { + const start = body.indexOf('## Links') + if (start === -1) return '' + const end = body.indexOf('\n## ', start) + return end === -1 ? body.slice(start) : body.slice(start, end) +} + +describe.each([ + { + name: 'discovery', + layout: 'discovery-landing', + transformer: new DiscoveryLandingTransformer(), + }, + { name: 'bespoke', layout: 'bespoke-landing', transformer: new BespokeLandingTransformer() }, +])('$name landing intro links', ({ layout, transformer }) => { + test('renders raw intro links for fpt when the page holds a GHES render', async () => { + const page = makeLandingPage(layout, ghesOverviewPath) + const body = await transformer.transform( + page, + '/en/billing', + await buildContext('free-pro-team@latest'), + ) + + const linksSection = getLinksSection(body) + expect(linksSection).toContain('### Getting started') + expect(linksSection).toContain(`[How billing works](/en${overviewPath})`) + expect(linksSection).not.toContain('Billing for enterprises') + }) + + test('renders raw intro links for GHES when the page holds an fpt render', async () => { + const page = makeLandingPage(layout, overviewPath) + const body = await transformer.transform(page, '/en/billing', await buildContext(ghesVersion)) + + const linksSection = getLinksSection(body) + expect(linksSection).toContain( + `[Billing for enterprises](/en/${ghesVersion}${ghesOverviewPath})`, + ) + expect(body).not.toContain('How billing works') + }) +}) + +describe('bespoke landing intro links order', () => { + test('lists intro links before carousels, like the page hero', async () => { + const page = makeLandingPage('bespoke-landing', overviewPath) + const body = await new BespokeLandingTransformer().transform( + page, + '/en/billing', + await buildContext('free-pro-team@latest'), + ) + + expect(body.indexOf('## Links')).toBeGreaterThan(-1) + expect(body.indexOf('## Links')).toBeLessThan(body.indexOf('## Recommended')) + }) +}) diff --git a/src/article-api/tests/page-validation.ts b/src/article-api/tests/page-validation.ts new file mode 100644 index 000000000000..da0716d23458 --- /dev/null +++ b/src/article-api/tests/page-validation.ts @@ -0,0 +1,67 @@ +import { describe, expect, test, vi } from 'vitest' +import type { Response } from 'express' + +import { pageValidationMiddleware } from '@/article-api/middleware/validation' +import type { ExtendedRequestWithPageInfo } from '@/article-api/types' +import { latest } from '@/versions/lib/enterprise-server-releases' +import type { Page } from '@/types' + +const restPage = { title: 'Activity' } as unknown as Page +const checksPage = { title: 'Checks' } as unknown as Page +const referencePage = { title: 'Reference' } as unknown as Page + +const pages: Record = { + [`/en/enterprise-server@${latest}/rest/activity`]: restPage, + '/en/graphql/reference/checks': checksPage, + '/en/graphql/reference': referencePage, +} + +// Mirrors developer.json entries after precompile resolves enterprise-server@latest. +const redirects: Record = { + '/enterprise/v3/activity/events': `/enterprise-server@${latest}/rest/activity#events`, + '/v4/enum/checkruntype': '/graphql/reference/enums#checkruntype', + '/v4/mutation': '/graphql/reference/mutations', +} + +function run(pathname: string) { + const req = { + pageinfo: { pathname, page: {} as Page }, + context: { pages, redirects, currentLanguage: 'en', userLanguage: 'en' }, + } as unknown as ExtendedRequestWithPageInfo + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + } as unknown as Response + const next = vi.fn() + pageValidationMiddleware(req, res, next) + return { req, res, next } +} + +describe('pageValidationMiddleware redirects', () => { + test('drops the fragment from a redirect destination before the page lookup', () => { + const { req, res, next } = run('/en/enterprise/v3/activity/events') + expect(res.status).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + expect(req.pageinfo.page).toBe(restPage) + expect(req.pageinfo.pathname).toBe(`/en/enterprise-server@${latest}/rest/activity`) + expect(req.pageinfo.redirectedFrom).toBe('/en/enterprise/v3/activity/events') + }) + + test('maps legacy GraphQL kind redirects with a type fragment to category pages', () => { + const { req, res, next } = run('/en/v4/enum/checkruntype') + expect(res.status).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + expect(req.pageinfo.page).toBe(checksPage) + expect(req.pageinfo.pathname).toBe('/en/graphql/reference/checks') + expect(req.pageinfo.redirectedFrom).toBe('/en/v4/enum/checkruntype') + }) + + test('maps legacy GraphQL kind redirects without a fragment to the reference root', () => { + const { req, res, next } = run('/en/v4/mutation') + expect(res.status).not.toHaveBeenCalled() + expect(next).toHaveBeenCalled() + expect(req.pageinfo.page).toBe(referencePage) + expect(req.pageinfo.pathname).toBe('/en/graphql/reference') + expect(req.pageinfo.redirectedFrom).toBe('/en/v4/mutation') + }) +}) diff --git a/src/article-api/tests/pageinfo.ts b/src/article-api/tests/pageinfo.ts index 1e6553864201..291c15fc8a93 100644 --- a/src/article-api/tests/pageinfo.ts +++ b/src/article-api/tests/pageinfo.ts @@ -138,6 +138,21 @@ describe('pageinfo api', () => { const meta = JSON.parse(res.body) as PageMetadata expect(meta.title).toBe('Only in Enterprise Cloud and Enterprise Server') } + // Legacy GraphQL redirect destinations carry a kind and #fragment that pages lack. + { + const res = await get(makeURL('/en/v4/enum/checkruntype')) + expect(res.statusCode).toBe(200) + const meta = JSON.parse(res.body) as PageMetadata + expect(meta.title).toBe('Checks') + expect(meta.redirectedFrom).toBe('/en/v4/enum/checkruntype') + } + { + const res = await get(makeURL('/en/v4/mutation')) + expect(res.statusCode).toBe(200) + const meta = JSON.parse(res.body) as PageMetadata + expect(meta.title).toBe('Reference') + expect(meta.redirectedFrom).toBe('/en/v4/mutation') + } }) test('a page that uses non-trivial Liquid to render', async () => { diff --git a/src/article-api/tests/rest-tags.test.ts b/src/article-api/tests/rest-tags.test.ts new file mode 100644 index 000000000000..3081e7f62c77 --- /dev/null +++ b/src/article-api/tests/rest-tags.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest' +import { Liquid } from 'liquidjs' +import { fromMarkdown } from 'mdast-util-from-markdown' +import type { List } from 'mdast' + +import { restTags } from '@/article-api/liquid-renderers/rest-tags' + +const engine = new Liquid() +for (const [tagName, tagClass] of Object.entries(restTags)) { + engine.registerTag(tagName, tagClass as unknown as Parameters[1]) +} + +const multiLineDescription = + '

Intro.

\n
    \n
  • a: first value
  • \n
  • b: second value
  • \n
\n

Closing note.

' + +async function render(template: string, param: unknown): Promise { + return engine.parseAndRender(template, { param, context: {} }) +} + +function linesAfterFirst(output: string): string[] { + return output + .split('\n') + .slice(1) + .filter((line) => line.trim() !== '') +} + +describe('REST Liquid tags with multi-line descriptions', () => { + test('rest_parameter indents every description line under the list item', async () => { + const output = await render('{% rest_parameter param %}', { + name: 'scope', + in: 'query', + required: false, + description: multiLineDescription, + schema: { type: 'string', enum: ['a', 'b'] }, + }) + + expect(output).toContain('a: first value') + expect(output).toContain('Closing note.') + for (const line of linesAfterFirst(output)) { + expect(line).toMatch(/^ {2}\S/) + } + + const tree = fromMarkdown(output) + expect(tree.children.map((node) => node.type)).toEqual(['list']) + }) + + test('rest_body_parameter keeps child parameters nested under their parent', async () => { + const output = await render('{% rest_body_parameter param %}', { + name: 'budget_scope', + type: 'object', + isRequired: true, + description: multiLineDescription, + enum: ['a', 'b'], + childParamsGroups: [ + { + name: 'value', + type: 'string', + description: multiLineDescription, + }, + ], + }) + + const lines = output.split('\n') + const childIndex = lines.findIndex((line) => line.includes('**`value`**')) + expect(childIndex).toBeGreaterThan(0) + for (const line of lines.slice(1, childIndex).filter((l) => l.trim() !== '')) { + expect(line).toMatch(/^ {2}\S/) + } + expect(lines[childIndex]).toMatch(/^ {2}- /) + for (const line of lines.slice(childIndex + 1).filter((l) => l.trim() !== '')) { + expect(line).toMatch(/^ {4}\S/) + } + + const tree = fromMarkdown(output) + expect(tree.children.map((node) => node.type)).toEqual(['list']) + const topList = tree.children[0] as List + expect(topList.children).toHaveLength(1) + const nestedLists = topList.children[0].children.filter( + (node): node is List => node.type === 'list' && node.children.length === 1, + ) + expect(nestedLists.some((list) => JSON.stringify(list).includes('"value":"value"'))).toBe(true) + }) +}) diff --git a/src/article-api/tests/rest-transformer-auth.test.ts b/src/article-api/tests/rest-transformer-auth.test.ts new file mode 100644 index 000000000000..10961ef31086 --- /dev/null +++ b/src/article-api/tests/rest-transformer-auth.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test, vi } from 'vitest' + +import { RestTransformer } from '@/article-api/transformers/rest-transformer' +import getRest from '@/rest/lib/index' +import { allVersions } from '@/versions/lib/all-versions' +import type { CodeSample, Operation } from '@/rest/components/types' +import type { Context, Page } from '@/types' + +vi.mock('@/rest/lib/index', () => ({ default: vi.fn() })) + +const stubPage = { + autogenerated: 'rest', + relativePath: 'rest/apps/oauth-applications.md', + title: 'OAuth authorizations', + intro: '', + markdown: '', + renderProp: vi.fn().mockResolvedValue(''), +} as unknown as Page + +const context = { + currentVersion: 'free-pro-team@latest', + currentVersionObj: allVersions['free-pro-team@latest'], + allVersions, +} as unknown as Context + +const makeOperation = (title: string, requestPath: string, basicAuth: boolean): Operation => + ({ + serverUrl: 'https://api.github.com', + verb: 'post', + title, + requestPath, + descriptionHTML: '', + category: 'apps', + subcategory: 'oauth-applications', + statusCodes: [], + bodyParameters: [], + parameters: [], + progAccess: { permissions: [], ...(basicAuth ? { basicAuth: true } : {}) }, + codeExamples: [ + { + request: { description: 'Example', acceptHeader: 'application/vnd.github.v3+json' }, + response: { statusCode: '200' }, + } as unknown as CodeSample, + ], + }) as unknown as Operation + +async function renderSections() { + vi.mocked(getRest).mockResolvedValue({ + 'oauth-applications': [ + makeOperation('Check a token', '/applications/CLIENT_ID/token', true), + makeOperation('Create a thing', '/things', false), + ], + } as never) + const body = await new RestTransformer().transform( + stubPage, + '/en/rest/apps/oauth-applications', + context, + ) + const [, basicAuthSection, otherSection] = body.split(/^## /m) + return { basicAuthSection, otherSection } +} + +describe('RestTransformer basic authentication', () => { + test('adds Basic auth credentials to the curl example for basicAuth operations', async () => { + const { basicAuthSection, otherSection } = await renderSections() + + expect(basicAuthSection).toContain( + 'https://api.github.com/applications/CLIENT_ID/token \\\n -u ":"', + ) + expect(otherSection).not.toContain('-u "') + }) + + test('adds a Basic authentication note to basicAuth operations', async () => { + const { basicAuthSection, otherSection } = await renderSections() + + expect(basicAuthSection).toContain('Basic authentication') + expect(basicAuthSection).toContain('`client_id`') + expect(basicAuthSection).toContain('`client_secret`') + expect(basicAuthSection).toContain('Do not send an `Authorization: Bearer` header') + expect(otherSection).not.toContain('Do not send an `Authorization: Bearer` header') + expect(otherSection).not.toContain('Basic authentication') + expect(otherSection).not.toContain('client_secret') + }) +}) diff --git a/src/article-api/tests/search-page-transformer.test.ts b/src/article-api/tests/search-page-transformer.test.ts new file mode 100644 index 000000000000..b13bd1d2b3a7 --- /dev/null +++ b/src/article-api/tests/search-page-transformer.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test, vi } from 'vitest' + +import { SearchPageTransformer } from '@/article-api/transformers/search-page-transformer' +import type { Context, Page } from '@/types' + +const stubPage = { + relativePath: 'search/index.md', + renderTitle: vi.fn().mockResolvedValue('Search'), +} as unknown as Page + +describe('SearchPageTransformer', () => { + test('points to the versioned Search API endpoint', async () => { + const transformer = new SearchPageTransformer() + const result = await transformer.transform(stubPage, '/en/search', {} as Context) + + expect(result).toContain('# Search') + // The bare /api/search path redirects, and curl requests without client_name get a 400. + expect(result).toContain( + 'curl "https://docs.github.com/api/search/v1?query=actions&client_name=docs-api-example&language=en&version=free-pro-team@latest"', + ) + }) +}) diff --git a/src/article-api/transformers/bespoke-landing-transformer.ts b/src/article-api/transformers/bespoke-landing-transformer.ts index 2d95160ba0bf..3927bf6c936a 100644 --- a/src/article-api/transformers/bespoke-landing-transformer.ts +++ b/src/article-api/transformers/bespoke-landing-transformer.ts @@ -3,6 +3,7 @@ import type { PageTransformer, TemplateData, Section } 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' +import { getIntroLinksSection } from '@/article-api/lib/get-intro-links-section' import { resolveCarouselLinks } from '@/article-api/lib/resolve-carousel-links' interface BespokeLandingPage extends Omit { @@ -42,6 +43,10 @@ export class BespokeLandingTransformer implements PageTransformer { const bespokePage = page as BespokeLandingPage const sections: Section[] = [] + // Intro links come first to match the hero, which renders above the carousels. + const introLinksSection = await getIntroLinksSection(page, context) + if (introLinksSection) sections.push(introLinksSection) + // resolve-carousels overwrites page.carousels on the shared Page with another version's links. const carousels = bespokePage.rawCarousels if (carousels && typeof carousels === 'object') { diff --git a/src/article-api/transformers/discovery-landing-transformer.ts b/src/article-api/transformers/discovery-landing-transformer.ts index 331b6f46e006..03d5a9cc8e7e 100644 --- a/src/article-api/transformers/discovery-landing-transformer.ts +++ b/src/article-api/transformers/discovery-landing-transformer.ts @@ -1,13 +1,12 @@ import type { Context, Page } from '@/types' -import type { PageTransformer, TemplateData, Section, LinkData } from './types' +import type { PageTransformer, TemplateData, Section } 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' +import { getIntroLinksSection } from '@/article-api/lib/get-intro-links-section' import { resolveCarouselLinks } from '@/article-api/lib/resolve-carousel-links' interface DiscoveryPage extends Page { - rawIntroLinks?: Record - introLinks?: Record rawCarousels?: Record includedCategories?: string[] children?: string[] @@ -60,41 +59,8 @@ export class DiscoveryLandingTransformer implements PageTransformer { } } - const rawIntroLinks = discoveryPage.introLinks ?? discoveryPage.rawIntroLinks - if (rawIntroLinks) { - const { default: getPageLinkData } = await import('@/frame/lib/get-link-data') - const links = await Promise.all( - Object.values(rawIntroLinks).map(async (href): Promise => { - if (typeof href === 'string') { - const linkData = await getPageLinkData(href, context) - if (Array.isArray(linkData) && linkData.length > 0) { - const item = linkData[0] - return { href: item.href || '', title: item.title || '', intro: item.intro || '' } - } else if ( - linkData && - typeof linkData === 'object' && - !Array.isArray(linkData) && - 'href' in linkData - ) { - const item = linkData as { href?: string; title?: string; intro?: string } - return { - href: item.href || '', - title: item.title || '', - intro: item.intro || '', - } - } - } - return { href: '', title: '' } - }), - ) - const validLinks = links.filter((l) => l.href) - if (validLinks.length > 0) { - sections.push({ - title: 'Links', - groups: [{ title: 'Getting started', links: validLinks }], - }) - } - } + const introLinksSection = await getIntroLinksSection(page, context) + if (introLinksSection) sections.push(introLinksSection) // getAllTocItems sets basePath to keep /rest from recursing into /enterprise-admin children. if (discoveryPage.children && discoveryPage.children.length > 0) { diff --git a/src/article-api/transformers/graphql-reference-transformer.ts b/src/article-api/transformers/graphql-reference-transformer.ts index 8335f91b0ab5..7de9c1f68c4e 100644 --- a/src/article-api/transformers/graphql-reference-transformer.ts +++ b/src/article-api/transformers/graphql-reference-transformer.ts @@ -227,6 +227,10 @@ export class GraphQLReferenceTransformer implements PageTransformer { values: item.values.map((value) => ({ name: value.name, description: value.description ? fastTextOnly(value.description) : '', + isDeprecated: value.isDeprecated || false, + deprecationReason: value.deprecationReason + ? fastTextOnly(value.deprecationReason) + : undefined, })), } } diff --git a/src/article-api/transformers/rest-transformer.ts b/src/article-api/transformers/rest-transformer.ts index 8f0a22315896..8d5aeacdae2c 100644 --- a/src/article-api/transformers/rest-transformer.ts +++ b/src/article-api/transformers/rest-transformer.ts @@ -46,6 +46,7 @@ type PreparedOperation = Omit & { hasParameters: boolean showHeaders: boolean needsContentTypeHeader: boolean + basicAuth: boolean statusCodes?: Array<{ httpStatusCode: string; description?: string }> codeExamples: PreparedCodeExample[] } @@ -262,6 +263,7 @@ export class RestTransformer implements PageTransformer { hasParameters, showHeaders, needsContentTypeHeader, + basicAuth: Boolean(operation.progAccess?.basicAuth), statusCodes, codeExamples, } diff --git a/src/article-api/transformers/search-page-transformer.ts b/src/article-api/transformers/search-page-transformer.ts index d489a2ebcf78..d5b71f206303 100644 --- a/src/article-api/transformers/search-page-transformer.ts +++ b/src/article-api/transformers/search-page-transformer.ts @@ -16,7 +16,7 @@ export class SearchPageTransformer implements PageTransformer { Use the Search API to search programmatically: \`\`\` -curl "https://docs.github.com/api/search?query=actions&language=en&version=free-pro-team@latest" +curl "https://docs.github.com/api/search/v1?query=actions&client_name=docs-api-example&language=en&version=free-pro-team@latest" \`\`\` ` } diff --git a/src/content-linter/lib/linting-rules/early-access-references.ts b/src/content-linter/lib/linting-rules/early-access-references.ts index 327487f64a79..39efb87c50f9 100644 --- a/src/content-linter/lib/linting-rules/early-access-references.ts +++ b/src/content-linter/lib/linting-rules/early-access-references.ts @@ -1,15 +1,10 @@ import { addError } from 'markdownlint-rule-helpers' -import { dump } from 'js-yaml' +import { EVENT_ID, getScalarValue, parseEvents } from 'js-yaml' +import type { Event } from 'js-yaml' -import { getRange, getFrontmatter } from '../helpers/utils' +import { getRange, getFrontmatterLines } from '../helpers/utils' import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types' -interface Frontmatter { - redirect_from?: string | string[] - children?: string[] - [key: string]: unknown -} - const ERROR_MESSAGE = 'An early access reference appears to be used in a non-early access doc. Remove early access references or disable this rule.' @@ -56,31 +51,65 @@ export const frontmatterEarlyAccessReferences: Rule = { const filepath = params.name if (isEarlyAccessFilepath(filepath)) return - const fm = getFrontmatter(params.lines) as Frontmatter | null - if (!fm) return + if (params.lines[0] !== '---') return // redirect_from preserves early-access paths for redirects. - delete fm.redirect_from - + const skippedKeys = new Set(['redirect_from']) // The home page links to early access content, and fixture indexes mirror that pattern. if (filepath === 'content/index.md' || filepath.includes('fixtures/content/index.md')) - delete fm.children + skippedKeys.add('children') - // YAML serialization lets the rule search every remaining frontmatter value. - const fmStrings = dump(fm).split('\n') + // Parser events exclude YAML comments and give source offsets for quoted, folded, and block values. + const fmLines = getFrontmatterLines(params.lines) + const source = fmLines.slice(1, -1).join('\n') + let events: Event[] + try { + events = parseEvents(source, {}) + } catch { + // Other frontmatter rules and page loading report invalid YAML. + return + } - for (const line of fmStrings) { - const matches = line.match(EARLY_ACCESS_REGEX) - if (matches && !EARLY_ACCESS_ARTICLE_REGEX.test(line)) { - for (const match of matches) { - const lineNumber = params.lines.indexOf(line) + 1 - const range = getRange(line, match) + const lineStarts = [0] + for (let i = 0; i < source.length; i++) if (source[i] === '\n') lineStarts.push(i + 1) + const containers: { isMapping: boolean; count: number }[] = [] + let topLevelKey = '' + for (const event of events) { + if (event.type === EVENT_ID.POP) { + containers.pop() + continue + } + const parent = containers.at(-1) + const isTopLevelKey = + containers.length === 2 && parent?.isMapping === true && parent.count % 2 === 0 + if (parent) parent.count++ + if (event.type !== EVENT_ID.SCALAR) { + if (isTopLevelKey) topLevelKey = '' + if (event.type !== EVENT_ID.ALIAS) { + containers.push({ isMapping: event.type === EVENT_ID.MAPPING, count: 0 }) + } + continue + } + if (isTopLevelKey) topLevelKey = getScalarValue(source, event) + if (skippedKeys.has(topLevelKey)) continue + + for (let i = 0; i < lineStarts.length; i++) { + const lineStart = lineStarts[i] + const lineEnd = i + 1 < lineStarts.length ? lineStarts[i + 1] - 1 : source.length + const segmentStart = Math.max(event.valueStart, lineStart) + const segmentEnd = Math.min(event.valueEnd, lineEnd) + if (segmentStart >= segmentEnd) continue + + const segment = source.slice(segmentStart, segmentEnd) + if (EARLY_ACCESS_ARTICLE_REGEX.test(segment)) continue + const line = fmLines[i + 1] + for (const match of segment.matchAll(EARLY_ACCESS_REGEX)) { addError( onError, - lineNumber, + i + 2, ERROR_MESSAGE, line, - range, + [segmentStart - lineStart + match.index + 1, match[0].length], null, // No fix possible ) } diff --git a/src/content-linter/lib/linting-rules/frontmatter-children.ts b/src/content-linter/lib/linting-rules/frontmatter-children.ts index f09c80f1a93e..677754bf5bba 100644 --- a/src/content-linter/lib/linting-rules/frontmatter-children.ts +++ b/src/content-linter/lib/linting-rules/frontmatter-children.ts @@ -32,6 +32,12 @@ function isValidChildPath(childPath: string, currentFilePath: string): boolean { return false } + // Early access content comes from a separate repo that may not be cloned, so create-tree only warns when it is missing. + const earlyAccessDir = path.join(contentDir, 'early-access') + if (resolvedPath === earlyAccessDir || resolvedPath.startsWith(earlyAccessDir + path.sep)) { + return true + } + const mdPath = `${resolvedPath}.md` if (fs.existsSync(mdPath) && fs.statSync(mdPath).isFile()) { return true diff --git a/src/content-linter/lib/linting-rules/frontmatter-curly-quotes.ts b/src/content-linter/lib/linting-rules/frontmatter-curly-quotes.ts index a6b04cbb37de..2ddfd9d97896 100644 --- a/src/content-linter/lib/linting-rules/frontmatter-curly-quotes.ts +++ b/src/content-linter/lib/linting-rules/frontmatter-curly-quotes.ts @@ -1,19 +1,57 @@ +import { isDeepStrictEqual } from 'node:util' import { addError } from 'markdownlint-rule-helpers' +import { load } from 'js-yaml' import { getFrontmatterLines } from '../helpers/utils' import type { RuleParams, RuleErrorCallback, Rule } from '@/content-linter/types' const CURLY_QUOTE_PATTERN = /[\u2018\u2019\u201c\u201d]/g -const CURLY_QUOTE_MAP: Record = { - '\u2018': "'", - '\u2019': "'", - '\u201c': '"', - '\u201d': '"', -} const CHECKED_FIELDS = ['title', 'intro'] const BLOCK_SCALAR_PATTERN = /^\s*[|>][-+1-9]*\s*(#.*)?$/ +function toStraight(text: string, single: string, double: string): string { + return text.replace(CURLY_QUOTE_PATTERN, (ch) => + ch === '\u2018' || ch === '\u2019' ? single : double, + ) +} + +// The replacement must respect the YAML quote style, or a bare quote can end the string early. +function getFixedLine(fmLines: string[], idx: number, field: string): string | undefined { + const line = fmLines[idx] + const match = line.match(new RegExp(`^(\\s*${field}:\\s*)(.*)$`)) + if (!match) return undefined + const [, prefix, value] = match + + let fixedValue: string + if (value.startsWith("'")) { + fixedValue = toStraight(value, "''", '"') + } else if (value.startsWith('"')) { + fixedValue = toStraight(value, "'", '\\"') + } else { + fixedValue = toStraight(value, "'", '"') + if (fixedValue.startsWith("'") || fixedValue.startsWith('"')) { + fixedValue = `'${fixedValue.replace(/'/g, "''")}'` + } + } + const fixedLine = prefix + fixedValue + + // Parse the whole frontmatter because a value can continue onto the next lines. + const fixedLines = [...fmLines] + fixedLines[idx] = fixedLine + try { + const original = load(fmLines.slice(1, -1).join('\n')) as Record + const fixed = load(fixedLines.slice(1, -1).join('\n')) + const originalValue = original[field] + if (typeof originalValue !== 'string') return undefined + const expected = { ...original, [field]: toStraight(originalValue, "'", '"') } + if (!isDeepStrictEqual(fixed, expected)) return undefined + } catch { + return undefined + } + return fixedLine +} + export const frontmatterCurlyQuotes: Rule = { names: ['GHD034', 'frontmatter-curly-quotes'], description: 'Frontmatter title and intro should not contain curly quotes', @@ -24,11 +62,16 @@ export const frontmatterCurlyQuotes: Rule = { const fmStart = params.lines.indexOf('---') - const checkLine = (line: string, lineNumber: number, field: string) => { + const checkLine = ( + line: string, + lineNumber: number, + field: string, + getFix: () => string | undefined, + ) => { if (!CURLY_QUOTE_PATTERN.test(line)) return CURLY_QUOTE_PATTERN.lastIndex = 0 - const fixedLine = line.replace(CURLY_QUOTE_PATTERN, (ch) => CURLY_QUOTE_MAP[ch] || ch) + const fixedLine = getFix() addError( onError, @@ -36,12 +79,14 @@ export const frontmatterCurlyQuotes: Rule = { `Curly quotes in '${field}' cause mojibake in plain-text contexts like llms.txt. Use straight quotes instead.`, line, [1, line.length], - { - lineNumber, - editColumn: 1, - deleteCount: line.length, - insertText: fixedLine, - }, + fixedLine === undefined + ? undefined + : { + lineNumber, + editColumn: 1, + deleteCount: line.length, + insertText: fixedLine, + }, ) } @@ -50,7 +95,7 @@ export const frontmatterCurlyQuotes: Rule = { if (idx === -1) continue const line = fmLines[idx] - checkLine(line, fmStart + idx + 1, field) + checkLine(line, fmStart + idx + 1, field, () => getFixedLine(fmLines, idx, field)) const value = line.trim().slice(field.length + 1) if (!BLOCK_SCALAR_PATTERN.test(value)) continue @@ -60,7 +105,8 @@ export const frontmatterCurlyQuotes: Rule = { for (let j = idx + 1; j < fmLines.length - 1; j++) { const next = fmLines[j] if (next.trim() && next.length - next.trimStart().length <= keyIndent) break - checkLine(next, fmStart + j + 1, field) + // Block scalar text is literal, so straight quotes need no YAML escaping. + checkLine(next, fmStart + j + 1, field, () => toStraight(next, "'", '"')) } } }, diff --git a/src/content-linter/lib/linting-rules/image-alt-text-exclude-start-words.ts b/src/content-linter/lib/linting-rules/image-alt-text-exclude-start-words.ts index b4ecea14d1a4..77b04477cb64 100644 --- a/src/content-linter/lib/linting-rules/image-alt-text-exclude-start-words.ts +++ b/src/content-linter/lib/linting-rules/image-alt-text-exclude-start-words.ts @@ -12,15 +12,15 @@ export const imageAltTextExcludeStartWords: Rule = { parser: 'markdownit', function: (params: RuleParams, onError: RuleErrorCallback) => { forEachInlineChild(params, 'image', function forToken(token: MarkdownToken) { - // Empty alt text belongs to image-alt-text-length and cannot produce a range. - if (!token.content) return + const imageAltText = token.content?.trim() - const imageAltText = token.content.trim() + // Empty alt text belongs to image-alt-text-length and cannot produce a range. + if (!imageAltText) return - const range = getRange(token.line, imageAltText) if ( excludeStartWords.some((excludeWord) => imageAltText.toLowerCase().startsWith(excludeWord)) ) { + const range = getRange(token.line, imageAltText) addError( onError, token.lineNumber, diff --git a/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts b/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts index 30b176e2b028..9abc73c5820d 100644 --- a/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts +++ b/src/content-linter/lib/linting-rules/liquid-ifversion-versions.ts @@ -59,6 +59,8 @@ type CondTagItem = { versionsObj: VersionsObject featureVersionsObj?: VersionsObject versionsObjAll: VersionsObject + // A bare ghes in an and chain, such as ghes and ghes != 3.20, keeps fpt and ghec excluded. + ghesGuard: boolean versions: string[] action: CondTagAction // addError accepts this cached range, but this rule never sets it. @@ -314,6 +316,7 @@ async function initTagObject( versionsObj: {}, featureVersionsObj: undefined, versionsObjAll: {}, + ghesGuard: false, versions: [], action: { type: 'none' }, } @@ -326,14 +329,57 @@ async function initTagObject( ...condTagItem.versionsObj, ...condTagItem.featureVersionsObj, } - condTagItem.versions = getApplicableVersions(condTagItem.versionsObj, '', { - doNotThrow: true, - includeNextVersion: true, - }) + condTagItem.ghesGuard = condTagItem.cond + .split(' or ') + .some( + (part) => + part.includes(' and ') && part.split(' and ').some((and) => and.trim() === 'ghes'), + ) + condTagItem.versions = getApplicableVersions( + toSemverVersionsObj(condTagItem.versionsObj, condTagItem.ghesGuard), + '', + { doNotThrow: true, includeNextVersion: true }, + ) } return condTagItem } +// semver has no != comparator, so split each != term into < and > branches. +// The ifversion tag renders a version comparison as false on other plans, except !=, which +// renders as true. So a GHES range made only of != terms also applies to fpt and ghec. +function toSemverVersionsObj(versionsObj: VersionsObject, ghesGuard: boolean): VersionsObject { + const ghes = versionsObj.ghes + if (!ghes || !ghes.includes('!=')) return versionsObj + const terms = rangeTerms(ghes) + const notEqualReleases = terms + .filter(([operator]) => operator === '!=') + .map(([, release]) => release) + const otherTerms = terms + .filter(([operator]) => operator !== '!=') + .map(([operator, release]) => `${operator} ${release}`) + let branches: string[][] = [otherTerms] + for (const release of notEqualReleases) { + branches = branches.flatMap((branch) => [ + [...branch, `< ${release}`], + [...branch, `> ${release}`], + ]) + } + const semverRange = branches.map((branch) => branch.join(' ')).join(' || ') + return otherTerms.length || ghesGuard + ? { ...versionsObj, ghes: semverRange } + : { ...versionsObj, fpt: '*', ghec: '*', ghes: semverRange } +} + +// Dropping the last bound beside != terms would make the condition render true on fpt and ghec. +function widensToOtherPlans(range: string, simplifiedRange: string): boolean { + return !isNotEqualOnly(range) && isNotEqualOnly(simplifiedRange) +} + +function isNotEqualOnly(range: string): boolean { + const terms = rangeTerms(range) + return terms.length > 0 && terms.every(([operator]) => operator === '!=') +} + // Resolve each feature through getFeatureVersionsObject, which reads ROOT at call time. // Frontmatter can combine plans with a feature, list several features, or use full plan names. function getResolvedVersions(versionsObj: FileVersionsFm): string[] { @@ -497,13 +543,16 @@ function updateConditionals(condTagItems: CondTagItem[]) { } // Also drop deprecated lower bounds so one fix pass leaves no GHD022 error. if (item.versionsObj.ghes && item.versionsObj.ghes !== '*') { - item.versionsObj.ghes = getSimplifiedSemverRange( + const simplifiedGhes = getSimplifiedSemverRange( rangeTerms(item.versionsObj.ghes) .map((term) => term.join(' ')) .join(' '), ) + if (!widensToOtherPlans(item.versionsObj.ghes, simplifiedGhes)) { + item.versionsObj.ghes = simplifiedGhes + } } - item.action.cond = toLiquidCondition(item.versionsObj) + item.action.cond = toLiquidCondition(item.versionsObj, item.ghesGuard) item.action.type = 'change' continue } @@ -512,11 +561,12 @@ function updateConditionals(condTagItems: CondTagItem[]) { if (!item.versionsObjAll.ghes || item.versionsObjAll.ghes === '*') continue const simplifiedSemver = getSimplifiedSemverRange(item.versionsObjAll.ghes) + if (widensToOtherPlans(item.versionsObjAll.ghes, simplifiedSemver)) continue // Drop GHES from the conditional when other products still apply. if (simplifiedSemver === '' && Object.keys(item.versionsObj).length > 1) { item.action.type = 'change' delete item.versionsObj.ghes - item.action.cond = toLiquidCondition(item.versionsObj) + item.action.cond = toLiquidCondition(item.versionsObj, item.ghesGuard) continue } @@ -524,7 +574,7 @@ function updateConditionals(condTagItems: CondTagItem[]) { if (item.versionsObjAll.ghes !== simplifiedSemver && !item.versionsObjAll.feature) { item.action.type = 'change' item.versionsObj.ghes = simplifiedSemver - item.action.cond = toLiquidCondition(item.versionsObj) + item.action.cond = toLiquidCondition(item.versionsObj, item.ghesGuard) } } @@ -562,11 +612,12 @@ function updateConditionals(condTagItems: CondTagItem[]) { // Turn a versions object back into Liquid, such as { ghes: '> 3.18 < 3.22', fpt: '*' } // into 'ghes > 3.18 and ghes < 3.22 or fpt'. -function toLiquidCondition(versionsObj: VersionsObject): string { +function toLiquidCondition(versionsObj: VersionsObject, ghesGuard = false): string { return Object.entries(versionsObj) .map(([key, value]) => { if (key === 'feature') return value const terms = rangeTerms(value).map(([operator, release]) => `${key} ${operator} ${release}`) + if (key === 'ghes' && ghesGuard && isNotEqualOnly(value)) terms.unshift(key) return terms.length ? terms.join(' and ') : key }) .join(' or ') diff --git a/src/content-linter/lib/linting-rules/table-column-integrity.ts b/src/content-linter/lib/linting-rules/table-column-integrity.ts index 51682abde73d..7d39cb4541da 100644 --- a/src/content-linter/lib/linting-rules/table-column-integrity.ts +++ b/src/content-linter/lib/linting-rules/table-column-integrity.ts @@ -4,12 +4,15 @@ import frontmatter from '@/frame/lib/read-frontmatter' import type { RuleParams, RuleErrorCallback } from '@/content-linter/types' -// After optional leading whitespace, a candidate table row opens and closes -// with a pipe. -const TABLE_ROW_REGEX = /^\s*\|.*\|\s*$/ -// Between the boundary pipes, a candidate separator holds only pipes, colons, +// After optional leading whitespace, a candidate table row opens with a pipe. +// GFM does not require a closing pipe. +const TABLE_ROW_REGEX = /^\s*\|/ +// After the opening pipe, a candidate separator holds only pipes, colons, // hyphens, and whitespace. -const TABLE_SEPARATOR_REGEX = /^\s*\|[\s\-:|\s]*\|\s*$/ +const TABLE_SEPARATOR_REGEX = /^\s*\|[\s\-:|\s]*$/ +// Trailing HTML comments are often markdownlint directives rather than cell content. +// Comment bodies exclude "|)+\s*$/ // A Liquid-only cell is whitespace, a Liquid tag, then whitespace. const LIQUID_ONLY_CELL_REGEX = /^\s*{%\s*(ifversion|else|endif|elsif|for|endfor).*%}\s*$/ // Escaped pipes (\|) are cell content, not separators. @@ -34,6 +37,12 @@ function countColumns(row: string): number { return cells.length } +// Without a closing pipe, a trailing comment can be the content of the last cell, +// so return the count without trailing comments and the count with them. +function countColumnOptions(row: string): [number, number] { + return [countColumns(row.replace(TRAILING_HTML_COMMENTS_REGEX, '')), countColumns(row)] +} + function isLiquidOnlyRow(row: string): boolean { const trimmed = row.trim() if (!trimmed.includes('|')) return false @@ -66,7 +75,7 @@ export const tableColumnIntegrity = { let expectedColumnCount: number | null = null for (let i = 0; i < lines.length; i++) { - const line = lines[i] + const line = lines[i].replace(TRAILING_HTML_COMMENTS_REGEX, '') if (line.trim().startsWith('```')) { inCodeFence = !inCodeFence @@ -82,10 +91,15 @@ export const tableColumnIntegrity = { if (!inTable && isTableRow) { // A following separator row confirms the current row starts a table. - const nextLine = lines[i + 1] + const nextLine = lines[i + 1]?.replace(TRAILING_HTML_COMMENTS_REGEX, '') if (nextLine && TABLE_SEPARATOR_REGEX.test(nextLine)) { inTable = true - expectedColumnCount = countColumns(line) + const [headerCount, headerCountWithComments] = countColumnOptions(lines[i]) + // GFM requires the header and separator to have the same number of cells. + expectedColumnCount = + headerCountWithComments === countColumns(nextLine) + ? headerCountWithComments + : headerCount continue } } @@ -102,9 +116,12 @@ export const tableColumnIntegrity = { continue } - const actualColumnCount = countColumns(line) + const [actualColumnCount, actualCountWithComments] = countColumnOptions(lines[i]) - if (actualColumnCount !== expectedColumnCount) { + if ( + actualColumnCount !== expectedColumnCount && + actualCountWithComments !== expectedColumnCount + ) { const range = getRange(line, line.trim()) let errorMessage diff --git a/src/content-linter/tests/fixtures/frontmatter-children/early-access-child.md b/src/content-linter/tests/fixtures/frontmatter-children/early-access-child.md new file mode 100644 index 000000000000..6ae5bbd7faea --- /dev/null +++ b/src/content-linter/tests/fixtures/frontmatter-children/early-access-child.md @@ -0,0 +1,8 @@ +--- +title: Missing early access child +children: + - /content/get-started/foo + - /content/early-access/not-cloned +--- + +Early access content lives in a separate repository that might not be cloned. diff --git a/src/content-linter/tests/unit/early-access-references.ts b/src/content-linter/tests/unit/early-access-references.ts index cb6209e80094..b442446512bd 100644 --- a/src/content-linter/tests/unit/early-access-references.ts +++ b/src/content-linter/tests/unit/early-access-references.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' +import { getFrontmatter } from '../../lib/helpers/utils' import { earlyAccessReferences, frontmatterEarlyAccessReferences, @@ -51,4 +52,93 @@ describe(frontmatterEarlyAccessReferences.names.join(' - '), () => { const errors = result[FIXTURE_FILEPATH_EA] expect(errors.length).toBe(0) }) + test('reports source line numbers for quoted and folded values', async () => { + const markdown = [ + '---', + "title: 'About early-access features'", + 'intro: "Join the early-access program"', + 'shortTitle: >-', + ' A folded value that mentions', + ' early-access on a continuation line', + 'redirect_from:', + ' - /early-access/old', + '---', + '', + ].join('\n') + const result = await runRule(frontmatterEarlyAccessReferences, { + strings: { markdown }, + ...fmOptions, + }) + const lineNumbers = result.markdown.map((error) => error.lineNumber) + expect(lineNumbers).toEqual([2, 3, 6]) + }) + test('home page children can reference early access', async () => { + const markdown = ['---', 'title: Home', 'children:', ' - early-access', '---', ''].join('\n') + const result = await runRule(frontmatterEarlyAccessReferences, { + strings: { 'content/index.md': markdown }, + ...fmOptions, + }) + expect(result['content/index.md']).toEqual([]) + }) + test('quoted skipped keys can reference early access', async () => { + const markdown = [ + '---', + 'title: Home', + '"redirect_from":', + ' - /early-access/double', + "'children':", + ' - early-access', + '---', + '', + ].join('\n') + const result = await runRule(frontmatterEarlyAccessReferences, { + strings: { 'content/index.md': markdown }, + ...fmOptions, + }) + expect(result['content/index.md']).toEqual([]) + const swapped = markdown + .replace('"redirect_from"', "'redirect_from'") + .replace("'children'", '"children"') + const swappedResult = await runRule(frontmatterEarlyAccessReferences, { + strings: { 'content/index.md': swapped }, + ...fmOptions, + }) + expect(swappedResult['content/index.md']).toEqual([]) + }) + test('ignores YAML comments and checks block scalar lines that start with #', async () => { + const markdown = [ + '---', + 'title: Public # early-access reminder', + '# early-access comment', + 'intro: |', + ' # [Preview](/early-access/secret)', + '---', + '', + ].join('\n') + const result = await runRule(frontmatterEarlyAccessReferences, { + strings: { markdown }, + ...fmOptions, + }) + expect(result.markdown.map((error) => error.lineNumber)).toEqual([5]) + expect(result.markdown[0].errorRange).toEqual([16, 12]) + }) + test('does not remove frontmatter keys that later rules read', async () => { + const markdown = [ + '---', + 'title: Home', + 'redirect_from:', + ' - /early-access/old', + 'children:', + ' - early-access', + '---', + '', + ].join('\n') + await runRule(frontmatterEarlyAccessReferences, { + strings: { 'content/index.md': markdown }, + ...fmOptions, + }) + const fm = getFrontmatter(markdown.split('\n')) + expect(fm?.redirect_from).toEqual(['/early-access/old']) + expect(fm?.children).toEqual(['early-access']) + }) }) diff --git a/src/content-linter/tests/unit/frontmatter-children.ts b/src/content-linter/tests/unit/frontmatter-children.ts index 32fa9eca920f..b45ef3727012 100644 --- a/src/content-linter/tests/unit/frontmatter-children.ts +++ b/src/content-linter/tests/unit/frontmatter-children.ts @@ -7,6 +7,8 @@ const VALID_CONTENT_PREFIX = 'src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md' const INVALID_PATHS = 'src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md' const NO_CHILDREN = 'src/content-linter/tests/fixtures/frontmatter-children/no-children.md' +const EARLY_ACCESS_CHILD = + 'src/content-linter/tests/fixtures/frontmatter-children/early-access-child.md' const ruleName = frontmatterChildren.names[1] @@ -50,4 +52,12 @@ describe(ruleName, () => { expect(result[INVALID_PATHS][0].errorDetail).toContain('/content/nonexistent/product') expect(result[INVALID_PATHS][0].errorDetail).toContain('/another/invalid/path') }) + + test('missing early access children pass', async () => { + const result = await runRule(frontmatterChildren, { + files: [EARLY_ACCESS_CHILD], + ...fmOptions, + }) + expect(result[EARLY_ACCESS_CHILD]).toEqual([]) + }) }) diff --git a/src/content-linter/tests/unit/frontmatter-curly-quotes.ts b/src/content-linter/tests/unit/frontmatter-curly-quotes.ts index d44c579358ca..bb5fff9f75f1 100644 --- a/src/content-linter/tests/unit/frontmatter-curly-quotes.ts +++ b/src/content-linter/tests/unit/frontmatter-curly-quotes.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'vitest' +import { load } from 'js-yaml' import { runRule } from '../../lib/init-test' import { frontmatterCurlyQuotes } from '../../lib/linting-rules/frontmatter-curly-quotes' @@ -91,6 +92,86 @@ describe(frontmatterCurlyQuotes.names.join(' - '), () => { expect(errors.length).toBe(2) }) + const fixCases = [ + { + name: 'single-quoted value with curly apostrophe', + line: "intro: 'Learn about the repository\u2019s settings.'", + field: 'intro', + expected: "Learn about the repository's settings.", + }, + { + name: 'double-quoted value with curly double quotes', + line: 'title: "Use the \u201cupstream\u201d remote"', + field: 'title', + expected: 'Use the "upstream" remote', + }, + { + name: 'plain value that starts with a curly double quote', + line: 'title: \u201cUpstream\u201d remotes', + field: 'title', + expected: '"Upstream" remotes', + }, + { + name: 'single-quoted value with curly double quotes', + line: "intro: 'About the \u201cupstream\u201d repo.'", + field: 'intro', + expected: 'About the "upstream" repo.', + }, + { + name: 'double-quoted value with curly apostrophe', + line: 'title: "Your organization\u2019s settings"', + field: 'title', + expected: "Your organization's settings", + }, + { + name: 'plain value with curly apostrophe', + line: 'title: Managing your repository\u2019s settings', + field: 'title', + expected: "Managing your repository's settings", + }, + ] + + for (const { name, line, field, expected } of fixCases) { + test(`fix produces valid YAML for ${name}`, async () => { + const strings = { test: ['---', line, '---', '', 'Some content.'].join('\n') } + const result = await runRule(frontmatterCurlyQuotes, { strings, ...fmOptions }) + const errors = result.test + expect(errors.length).toBe(1) + expect(errors[0].fixInfo).toBeTruthy() + const parsed = load(errors[0].fixInfo!.insertText!) as Record + expect(parsed[field]).toBe(expected) + }) + } + + test('fix keeps a quoted value spanning multiple lines valid', async () => { + const fm = ['intro: "Learn about the \u201cupstream\u201d', ' repository."'] + const strings = { test: ['---', ...fm, '---', '', 'Some content.'].join('\n') } + const result = await runRule(frontmatterCurlyQuotes, { strings, ...fmOptions }) + const errors = result.test + expect(errors.length).toBe(1) + expect(errors[0].fixInfo).toBeTruthy() + const fixed = [errors[0].fixInfo!.insertText!, ...fm.slice(1)].join('\n') + const parsed = load(fixed) as Record + expect(parsed.intro).toBe('Learn about the "upstream" repository.') + }) + + test('plain value continued on the next line is reported without a fix', async () => { + const strings = { + test: [ + '---', + 'intro: \u201cUpstream\u201d remotes', + ' and forks', + '---', + '', + 'Some content.', + ].join('\n'), + } + const result = await runRule(frontmatterCurlyQuotes, { strings, ...fmOptions }) + const errors = result.test + expect(errors.length).toBe(1) + expect(errors[0].fixInfo).toBeFalsy() + }) + test('curly quotes in block scalar intro trigger warning', async () => { const strings = { test: [ diff --git a/src/content-linter/tests/unit/image-alt-text-exclude-start-words.ts b/src/content-linter/tests/unit/image-alt-text-exclude-start-words.ts index b63b6674828b..3194768fb143 100644 --- a/src/content-linter/tests/unit/image-alt-text-exclude-start-words.ts +++ b/src/content-linter/tests/unit/image-alt-text-exclude-start-words.ts @@ -41,4 +41,10 @@ describe(imageAltTextExcludeStartWords.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(0) }) + test('image alt text that is only whitespace', async () => { + const markdown = ['![ ](/images/x.png)', '![\t](/images/x.png)'].join('\n') + const result = await runRule(imageAltTextExcludeStartWords, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(0) + }) }) diff --git a/src/content-linter/tests/unit/liquid-ifversion-versions.ts b/src/content-linter/tests/unit/liquid-ifversion-versions.ts index 4802a6786b28..cc47a4f5d497 100644 --- a/src/content-linter/tests/unit/liquid-ifversion-versions.ts +++ b/src/content-linter/tests/unit/liquid-ifversion-versions.ts @@ -2,7 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { liquidIfversionVersions } from '../../lib/linting-rules/liquid-ifversion-versions' -import { supported } from '@/versions/lib/enterprise-server-releases' +import { deprecated, supported } from '@/versions/lib/enterprise-server-releases' describe(liquidIfversionVersions.names.join(' - '), () => { const envVarValueBefore: string | undefined = process.env.ROOT @@ -385,6 +385,132 @@ describe(liquidIfversionVersions.names.join(' - '), () => { expect(errors[0].fixInfo?.insertText).toBe('ifversion ghec') }) + test.each([ + `{% ifversion ghes != ${supported[2]} %}A{% endif %}`, + `{% ifversion ghes != ${supported[2]} %}A{% else %}B{% endif %}`, + `{% ifversion ghes != ${supported[2]} and ghes > ${supported[4]} %}A{% else %}B{% endif %}`, + `{% ifversion ghes != ${supported[2]} and ghes != ${supported[1]} %}A{% else %}B{% endif %}`, + ])('ifversion using != keeps the content: %s', async (liquid) => { + const markdown = [...placeholderAllVersionsFm, liquid].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + expect(result.markdown).toEqual([]) + }) + + test('ifversion using != on a GHES-only page keeps the content', async () => { + const markdown = [ + '---', + 'title: "Hello"', + 'versions:', + ' ghes: "*"', + '---', + `{% ifversion ghes != ${supported[2]} %}A{% else %}B{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + expect(result.markdown).toEqual([]) + }) + + test('ifversion using != with a deprecated release applies to all versions', async () => { + const markdown = [ + ...placeholderAllVersionsFm, + `{% ifversion ghes != ${deprecated[0]} %}A{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors.length).toBe(2) + expect(errors[0].errorDetail).toContain('applies to all versions') + }) + + test.each([ + `{% ifversion ghes and ghes != ${supported[2]} %}A{% else %}B{% endif %}`, + `{% ifversion ghes != ${supported[2]} and ghes %}A{% else %}B{% endif %}`, + ])('ifversion using != with a ghes guard applies to no cloud versions: %s', async (liquid) => { + const markdown = [ + '---', + 'title: "Hello"', + 'versions:', + ' fpt: "*"', + ' ghec: "*"', + '---', + liquid, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors[0].errorDetail).toContain('applies to no versions') + expect(errors.some((error) => error.errorDetail.includes('applies to all versions'))).toBe(true) + }) + + test.each([ + `{% ifversion ghes and ghes != ${deprecated[0]} %}A{% else %}B{% endif %}`, + `{% ifversion ghes != ${deprecated[0]} and ghes %}A{% else %}B{% endif %}`, + ])('ifversion using != with a ghes guard keeps the else content: %s', async (liquid) => { + const markdown = [...placeholderAllVersionsFm, liquid].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + expect(result.markdown).toEqual([]) + }) + + test('ifversion using != keeps the ghes guard when removing a product', async () => { + const markdown = [ + '---', + 'title: "Hello"', + 'versions:', + ' fpt: "*"', + ' ghes: "*"', + '---', + `{% ifversion ghes and ghes != ${supported[0]} or ghec %}A{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].fixInfo?.insertText).toBe(`ifversion ghes and ghes != ${supported[0]}`) + }) + + test('ifversion using != keeps a deprecated lower bound so fpt and ghec stay excluded', async () => { + const markdown = [ + ...placeholderAllVersionsFm, + `{% ifversion ghes != ${supported[0]} and ghes > ${deprecated[0]} %}A{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors.map((error) => error.fixInfo?.insertText)).not.toContain( + `ifversion ghes != ${supported[0]}`, + ) + expect(errors).toEqual([]) + }) + + test('ifversion using != keeps a deprecated lower bound when removing a product', async () => { + const markdown = [ + '---', + 'title: "Hello"', + 'versions:', + ' fpt: "*"', + ' ghes: "*"', + '---', + `{% ifversion ghes != ${supported[0]} and ghes > ${deprecated[0]} or ghec %}A{% endif %}`, + ].join('\n') + const result = await runRule(liquidIfversionVersions, { + strings: { markdown }, + }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].fixInfo?.insertText).toBe( + `ifversion ghes != ${supported[0]} and ghes > ${deprecated[0]}`, + ) + }) + test('does not crash with nested if blocks inside ifversion', async () => { const markdown = [ ...placeholderAllVersionsFm, diff --git a/src/content-linter/tests/unit/table-column-integrity-simple.ts b/src/content-linter/tests/unit/table-column-integrity-simple.ts index 14137e8709e8..bca8bc9cc861 100644 --- a/src/content-linter/tests/unit/table-column-integrity-simple.ts +++ b/src/content-linter/tests/unit/table-column-integrity-simple.ts @@ -52,6 +52,91 @@ describe(tableColumnIntegrity.names.join(' - '), () => { } }) + test('Rows after a row without a closing pipe are still checked', async () => { + const markdown = ['| A | B |', '| --- | --- |', '| x | y', '| p | q | r |'].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(4) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Tables whose header and separator lack closing pipes are checked', async () => { + const markdown = ['| A | B', '| --- | ---', '| p | q | r |'].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(3) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Trailing HTML comments are not counted as cells', async () => { + const markdown = [ + '| Option | Description |', + '|--------|-------------|', + '| `--json` | Emit JSON. | ', + '| `--help` | Show help. | Extra |', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(4) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Multiple trailing HTML comments are not counted as cells', async () => { + const markdown = [ + '| A | B | ', + '| --- | --- |', + '| x | y | ', + '| p | q | r |', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(4) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Comment openers inside code spans do not hide cells', async () => { + const markdown = [ + '| Syntax | Meaning |', + '| --- | --- |', + '| `', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(0) + }) + + test('Comment-only final cells without a closing pipe are counted', async () => { + const markdown = [ + '| A | ', + '| --- | --- |', + '| value | ', + '| x | y |', + '| p | q | r |', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(1) + expect(errors[0].lineNumber).toBe(5) + expect(errors[0].errorDetail).toContain('Table row has 3 columns but header has 2') + }) + + test('Valid table mixing rows with and without closing pipes passes', async () => { + const markdown = [ + '| Name | Status', + '| ---- | ------', + '| Alpha | Active |', + '| Beta | Inactive', + '| Gamma | Active |', + ].join('\n') + const result = await runRule(tableColumnIntegrity, { strings: { markdown } }) + const errors = result.markdown + expect(errors.length).toBe(0) + }) + test('Liquid-only rows are ignored', async () => { const markdown = [ '| Feature | Status |', diff --git a/src/content-pipelines/state/copilot-cli.sha b/src/content-pipelines/state/copilot-cli.sha index 8ab18a85c9e2..8b56a4ad4020 100644 --- a/src/content-pipelines/state/copilot-cli.sha +++ b/src/content-pipelines/state/copilot-cli.sha @@ -1 +1 @@ -8860daf23f911fa47684866555ce82856e79a14a +4fe31058ce5a3e55180ccda41fc625afcb7f5ead diff --git a/src/content-render/lib/is-path-within.ts b/src/content-render/lib/is-path-within.ts new file mode 100644 index 000000000000..ea6ac727d5f6 --- /dev/null +++ b/src/content-render/lib/is-path-within.ts @@ -0,0 +1,10 @@ +import path from 'path' + +// Compare whole path segments so content/foo does not also match content/foo-bar. +export function isPathWithin(file: string, target: string): boolean { + const normalizedFile = path.normalize(file) + const normalizedTarget = path.normalize(target).replace(/[\\/]+$/, '') + return ( + normalizedFile === normalizedTarget || normalizedFile.startsWith(normalizedTarget + path.sep) + ) +} diff --git a/src/content-render/scripts/move-content.ts b/src/content-render/scripts/move-content.ts index 07aab6e2096e..da4b94b4290e 100755 --- a/src/content-render/scripts/move-content.ts +++ b/src/content-render/scripts/move-content.ts @@ -7,7 +7,7 @@ // Run npm run move-content --help for options. // Run file: npm run move-content PATH/TO/CURRENT-FILE.md PATH/TO/DESIRED-FILE-LOCATION-OR-NAME.md. // Run folder: npm run move-content PATH/TO/CURRENT-FOLDER PATH/TO/DESIRED-FOLDER-LOCATION-OR-NAME. -// Undo: npm run move-content --undo PATH/TO/OLD PATH/TO/NEW. +// Undo: npm run move-content -- --undo PATH/TO/OLD PATH/TO/NEW. import fs from 'fs' import path from 'path' @@ -161,10 +161,13 @@ async function main(opts: MoveOptions, nameTuple: string[]) { } } + // oldHref and newHref keep the forward direction for undoFiles, so swap them to revert links. + const [fromHref, toHref] = undo ? [newHref, oldHref] : [oldHref, newHref] + // featuredLinks updates need old and new hrefs, not whether the path is a file or folder. - changeFeaturedLinks(oldHref, newHref) + changeFeaturedLinks(fromHref, toHref) - changeHomepageLinks(oldHref, newHref, verbose) + changeHomepageLinks(fromHref, toHref, verbose) if (!undo) { if (verbose) { diff --git a/src/content-render/scripts/reusables-cli/README.md b/src/content-render/scripts/reusables-cli/README.md index eae841f38b02..75dd77bf6473 100644 --- a/src/content-render/scripts/reusables-cli/README.md +++ b/src/content-render/scripts/reusables-cli/README.md @@ -20,7 +20,7 @@ Helps find where reusables are already used, and where they could be used. ### Command: `npm run reusables -- find used ` -Find where a specific reusable is used +Find where a specific reusable is used. This counts both `{% data %}` and `{% indented_data_reference %}` tags. #### Example diff --git a/src/content-render/scripts/reusables-cli/find/used.ts b/src/content-render/scripts/reusables-cli/find/used.ts index 23e44a37d38f..1578e99d2811 100644 --- a/src/content-render/scripts/reusables-cli/find/used.ts +++ b/src/content-render/scripts/reusables-cli/find/used.ts @@ -57,8 +57,10 @@ export function findTopUsed(numberOfMostUsedToFind: number, { absolute }: { abso ) for (const token of liquidTokens) { const { args, name } = token - if (name === 'data' && args.startsWith('reusables.')) { - reusableCounts.set(args, (reusableCounts.get(args) || 0) + 1) + if (name !== 'data' && name !== 'indented_data_reference') continue + const key = args.trim().split(/\s+/)[0] + if (key.startsWith('reusables.')) { + reusableCounts.set(key, (reusableCounts.get(key) || 0) + 1) } } } diff --git a/src/content-render/scripts/reusables-cli/shared.ts b/src/content-render/scripts/reusables-cli/shared.ts index 454e0477159e..5f30e7a3972e 100644 --- a/src/content-render/scripts/reusables-cli/shared.ts +++ b/src/content-render/scripts/reusables-cli/shared.ts @@ -1,10 +1,11 @@ import walk from 'walk-sync' import path from 'path' +import { fileURLToPath } from 'url' import { TokenizationError, TokenKind } from 'liquidjs' import type { TagToken } from 'liquidjs' import { getLiquidTokens } from '@/content-linter/lib/helpers/liquid-utils' -const __dirname = path.dirname(new URL(import.meta.url).pathname) +const __dirname = path.dirname(fileURLToPath(import.meta.url)) const repoRoot = path.resolve(__dirname, '../../../../') const contentDirectory = path.resolve(__dirname, repoRoot, 'content/') @@ -60,7 +61,10 @@ export function getIndicesOfLiquidVariable(liquidVariable: string, fileContents: (token): token is TagToken => token.kind === TokenKind.Tag, ) for (const token of tokens) { - if (token.name === 'data' && token.args.trim() === liquidVariable) { + if ( + (token.name === 'data' || token.name === 'indented_data_reference') && + token.args.trim().split(/\s+/)[0] === liquidVariable + ) { indices.push(token.begin) } } diff --git a/src/content-render/scripts/update-filepaths.ts b/src/content-render/scripts/update-filepaths.ts index 45bc147c26d8..0a93b486c511 100755 --- a/src/content-render/scripts/update-filepaths.ts +++ b/src/content-render/scripts/update-filepaths.ts @@ -10,6 +10,7 @@ import { execFileSync } from 'child_process' import walkFiles from '@/workflows/walk-files' import frontmatter from '@/frame/lib/read-frontmatter' import { renderContent } from '@/content-render/index' +import { isPathWithin } from '@/content-render/lib/is-path-within' import fpt from '@/versions/lib/non-enterprise-default-version' import { allVersions } from '@/versions/lib/all-versions' import type { PageFrontmatter, Context } from '@/types' @@ -205,8 +206,7 @@ function filterFiles(contentDir: string, scriptOptions: ScriptOptions) { console.error(`${p} not found`) process.exit(1) } - if (path.relative(process.cwd(), file).startsWith(p)) return true - return false + return isPathWithin(path.relative(process.cwd(), file), p) }) }) } diff --git a/src/content-render/tests/is-path-within.ts b/src/content-render/tests/is-path-within.ts new file mode 100644 index 000000000000..0db9ac69db46 --- /dev/null +++ b/src/content-render/tests/is-path-within.ts @@ -0,0 +1,37 @@ +import path from 'path' +import { describe, expect, test } from 'vitest' +import { isPathWithin } from '@/content-render/lib/is-path-within' + +const p = (...segments: string[]) => path.join(...segments) + +describe('isPathWithin', () => { + test('matches files inside the target directory', () => { + expect(isPathWithin(p('content', 'search', 'foo.md'), p('content', 'search'))).toBe(true) + expect(isPathWithin(p('content', 'search', 'a', 'index.md'), p('content', 'search'))).toBe(true) + }) + + test('matches the target file itself', () => { + const file = p('content', 'search', 'foo.md') + expect(isPathWithin(file, file)).toBe(true) + }) + + test('does not match sibling directories that share a name prefix', () => { + expect(isPathWithin(p('content', 'search-github', 'foo.md'), p('content', 'search'))).toBe( + false, + ) + expect( + isPathWithin(p('content', 'rest', 'gitignore', 'x.md'), p('content', 'rest', 'git')), + ).toBe(false) + }) + + test('does not match sibling files that share a name prefix', () => { + expect(isPathWithin(p('content', 'a', 'foo-bar.md'), p('content', 'a', 'foo'))).toBe(false) + }) + + test('accepts a trailing separator on the target', () => { + expect(isPathWithin(p('content', 'search', 'foo.md'), `${p('content', 'search')}/`)).toBe(true) + expect( + isPathWithin(p('content', 'search-github', 'foo.md'), `${p('content', 'search')}/`), + ).toBe(false) + }) +}) diff --git a/src/content-render/tests/reusables-cli.ts b/src/content-render/tests/reusables-cli.ts new file mode 100644 index 000000000000..3258788a03e1 --- /dev/null +++ b/src/content-render/tests/reusables-cli.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from 'vitest' + +import { getIndicesOfLiquidVariable } from '@/content-render/scripts/reusables-cli/shared' + +describe('getIndicesOfLiquidVariable', () => { + test('finds data and indented_data_reference tags', () => { + const contents = [ + '{% data reusables.foo.bar %}', + '1. Step', + ' {% indented_data_reference reusables.foo.bar spaces=3 %}', + ].join('\n') + const indices = getIndicesOfLiquidVariable('reusables.foo.bar', contents) + expect(indices).toEqual([0, contents.indexOf('{% indented_data_reference')]) + }) + + test('does not match a reusable that shares a prefix', () => { + const contents = [ + '{% data reusables.foo.barbaz %}', + '{% indented_data_reference reusables.foo.barbaz spaces=3 %}', + ].join('\n') + expect(getIndicesOfLiquidVariable('reusables.foo.bar', contents)).toEqual([]) + }) + + test('ignores other tags', () => { + const contents = [ + '{% octicon "check" aria-label="reusables.foo.bar" %}', + '{% other_tag reusables.foo.bar spaces=3 %}', + ].join('\n') + expect(getIndicesOfLiquidVariable('reusables.foo.bar', contents)).toEqual([]) + }) +}) diff --git a/src/content-render/unified/rewrite-local-links.ts b/src/content-render/unified/rewrite-local-links.ts index 924b01a925e5..c8eeaa65b616 100644 --- a/src/content-render/unified/rewrite-local-links.ts +++ b/src/content-render/unified/rewrite-local-links.ts @@ -197,7 +197,21 @@ async function getNewTitle(href: string, context: Context, child: Text, original const line = child.position?.start.line || 1 throw autotitleResolutionError(originalHref || href, line, context) } - return await page.renderProp('title', context, { textOnly: true }) + return await page.renderProp('title', getTitleContext(page, href, context), { textOnly: true }) +} + +// Translated title fallbacks need the linked page's English version, not the rendered page's. +function getTitleContext(page: Page, href: string, context: Context): Context { + if (!context.getEnglishPage) return context + const enPage = + page.languageCode === 'en' + ? page + : findPage( + path.posix.join('/en', getPathWithoutLanguage(href)), + context.pages, + context.redirects, + ) + return enPage ? { ...context, enPage } : context } export function autotitleResolutionError( diff --git a/src/content-render/unified/use-english-headings.ts b/src/content-render/unified/use-english-headings.ts index aef89ec7333a..7035f9a3f162 100644 --- a/src/content-render/unified/use-english-headings.ts +++ b/src/content-render/unified/use-english-headings.ts @@ -3,8 +3,6 @@ import { toString } from 'hast-util-to-string' import { visit } from 'unist-util-visit' import type { Element, Root } from 'hast' -const slugger = new GithubSlugger() - interface UseEnglishHeadingsOptions { englishHeadings?: Record } @@ -13,11 +11,14 @@ interface UseEnglishHeadingsOptions { export default function useEnglishHeadings({ englishHeadings }: UseEnglishHeadingsOptions) { if (!englishHeadings) return return (tree: Root) => { + // One slugger per document gives repeated headings the same suffixes as the English page. + const slugger = new GithubSlugger() visit(tree, 'element', (node: Element) => { if (!['h2', 'h3', 'h4'].includes(node.tagName)) return - slugger.reset() const text: string = toString(node) - const englishHeading: string = englishHeadings[text] + const englishHeading: string | undefined = englishHeadings[text] + // Unmatched headings would otherwise register an empty slug and shift later suffixes. + if (!englishHeading) return const englishSlug: string = slugger.slug(englishHeading) if (englishSlug) { // Leave the existing ID when no English slug exists. diff --git a/src/data-directory/README.md b/src/data-directory/README.md index e1c644be9ac9..85da680e7899 100644 --- a/src/data-directory/README.md +++ b/src/data-directory/README.md @@ -31,7 +31,7 @@ Purpose-built utilities, schemas, and workflows that power our Liquid `{% data % - Tests: `src/data-directory/tests/data-schemas.ts` loads schemas via AJV and asserts every registered file validates. - Adding a schema: 1. Create `src/data-directory/lib/data-schemas/.ts` (or `tables/.ts`). - 2. If non-table, add to `manualSchemas` in `data-schemas/index.ts`; table schemas are auto-detected. + 2. If non-table or nested under `data/tables//`, add to `manualSchemas` in `data-schemas/index.ts`. Only top-level `data/tables/*.yml` schemas are auto-detected. 3. Run tests (see below). ## Middleware @@ -70,7 +70,7 @@ Purpose-built utilities, schemas, and workflows that power our Liquid `{% data % - When adding a new data directory: - Prefer YAML for structured data; add schema if shape matters to correctness. - Add README under `data//` when introducing new contracts. - - Update `manualSchemas` if not a table. + - Update `manualSchemas` if not a top-level table. ## Ownership & escalation - Primary: Docs Engineering. diff --git a/src/data-directory/lib/data-schemas/index.ts b/src/data-directory/lib/data-schemas/index.ts index 8d7b75ce9e60..403e0efe9305 100644 --- a/src/data-directory/lib/data-schemas/index.ts +++ b/src/data-directory/lib/data-schemas/index.ts @@ -55,9 +55,18 @@ const manualSchemas: DataSchemas = { 'data/tables/copilot/model-supported-plans.yml': resolveSchemaPath( 'tables/copilot/model-supported-plans.ts', ), + 'data/tables/copilot/model-release-status.yml': resolveSchemaPath( + 'tables/copilot/model-release-status.ts', + ), + 'data/tables/copilot/model-comparison.yml': resolveSchemaPath( + 'tables/copilot/model-comparison.ts', + ), 'data/tables/copilot/auto-model-selection.yml': resolveSchemaPath( 'tables/copilot/auto-model-selection.ts', ), + 'data/tables/copilot/model-deprecation-history.yml': resolveSchemaPath( + 'tables/copilot/model-deprecation-history.ts', + ), } const dataSchemas: DataSchemas = { diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts index 6189c00e9ff6..f768c02e6aba 100644 --- a/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts +++ b/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts @@ -1,32 +1,25 @@ const modelComparisonSchema = { - type: 'object', - additionalProperties: false, - required: ['models'], - properties: { - models: { - type: 'array', - items: { - type: 'object', - additionalProperties: false, - required: ['name', 'task_area', 'excels_at', 'further_reading'], - properties: { - name: { - type: 'string', - lintable: true, - }, - task_area: { - type: 'string', - lintable: true, - }, - excels_at: { - type: 'string', - lintable: true, - }, - further_reading: { - type: 'string', - lintable: true, - }, - }, + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['name', 'task_area', 'excels_at', 'further_reading'], + properties: { + name: { + type: 'string', + lintable: true, + }, + task_area: { + type: 'string', + lintable: true, + }, + excels_at: { + type: 'string', + lintable: true, + }, + further_reading: { + type: 'string', + lintable: true, }, }, }, diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-deprecation-history.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-deprecation-history.ts index 84fc98abac18..894fc74b2349 100644 --- a/src/data-directory/lib/data-schemas/tables/copilot/model-deprecation-history.ts +++ b/src/data-directory/lib/data-schemas/tables/copilot/model-deprecation-history.ts @@ -1,28 +1,21 @@ const modelDeprecationHistorySchema = { - type: 'object', - additionalProperties: false, - required: ['models'], - properties: { - models: { - type: 'object', - items: { - type: 'object', - additionalProperties: false, - required: ['name', 'retirement_date', 'suggested_alternative'], - properties: { - name: { - type: 'string', - lintable: true, - }, - retirement_date: { - type: 'string', - format: 'date', - }, - suggested_alternative: { - type: 'string', - lintable: true, - }, - }, + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['name', 'retirement_date', 'suggested_alternative'], + properties: { + name: { + type: 'string', + lintable: true, + }, + retirement_date: { + type: 'string', + format: 'date', + }, + suggested_alternative: { + type: 'string', + lintable: true, }, }, }, diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-release-status.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-release-status.ts index 6918f92a8903..0ea1641d7a40 100644 --- a/src/data-directory/lib/data-schemas/tables/copilot/model-release-status.ts +++ b/src/data-directory/lib/data-schemas/tables/copilot/model-release-status.ts @@ -1,28 +1,20 @@ const modelsReleaseStatusSchema = { - type: 'object', - additionalProperties: false, - required: ['models'], - properties: { - models: { - type: 'object', - items: { - type: 'object', - additionalProperties: false, - required: ['name', 'provider', 'release_status'], - properties: { - name: { - type: 'string', - lintable: true, - }, - provider: { - type: 'string', - enum: ['OpenAI', 'Anthropic', 'Google', 'xAI'], - }, - release_status: { - type: 'string', - lintable: true, - }, - }, + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['name', 'provider', 'release_status'], + properties: { + name: { + type: 'string', + lintable: true, + }, + provider: { + type: 'string', + }, + release_status: { + type: 'string', + lintable: true, }, }, }, diff --git a/src/events/components/Survey.tsx b/src/events/components/Survey.tsx index 43f76212cdcb..0147c72aae56 100644 --- a/src/events/components/Survey.tsx +++ b/src/events/components/Survey.tsx @@ -185,7 +185,7 @@ export const Survey = () => { >

-
3 ? 'warn' : '')}> +
@@ -202,7 +202,7 @@ export const Survey = () => { aria-invalid={isEmailError} {...(isEmailError ? { 'aria-describedby': 'email-input-validation' } : {})} /> - {isEmailError && email.trim().length > 3 && ( + {isEmailError && (

{t`email_validation`}

diff --git a/src/fixtures/fixtures/article-with-introLinks.md b/src/fixtures/fixtures/article-with-introLinks.md index bfff0ffec3d2..5f7491a83e10 100644 --- a/src/fixtures/fixtures/article-with-introLinks.md +++ b/src/fixtures/fixtures/article-with-introLinks.md @@ -6,4 +6,6 @@ versions: introLinks: overview: 'https://github.com' custom link!: 'https://github.com/features' + quickstart: /get-started/quickstart + reference: /get-started/reference --- 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 44447fafcc3a..fa16125b4202 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,6 +6,9 @@ versions: ghes: "*" ghec: "*" layout: bespoke-landing +introLinks: + overview: "{% ifversion fpt or ghec %}/get-started/article-grid-bespoke/grid-category-one/grid-article-one{% elsif ghes %}/get-started/article-grid-bespoke/grid-category-one/grid-article-two{% endif %}" + quickstart: /get-started/article-grid-bespoke/grid-category-two/grid-article-three carousels: recommended: - /grid-category-one/grid-article-one diff --git a/src/fixtures/tests/bad-urls.ts b/src/fixtures/tests/bad-urls.ts index eefeeb7f879b..1eb7950e4396 100644 --- a/src/fixtures/tests/bad-urls.ts +++ b/src/fixtures/tests/bad-urls.ts @@ -8,7 +8,7 @@ describe('bad URLs', () => { for (const url of URLs) { const res = await head(url) expect(res.statusCode).toBe(302) - expect(res.headers.location).toBe(url.replace(/\/index\.md$/, '')) + expect(res.headers.location).toBe(url.replace(/\/index\.md$/, '') || '/') expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) } diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index 99d7846315d0..609e8fe67e54 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -6,6 +6,7 @@ import { HOVERCARDS_ENABLED, ANALYTICS_ENABLED, COLOR_MODE_COOKIE_NAME, + TOOL_PREFERRED_COOKIE_NAME, } from '../../frame/lib/constants' // Local Playwright loads .env so tests read ELASTICSEARCH_URL independently of start-server.ts. @@ -879,6 +880,86 @@ test.describe('search dialog', () => { await expect(input).toHaveAttribute('aria-activedescendant', 'search-option-reference-0') }) + // A flash can last one frame, so record every text the page adds instead of the end state. + async function recordAddedText(page: Page) { + await page.evaluate(() => { + const seen: string[] = [] + ;(window as unknown as { __addedText: string[] }).__addedText = seen + new MutationObserver((records) => { + for (const record of records) { + if (record.type === 'characterData') seen.push(record.target.textContent || '') + for (const node of record.addedNodes) seen.push(node.textContent || '') + } + }).observe(document.body, { subtree: true, childList: true, characterData: true }) + }) + } + + async function addedText(page: Page) { + return page.evaluate(() => + (window as unknown as { __addedText: string[] }).__addedText.join('\n'), + ) + } + + test('asking Copilot and going back never flashes "No results found"', async ({ page }) => { + await page.route('**/api/ai-search/v1', (route) => + route.fulfill({ + status: 200, + headers: { 'Content-Type': 'application/x-ndjson' }, + body: '{"chunkType":"MESSAGE_CHUNK","text":"An answer."}\n', + }), + ) + await mockSearch(page) + await page.goto('/') + await turnOffExperimentsInPage(page) + + await page.getByTestId('toggle-search').click() + const input = page.getByTestId('overlay-search-input') + await input.fill('foo') + await expect(page.locator('#search-option-general-0')).toBeVisible() + await recordAddedText(page) + + await input.press('Enter') // nothing selected: ask Copilot + await expect(page.getByText('An answer.')).toBeVisible() + await page.getByRole('button', { name: 'Back to search' }).click() + await expect(page.locator('#search-option-ai-0')).toBeVisible() + await expect(page.locator('#search-option-general-0')).toBeVisible() + // Past the 100ms autocomplete debounce, while the emptied results used to render. + await page.waitForTimeout(300) + + // Also matches the "Search Docs: No results found" live region announcement. + expect(await addedText(page)).not.toContain('No results found') + }) + + test('a Copilot cannot-answer response never flashes "No results found"', async ({ page }) => { + await page.route('**/api/ai-search/v1', (route) => + route.fulfill({ + status: 200, + headers: { 'Content-Type': 'application/x-ndjson' }, + body: '{"chunkType":"NO_CONTENT_SIGNAL"}\n', + }), + ) + await mockSearch(page) + await page.goto('/') + await turnOffExperimentsInPage(page) + + await page.getByTestId('toggle-search').click() + const input = page.getByTestId('overlay-search-input') + await input.fill('foo') + await expect(page.locator('#search-option-general-0')).toBeVisible() + await recordAddedText(page) + + await input.press('Enter') // nothing selected: ask Copilot + await expect(page.locator('#ask-ai-result-container article')).toContainText( + "I'm unable to answer that question", + ) + await expect(page.locator('#search-option-general-0')).toBeVisible() + // Past the 100ms autocomplete debounce, while the emptied results used to render. + await page.waitForTimeout(300) + + // Also matches the "Search Docs: No results found" live region announcement. + expect(await addedText(page)).not.toContain('No results found') + }) + // Streams one answer chunk, then holds until releaseCopilot, then ends the stream as asked. // Also records event beacons, since Chromium hides sendBeacon payloads from Playwright. async function streamCopilotAnswer( @@ -1052,6 +1133,27 @@ test.describe('search dialog', () => { await expect(rule).toHaveCSS('background-image', 'none') }) + test('Enter with nothing selected runs the docs search after a Copilot error', async ({ + page, + }) => { + // No upstreamStatus, so the overlay treats it as a Copilot error and shows the banner. + await page.route('**/api/ai-search/v1', (route) => route.fulfill({ status: 500, body: '{}' })) + await mockSearch(page) + await page.goto('/') + await turnOffExperimentsInPage(page) + + await page.getByTestId('toggle-search').click() + const input = page.getByTestId('overlay-search-input') + await input.fill('foo') + await expect(page.locator('#search-option-general-0')).toBeVisible() + await input.press('Enter') // nothing selected: ask Copilot + await expect(page.getByText('There was an error loading Copilot.')).toBeVisible() + + await input.fill('bar') + await input.press('Enter') + await expect(page).toHaveURL(/\/search\?.*query=bar/) + }) + test.describe('in a German-language browser', () => { test.use({ locale: 'de-DE' }) @@ -1151,6 +1253,45 @@ test('search from enterprise-cloud and filter by top-level Fooing', async ({ pag await page.getByRole('link', { name: 'Clear' }).click() }) +test('search pagination focuses the first new result after the next page renders', async ({ + page, +}) => { + test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') + + // size=1 puts each fixture hit that matches foo on its own page. + await page.goto('/search?query=foo&size=1') + const firstResultLink = page.locator('.search-result-link').first() + // Primer Brand appends "..." to the last page's aria-label. + const pageTwo = page.getByLabel(/^Page 2(\.\.\.)?$/) + await expect(firstResultLink).toHaveText('Foo') + + // Holding the data response proves focus waits for the new hits, not the old ones. + let releaseNavigation = () => {} + const navigationHeld = new Promise((resolve) => { + releaseNavigation = resolve + }) + await page.route('**/_next/data/**', async (route) => { + await navigationHeld + await route.continue() + }) + + await pageTwo.press('Enter') + await expect(page).not.toHaveURL(/page=2/) + releaseNavigation() + await expect(page).toHaveURL(/page=2/) + await expect(firstResultLink).not.toHaveText('Foo') + await expect(firstResultLink).toBeFocused() + + // Pagination stays mounted across Back, so a repeat visit to page 2 must still move focus. + await page.goBack() + await expect(page).not.toHaveURL(/page=2/) + await expect(firstResultLink).toHaveText('Foo') + await pageTwo.press('Enter') + await expect(page).toHaveURL(/page=2/) + await expect(firstResultLink).not.toHaveText('Foo') + await expect(firstResultLink).toBeFocused() +}) + test('404 page renders correctly', async ({ page }) => { const response = await page.goto('/this-definitely-does-not-exist') expect(response?.status()).toBe(404) @@ -1266,6 +1407,30 @@ test.describe('tool picker', () => { await expect(page.getByText('This is webui content')).toBeVisible() }) + test('modifier-click opens the tool in a new tab', async ({ page }) => { + await page.goto('/get-started/liquid/tool-specific') + await turnOffExperimentsInPage(page) + await page.getByTestId('tool-picker').getByRole('link', { name: 'Web browser' }).click() + await expect(page).toHaveURL(/\?tool=webui/) + + // Chromium opens modifier-click tabs without an opener, so the page never emits 'popup'. + const newTabPromise = page.context().waitForEvent('page') + await page + .getByTestId('tool-picker') + .getByRole('link', { name: 'GitHub CLI' }) + .click({ modifiers: ['ControlOrMeta'] }) + const newTab = await newTabPromise + await expect(newTab).toHaveURL(/\?tool=cli/) + await newTab.close() + + await expect(page).toHaveURL(/\?tool=webui/) + await expect(page.getByText('This is webui content')).toBeVisible() + await expect(page.getByText('This is cli content')).not.toBeVisible() + const cookies = await page.context().cookies() + const toolCookie = cookies.find((cookie) => cookie.name === TOOL_PREFERRED_COOKIE_NAME) + expect(toolCookie?.value).toBe('webui') + }) + test('minitoc matches picker', async ({ page }) => { // Avoid the drawer's exact reveal breakpoint. await page.setViewportSize({ width: 1440, height: 900 }) @@ -1483,6 +1648,88 @@ test.describe('hover cards', () => { ), ).not.toBeVisible() }) + + test('Escape before the hovercard opening delay ends keeps the card closed', async ({ page }) => { + await page.goto('/pages/quickstart') + await turnOffExperimentsInPage(page) + + let metaRequests = 0 + page.on('request', (request) => { + if (/\/api\/article\/meta\?/.test(request.url())) metaRequests++ + }) + + const link = page.locator('#article-contents').getByRole('link', { name: 'Start your journey' }) + await link.focus() + await page.keyboard.press('Alt+ArrowUp') + await page.keyboard.press('Escape') + + await page.waitForTimeout(1000) + expect(metaRequests).toBe(0) + await expect(page.getByTestId('popover')).not.toBeVisible() + await expect(link).toBeFocused() + }) + + test('late hovercard response after the pointer leaves does not open the card', async ({ + page, + }) => { + await page.goto('/pages/quickstart') + await turnOffExperimentsInPage(page) + + let releaseMeta = () => {} + const metaHeld = new Promise((resolve) => { + releaseMeta = resolve + }) + await page.route(/\/api\/article\/meta\?/, async (route) => { + await metaHeld + await route.continue() + }) + + const metaRequest = page.waitForRequest(/\/api\/article\/meta\?/) + await page + .locator('#article-contents') + .getByRole('link', { name: 'Start your journey' }) + .hover() + const request = await metaRequest + await page.mouse.move(0, 0) + + releaseMeta() + await request.response() + await page.waitForTimeout(500) + await expect(page.getByTestId('popover')).not.toBeVisible() + }) + + test('late hovercard response for an earlier link does not replace the current card', async ({ + page, + }) => { + await page.goto('/pages/quickstart') + await turnOffExperimentsInPage(page) + + let releaseMeta = () => {} + const metaHeld = new Promise((resolve) => { + releaseMeta = resolve + }) + await page.route(/\/api\/article\/meta\?/, async (route) => { + await metaHeld + await route.continue() + }) + + const intro = page.locator('#article-intro') + const popover = page.getByTestId('popover') + const metaRequest = page.waitForRequest(/\/api\/article\/meta\?/) + await intro.getByRole('link', { name: 'another link' }).hover() + const request = await metaRequest + await intro.getByRole('link', { name: 'article intro link' }).hover() + await expect(popover.getByText('You can use HubGit Pages to showcase')).toBeVisible() + + // The in-page anchor card fills without a fetch, so only the earlier link's request is held. + releaseMeta() + await request.response() + await page.waitForTimeout(500) + await expect(popover.getByText('You can use HubGit Pages to showcase')).toBeVisible() + await expect( + popover.getByText('Follow this Hello World exercise to get started with HubGit.'), + ).not.toBeVisible() + }) }) test.describe('test nav at different viewports', () => { @@ -2036,6 +2283,24 @@ test.describe('survey', () => { expect(typesSentOnSecondHide).toContain('survey') }) + test('short invalid email shows the warning while Send is disabled', async ({ page }) => { + // Install the route before interacting with the page to avoid event races. + await page.route('**/api/events', (route) => { + route.fulfill({}) + }) + + await page.goto('/get-started/foo/for-playwright') + + await page.locator('[for=survey-yes]').click() + await page.locator('[name=survey-email]').fill('no') + await expect(page.locator('#email-input-validation')).toBeVisible() + await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled() + + await page.locator('[name=survey-email]').fill('test@example.com') + await expect(page.locator('#email-input-validation')).not.toBeVisible() + await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled() + }) + test('vote on one page, then go to another and it should reset', async ({ page }) => { // Install the route before interacting with the page to avoid event races. await page.route('**/api/events', (route) => { diff --git a/src/fixtures/tests/playwright-secret-scanning.spec.ts b/src/fixtures/tests/playwright-secret-scanning.spec.ts index cf8d459ba466..541189fd0ca9 100644 --- a/src/fixtures/tests/playwright-secret-scanning.spec.ts +++ b/src/fixtures/tests/playwright-secret-scanning.spec.ts @@ -141,6 +141,34 @@ test.describe('Secret scanning DataTable accessibility', () => { expect(count).toBeGreaterThan(0) }) + test('pagination stays in range after switching to a version with fewer patterns', async ({ + page, + }) => { + await page.goto(PAGE_PATH) + + const status = page.getByText(/^Showing \d+ of \d+ patterns$/) + const fptStatus = await status.textContent() + const pagination = page.getByRole('navigation', { + name: 'Secret scanning patterns pagination', + }) + const lastPage = pagination.locator('[aria-label^="Page "]').last() + await lastPage.click() + await expect(lastPage).toHaveAttribute('aria-current', 'page') + expect(new URL(page.url()).hash).toBe('') + + // The oldest supported Enterprise Server release has fewer patterns than Free, Pro, & Team. + await page.getByTestId('desktop-header').getByTestId('version-picker-button').click() + await page + .getByRole('menuitemradio', { name: /^Enterprise Server / }) + .last() + .click() + await expect(page).toHaveURL(/\/enterprise-server@[\d.]+\//) + await expect(status).not.toHaveText(fptStatus!) + + await expect(page.locator('[role="rowheader"]').first()).toBeVisible() + await expect(pagination.locator('[aria-current="page"]')).toHaveCount(1) + }) + test.describe('narrow viewport', () => { test.use({ viewport: { width: 320, height: 256 } }) diff --git a/src/frame/lib/create-tree.ts b/src/frame/lib/create-tree.ts index aac7b670d404..507864b7eba1 100644 --- a/src/frame/lib/create-tree.ts +++ b/src/frame/lib/create-tree.ts @@ -68,77 +68,73 @@ export default async function createTree( page = newPage as unknown as Page } + // A reused page has filtered children, so the declared list comes from the previous tree. + const declaredChildren = + previousTree && previousTree.page === page ? previousTree.children : page.children || [] + const item: UnversionedTree = { page, - // Development reloads reuse subtrees only when the ordered children list matches. - children: page.children || [], + // Declared children stay intact so later reloads retry skipped content that appears on disk. + children: declaredChildren, childPages: [], } - if (page.children) { + if (declaredChildren.length) { assertUniqueChildren(page) - item.childPages = ( - await Promise.all( - (page.children as string[]).map(async (child: string, i: number) => { - let childPreviousTree: UnversionedTree | undefined - if (previousTree && previousTree.childPages) { - if (equalArray(page.children as string[], previousTree.children)) { - // Matching child names and order keep indexes tied to the same previous subtree. - childPreviousTree = previousTree.childPages[i] - } - } - - // Absolute paths such as /content/actions/workflows pull a subtree, even from another product. - let childPath: string - if (child.startsWith('/content/')) { - const absoluteChildPath = child.slice('/content/'.length) - childPath = path.posix.join(basePath, absoluteChildPath) - - // Reject path traversal by requiring the resolved path to stay under the content root. - const resolvedPath = path.resolve(childPath) - const resolvedBasePath = path.resolve(basePath) - if (!resolvedPath.startsWith(resolvedBasePath + path.sep)) { - throw new Error( - `Invalid child path "${child}" in ${originalPath}/index.md - path traversal detected. ` + - `Resolved path "${resolvedPath}" escapes content directory "${resolvedBasePath}".`, - ) - } - } else { - // Relative child paths resolve from their parent path. - childPath = path.posix.join(originalPath, child) - } - - const subTree = await createTree(childPath, basePath, childPreviousTree) - if (subTree && child.startsWith('/content/')) { - // Cross-product children stay out of the sidebar. - subTree.crossProductChild = true - } - if (!subTree) { - // Remove skipped subtrees so development reloads compare against the rendered child list. - ;(page.children as string[]) = (page.children as string[]).filter( - (c: string) => c !== child, + // The previous page's children line up with its childPages, so cached subtrees match by name. + const previousChildPages = new Map() + if (previousTree) { + for (const [i, child] of (previousTree.page.children || []).entries()) { + previousChildPages.set(child, previousTree.childPages[i]) + } + } + const subTrees = await Promise.all( + declaredChildren.map(async (child: string) => { + const childPreviousTree = previousChildPages.get(child) + + // Absolute paths such as /content/actions/workflows pull a subtree, even from another product. + let childPath: string + if (child.startsWith('/content/')) { + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join(basePath, absoluteChildPath) + + // Reject path traversal by requiring the resolved path to stay under the content root. + const resolvedPath = path.resolve(childPath) + const resolvedBasePath = path.resolve(basePath) + if (!resolvedPath.startsWith(resolvedBasePath + path.sep)) { + throw new Error( + `Invalid child path "${child}" in ${originalPath}/index.md - path traversal detected. ` + + `Resolved path "${resolvedPath}" escapes content directory "${resolvedBasePath}".`, ) } - return subTree - }), - ) - ).filter((tree): tree is UnversionedTree => tree !== undefined) + } else { + // Relative child paths resolve from their parent path. + childPath = path.posix.join(originalPath, child) + } + + const subTree = await createTree(childPath, basePath, childPreviousTree) + if (subTree && child.startsWith('/content/')) { + // Cross-product children stay out of the sidebar. + subTree.crossProductChild = true + } + return subTree + }), + ) + // Skipped subtrees, such as missing early-access content, leave the rendered children list. + page.children = declaredChildren.filter((_, i) => subTrees[i]) + item.childPages = subTrees.filter((tree): tree is UnversionedTree => tree !== undefined) } return item } -function equalArray(arr1: string[], arr2: string[]): boolean { - return arr1.length === arr2.length && arr1.every((value, i) => value === arr2[i]) -} - async function getMtime(filePath: string): Promise { if (isProduction) { // Production skips the full stat but still verifies existence. await fs.access(filePath) return 1 } - return Math.round((await fs.stat(filePath)).mtimeMs) + return (await fs.stat(filePath)).mtimeMs } function assertUniqueChildren(page: Page): void { diff --git a/src/frame/lib/page-data.ts b/src/frame/lib/page-data.ts index fc77b6db4d4c..499e06bfd1f9 100644 --- a/src/frame/lib/page-data.ts +++ b/src/frame/lib/page-data.ts @@ -85,9 +85,12 @@ export async function loadUnversionedTree( } // Category pages inherit applicable versions from immediate children. -function setCategoryApplicableVersions(tree: UnversionedTree): void { - for (const childPage of tree.childPages) { +export function setCategoryApplicableVersions(tree: UnversionedTree): void { + // Translated leaves have no childPages array. + for (const childPage of tree.childPages || []) { if (childPage.page.relativePath.endsWith('index.md')) { + // Nested categories settle first so the union reads their current versions. + setCategoryApplicableVersions(childPage) const combinedApplicableVersions: string[] = [] let moreThanOneChild = false for (const childChildPage of childPage.childPages || []) { @@ -96,7 +99,6 @@ function setCategoryApplicableVersions(tree: UnversionedTree): void { combinedApplicableVersions.push(version) } } - setCategoryApplicableVersions(childPage) moreThanOneChild = true } if ( diff --git a/src/frame/lib/page.ts b/src/frame/lib/page.ts index b8aa960970ff..77fd93f680bb 100644 --- a/src/frame/lib/page.ts +++ b/src/frame/lib/page.ts @@ -286,7 +286,7 @@ class Page { private async _render(context: Context): Promise { // English heading IDs keep translated links stable. if (this.languageCode !== 'en') { - const englishHeadings = getEnglishHeadings(this, context) + const englishHeadings = await getEnglishHeadings(this, context) context.englishHeadings = englishHeadings } diff --git a/src/frame/middleware/reload-tree.ts b/src/frame/middleware/reload-tree.ts index 4432194a54dc..9ea38a29e5ab 100644 --- a/src/frame/middleware/reload-tree.ts +++ b/src/frame/middleware/reload-tree.ts @@ -11,7 +11,12 @@ import type { ExtendedRequest, UnversionedTree, SiteTree } from '@/types' import languages, { languageKeys } from '@/languages/lib/languages-server' import createTree from '@/frame/lib/create-tree' import warmServer from '@/frame/lib/warm-server' -import { loadSiteTree, loadPages, loadPageMap } from '@/frame/lib/page-data' +import { + loadSiteTree, + loadPages, + loadPageMap, + setCategoryApplicableVersions, +} from '@/frame/lib/page-data' import loadRedirects from '@/redirects/lib/precompile' const languagePrefixRegex = new RegExp(`^/(${languageKeys.join('|')})(/|$)`) @@ -38,6 +43,8 @@ export default async function reloadTree(req: ExtendedRequest, res: Response, ne const after = getMtimes(warmed.unversionedTree.en) // Dependent maps take about 140ms after a 40ms tree refresh, so skip them when mtimes match. if (before !== after) { + // Reinitialized category pages lose the child-derived versions that startup applies. + setCategoryApplicableVersions(warmed.unversionedTree.en) warmed.siteTree = (await loadSiteTree(warmed.unversionedTree)) as SiteTree warmed.pageList = await loadPages(warmed.unversionedTree) warmed.pages = await loadPageMap(warmed.pageList) diff --git a/src/frame/middleware/trailing-slashes.ts b/src/frame/middleware/trailing-slashes.ts index cd80572031b9..0a0f6bd52453 100644 --- a/src/frame/middleware/trailing-slashes.ts +++ b/src/frame/middleware/trailing-slashes.ts @@ -11,12 +11,13 @@ export default function trailingSlashes(req: ExtendedRequest, res: Response, nex while (pathname.endsWith('/')) { pathname = pathname.slice(0, pathname.length - 1) } - let url = pathname + // An empty Location resolves to the same URL, so a path of only slashes goes to the homepage. + if (!pathname) pathname = '/' + // Collapse repeated slashes in the path only, because query values can contain //. + let url = pathname.replace(/\/+/g, '/') if (split.length) { url += `?${split.join('?')}` } - // Collapse repeated slashes so the redirect points to one canonical URL. - url = url.replace(/\/+/g, '/') defaultCacheControl(res) return res.safeRedirect(301, url) } diff --git a/src/frame/tests/create-tree.ts b/src/frame/tests/create-tree.ts new file mode 100644 index 000000000000..c0ba7396c1f1 --- /dev/null +++ b/src/frame/tests/create-tree.ts @@ -0,0 +1,115 @@ +import fs from 'fs/promises' + +import { describe, expect, test } from 'vitest' + +import createTree from '@/frame/lib/create-tree' +import { setCategoryApplicableVersions } from '@/frame/lib/page-data' +import type { Page, UnversionedTree } from '@/types' + +const contentDir = 'src/fixtures/fixtures/content' + +function flattenPages(tree: UnversionedTree): Page[] { + return [tree.page, ...tree.childPages.flatMap(flattenPages)] +} + +function stubTree(relativePath: string, versions: string[], childPages: UnversionedTree[] = []) { + const page = { + relativePath, + languageCode: 'en', + title: relativePath, + applicableVersions: versions, + permalinks: [], + } as unknown as Page + return { page, children: [], childPages } as UnversionedTree +} + +describe('createTree', () => { + test('reuses every unchanged page from the previous tree', async () => { + const first = (await createTree(contentDir)) as UnversionedTree + const second = (await createTree(contentDir, undefined, first)) as UnversionedTree + + const firstPages = flattenPages(first) + const secondPages = flattenPages(second) + expect(firstPages.length).toBeGreaterThan(1) + expect(secondPages.length).toBe(firstPages.length) + const notReused = secondPages.filter((page, i) => page !== firstPages[i]) + expect(notReused.map((page) => page.relativePath)).toEqual([]) + }) + + test('revisits a declared child that an earlier build skipped', async () => { + const first = (await createTree(contentDir)) as UnversionedTree + const declared = [...first.children] + const skipped = declared[1] + // Missing early-access content leaves this shape: declared children kept, rendered list filtered. + first.page.children = declared.filter((child) => child !== skipped) + first.childPages.splice(1, 1) + + const second = (await createTree(contentDir, undefined, first)) as UnversionedTree + + expect(second.page).toBe(first.page) + expect(second.children).toEqual(declared) + expect(second.page.children).toEqual(declared) + expect(second.childPages.length).toBe(declared.length) + const previousPages = new Set(first.childPages.map((tree) => tree.page)) + const reused = second.childPages.filter((tree) => previousPages.has(tree.page)) + expect(reused.length).toBe(declared.length - 1) + }) + + test('stores the file mtime on the page', async () => { + const tree = (await createTree(contentDir)) as UnversionedTree + const { mtimeMs } = await fs.stat(tree.page.fullPath) + expect(tree.page.mtime).toBe(mtimeMs) + }) +}) + +describe('setCategoryApplicableVersions', () => { + test('narrows a category to the versions of its children', () => { + const category = stubTree( + 'rest/credentials/index.md', + ['free-pro-team@latest', 'enterprise-cloud@latest', 'enterprise-server@3.20'], + [ + stubTree('rest/credentials/article.md', [ + 'free-pro-team@latest', + 'enterprise-cloud@latest', + ]), + ], + ) + const root = stubTree('index.md', ['free-pro-team@latest'], [category]) + + setCategoryApplicableVersions(root) + + expect(category.page.applicableVersions).toEqual([ + 'free-pro-team@latest', + 'enterprise-cloud@latest', + ]) + expect(category.page.permalinks.map((permalink) => permalink.pageVersion)).toEqual([ + 'free-pro-team@latest', + 'enterprise-cloud@latest', + ]) + }) + + test('carries a leaf version change up through an already narrowed tree', () => { + const allVersions = ['free-pro-team@latest', 'enterprise-cloud@latest'] + const article = stubTree('rest/credentials/article.md', ['free-pro-team@latest']) + const category = stubTree('rest/credentials/index.md', allVersions, [article]) + const product = stubTree('rest/index.md', allVersions, [category]) + const root = stubTree('index.md', allVersions, [product]) + setCategoryApplicableVersions(root) + expect(product.page.applicableVersions).toEqual(['free-pro-team@latest']) + + article.page.applicableVersions = allVersions + setCategoryApplicableVersions(root) + + expect(category.page.applicableVersions).toEqual(allVersions) + expect(product.page.applicableVersions).toEqual(allVersions) + }) + + test('skips a category without a childPages array, as in translated trees', () => { + const leafCategory = stubTree('rest/empty/index.md', ['free-pro-team@latest']) + delete (leafCategory as Partial).childPages + const root = stubTree('index.md', ['free-pro-team@latest'], [leafCategory]) + + expect(() => setCategoryApplicableVersions(root)).not.toThrow() + expect(leafCategory.page.applicableVersions).toEqual(['free-pro-team@latest']) + }) +}) diff --git a/src/frame/tests/page.ts b/src/frame/tests/page.ts index 7e14641ed98d..7242c55800fc 100644 --- a/src/frame/tests/page.ts +++ b/src/frame/tests/page.ts @@ -270,8 +270,36 @@ describe('Page class', () => { expect(page!.introLinks).toStrictEqual({ overview: 'https://github.com', 'custom link!': 'https://github.com/features', + quickstart: '/get-started/quickstart', + reference: '/get-started/reference', }) }) + + // Markdown and article API requests read introLinks from the shared Page without rendering it. + test('keeps every link after rendering a version that lacks a target page', async () => { + const page = await Page.init({ + relativePath: 'article-with-introLinks.md', + basePath: path.join(__dirname, '../../../src/fixtures/fixtures'), + languageCode: 'en', + }) + const pages = { + '/en/get-started/quickstart': { applicableVersions: [nonEnterpriseDefaultVersion] }, + '/en/get-started/reference': { applicableVersions: Object.keys(allVersions) }, + } as unknown as Context['pages'] + + await page!.render({ + currentLanguage: 'en', + currentVersion: `enterprise-server@${latest}`, + pages, + redirects: {}, + }) + expect(Object.keys(page!.introLinks!)).toEqual([ + 'overview', + 'custom link!', + 'quickstart', + 'reference', + ]) + }) }) describe('Page.parseFrontmatter()', () => { diff --git a/src/frame/tests/trailing-slashes.ts b/src/frame/tests/trailing-slashes.ts new file mode 100644 index 000000000000..0204ced3a551 --- /dev/null +++ b/src/frame/tests/trailing-slashes.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from 'vitest' +import type { Response } from 'express' + +import trailingSlashes from '@/frame/middleware/trailing-slashes' +import { safeRedirectUrl } from '@/frame/middleware/safe-redirect' +import type { ExtendedRequest } from '@/types' + +function run(url: string) { + const req = { method: 'GET', url } as ExtendedRequest + const headers: Record = {} + const result: { status?: number; location?: string; nextCalled: boolean } = { + nextCalled: false, + } + const res = { + set(key: string, value: string) { + headers[key.toLowerCase()] = value + return this + }, + hasHeader(key: string) { + return key.toLowerCase() in headers + }, + safeRedirect(status: number, location: string) { + result.status = status + result.location = safeRedirectUrl(location) + }, + } as unknown as Response + trailingSlashes(req, res, () => { + result.nextCalled = true + }) + return { ...result, headers } +} + +describe('trailingSlashes middleware', () => { + test('redirects a path of only slashes to the homepage', () => { + const result = run('//') + expect(result.status).toBe(301) + expect(result.location).toBe('/') + expect(result.headers['cache-control']).toMatch(/max-age=\d+/) + }) + + test('keeps the query string when redirecting a path of only slashes', () => { + const result = run('//?foo=1') + expect(result.status).toBe(301) + expect(result.location).toBe('/?foo=1') + }) + + test('keeps double slashes inside query values', () => { + expect(run('//?next=https://github.com').location).toBe('/?next=https://github.com') + expect(run('/en//search/?query=a//b').location).toBe('/en/search?query=a//b') + }) + + test('removes a trailing slash and keeps the query string', () => { + const result = run('/foo/?a=1') + expect(result.status).toBe(301) + expect(result.location).toBe('/foo?a=1') + }) + + test('removes trailing double slashes', () => { + const result = run('/en//') + expect(result.status).toBe(301) + expect(result.location).toBe('/en') + }) + + test('calls next for the homepage', () => { + const result = run('/') + expect(result.nextCalled).toBe(true) + expect(result.status).toBeUndefined() + }) +}) diff --git a/src/ghes-releases/lib/parse-release-notes.ts b/src/ghes-releases/lib/parse-release-notes.ts index 080232749eb8..ef64a7f3698f 100644 --- a/src/ghes-releases/lib/parse-release-notes.ts +++ b/src/ghes-releases/lib/parse-release-notes.ts @@ -220,6 +220,35 @@ export function loadExistingEntriesFromString(content: string): { return { entries, coveredUrls } } +export function isSameNoteEntry(a: NoteEntry, b: NoteEntry): boolean { + return ( + a.sourceUrl === b.sourceUrl && + a.heading === b.heading && + a.notes.length === b.notes.length && + a.notes.every((n, idx) => n === b.notes[idx]) + ) +} + +// Mutate in place because the Ctrl+C flush closure holds a reference to this array. +// With no new entries, keep the existing ones so a failed regeneration does not delete a note. +// Returns how many unique entries were added, since agent output can repeat an entry. +export function replaceEntriesForSource( + entries: NoteEntry[], + sourceUrl: string, + newEntries: NoteEntry[], +): number { + if (newEntries.length === 0) return 0 + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].sourceUrl === sourceUrl) entries.splice(i, 1) + } + const unique: NoteEntry[] = [] + for (const entry of newEntries) { + if (!unique.some((u) => isSameNoteEntry(u, entry))) unique.push(entry) + } + entries.push(...unique) + return unique.length +} + export function appendNoteLines(lines: string[], noteEntries: NoteEntry[], indent: string): void { for (const entry of noteEntries) { lines.push(`${indent}# ${entry.sourceUrl}`) @@ -271,8 +300,10 @@ export function buildReleaseNotesYaml( lines.push('sections:') - const featureEntries = noteEntries.filter((e) => featureHeadings.includes(e.heading)) - const otherEntries = noteEntries.filter((e) => !featureHeadings.includes(e.heading)) + // Writers add their own feature headings, so only section headings stay out of features. + const sectionHeadings = ['Changes', 'Closing down', 'Retired'] + const featureEntries = noteEntries.filter((e) => !sectionHeadings.includes(e.heading)) + const otherEntries = noteEntries.filter((e) => sectionHeadings.includes(e.heading)) lines.push('') lines.push(' features:') @@ -285,7 +316,8 @@ export function buildReleaseNotesYaml( byHeading.set(entry.heading, existing) } - for (const heading of featureHeadings) { + const extraHeadings = [...byHeading.keys()].filter((h) => !featureHeadings.includes(h)) + for (const heading of [...featureHeadings, ...extraHeadings]) { const entries = byHeading.get(heading) if (!entries) continue @@ -298,7 +330,7 @@ export function buildReleaseNotesYaml( lines.push(' # TODO: Add feature notes') } - const changeEntries = otherEntries.filter((e) => !['Closing down', 'Retired'].includes(e.heading)) + const changeEntries = otherEntries.filter((e) => e.heading === 'Changes') if (changeEntries.length > 0) { lines.push('') lines.push(' changes:') diff --git a/src/ghes-releases/scripts/generate-release-notes.ts b/src/ghes-releases/scripts/generate-release-notes.ts index a034e0a179b7..b6995c3ac2d2 100644 --- a/src/ghes-releases/scripts/generate-release-notes.ts +++ b/src/ghes-releases/scripts/generate-release-notes.ts @@ -21,6 +21,8 @@ import { extractPreservedBlocks, buildReleaseNotesYaml, appendNoteLines, + isSameNoteEntry, + replaceEntriesForSource, } from '@/ghes-releases/lib/parse-release-notes' import { type IssueState, @@ -577,14 +579,12 @@ program preserved = extractPreservedBlocks(existingContent) ?? undefined const existing = loadExistingEntriesFromString(existingContent) if (existing.entries.length > 0) { - // Regenerate a single issue by dropping its previous source URL entry first. + // Keep the issue's current entry until the agent produces a replacement. if (singleIssue) { - const issueUrl = `https://github.com/github/releases/issues/${singleIssue}` - const kept = existing.entries.filter((e) => e.sourceUrl !== issueUrl) - allEntries.push(...kept) - existing.coveredUrls.delete(issueUrl) - existingCoveredUrls = existing.coveredUrls - console.log(` Loaded ${kept.length} existing note(s), will regenerate #${singleIssue}`) + allEntries.push(...existing.entries) + console.log( + ` Loaded ${existing.entries.length} existing note(s), will regenerate #${singleIssue}`, + ) } else { allEntries.push(...existing.entries) existingCoveredUrls = existing.coveredUrls @@ -622,12 +622,14 @@ program const failures: { issue: ReleaseIssue; error: string }[] = [] const existingEntryCount = allEntries.length + let newCount = 0 const featureHeadings = loadFeatureHeadings() // Flush after each success and on Ctrl+C so long runs keep completed notes. const writeCurrentOutput = () => { - if (stdout || allEntries.length === 0) return + // Skip when nothing is new because rebuilding drops known issues, the date, and stacked source URL comments. + if (stdout || newCount === 0) return try { if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }) @@ -683,21 +685,21 @@ program console.log( ` ⚠ Unknown heading "${entry.heading}" — will be placed in changes section`, ) + entry.heading = 'Changes' } } } - // Avoid duplicates when an existing file already contributed the same issue note. - for (const entry of entries) { - const isDuplicate = allEntries.some( - (existing) => - existing.sourceUrl === entry.sourceUrl && - existing.heading === entry.heading && - existing.notes.length === entry.notes.length && - existing.notes.every((n, idx) => n === entry.notes[idx]), - ) - if (!isDuplicate) { - allEntries.push(entry) + if (singleIssue) { + newCount += replaceEntriesForSource(allEntries, issue.url, entries) + } else { + // Avoid duplicates when an existing file already contributed the same issue note. + for (const entry of entries) { + const isDuplicate = allEntries.some((existing) => isSameNoteEntry(existing, entry)) + if (!isDuplicate) { + allEntries.push(entry) + newCount++ + } } } const retryNote = result.attempts > 1 ? ` (attempt ${result.attempts})` : '' @@ -733,10 +735,9 @@ program } flushBeforeExit = null - const newCount = allEntries.length - existingEntryCount if (existingEntryCount > 0) { console.log( - `\nTotal: ${allEntries.length} note(s) (${existingEntryCount} existing + ${newCount} new from ${issues.length} issues)`, + `\nTotal: ${allEntries.length} note(s) (${allEntries.length - newCount} existing + ${newCount} new from ${issues.length} issues)`, ) } else { console.log(`\nGenerated ${allEntries.length} note(s) from ${issues.length} issues`) @@ -773,11 +774,11 @@ program console.log('\n--- Generated YAML ---\n') console.log(yamlOutput) } - } else if (allEntries.length > 0) { + } else if (newCount > 0) { writeCurrentOutput() console.log(`\n✅ Written to: ${path.relative(process.cwd(), outputPath)}`) } else { - console.log('\nNo entries generated — no file written.') + console.log('\nNo new notes generated. No file written.') } // Clean up stdin raw mode so the process can exit gracefully. diff --git a/src/ghes-releases/tests/generate-release-notes.ts b/src/ghes-releases/tests/generate-release-notes.ts index 6d78f7826177..53b987acf9af 100644 --- a/src/ghes-releases/tests/generate-release-notes.ts +++ b/src/ghes-releases/tests/generate-release-notes.ts @@ -8,6 +8,7 @@ import { loadExistingEntriesFromString, extractPreservedBlocks, buildReleaseNotesYaml, + replaceEntriesForSource, } from '@/ghes-releases/lib/parse-release-notes' const handEditedFile = `date: '2026-05-12' @@ -340,9 +341,9 @@ describe('buildReleaseNotesYaml', () => { expect(yaml).toContain('Repo note.') }) - test('places non-feature entries in changes section', () => { + test('places Changes entries in changes section', () => { const entries = [ - { heading: 'Some Other Heading', notes: ['A change.'], sourceUrl: 'https://example.com/1' }, + { heading: 'Changes', notes: ['A change.'], sourceUrl: 'https://example.com/1' }, ] const yaml = buildReleaseNotesYaml(entries, false, featureHeadings) @@ -352,6 +353,46 @@ describe('buildReleaseNotesYaml', () => { expect(yaml).toContain('A change.') }) + test('keeps feature headings that are not in the template after template headings', () => { + const entries = [ + { heading: 'Copilot', notes: ['Copilot note.'], sourceUrl: 'https://example.com/1' }, + { heading: 'APIs', notes: ['API note.'], sourceUrl: 'https://example.com/2' }, + ] + const yaml = buildReleaseNotesYaml(entries, false, featureHeadings) + + const apisIdx = yaml.indexOf(' - heading: APIs') + const copilotIdx = yaml.indexOf(' - heading: Copilot') + expect(apisIdx).toBeGreaterThan(-1) + expect(copilotIdx).toBeGreaterThan(apisIdx) + expect(copilotIdx).toBeLessThan(yaml.indexOf(' known_issues:')) + expect(yaml).toContain('Copilot note.') + expect(yaml).not.toContain(' changes:') + }) + + test('preserves feature headings that are not in the template on reload', () => { + const entries = [ + { + heading: 'Code scanning', + notes: ['Code scanning note.'], + sourceUrl: 'https://github.com/github/releases/issues/1', + }, + { + heading: 'Secret scanning', + notes: ['Secret scanning note.'], + sourceUrl: 'https://github.com/github/releases/issues/2', + }, + { + heading: 'Changes', + notes: ['A change.'], + sourceUrl: 'https://github.com/github/releases/issues/3', + }, + ] + const yaml = buildReleaseNotesYaml(entries, false, featureHeadings) + const reloaded = loadExistingEntriesFromString(yaml) + + expect(reloaded.entries).toEqual(entries) + }) + test('places Closing down and Retired entries in their own sections', () => { const entries = [ { @@ -494,3 +535,64 @@ sections: ]) }) }) + +describe('replaceEntriesForSource', () => { + const urlA = 'https://github.com/github/releases/issues/1' + const urlB = 'https://github.com/github/releases/issues/2' + + test('replaces entries for the source URL and keeps other entries and array identity', () => { + const entries = [ + { heading: 'Repositories', notes: ['Old A note.'], sourceUrl: urlA }, + { heading: 'APIs', notes: ['B note.'], sourceUrl: urlB }, + { heading: 'Changes', notes: ['Old A change.'], sourceUrl: urlA }, + ] + const original = entries + const added = replaceEntriesForSource(entries, urlA, [ + { heading: 'Repositories', notes: ['New A note.'], sourceUrl: urlA }, + ]) + + expect(added).toBe(1) + expect(entries).toBe(original) + expect(entries).toEqual([ + { heading: 'APIs', notes: ['B note.'], sourceUrl: urlB }, + { heading: 'Repositories', notes: ['New A note.'], sourceUrl: urlA }, + ]) + }) + + test('appends new entries when no existing entry matches the source URL', () => { + const entries = [{ heading: 'APIs', notes: ['B note.'], sourceUrl: urlB }] + replaceEntriesForSource(entries, urlA, [ + { heading: 'Repositories', notes: ['A note.'], sourceUrl: urlA }, + ]) + + expect(entries).toEqual([ + { heading: 'APIs', notes: ['B note.'], sourceUrl: urlB }, + { heading: 'Repositories', notes: ['A note.'], sourceUrl: urlA }, + ]) + }) + + test('keeps existing entries when there are no new entries', () => { + const entries = [ + { heading: 'Repositories', notes: ['Old A note.'], sourceUrl: urlA }, + { heading: 'APIs', notes: ['B note.'], sourceUrl: urlB }, + ] + expect(replaceEntriesForSource(entries, urlA, [])).toBe(0) + + expect(entries).toEqual([ + { heading: 'Repositories', notes: ['Old A note.'], sourceUrl: urlA }, + { heading: 'APIs', notes: ['B note.'], sourceUrl: urlB }, + ]) + const yaml = buildReleaseNotesYaml(entries, false, ['Repositories', 'APIs']) + expect(yaml).toContain(`# ${urlA}`) + expect(yaml).toContain('Old A note.') + }) + + test('adds identical new entries only once', () => { + const entries = [{ heading: 'Repositories', notes: ['Old A note.'], sourceUrl: urlA }] + const repeated = { heading: 'Repositories', notes: ['New A note.'], sourceUrl: urlA } + const added = replaceEntriesForSource(entries, urlA, [repeated, { ...repeated }]) + + expect(added).toBe(1) + expect(entries).toEqual([repeated]) + }) +}) diff --git a/src/graphql/components/Enum.tsx b/src/graphql/components/Enum.tsx index 68d802f131ae..bafb0e5f1568 100644 --- a/src/graphql/components/Enum.tsx +++ b/src/graphql/components/Enum.tsx @@ -1,5 +1,6 @@ import { useTranslation } from '@/languages/components/useTranslation' import { GraphqlItem } from './GraphqlItem' +import { Notice } from '@/graphql/components/Notice' import type { EnumT } from './types' import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML' @@ -27,7 +28,10 @@ export function Enum({ item, headingLevel = 2 }: Props) {
- + ))} diff --git a/src/graphql/components/Notice.tsx b/src/graphql/components/Notice.tsx index 48a69fbed1b6..088eed817d21 100644 --- a/src/graphql/components/Notice.tsx +++ b/src/graphql/components/Notice.tsx @@ -5,7 +5,7 @@ import type { GraphqlT } from './types' import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML' type Props = { - item: GraphqlT + item: Pick variant: 'preview' | 'deprecation' } diff --git a/src/graphql/components/types.tsx b/src/graphql/components/types.tsx index 7f3d1d7c3596..54c214cf0c94 100644 --- a/src/graphql/components/types.tsx +++ b/src/graphql/components/types.tsx @@ -84,6 +84,8 @@ export type InterfaceImplementerT = Pick & { export type EnumValuesT = { name: string description: string + isDeprecated?: boolean + deprecationReason?: string } export type EnumT = GraphqlT & { diff --git a/src/graphql/lib/validator.ts b/src/graphql/lib/validator.ts index eac02970e166..b0560561a7c7 100644 --- a/src/graphql/lib/validator.ts +++ b/src/graphql/lib/validator.ts @@ -213,6 +213,12 @@ enums.properties.values = { description: { type: 'string', }, + isDeprecated: { + type: 'boolean', + }, + deprecationReason: { + type: 'string', + }, }, }, } diff --git a/src/graphql/scripts/utils/process-schemas.ts b/src/graphql/scripts/utils/process-schemas.ts index d415670c3485..a6ade603dd05 100755 --- a/src/graphql/scripts/utils/process-schemas.ts +++ b/src/graphql/scripts/utils/process-schemas.ts @@ -143,6 +143,8 @@ interface GraphQLInterfaceInfo { interface EnumValueInfo { name: string description: string + isDeprecated?: boolean + deprecationReason?: string } interface EnumInfo { @@ -820,6 +822,11 @@ export default async function processSchemas( name: value.name.value, description: await helpers.getDescription(value.description?.value || ''), } + enumValue.isDeprecated = helpers.getDeprecationStatus(value.directives || []) + enumValue.deprecationReason = await helpers.getDeprecationReason( + value.directives || [], + enumValue, + ) enumValues.push(enumValue) }), ) diff --git a/src/graphql/tests/enum-value-deprecation.ts b/src/graphql/tests/enum-value-deprecation.ts new file mode 100644 index 000000000000..59501e0561e9 --- /dev/null +++ b/src/graphql/tests/enum-value-deprecation.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest' + +import processSchemas from '@/graphql/scripts/utils/process-schemas' + +const IDL = ` +type Query { + viewer: String +} + +enum MergeStateStatus { + "The head ref is out of date." + BEHIND + "The merge is blocked due to the pull request being a draft." + DRAFT + @deprecated( + reason: "DRAFT state will be removed from this enum and \`isDraft\` should be used instead Use PullRequest.isDraft instead. Removal on 2021-01-01 UTC." + ) +} +` + +describe('enum value deprecation', () => { + test('keeps the deprecated status and reason on enum values', async () => { + const data = await processSchemas(IDL, []) + const mergeStateStatus = data.enums.find((item) => item.name === 'MergeStateStatus') + const draft = mergeStateStatus?.values.find((value) => value.name === 'DRAFT') + const behind = mergeStateStatus?.values.find((value) => value.name === 'BEHIND') + + expect(draft?.isDeprecated).toBe(true) + expect(draft?.deprecationReason).toContain('PullRequest.isDraft') + expect(behind?.isDeprecated).toBeUndefined() + expect(behind?.deprecationReason).toBeUndefined() + }) +}) diff --git a/src/landings/components/SidebarProduct.tsx b/src/landings/components/SidebarProduct.tsx index c97d05be3cae..2b866e87ad6d 100644 --- a/src/landings/components/SidebarProduct.tsx +++ b/src/landings/components/SidebarProduct.tsx @@ -20,6 +20,7 @@ import { usePrefetchOnInteraction } from '@/frame/components/lib/prefetch' import { useIsHydrating } from '@/frame/components/hooks/useIsHydrating' import { SidebarExpandStateProvider, useSidebarExpandState } from './useSidebarExpandState' import { flattenDescendants, MAX_NAVLIST_LEVEL } from './sidebar-navlist-depth' +import { isOnActiveChain } from '@/landings/components/sidebar-active-chain' import styles from './SidebarProduct.module.scss' @@ -334,7 +335,7 @@ const NavListItem = memo(function NavListItem({ {childPage.sidebarLink && ( @@ -427,7 +428,7 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) { {category.childPages.map((childPage) => { diff --git a/src/landings/components/shared/LandingHero.tsx b/src/landings/components/shared/LandingHero.tsx index 4870022da520..6eb7da6e8aea 100644 --- a/src/landings/components/shared/LandingHero.tsx +++ b/src/landings/components/shared/LandingHero.tsx @@ -1,8 +1,10 @@ +import { useRouter } from 'next/router' import { LinkExternalIcon } from '@primer/octicons-react' import { Button, ButtonGroup, Heading, Text } from '@primer/react-brand' import styles from './LandingHero.module.scss' import { LandingSection } from './LandingSection' import { useTranslation } from '@/languages/components/useTranslation' +import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion' import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML' type LandingHeroProps = { @@ -24,6 +26,15 @@ function heroBackgroundCss(heroImage: string | undefined) { export const LandingHero = ({ title, intro, heroImage, introLinks }: LandingHeroProps) => { const { t } = useTranslation(['product_landing']) + const router = useRouter() + const { currentVersion } = useVersion() + + // Frontmatter paths lack locale and version, and redirects for bare paths take both from cookies or headers, not this page. + const localizeHref = (href: string) => { + if (!href.startsWith('/') || href.startsWith('//')) return href + const versionPrefix = currentVersion !== DEFAULT_VERSION ? `/${currentVersion}` : '' + return `/${router.locale}${versionPrefix}${href}` + } const linkEntries = introLinks ? Object.entries(introLinks) : [] @@ -51,7 +62,7 @@ export const LandingHero = ({ title, intro, heroImage, introLinks }: LandingHero
{value.name} + + {value.isDeprecated && } +