diff --git a/.github/workflows/check-for-non-english.yml b/.github/workflows/check-for-non-english.yml new file mode 100644 index 000000000000..a80541642904 --- /dev/null +++ b/.github/workflows/check-for-non-english.yml @@ -0,0 +1,87 @@ +name: Check for non-English titles + +# **What it does**: Closes issues/PRs whose title contains non-Latin-script +# characters, which is a common pattern for spam/scam submissions (e.g. +# fake certificate/badge issues written in Arabic, Cyrillic, CJK, etc.) +# **Why we have it**: We get spam in the open-source repo with titles in +# scripts our triage team can't read, making it hard to evaluate intent. +# **Who does it impact**: Open-source contributors. + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + non-english-title-check: + name: Flag and close non-English titles + if: github.repository == 'github/docs' + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + with: + github-token: ${{ secrets.DOCS_BOT_PAT_BASE }} + script: | + const isIssue = !!context.payload.issue + const item = context.payload.issue || context.payload.pull_request + const owner = 'github' + const repo = 'docs' + const title = item.title || '' + + // Allow: basic Latin letters/digits/punctuation, common symbols, + // and emoji (surrogate pairs), so normal English titles pass. + // Flag titles containing letters from non-Latin scripts + // (Arabic, CJK, Cyrillic, Devanagari, Hebrew, Thai, etc.) + const nonLatinScriptRegex = /(?!\p{Script=Latin})\p{Letter}/u + + if (!nonLatinScriptRegex.test(title)) { + return + } + + try { + await github.rest.teams.getMembershipForUserInOrg({ + org: 'github', + team_slug: 'employees', + username: context.payload.sender.login, + }) + // Don't action GitHub employees + return + } catch (err) { + // Not an employee — continue + } + + const commentBody = + `Thanks for your contribution! It looks like the title of this ${isIssue ? 'issue' : 'pull request'} contains non-English characters. ` + + `To help our team triage effectively, please resubmit with an English-language title describing the change or problem. ` + + `I'm closing this for now, but feel free to open a new one!` + + if (isIssue) { + await github.rest.issues.update({ + owner, repo, + issue_number: item.number, + labels: ['invalid'], + state: 'closed', + }) + await github.rest.issues.createComment({ + owner, repo, + issue_number: item.number, + body: commentBody, + }) + } else { + await github.rest.pulls.update({ + owner, repo, + pull_number: item.number, + state: 'closed', + }) + await github.rest.issues.createComment({ + owner, repo, + issue_number: item.number, + body: commentBody, + }) + } diff --git a/.github/workflows/check-for-spammy-prs.yml b/.github/workflows/check-for-spammy-prs.yml index ac862691065a..6568410ab995 100644 --- a/.github/workflows/check-for-spammy-prs.yml +++ b/.github/workflows/check-for-spammy-prs.yml @@ -6,7 +6,7 @@ name: Check for Spammy PRs on: pull_request_target: - types: [opened] + types: [opened, edited, reopened, synchronize] permissions: contents: read diff --git a/.github/workflows/send-to-triage-board.yml b/.github/workflows/send-to-triage-board.yml index f5defa974e68..d5bda9b77e7f 100644 --- a/.github/workflows/send-to-triage-board.yml +++ b/.github/workflows/send-to-triage-board.yml @@ -1,8 +1,7 @@ name: Add new issues and PRs to central triage board -# **What it does**: Adds newly opened or reopened issues and pull requests in github/docs to the right place for triage, and stamps the item with today's date. -# **Why we have it**: To ensure incoming work in the public docs repo is triaged properly. -# **Who does it impact**: Writers, FRs. +# Adds new, reopened, and ready-for-review github/docs work to the Central Triage Group board. +# Sets today's date so first responders can triage it. on: issues: @@ -22,7 +21,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} ITEM_URL: ${{ github.event.issue.html_url || github.event.pull_request.html_url }} - # Add to the Central Triage Group project board and set date to now + # These IDs point to the Central Triage Group board and its date field. PROJECT_NUMBER: '19598' PROJECT_ID: 'PVT_kwDNJr_OAJ4AfQ' DATE_FIELD_ID: 'PVTF_lADNJr_OAJ4Afc4IAbbv' diff --git a/.github/workflows/site-policy-reminder.yml b/.github/workflows/site-policy-reminder.yml index 7c335093c669..db8c3902894e 100644 --- a/.github/workflows/site-policy-reminder.yml +++ b/.github/workflows/site-policy-reminder.yml @@ -1,8 +1,7 @@ name: Site Policy Reminder -# **What it does**: Automated comment reminder on a PR to change the title for public consumption before merging and to run the Site Policy repo sync action -# **Why we have it**: Titles of merged PRs to Site Policies are sent to the public site-policy repo when the repos are synced -# **Who does it impact**: Everyone merging changes to Site Policies +# Site Policy PR titles appear in github/site-policy after sync, so they need public wording. +# The reminder tells admins when to run the sync action. on: pull_request: diff --git a/.github/workflows/site-policy-sync.yml b/.github/workflows/site-policy-sync.yml index 318a2de1d387..7cc23c8e72b1 100644 --- a/.github/workflows/site-policy-sync.yml +++ b/.github/workflows/site-policy-sync.yml @@ -1,12 +1,8 @@ name: Site policy sync -# **What it does**: Creates a branch in our site-policy repo with changes to site policy docs. -# **Why we have it**: We want to keep the site-policy repo up to date. -# **Who does it impact**: site-policy-admins and Developer Policy teams. +# Keeps github/site-policy current with internal site policy docs. -# Controls when the action will run. on: - # Triggers the workflow pull requests merged to the main branch pull_request: branches: - main diff --git a/.github/workflows/sme-review-tracking-issue.yml b/.github/workflows/sme-review-tracking-issue.yml index a1e33167c8e4..c22deb06fb50 100644 --- a/.github/workflows/sme-review-tracking-issue.yml +++ b/.github/workflows/sme-review-tracking-issue.yml @@ -1,14 +1,12 @@ name: Create SME review tracking issue -# **What it does**: Creates an SME review tracking issue when the `needs SME` label is applied to a PR or issue -# **Why we have it**: We do not want to manually create an SME review tracking issue when an SME review is needed -# **Who does it impact**: Hubbers +# Creates a technical-content tracking issue when a github/docs PR or issue gets the needs SME label. on: issues: types: - labeled - # Required in lieu of `pull_request` so that this workflow can query users in org to determine membership. + # pull_request_target gives fork PRs the DOCS_BOT_PAT_BASE secret to create the issue. pull_request_target: types: - labeled @@ -31,7 +29,6 @@ jobs: const issueNo = context.number || context.issue.number - // Create an issue in technical-content repo await github.rest.issues.create({ owner: 'github', repo: 'technical-content', diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d008aa8e812a..5f9456e02dbb 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -1,8 +1,6 @@ name: Stale check for stalled pull requests in the docs-internal repository -# **What it does**: Identifies pull requests that have been inactive for 30 days. -# **Why we have it**: We want to avoid pull requests that are stalled and not being reviewed. -# **Who does it impact**: Everyone that works in the internal repository. +# Marks internal PRs stale after 30 inactive days and closes them 14 days later without a response. on: schedule: diff --git a/.github/workflows/sync-audit-logs.yml b/.github/workflows/sync-audit-logs.yml index c80d44bef7d3..f19d5753d4b7 100644 --- a/.github/workflows/sync-audit-logs.yml +++ b/.github/workflows/sync-audit-logs.yml @@ -1,8 +1,6 @@ name: Sync Audit Log data -# **What it does**: This updates our Audit Logs schema. -# **Why we have it**: We want our Audit Logs up to date. -# **Who does it impact**: Docs engineering, people reading Audit Logs. +# Keeps Audit Logs schema docs current with github/audit-log-allowlists. on: workflow_dispatch: @@ -13,7 +11,7 @@ permissions: contents: write pull-requests: write -# This allows a subsequently queued workflow run to interrupt previous runs +# Cancel older syncs so stale schema data does not open older PRs. concurrency: group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true @@ -29,7 +27,7 @@ jobs: - name: Run updater script env: - # need to use a token from a user with access to github/audit-log-allowlists for this step + # DOCS_BOT_PAT_BASE can read github/audit-log-allowlists. GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} run: | npm run sync-audit-log @@ -47,14 +45,12 @@ jobs: - name: Create and merge pull request env: - # Needed for gh GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} run: | echo "Creating a new branch if needed..." branchname=audit-logs-schema-update-${{ steps.audit-log-allowlists.outputs.COMMIT_SHA }} remotesha=$(git ls-remote --heads origin $branchname) if [ -n "$remotesha" ]; then - # output is not empty, it means the remote branch exists echo "Branch $branchname already exists in 'github/docs-internal'. Exiting..." exit 0 fi @@ -94,14 +90,14 @@ jobs: --head=$branchname echo "Created pull request" - # can't approve your own PR, approve with Actions + # docs-bot cannot approve its own PR, so GITHUB_TOKEN approves it. echo "Approving pull request..." unset GITHUB_TOKEN gh auth login --with-token <<< "${{ secrets.GITHUB_TOKEN }}" gh pr review --approve echo "Approved pull request" - # Actions can't merge the PR so back to docs-bot to merge the PR + # GITHUB_TOKEN cannot enable auto-merge here, so docs-bot enables it. echo "Setting pull request to auto merge..." unset GITHUB_TOKEN gh auth login --with-token <<< "${{ secrets.DOCS_BOT_PAT_BASE }}" diff --git a/.github/workflows/sync-codeql-cli.yml b/.github/workflows/sync-codeql-cli.yml index ed091a9450ff..60c4b93636aa 100644 --- a/.github/workflows/sync-codeql-cli.yml +++ b/.github/workflows/sync-codeql-cli.yml @@ -1,10 +1,7 @@ name: Sync CodeQL CLI -# **What it does**: This workflow is run manually approximately every two weeks. -# When run, this workflow syncs the CodeQL CLI automated pipeline with the semmle-code -# repository, and creates a pull request if there are updates. -# **Why we have it**: So we can automate CodeQL CLI documentation. -# **Who does it impact**: Anyone making CodeQL CLI changes in `github/semmle-code`, and wanting to get them published on the docs site. +# Updates CodeQL CLI docs from github/semmle-code and opens a PR when files change. +# Run it manually about every two weeks to publish upstream changes. on: workflow_dispatch: @@ -19,7 +16,7 @@ permissions: contents: write pull-requests: write -# This allows a subsequently queued workflow run to interrupt previous runs +# Cancel older syncs so stale CodeQL CLI data does not open older PRs. concurrency: group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true @@ -34,8 +31,6 @@ jobs: - name: Checkout semmle-code repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - # By default, only the most recent commit of the `main` branch - # will be checked out token: ${{ secrets.DOCS_BOT_PAT_BASE }} repository: github/semmle-code path: semmle-code @@ -53,13 +48,9 @@ jobs: - name: Install pandoc run: | - # Remove all previous pandoc versions sudo apt-get purge --auto-remove pandoc - # Download pandoc wget https://github.com/jgm/pandoc/releases/download/3.0.1/pandoc-3.0.1-1-amd64.deb - # Install pandoc sudo dpkg -i pandoc-3.0.1-1-amd64.deb - # Output the pandoc version installed pandoc -v rm pandoc-3.0.1-1-amd64.deb @@ -72,10 +63,9 @@ jobs: - name: Create pull request env: - # Needed for gh GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} run: | - # If nothing to commit, exit now. It's fine. No orphans. + # Exit before branch creation so a no-op sync does not leave an orphan branch. changes=$(git diff --name-only | wc -l) untracked=$(git status --untracked-files --short | wc -l) if [[ $changes -eq 0 ]] && [[ $untracked -eq 0 ]]; then @@ -92,13 +82,10 @@ jobs: git add . git commit -m "Update CodeQL CLI data" - # Force-push to handle reruns where the branch already exists on the - # remote from a prior failed attempt. Plain --force is safe here - # because these branches are exclusively managed by this workflow. + # Force-push reruns over failed attempts; this workflow exclusively owns these branches. git push --force -u origin $branchname - # If a PR already exists for this branch (e.g. a previous run - # succeeded but the workflow still reported failure), skip creation. + # Skip PR creation when a prior run already opened one for this branch. existing_pr=$(gh pr list --repo github/docs-internal --head "$branchname" --json number --jq '.[0].number') if [[ -n "$existing_pr" ]]; then echo "Pull request #$existing_pr already exists for branch $branchname. Skipping PR creation." diff --git a/.github/workflows/sync-graphql.yml b/.github/workflows/sync-graphql.yml index c814ca667ee2..09784a8705dc 100644 --- a/.github/workflows/sync-graphql.yml +++ b/.github/workflows/sync-graphql.yml @@ -1,8 +1,6 @@ name: Sync GraphQL schema -# **What it does**: This updates our GraphQL schemas. -# **Why we have it**: We want our GraphQL docs up to date. -# **Who does it impact**: Docs engineering, people reading GraphQL docs. +# Keeps GraphQL docs current with schema changes from github/github. on: workflow_dispatch: @@ -30,7 +28,7 @@ jobs: - name: Run updater scripts id: sync env: - # need to use a token from a user with access to github/github for this step + # DOCS_BOT_PAT_BASE can read github/github. GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} NODE_OPTIONS: '--max-old-space-size=8192' run: npm run sync-graphql @@ -38,13 +36,12 @@ jobs: id: create-pull-request uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # pin @v8.0.0 env: - # Disable pre-commit hooks; they don't play nicely here + # Disable Husky because create-pull-request commits inside Actions. HUSKY: '0' with: - # Need to use a token with repo and workflow scopes for this step. - # Token should be a PAT because actions performed with GITHUB_TOKEN - # don't trigger other workflows and this action force pushes updates - # from the default branch. + # DOCS_BOT_PAT_BASE has repo and workflow scopes. + # GITHUB_TOKEN does not trigger follow-up workflows. + # create-pull-request force-pushes updates from the default branch. token: ${{ secrets.DOCS_BOT_PAT_BASE }} commit-message: 'Update GraphQL data files' title: GraphQL schema update diff --git a/.github/workflows/sync-llms-txt.yml b/.github/workflows/sync-llms-txt.yml index f2592e684c30..e6a86c16dd03 100644 --- a/.github/workflows/sync-llms-txt.yml +++ b/.github/workflows/sync-llms-txt.yml @@ -1,12 +1,7 @@ name: Sync llms.txt -# **What it does**: Generates docs.github.com/llms.txt, github.com/llms.txt, and -# github.com/llms-full.txt from the page catalog and popularity data, then -# opens PRs to update them. -# **Why we have it**: Agents discover docs through llms.txt; the page list keeps -# pace with what's actually popular without writers updating it by hand. -# **Who does it impact**: Docs consumers via agents, and anyone landing on -# github.com/llms.txt, github.com/llms-full.txt, or docs.github.com/llms.txt. +# Generates docs.github.com/llms.txt, github.com/llms.txt, and github.com/llms-full.txt. +# Uses the page catalog and popularity data, then opens PRs to update them. on: workflow_dispatch: @@ -59,8 +54,6 @@ jobs: --output /tmp/monolith-llms.txt echo "Generated monolith llms.txt ($(wc -l < /tmp/monolith-llms.txt) lines, $(wc -c < /tmp/monolith-llms.txt) bytes)" - # ---------- PR to docs-internal: update data/llms-txt/docs.md ---------- - - name: Diff docs llms.txt against committed copy id: diff_docs run: | @@ -97,11 +90,8 @@ jobs: git config user.name "docs-bot" git config user.email "77750099+docs-bot@users.noreply.github.com" git add data/llms-txt/docs.md - # diff_docs compares against main, but the sync branch may already - # exist with this exact content (open PR from a prior run). In that - # case there is nothing new to stage, and `git commit` would exit 1 - # and fail the whole workflow. Skip the commit and push when the - # branch is already up to date. + # diff_docs compares against main, but an open sync-branch PR can already hold this content. + # Skip commit and push because git commit exits 1 with nothing staged. if git diff --cached --quiet; then echo "Sync branch already has the latest generated docs.md; nothing to commit." else @@ -135,8 +125,6 @@ jobs: --draft \ --label "llm-generated" - # ---------- PR to github/github: update public/llms*.txt ---------- - - name: Fetch current public llms files from github/github id: fetch_monolith env: diff --git a/.github/workflows/sync-openapi.yml b/.github/workflows/sync-openapi.yml index eb15a2db3d91..0349f529c5a1 100644 --- a/.github/workflows/sync-openapi.yml +++ b/.github/workflows/sync-openapi.yml @@ -1,8 +1,7 @@ name: Sync OpenAPI schema -# **What it does**: Syncs the REST, Webhooks, and GitHub Apps automated pipelines with the github/rest-api-description repository, and creates a pull request if there are updates to any of the data files we generate from the OpenAPI. Runs on a weekday schedule or a `sync-openapi` repository dispatch. -# **Why we have it**: So we can automate updates to REST, Webhooks, and GitHub Apps documentation -# **Who does it impact**: Anyone making OpenAPI changes in `github/github`, and wanting to get them published on the docs site. +# Updates REST, Webhooks, and GitHub Apps docs from github/rest-api-description. +# Opens a PR when generated OpenAPI data files change. on: workflow_dispatch: @@ -21,7 +20,7 @@ permissions: contents: write pull-requests: write -# This allows a subsequently queued workflow run to interrupt previous runs +# Cancel older syncs so stale OpenAPI data does not open older PRs. concurrency: group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true @@ -34,12 +33,9 @@ jobs: - name: Checkout repository code uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - # Check out a nested repository inside of previous checkout - name: Checkout rest-api-description repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - # By default, only the most recent commit of the `main` branch - # will be checked out repository: github/rest-api-description path: rest-api-description ref: ${{ inputs.SOURCE_BRANCH || github.event.client_payload.ref || 'main' }} @@ -48,7 +44,6 @@ jobs: - name: Sync the REST, Webhooks, and GitHub Apps schemas env: - # Needed for gh GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} NODE_OPTIONS: '--max-old-space-size=8192' run: | @@ -72,10 +67,9 @@ jobs: - name: Create pull request env: - # Needed for gh GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} run: | - # If nothing to commit, exit now. It's fine. No orphans. + # Exit before branch creation so a no-op sync does not leave an orphan branch. changes=$(git diff --name-only | wc -l) if [[ $changes -eq 0 ]]; then echo "There are no changes to commit after running 'npm run sync-rest'. Exiting..." @@ -89,7 +83,6 @@ jobs: remotesha=$(git ls-remote --heads origin $branchname) if [ -n "$remotesha" ]; then - # output is not empty, it means the remote branch exists echo "Branch $branchname already exists in 'github/docs-internal'. Exiting..." exit 0 fi diff --git a/.github/workflows/sync-sdk-docs.yml b/.github/workflows/sync-sdk-docs.yml index 92796217453e..31394c19c172 100644 --- a/.github/workflows/sync-sdk-docs.yml +++ b/.github/workflows/sync-sdk-docs.yml @@ -1,11 +1,12 @@ name: 'Sync Copilot SDK docs' +# Syncs Copilot SDK docs after copilot-sdk docs changes and opens an update PR. +# Supports manual dry runs and pull request validation without publishing. + on: - # Event-driven sync — triggered by copilot-sdk when docs/ changes are pushed repository_dispatch: types: [sync-sdk-docs] - # Manual trigger for on-demand syncs and testing workflow_dispatch: inputs: dry_run: @@ -19,7 +20,6 @@ on: default: 'main' type: string - # PR validation — dry-run only, verifies scripts work on CI pull_request: types: [opened, synchronize, reopened] paths: @@ -50,12 +50,8 @@ jobs: - name: Checkout docs-internal uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - # `preserve-redirects.ts` reads the pre-sync state from `git HEAD` to learn - # which URLs are currently live. That is only a valid baseline when HEAD is - # the published branch. `pull_request` runs are safe because they never - # publish, but a `workflow_dispatch` from another branch would publish while - # comparing against that branch's tree, writing stale redirects into a real - # PR. Fail early rather than let the run reach the push step. + # Publishing runs must start from the default branch because preserve-redirects.ts reads + # live URLs from git HEAD. workflow_dispatch from another branch could write stale redirects. - name: Verify publishing runs start from the default branch if: github.event_name != 'pull_request' && inputs.dry_run != 'true' run: | @@ -98,10 +94,9 @@ jobs: - name: Copy SDK docs run: | mkdir -p "$SDK_DOCS_TARGET" - # Pages relocated out of this tree into hand-authored content are not - # excluded here — they are removed by the RELOCATED_PAGES map in - # src/workflows/sync-sdk-docs/normalize-sdk-docs.ts, which also - # repoints inbound links at their new URLs. + # Keep relocated pages in the rsync input. RELOCATED_PAGES in + # src/workflows/sync-sdk-docs/normalize-sdk-docs.ts removes them and + # repoints inbound links. rsync -av --exclude='.validation/' --exclude='developer-docs/' "$SDK_TMP/docs/" "$SDK_DOCS_TARGET/" echo "Copied $(find "$SDK_DOCS_TARGET" -name '*.md' | wc -l | tr -d ' ') markdown files" @@ -113,16 +108,10 @@ jobs: - name: Preserve redirects run: | - # `--git-ref HEAD` is the record of which URLs are currently live. On - # publishing runs a preceding step has verified HEAD is the default - # branch, and the sync branch is only created later with `checkout -B`. - # On `pull_request` runs HEAD is the merge commit instead, which is fine - # because those runs are dry-run only. - # - # A removed page that needs a redirect decision should still produce a - # PR, because the PR is where that decision gets made and committed. The - # script still writes the at-risk URLs and a copy-pasteable - # `redirect_from` block to the run summary either way. + # --git-ref HEAD points preserve-redirects.ts at the currently live URLs. Publishing runs + # have already verified HEAD is the default branch; pull_request runs stay dry-run only. + # Removed pages still produce a PR for redirect review. The script writes at-risk URLs and + # a copy-pasteable redirect_from block to the run summary either way. npx tsx src/workflows/sync-sdk-docs/preserve-redirects.ts \ --sdk-docs-dir "$SDK_DOCS_TARGET" \ --git-ref HEAD @@ -131,7 +120,7 @@ jobs: env: PUPPETEER_CHROMIUM_REVISION: '' run: | - # Puppeteer needs --no-sandbox on GitHub Actions runners + # Puppeteer needs --no-sandbox on GitHub Actions runners. echo '{ "args": ["--no-sandbox", "--disable-setuid-sandbox"] }' > /tmp/puppeteer-config.json npx tsx src/workflows/sync-sdk-docs/convert-mermaid.ts \ --sdk-docs-dir "$SDK_DOCS_TARGET" \ @@ -145,7 +134,6 @@ jobs: echo "No assets directory — nothing to clean." exit 0 fi - # Collect image filenames referenced in the current SDK docs REFERENCED=$(grep -roh '/assets/images/help/copilot/copilot-sdk/[^)]*' "$SDK_DOCS_TARGET" \ | sed 's|.*/||' | sort -u) STALE=0 @@ -190,7 +178,6 @@ jobs: git diff --cached --stat fi - # --- PR-only: upload artifacts for review --- - name: Upload normalized docs (PR validation) if: github.event_name == 'pull_request' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 @@ -201,7 +188,6 @@ jobs: ${{ env.ASSETS_TARGET }} retention-days: 7 - # --- Push and PR (only on dispatch, not dry-run, and changes exist) --- - name: Commit and push if: >- env.has_changes == 'true' @@ -213,7 +199,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Fetch the sync branch if it exists so force-with-lease knows the remote state + # Fetch the sync branch if it exists so force-with-lease knows the remote state. git fetch origin "$SYNC_BRANCH" 2>/dev/null || true git checkout -B "$SYNC_BRANCH" diff --git a/.github/workflows/sync-secret-scanning.yml b/.github/workflows/sync-secret-scanning.yml index 730464d206ce..bd35f814b776 100644 --- a/.github/workflows/sync-secret-scanning.yml +++ b/.github/workflows/sync-secret-scanning.yml @@ -1,8 +1,6 @@ name: Sync Secret Scanning data -# **What it does**: This updates the data used by the secret scanning patterns page. -# **Why we have it**: To automate updates to the secret scanning pattern data in our public-facing documentation. -# **Who does it impact**: Docs engineering, content writers. +# Keeps the public secret scanning patterns page current with github/token-scanning-service. on: workflow_dispatch: @@ -13,7 +11,7 @@ permissions: contents: write pull-requests: write -# This allows a subsequently queued workflow run to interrupt previous runs +# Cancel older syncs so stale secret scanning data does not open older PRs. concurrency: group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true @@ -30,8 +28,7 @@ jobs: - name: Sync secret scanning data id: secret-scanning-sync env: - # need to use a token from a user with access to - # github/token-scanning-service for this step + # DOCS_BOT_PAT_BASE can read github/token-scanning-service. GITHUB_TOKEN: ${{ secrets.DOCS_BOT_PAT_BASE }} run: | npm run sync-secret-scanning @@ -40,10 +37,10 @@ jobs: id: create-pull-request uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # pin @v8.0.0 env: - # Disable pre-commit hooks; they don't play nicely here + # Disable Husky because create-pull-request commits inside Actions. HUSKY: '0' with: - # need to use a token with repo and workflow scopes for this step + # DOCS_BOT_PAT_BASE has repo and workflow scopes. token: ${{ secrets.DOCS_BOT_PAT_BASE }} commit-message: 'Add updated secret scanning data' title: Sync secret scanning data diff --git a/.github/workflows/test-changed-content.yml b/.github/workflows/test-changed-content.yml index 29c9cd9704b6..9a4eddc71a5e 100644 --- a/.github/workflows/test-changed-content.yml +++ b/.github/workflows/test-changed-content.yml @@ -1,16 +1,11 @@ name: Test changed content -# **What it does**: Runs the vitest tests for changed and deleted content files. -# **Why we have it**: Use GitHub Actions to run tests on changed content files. -# **Who does it impact**: Docs engineering, open-source engineering contributors. +# Renders changed pages and checks that deleted or renamed URLs still resolve before main-bound PRs merge. on: pull_request: branches: - # This is important! If you make a PR against a megabranch, you - # might actually want to delete a file without setting up a - # redirect in its place. But if it's going into `main` we'll - # want to make sure that doesn't happen. + # Megabranch PRs may skip deleted-page checks, but main-bound PRs must keep old URLs resolving. - main paths: - 'content/**' @@ -24,8 +19,7 @@ jobs: runs-on: ubuntu-latest if: ${{ github.repository == 'github/docs-internal' || github.repository == 'github/docs' }} steps: - # Each of these ifs needs to be repeated at each step to make sure the required check still runs - # Even if if doesn't do anything + # Repeat each if on its step so skipped work still leaves the required check present. - name: Check out repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -49,16 +43,14 @@ jobs: uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 with: files: 'content/**' - # Needed to expose `all_old_new_renamed_files` (old,new pairs for renames). - # Without this, files git classifies as renames (status R) are invisible to - # the deleted-file redirect check below and old URLs can silently 404. + # all_old_new_renamed_files exposes old,new pairs for renames. + # Without it, git status R files skip the deleted-file redirect check and old URLs 404. include_all_old_new_renamed_files: true - name: Run tests env: CHANGED_FILES: ${{ steps.changed_files.outputs.all_modified_files }} DELETED_FILES: ${{ steps.changed_files.outputs.deleted_files }} - # Space-separated `oldPath,newPath` pairs. The test treats the old paths - # like deleted files so missing redirects on renames are caught. + # Space-separated oldPath,newPath pairs; the test checks each old path like a deleted file. RENAMED_FILES: ${{ steps.changed_files.outputs.all_old_new_renamed_files }} run: npm test -- src/content-render/tests/render-changed-and-deleted-files.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 640859c1d829..58dfd68c535c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,11 +1,6 @@ name: Test -# **What it does**: Runs our tests. -# **Why we have it**: We want our tests to pass before merging code. -# **Who does it impact**: Docs engineering, open-source engineering contributors. -# -# For a catalog of what each suite covers and how risky it is to admin-merge -# past it when red, see src/tests/SUITES.md. +# Runs required test suites before merge. src/tests/SUITES.md explains coverage and merge risk. on: workflow_dispatch: @@ -16,14 +11,13 @@ permissions: contents: read pull-requests: read -# This allows a subsequently queued workflow run to interrupt previous runs +# Cancel older test runs for the same ref so newer commits get the runner. concurrency: group: '${{ github.workflow }} @ ${{ github.event.pull_request.head.label || github.head_ref || github.ref }}' cancel-in-progress: true env: - # Setting this will activate the vitest tests that depend on actually - # sending real search queries to Elasticsearch + # ELASTICSEARCH_URL enables Vitest suites that send real search queries. ELASTICSEARCH_URL: http://localhost:9200/ jobs: @@ -35,11 +29,9 @@ jobs: strategy: fail-fast: false matrix: - # Note that *if you add* to this, remember to also add that - # to the **required checks** in the branch protection rules. + # Add new matrix suites to branch protection required checks too. name: - # Every directory in src/ is listed here. - # A commented out entry has no test files of its own. + # Lists every src directory. Commented-out entries have no suite-specific tests. # - ai-tools # - app - archives @@ -47,7 +39,7 @@ jobs: - assets - audit-logs - automated-pipelines - # - codeql-cli # enable once github/docs-internal#63239 removes the broken scratch test + # - codeql-cli # Enable when the broken scratch test is removed. # - codeql-queries - color-schemes - content-linter @@ -85,7 +77,7 @@ jobs: - webhooks - workflows - # The languages suite only runs on docs-internal + # The languages suite only runs on docs-internal. isPrivateRepo: - ${{ github.repository == 'github/docs-internal' }} exclude: @@ -93,8 +85,7 @@ jobs: isPrivateRepo: false steps: - # Each of these ifs needs to be repeated at each step to make sure the required check still runs - # Even if if doesn't do anything + # Repeat each if on its step so skipped work still leaves the required check present. - name: Check out repo uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -115,14 +106,12 @@ jobs: if: ${{ matrix.name == 'fixtures' }} run: npm run copy-fixture-data -- --check - # This keeps our fixture content/data in check - name: Check the test fixture content (if applicable) if: ${{ matrix.name == 'fixtures' }} env: ROOT: src/fixtures/fixtures run: | - # If either of these fail, it means our fixture content's internal - # links can and should be updated. + # A failure means fixture content has stale internal links the dry run can update. npm run update-internal-links -- --dry-run --check --strict \ src/fixtures/fixtures/content \ --exclude src/fixtures/fixtures/content/get-started/foo/typo-autotitling.md \ @@ -151,19 +140,17 @@ jobs: run: npm run build - uses: ./.github/actions/warmup-remotejson-cache - # Only the 'routing' tests include end-to-end tests about - # archived enterprise server URLs. + # Only routing tests cover archived enterprise server URLs. if: ${{ matrix.name == 'redirects' }} - uses: ./.github/actions/precompute-pageinfo - # Only the 'pageinfo' tests include end-to-end tests about this. + # Only pageinfo tests cover precomputed page info. if: ${{ matrix.name == 'article-api' }} env: ROOT: src/fixtures/fixtures - name: Index fixtures into the local Elasticsearch - # For the sake of saving time, only run this step if the group - # is one that will run tests against an Elasticsearch on localhost. + # Run indexing only for suites that query the local Elasticsearch service. if: ${{ matrix.name == 'search' || matrix.name == 'languages' }} run: npm run index-test-fixtures @@ -171,14 +158,11 @@ jobs: env: DIFF_FILE: get_diff_files.txt CHANGELOG_CACHE_FILE_PATH: src/fixtures/fixtures/changelog-feed.json - # By default, when `process.env.NODE_ENV === 'test'` it forces the - # tests run only in English. The exception is the - # `languages` suite which needs all languages to be set up. + # NODE_ENV=test forces English-only tests; the languages suite needs every language. ENABLED_LANGUAGES: ${{ matrix.name == 'languages' && 'all' || '' }} ROOT: ${{ (matrix.name == 'fixtures' || matrix.name == 'article-api' || matrix.name == 'landings' ) && 'src/fixtures/fixtures' || '' }} TRANSLATIONS_FIXTURE_ROOT: ${{ (matrix.name == 'fixtures' || matrix.name == 'article-api') && 'src/fixtures/fixtures/translations' || '' }} - # Enable debug logging when "Re-run jobs with debug logging" is used in GitHub Actions UI - # This will output additional timing and path information to help diagnose timeout issues + # RUNNER_DEBUG enables timing and path logs when Actions reruns jobs with debug logging. RUNNER_DEBUG: ${{ runner.debug }} VITEST_FLAGS: ${{ matrix.name == 'article-api' && '--no-file-parallelism --maxWorkers=1' || '' }} run: npm test -- $VITEST_FLAGS src/${{ matrix.name }}/tests/ diff --git a/.github/workflows/triage-issue-comments.yml b/.github/workflows/triage-issue-comments.yml index d56749eb48c3..70d836655b5e 100644 --- a/.github/workflows/triage-issue-comments.yml +++ b/.github/workflows/triage-issue-comments.yml @@ -1,8 +1,6 @@ name: Triage new issue comments -# **What it does**: Adds label triage to new issue comments in the open source repository. -# **Why we have it**: Update open source project board for review. -# **Who does it impact**: Docs open source. +# Labels public issues for triage when external contributors add new comments. on: issue_comment: diff --git a/.github/workflows/triage-issues.yml b/.github/workflows/triage-issues.yml index 55b333e5413c..e22a900f0b6d 100644 --- a/.github/workflows/triage-issues.yml +++ b/.github/workflows/triage-issues.yml @@ -1,8 +1,6 @@ name: Triage new issues -# **What it does**: Add the 'triage' label to new issues in the open source repository. -# **Why we have it**: We want to make sure that new issues are triaged and assigned to the right team. -# **Who does it impact**: Docs open source. +# Labels opened or reopened public docs issues for triage and team assignment. on: issues: diff --git a/.github/workflows/triage-pull-requests.yml b/.github/workflows/triage-pull-requests.yml index 39419711d2c5..bb001f429559 100644 --- a/.github/workflows/triage-pull-requests.yml +++ b/.github/workflows/triage-pull-requests.yml @@ -1,11 +1,9 @@ name: Triage new pull requests -# **What it does**: Adds triage label to new pull requests in the open source repository. -# **Why we have it**: Update project board for new pull requests for triage. -# **Who does it impact**: Docs open source. +# Labels opened or reopened public docs pull requests for triage. on: - # Needed in lieu of `pull_request` so that PRs from a fork can be triaged. + # pull_request_target lets PRs from forks be triaged. pull_request_target: types: - reopened diff --git a/.github/workflows/triage-stale-check.yml b/.github/workflows/triage-stale-check.yml index e7d2db0765e4..83a8d585cff7 100644 --- a/.github/workflows/triage-stale-check.yml +++ b/.github/workflows/triage-stale-check.yml @@ -1,8 +1,6 @@ name: Stale check for no activity -# **What it does**: Provides more aggressive stale checks in the open repo. -# **Why we have it**: In the open repo, we want more aggressive stale checking. -# **Who does it impact**: Anyone working in the open repo. +# Applies shorter stale windows in the public docs repo. on: schedule: diff --git a/.github/workflows/triage-unallowed-contributions.yml b/.github/workflows/triage-unallowed-contributions.yml index 13a2309ae208..549b90437251 100644 --- a/.github/workflows/triage-unallowed-contributions.yml +++ b/.github/workflows/triage-unallowed-contributions.yml @@ -1,11 +1,9 @@ name: Check unallowed file changes -# **What it does**: If someone changes some files in the open repo, we prevent the pull request from merging. -# **Why we have it**: Some files can only be changed in the internal repository for security and workflow reasons. -# **Who does it impact**: Open source contributors. +# Blocks public pull requests that change files managed only in docs-internal. on: - # Needed in lieu of `pull_request` so that PRs from a fork can be notified of unallowed changes. + # pull_request_target lets PRs from forks receive unallowed-change comments. pull_request_target: permissions: @@ -19,6 +17,7 @@ jobs: github.repository == 'github/docs' && github.event.pull_request.user.login != 'docs-bot' && github.event.pull_request.user.login != 'dependabot[bot]' + && !contains(github.event.pull_request.labels.*.name, 'skip-unallowed-check') }} runs-on: ubuntu-latest steps: @@ -29,22 +28,19 @@ jobs: uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 id: filter with: - # Base branch used to get changed files + # Compare against main to match the public docs repo base. base: 'main' - # Enables setting an output in the format in `${FILTER_NAME}_files - # with the names of the matching files formatted as JSON array + # list-files=json emits matching paths in FILTER_NAME_files outputs. list-files: json - # Returns list of changed files matching each filter filters: 'src/workflows/unallowed-contribution-filters.yml' - name: Set up Node and dependencies if: ${{ steps.filter.outputs.notAllowed == 'true' || steps.filter.outputs.contentTypes == 'true' }} uses: ./.github/actions/node-npm-setup - # When there are changes to files we can't accept, leave a comment - # explaining this to the PR author, and why their PR will close + # Comment when rejected file changes will close the PR, so the author knows why. - name: "Comment about changes we can't accept" if: ${{ steps.filter.outputs.notAllowed == 'true' || steps.filter.outputs.contentTypes == 'true' }} run: npm run unallowed-contributions diff --git a/.github/workflows/validate-asset-images.yml b/.github/workflows/validate-asset-images.yml index e29eb9eec307..f2e9a770c31d 100644 --- a/.github/workflows/validate-asset-images.yml +++ b/.github/workflows/validate-asset-images.yml @@ -1,8 +1,6 @@ name: Validate asset images -# **What it does**: Run ./src/assets/scripts/validate-asset-images.ts on all images in assets/ -# **Why we have it**: To protect from innocent and potentially malicious bad image assets -# **Who does it impact**: Docs content. +# Rejects malformed or risky image assets before they ship. on: workflow_dispatch: diff --git a/.github/workflows/validate-github-github-docs-urls.yml b/.github/workflows/validate-github-github-docs-urls.yml index b9577e49f7d4..b21893bb273b 100644 --- a/.github/workflows/validate-github-github-docs-urls.yml +++ b/.github/workflows/validate-github-github-docs-urls.yml @@ -1,23 +1,12 @@ name: Validate github/github docs URLs -# **What it does**: Checks the URLs in docs-urls.json in github/github -# **Why we have it**: To ensure the values in docs-urls.json are perfect. -# **Who does it impact**: Docs content. +# Checks github/github docs-urls.json entries against docs-internal content. on: workflow_dispatch: schedule: - cron: '20 16 * * 1' # Run every Monday at 16:20 UTC / 8:20 PST - # See https://gh.io/AAsyyao before uncommenting: - # pull_request: - # paths: - # - 'content/**' - # # In case a relevant dependency changes - # - 'package*.json' - # # The scripts - # - 'src/links/scripts/validate-github-github-docs-urls/**' - # # The workflow - # - .github/workflows/validate-github-github-docs-urls.yml + # Pull request triggers need https://gh.io/AAsyyao setup before they can run safely. permissions: contents: read @@ -46,8 +35,7 @@ jobs: - name: Run validation run: | - # This will generate a .json file which we can use to - # do other things in other steps. + # checks.json feeds the later update and comment steps. npm run validate-github-github-docs-urls -- validate \ --output checks.json \ --ignore-not-found \ @@ -79,10 +67,7 @@ jobs: git commit -a -m "Update Docs URLs from automation ($current_daystamp)" git push origin "$branch_name" - # XXX TODO - # Perhaps post an issue somewhere, about that the fact that this - # branch has been created and now needs to be turned into a PR - # that some human can take responsibility for. + # Scheduled and manual runs create update-docs-urls branches for a human to turn into PRs. - name: Clean up old branches in github/github if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }} @@ -94,15 +79,10 @@ jobs: echo "To see them all, go to:" echo "https://github.com/github/github/branches/all?query=update-docs-urls-" - # If a PR comes along to github/docs-internal that causes some - # URLs in docs-urls.json (in github/github) to now fail, then - # we'll want to make the PR author+reviewer aware of this. - # For example, you moved a page without setting up a redirect. - # Or you edited a heading that now breaks a URL with fragment. - # In the latter case, you might want to update the URL in docs-urls.json - # after this PR has landed, or consider using `` as a - # workaround for the time being. - # First, gather the URLs that were relevant + # When a PR breaks docs-urls.json entries in github/github, comment for the + # author and reviewer. + # Common causes are moved pages without redirects or edited headings that break URL fragments. + # The fix can update docs-urls.json after merge or add a stable anchor. - name: Get changed content/data files if: ${{ github.event_name == 'pull_request' }} id: changed_files diff --git a/.github/workflows/validate-openapi-check.yml b/.github/workflows/validate-openapi-check.yml index b602126df401..aa0edd90bef4 100644 --- a/.github/workflows/validate-openapi-check.yml +++ b/.github/workflows/validate-openapi-check.yml @@ -1,8 +1,6 @@ name: Validate OpenAPI Check Docker -# **What it does**: Tests building and running the OpenAPI check Docker container -# **Why we have it**: To ensure the Dockerfile and openapi-check script work correctly -# **Who does it impact**: Docs engineering. +# Tests the OpenAPI check Dockerfile and script before related changes merge. on: workflow_dispatch: @@ -16,7 +14,7 @@ on: - 'package.json' - 'package-lock.json' - 'tsconfig.json' - # Self-test + # Re-run this workflow when its own definition changes. - '.github/workflows/validate-openapi-check.yml' permissions: diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index 2b13f0935714..3052ec6ca99b 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -1,8 +1,6 @@ name: Workflow security lint -# **What it does**: Runs zizmor to detect security issues in GitHub Actions workflows. -# **Why we have it**: To catch injection vulnerabilities and other security misconfigurations before they ship. -# **Who does it impact**: Docs engineering. +# Runs zizmor so workflow injection vulnerabilities and security misconfigurations fail CI. on: pull_request: diff --git a/content/copilot/reference/ai-models/model-hosting.md b/content/copilot/reference/ai-models/model-hosting.md index b8d8c3502c71..4765f740441b 100644 --- a/content/copilot/reference/ai-models/model-hosting.md +++ b/content/copilot/reference/ai-models/model-hosting.md @@ -46,6 +46,7 @@ Used for: * {% data variables.copilot.copilot_claude_haiku_45 %} * {% 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_47 %} * {% data variables.copilot.copilot_claude_opus_48 %} * {% data variables.copilot.copilot_claude_opus_48_fast %} diff --git a/content/copilot/reference/ai-models/supported-models.md b/content/copilot/reference/ai-models/supported-models.md index e18f9c2166ef..cc4530968d72 100644 --- a/content/copilot/reference/ai-models/supported-models.md +++ b/content/copilot/reference/ai-models/supported-models.md @@ -84,6 +84,7 @@ Choosing a larger context window or higher reasoning will impact {% data variabl | {% 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_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" %} | @@ -143,6 +144,7 @@ Some {% data variables.product.prodname_copilot_short %} models require minimum | {% 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_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_k27_code %} | `v1.127` | `17.14.6` | `1.9.1-251` | TBD | TBD | diff --git a/content/copilot/reference/copilot-cli-reference/cli-command-reference.md b/content/copilot/reference/copilot-cli-reference/cli-command-reference.md index 2769496fdb21..5e96a76c75f4 100644 --- a/content/copilot/reference/copilot-cli-reference/cli-command-reference.md +++ b/content/copilot/reference/copilot-cli-reference/cli-command-reference.md @@ -31,6 +31,7 @@ docsTeamMetrics: | `copilot skill` | Manage agent skills from the command line (list, add, remove, enable, and disable skills). See [Managing skills non-interactively](#managing-skills-non-interactively). | | `copilot update` | Download and install the latest version. | | `copilot version` | Display version information and check for updates. | +| `copilot workflow run NAME` | Run a registered dynamic workflow directly, without a parent agent turn. See [Using `copilot workflow run`](#using-copilot-workflow-run). | ### `copilot login` options @@ -141,6 +142,34 @@ Each `--json` entry has the shape `{ id, fileExtensions?, sourcePlugin? }`. Custom agents and session-scoped hooks aren't covered by `copilot instruction`, `copilot lsp`, `copilot plugin`, `copilot mcp`, or `copilot skill`. All require a live session. +### Using `copilot workflow run` + +Run `copilot workflow run NAME` to run a registered dynamic workflow directly, without a parent agent turn. Progress is written to output before the final result, unless `--silent` is set. + +```bash +# Run a workflow without arguments +copilot workflow run summarize + +# Pass inline JSON arguments +copilot workflow run phased --args '{"tag":"demo"}' + +# Read arguments from a JSON file and write the result to another file +copilot workflow run phased --args @input.json --result-file result.json + +# Emit one machine-readable result +copilot workflow run echo --args '{"value":42}' --silent --output-format json +``` + +| Option | Description | +|----------------------------|-----------------------------------------------------------------------------| +| `NAME` | Registered dynamic workflow name (required). | +| `--args=JSON`, `--args=@PATH` | Workflow arguments as inline JSON, or an `@`-prefixed path to a JSON file. | +| `--result-file=PATH` | Write only the workflow result to this JSON file. | +| `--silent`, `-s` | Suppress workflow progress output. | +| `--output-format=FORMAT` | Output format: `text` (default) or `json` (JSONL). | + +The command exits `0` when the workflow completes and `1` otherwise; an interrupt signal (Ctrl+C) exits `130`. `copilot workflow run` can't be combined with other root mode flags (for example `--prompt`, `--interactive`, `--fleet`, `--autopilot`, `--agent`, `--resume`, `--continue`, `--worktree`, or `--ui-server`)—it always runs headlessly. + ## The sessions sidebar The sessions sidebar is a panel docked beside your current conversation that provides a quick way of working with your local {% data variables.copilot.copilot_cli %} sessions. @@ -251,7 +280,7 @@ For more information about the sessions sidebar, see [AUTOTITLE](/copilot/how-to | `! COMMAND` | Execute a command in your local shell, bypassing {% data variables.product.prodname_copilot_short %}. Enter `!` alone on an empty prompt to enter shell mode for running multiple shell commands in sequence. Press Esc or Ctrl+C on an empty prompt to exit shell mode. | | `$` | Type a lone `$` at the prompt and press Enter to hand the terminal over to a real interactive shell (`$SHELL` on Unix, `%COMSPEC%` on Windows) rooted at the session's working directory. Unlike `!` shell mode, this suspends the CLI UI entirely, so job control, full-screen apps, tab completion, and colors all work natively. Exit the shell (`exit`, or Ctrl+D on Unix) to return to the CLI. Only activates for a local, trusted, idle session on a real TTY. Can be disabled in enterprise managed settings. Enabled by default. Disable it with the `shellShortcut` setting—see [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-config-dir-reference#configuration-file-settings). | | `?` | Open quick help (on an empty prompt). Press again to dismiss and insert a literal `?`. | -| Esc | Cancel the current operation. Press twice to interrupt the running turn, or to stop background agents when the main agent is idle. | +| Esc | Cancel the current operation. Press twice to interrupt the running turn, or to stop background agents when the main agent is idle. In a local session, if the model hasn't started answering the turn yet, the second press displays your prompt in the prompt box again for editing. | | Ctrl+C | Cancel operation / clear input. Press twice to exit. | | Ctrl+D | Shutdown. | | Ctrl+G | Edit the prompt in an external editor (`$EDITOR`). | @@ -277,6 +306,8 @@ In local sessions, you can queue prompts, shell commands, and supported slash co With an empty prompt box, press ↑ to recall the most recently queued or steering prompt back into the prompt box for editing before it's resubmitted. A "recall" hint appears next to the queue when this is available. Use Ctrl+P instead for nondestructive navigation through submitted command history. +In a local session, pressing Esc twice on a submitted prompt whose turn the model hasn't started answering puts your prompt back in the prompt box and removes it from the conversation. If the model has already started answering, the same second Esc press instead interrupts the running turn. Afterward, with an empty prompt box, pressing ↑ restores the prompt to the prompt box. + ## Timeline shortcuts in the interactive interface | Shortcut | Purpose | @@ -366,10 +397,13 @@ The **Sessions** tab lists the current session plus your full resumable session | `n` | Start a new session. | | `a` | Cycle the filter scope: all → local → remote (cloud). | | `/` | Search live across name, branch or working directory, repository, and session ID. | +| `x`, `x` | Close or delete the selected row (armed for one keystroke, shown in red, before acting); Ctrl+X then `x` still works as a two-key alias. | | ←/→ | Switch tabs. | Remote (cloud) rows in the **Sessions** tab also show online or offline status and the repository. +Pressing `x` twice on a row closes a running session or, for a local resumable row, permanently deletes that session's stored history. Pressing Esc, pressing any other key, moving the highlight, or a timeout cancels the pending confirmation. + ## Diff mode shortcuts When diff mode is open (entered via `/diff`): @@ -433,7 +467,7 @@ These are the slash commands you can use from within an interactive CLI session. | `/autopilot [OBJECTIVE]`, `/goal [OBJECTIVE]` | Start or refocus autopilot mode, optionally with an explicit objective (for example, `/goal Refactor the auth module`). Without an objective, autopilot infers intent from context, and the status panel shows your last prompt as the inferred objective. You can cap AI-credit spend for the objective by using `--max-ai-credits N` (for example, `/goal Refactor the auth module --max-ai-credits 5`). When the cap is reached, autopilot pauses and opens a panel reporting credits used against the cap. Enter a new amount to resume with a fresh credit window, or dismiss the panel to stay paused. You can also resume a paused objective yourself, without the panel, by running the option on its own with no objective text—for example, `/goal --max-ai-credits 5`. This is the same action the panel performs: it opens a fresh window of the credits you specify (the full new cap, not an increment) and continues the objective. `/goal on` and `/goal off` toggle autopilot mode without setting an objective and don't accept `--max-ai-credits`. An active goal renders as a pinned panel above the prompt box, showing the objective, credits used, and todo progress. The panel auto-collapses to a single identity row on short terminals (below 30 rows) and expands above that threshold; press Ctrl+X then `g` to override the automatic sizing by hand. | | `/changelog [summarize] [VERSION\|last N\|since VERSION]`, `/release-notes [summarize] [VERSION\|last N\|since VERSION]` | Display the CLI changelog. Optionally specify a version, a count of recent releases, or a starting version. Add the keyword `summarize` for an AI-generated summary. | | `/chronicle ` | Session history tools and insights. The `skills` subcommands draft, review, and track the status of repository skill proposals generated from observed usage. See [AUTOTITLE](/copilot/how-tos/copilot-cli/use-copilot-cli/chronicle#using-the-chronicle-slash-command). | -| `/clear [PROMPT]`, `/new [PROMPT]`, `/reset [PROMPT]` | Start a new conversation. | +| `/clear [PROMPT]`, `/new [PROMPT]`, `/reset [PROMPT]` | Start a new conversation. `/new worktree` starts an empty session in a new Git worktree instead of clearing the current one, leaving the current conversation and its working directory unchanged. | | `/clikit [COMPONENT]` | Preview CLI business components (for example, quota info). | | `/compact [FOCUS-INSTRUCTIONS]` | Summarize the conversation history to reduce context window usage. Optionally provide focus instructions to steer the summary—for example, `/compact focus on the auth module`. See [AUTOTITLE](/copilot/concepts/agents/copilot-cli/context-management#compaction). | | `/context` | Show the context window token usage and visualization. See [AUTOTITLE](/copilot/concepts/agents/copilot-cli/context-management#checking-your-context-usage). | @@ -463,7 +497,7 @@ These are the slash commands you can use from within an interactive CLI session. | `/logout` | Log out of {% data variables.product.prodname_copilot_short %}. | | `/lsp [show\|test\|reload\|logs\|help] [SERVER-NAME]` | Manage the language server configuration. The `logs` subcommand opens the live LSP services log panel. | | `/mcp [config\|list\|show\|add\|edit\|delete\|disable\|enable\|auth\|reload\|search] [SERVER-NAME]` | Manage the MCP server configuration. With no subcommand, or with `config`, the plugins dashboard opens pinned to the MCP server list; the add, edit, and authenticate forms open inside that dashboard too, so closing a form returns you to the server list. Use `show` or `show SERVER-NAME` to display all configured servers or open one server's details directly, including its available tools, and to enable or disable it. For a plugin-provided server, `show SERVER-NAME` also displays the source attribution (for example, `Source: Plugin my-plugin (1.2.0)`). `list` (alias `ls`) prints a plain-text list of configured servers with connection status and live state. Bare `/mcp`, `config`, `show`, and `list` (alias `ls`) are read-only or open the dashboard, so they can run while the agent is busy processing a turn. The mutating subcommands (`add`, `edit`, `delete`, `disable`, `enable`, `auth`, `reload`, and `search`) are blocked until the turn finishes. `edit ` rejects a workspace-sourced server (one defined in a repository's `.mcp.json`) instead of opening the user-tier wizard, since saving would silently create a same-name user entry that the workspace one still shadows. The error names the file to edit directly. `delete ` reports the same file when asked to remove a workspace-sourced server. Sandboxed local servers show a `connected (sandboxed)` status. See [AUTOTITLE](/copilot/how-tos/copilot-cli/customize-copilot/add-mcp-servers#managing-mcp-servers). | -| `/model [--session\|--global\|--repo\|--local] [MODEL]`, `/models` | Select the AI model you want to use, or choose **Auto**. By default (or with `--session`, alias `-s`), changes the model, reasoning effort, or context window for the current session only, without touching saved settings. `--repo`/`--local` pins the default model in repository settings instead; `--global` (or `/config model`) sets the default for future sessions. Press Tab on a model with a long-context variant to toggle its Context column between the default and long-context window. The picker groups models into sections—press Shift+Tab to cycle grouping between recommended (Recent, Recommended, New, and other models), vendor, and category. A model with vendor-specific data retention terms shows a data retention warning banner with a link to the vendor's policy. Usable mid-turn: a change requested while the agent is running is queued as a cancellable (Ctrl+C) command and applied once the current turn finishes, instead of switching the live model mid-request. See [AUTOTITLE](/copilot/concepts/models/auto-model-selection). | +| `/model [--session\|--global\|--repo\|--local] [MODEL\|auto TIER]`, `/models` | Select the AI model you want to use, or choose **Auto**. By default (or with `--session`, alias `-s`), changes the model, reasoning effort, or context window for the current session only, without touching saved settings. `--repo`/`--local` pins the default model in repository settings instead; `--global` (or `/config model`) sets the default for future sessions. Press Tab on a model with a long-context variant to toggle its Context column between the default and long-context window. The picker groups models into sections—press Shift+Tab to cycle grouping between recommended (Recent, Recommended, New, and other models), vendor, and category. A model with vendor-specific data retention terms shows a data retention warning banner with a link to the vendor's policy. Usable mid-turn: a change requested while the agent is running is queued as a cancellable (Ctrl+C) command and applied once the current turn finishes, instead of switching the live model mid-request. Use `/model auto TIER` (`efficiency`, `balance`, or `intelligence`) to select a specific Auto routing tier directly, including from the "switch" action on an Auto tier recommendation hint. See [AUTOTITLE](/copilot/concepts/models/auto-model-selection). | | `/permissions [default\|assisted\|allow-all\|show]` | Switch between permission modes (`default`, `assisted`, `allow-all`), or show the current mode (`show`). This is the canonical command for permission mode changes; `/allow-all` and `/yolo` remain supported as aliases. | | `/permissions reset` | Reset all in-memory tool and path approvals for the current session (re-prompt on next use). | | `/plan [PROMPT]` | Create an implementation plan before coding. | @@ -512,9 +546,9 @@ These are the slash commands you can use from within an interactive CLI session. | `/version` | Display version information and check for updates. | | `/vim` | Toggle Vim mode for the prompt box, enabling Vim-style modal editing: motions (for example, `hjkl`, `w`, `b`, `e`, `0`, `$`, `gg`, `G`), character search (`f`/`F`/`t`/`T`/`;`/`,`), insert commands (`i`/`a`/`o`), edit commands (`r`/`~`/`J`/`x`/`D`/`C`), operators (`d`/`c`/`y`), yank and put (`y`/`p`/`P`), repeat (`.`), undo and redo (`u`/Ctrl+R), counts, and Esc to return to normal mode. Also configurable with the `editorMode` setting. See [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-config-dir-reference#user-settings-copilotsettingsjson). | | `/voice [on\|off\|models\|devices]` | Toggle voice mode, browse available voice models, or choose the input device (microphone). | -| `/fork [NAME]`, `/branch [NAME]` | Fork the current session into a new session, optionally with a name. | +| `/fork [NAME]`, `/branch [NAME]` | Fork the current session into a new session, optionally with a name. Usable while the agent is running—the source session keeps working in the background. `/fork worktree` forks the current session, preserving its conversation context, into a new Git worktree branched off `HEAD`. | | `/worktree [branch\|task]` | Create a new Git worktree and switch to it, leaving uncommitted changes behind in the current worktree. Pass a branch name, a task description (multiline supported, used as the opening prompt in the new worktree), or omit the argument to auto-generate a branch name from the conversation. By default, branches off the current checkout (`HEAD`); set the `worktreeBaseRef` setting to `"defaultBranch"` to branch off the remote default branch instead. See [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-config-dir-reference#user-settings-copilotsettingsjson). Requires a Git repository. | -| `/worktree new [PROMPT]` | Start a new conversation in a new Git worktree, leaving the current conversation and its working directory unchanged. Optionally provide the first prompt. `new` is reserved as the subcommand keyword and can't be used as a literal branch name. Follows the same `worktreeBaseRef` setting as `/worktree`. | +| `/worktree new [PROMPT]` | Deprecated—use `/new worktree` instead. Starts a new conversation in a new Git worktree, leaving the current conversation and its working directory unchanged. Optionally provide the first prompt. `new` is reserved as the subcommand keyword and can't be used as a literal branch name. Follows the same `worktreeBaseRef` setting as `/worktree`. | | `/move [branch\|task]` | Move uncommitted changes into a new Git worktree and switch to it. Pass a branch name, a task description (multiline supported, used as the opening prompt in the new worktree), or omit the argument to auto-generate a branch name from the conversation. Requires a Git repository. | For a complete list of available slash commands enter `/help` in the CLI's interactive interface. @@ -594,7 +628,7 @@ The footer shows an "N scheduled" indicator by default whenever the session has | `-p PROMPT`, `--prompt=PROMPT` | Execute a prompt programmatically (exits after completion). The exit summary includes a `copilot --resume=SESSION-ID` hint for continuing the session. See [AUTOTITLE](/copilot/how-tos/copilot-cli/automate-copilot-cli/run-cli-programmatically). | | `--plan` | Start in plan mode. Shorthand for `--mode plan`. Cannot be combined with `--autopilot`. Can be combined with `--mode autopilot` for plan-then-autopilot; any other `--mode` value is rejected. | | `--plain-diff` | Disable rich diff rendering (syntax highlighting via the diff tool specified by your Git config). | -| `--plugin-dir=DIRECTORY` | Load a plugin from a local directory (can be used multiple times). A relative path resolves against the session working directory (the `--resume`, `--worktree`, or `-C` directory), regardless of option order. | +| `--plugin-dir=DIRECTORY` | Load a plugin from a local directory (can be used multiple times). A relative path resolves against the session working directory (the `--resume`, `--worktree`, or `-C` directory), regardless of option order. Agents contributed by a `--plugin-dir` plugin are available in server-mode (`--server`) sessions as well as interactive and `-p` sessions. | | `--remote` | Enable remote access to this session from {% data variables.product.prodname_dotcom_the_website %} and {% data variables.product.prodname_mobile %}. See [AUTOTITLE](/copilot/how-tos/copilot-cli/use-copilot-cli/steer-remotely). | | `--remote-export` | Export your session to {% data variables.product.prodname_dotcom_the_website %} and {% data variables.product.prodname_mobile %} (read-only; does not enable remote control). | | `-r`, `--resume[=VALUE]` | Resume a previous interactive session by choosing from a list. Optionally specify a session ID, ID prefix, or session name. Name matching is exact and case-insensitive; falls back to the auto-generated summary when no explicit name matches. Conflicts with `--continue`. Bare `--resume` (no value) shows an interactive session picker, which requires a TTY. If multiple sessions exist and the picker can't be shown (for example under `-p`, a non-TTY `-i`, or piped stdin), the CLI exits with an error instead of silently starting a new session—pass an explicit `--resume=SESSION-ID` or use `--continue`. | @@ -608,7 +642,7 @@ The footer shows an "N scheduled" indicator by default whenever the session has | `--share-gist` | Share a session to a secret {% data variables.product.github %} gist after completion of a programmatic session. | | `--stream=MODE` | Enable or disable streaming mode, which displays {% data variables.product.prodname_copilot_short %}'s response progressively as it is generated rather than waiting for the full response to arrive (mode choices: `on` or `off`, default: `on`). | `-v`, `--version` | Show version information. | -| `-w`, `--worktree[=NAME]` | Create or reuse an isolated Git worktree under `.worktrees/` and start the session inside it. `NAME` is optional—omit it to auto-generate a branch name. By default, branches off the current checkout (`HEAD`); set the `worktreeBaseRef` setting to `"defaultBranch"` to branch off the remote default branch instead. Conflicts with `--resume`, `--continue`, and `--connect`. | +| `-w`, `--worktree[=NAME]` | Create or reuse an isolated Git worktree under `.worktrees/` by default and start the session inside it. Use the `worktreePathTemplate` setting to configure the location. `NAME` is optional—omit it to auto-generate a branch name. By default, branches off the current checkout (`HEAD`); set the `worktreeBaseRef` setting to `"defaultBranch"` to branch off the remote default branch instead. Conflicts with `--resume`, `--continue`, and `--connect`. | | `--yolo` | Enable all permissions (equivalent to `--allow-all`). | For a complete list of commands and options, run `copilot help`. @@ -665,6 +699,9 @@ Use `--model=MODEL` or the `COPILOT_MODEL` environment variable to select the AI | `claude-sonnet-4.6` | General-purpose coding (default) | | `gpt-5.4` | Complex reasoning tasks | | `gpt-6-astra` | New model, opt-in (not the automatic default) | +| `gpt-6-sol` | New model, opt-in (not the automatic default) | +| `gpt-6-luna` | New model, opt-in (not the automatic default) | +| `claude-opus-5.5` | New model, high-capability complex tasks | | `claude-haiku-4.5` | Fast, lightweight operations | | `gpt-5.3-codex` | Code-focused tasks | | `gemini-3.5-flash` | Fast Google Gemini responses | @@ -886,7 +923,7 @@ copilot mcp add --transport http SERVER-NAME URL | `--env KEY=VALUE` | Environment variable (repeatable). | | `--header "HEADER: VALUE"` | HTTP header for remote servers (repeatable). | | `--tools ` | Tool filter: `"*"` for all, a comma-separated list, or `""` for none. | -| `--timeout ` | Timeout in milliseconds for tool discovery and tool calls. Default: `30000`. | +| `--timeout ` | Timeout in milliseconds for tool discovery and tool calls. Default: `30000`. Must be a positive integer with no fractional part, unit suffix, sign, or exponent, from `1` to `4294967295`. | | `--json` | Output added configuration as JSON. | | `--show-secrets` | Show full environment variable and header values. | @@ -956,6 +993,7 @@ The `--registry` option and other npm configuration options (`--userconfig`, `-- | `tools` | Yes | Tools to enable. | | `headers` | No | HTTP headers. Supports variable expansion. | | `oauthClientId` | No | Static OAuth client ID (skips dynamic registration). | +| `oauthScopes` | No | Non-empty array of OAuth scope tokens to request. Requires `oauthClientId`. A non-empty scope in the server's `WWW-Authenticate` challenge still takes precedence; otherwise this overrides the discovered `scopes_supported` metadata. | | `oauthPublicClient` | No | Whether the OAuth client is public. Default: `true`. Set to `false` for confidential clients with a stored secret. | | `oauthGrantType` | No | OAuth grant type: `"authorization_code"` (default, browser-based flow) or `"client_credentials"` (fully headless, no browser or callback). | | `oidc` | No | Enable OIDC token injection. When `true`, the CLI injects OIDC tokens for any `GITHUB_COPILOT_OIDC_MCP_TOKEN` or `GITHUB_COPILOT_OIDC_MCP_TOKEN_` variable referenced in the server's `env` block (local servers), or sends the token as a `Bearer` `Authorization` header (remote servers). For local servers, prefer suffixed variants (for example, `${GITHUB_COPILOT_OIDC_MCP_TOKEN_MY_SVC}`) to assign a unique variable name per server. | @@ -1072,6 +1110,8 @@ MCP servers from different sources are merged in priority order (highest first). > [!NOTE] > Workspace MCP servers (`.mcp.json` and `.github/mcp.json`) are loaded in both interactive and SDK server-mode sessions, provided the working directory is trusted. For more information about folder trust, see [AUTOTITLE](/copilot/how-tos/copilot-cli/use-copilot-cli/allowing-tools). +If a workspace configuration file contains an invalid server entry, the CLI skips only that entry and keeps loading its valid siblings, printing `Warning: workspace MCP config "": ` for each skipped entry. A malformed or unreadable file (invalid JSON or an invalid top-level structure) is still skipped entirely. + ### Enterprise MCP allowlist {% data variables.product.prodname_enterprise %} organizations can enforce an allowlist of permitted MCP servers. When active, the CLI evaluates each non-default server against the enterprise policy before connecting. @@ -1129,7 +1169,7 @@ Skills are Markdown files that extend what the CLI can do. Each skill lives in i | Field | Type | Required | Description | |-------|------|----------|-------------| -| `name` | string | Yes | Unique identifier for the skill. Letters, numbers, and hyphens only. Max 64 characters. | +| `name` | string | Yes | Unique identifier for the skill. Must start with a letter or number and contain only letters, numbers, hyphens, underscores, dots, colons, and spaces. Max 64 characters. Colons allow namespaced names (for example, `my-plugin:search`). | | `description` | string | Yes | What the skill does and when to use it. Max 1024 characters. | | `argument-hint` | string | No | Freeform hint describing expected arguments, shown in the skill picker (for example, `"[target] [mode]"`). | | `allowed-tools` | string or string[] | No | Comma-separated list or YAML array of tools that are automatically allowed when the skill is active. Use `"*"` for all tools. | @@ -1156,6 +1196,8 @@ Skills are loaded from these locations in priority order (first found wins for d Remote skills are projected alongside local skills and follow the same name-based priority when a local skill has the same name. +Use the `ignoredSkillsLocations` setting to exclude specific directories (and their descendants) from discovery, regardless of which location above would otherwise surface them. See [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-config-dir-reference#configuration-file-settings). + When two plugins provide skills with the same name, both coexist using plugin-qualified invocation names such as `/my-plugin/search` and `/other-plugin/search`. The bare name routes to the higher-priority plugin. This applies to skills only; commands keep the standard tier-based deduplication, where the higher-priority source wins. ### Managing skills non-interactively 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 52693c3cfb61..fa014a9b24fb 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 @@ -446,6 +446,7 @@ These settings apply across all your sessions and repositories. You can use the |-----|------|---------|-------------| | `allowedUrls` | `string[]` | `[]` | URLs or domains allowed without prompting. Supports exact URLs, domain patterns, and wildcard subdomains (for example, `"*.github.com"`). | | `askUser` | `boolean` | `true` | Allow the agent to ask clarifying questions. Set to `false` for fully autonomous operation. Can also be set with `--no-ask-user`. | +| `autoTier` | `"efficiency"` \| `"balance"` \| `"intelligence"` | unset | Default Auto routing tier for new conversations when the selected model is `auto`. See the `/model` slash command. `"fast"` is no longer selectable and falls back to `"balance"` with a warning if set. | | `autoUpdate` | `boolean` | `true` | Automatically download CLI updates and update first-party plugins at the start of each session. | | `autoUpdatesChannel` | `"stable"` \| `"prerelease"` | `"stable"` | Update channel. Set to `"prerelease"` to receive pre-release updates. | | `banner` | `"always"` \| `"once"` \| `"never"` | `"once"` | Animated banner display frequency. | @@ -458,6 +459,7 @@ These settings apply across all your sessions and repositories. You can use the | `commandHistoryMaxSize` | `number` | `50` | Maximum number of recent commands retained for input history and reverse search. Must be an integer between `1` and `1000`. | | `compactPaste` | `boolean` | `true` | Collapse large pastes (more than 10 lines) into compact tokens. | | `companyAnnouncements` | `string[]` | `[]` | Custom messages shown randomly on startup. One message is randomly selected each time the CLI starts. Useful for team announcements or reminders. | +| `connectors` | `boolean` | `true` | Enable {% data variables.product.prodname_copilot_short %} Connectors when available. Set to `false` to disable. | | `continueOnAutoMode` | `boolean` | `false` | Automatically switch to auto mode when rate-limited. When `true`, eligible rate limit errors trigger an automatic switch to auto mode and retry. Does not apply to global rate limits or BYOK providers. | | `copyOnSelect` | `boolean` | `true` (macOS), `false` (other) | Automatically copy mouse-selected text to the system clipboard. | | `customAgents.defaultLocalOnly` | `boolean` | `false` | Only use local custom agents (no remote organization or enterprise agents). | @@ -476,6 +478,7 @@ These settings apply across all your sessions and repositories. You can use the | `hooks` | `object` | — | Inline user-level hook definitions, keyed by event name. Uses the same schema as `.github/hooks/*.json` files. See [AUTOTITLE](/copilot/how-tos/copilot-cli/customize-copilot/use-hooks). | | `ide.autoConnect` | `boolean` | `true` | Automatically connect to an IDE workspace on startup. When `false`, you can still connect manually using the `/ide` command. | | `ide.openDiffOnEdit` | `boolean` | `true` | Open file edit diffs in the connected IDE for approval. When `false`, file edit approvals are shown only in the terminal. | +| `ignoredSkillsLocations` | `string[]` | `[]` | Skill directories (and their descendants) excluded from discovery, regardless of which location would otherwise surface them. Supports `~`-relative paths. See [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-command-reference#skill-locations). | | `includeCoAuthoredBy` | `boolean` | `true` | Add a `Co-authored-by` trailer to git commits made by the agent. | | `keepAlive` | `"on"` \| `"off"` \| `"busy"` | `"off"` | Keep-alive mode applied at CLI startup. `"on"` always prevents the system from sleeping, `"busy"` prevents sleeping only while the agent is running, and `"off"` disables keep-alive. Also configurable with the `/keep-alive` slash command. | | `logLevel` | `"none"` \| `"error"` \| `"warning"` \| `"info"` \| `"debug"` \| `"all"` \| `"default"` | `"default"` | Logging verbosity. | @@ -496,7 +499,7 @@ These settings apply across all your sessions and repositories. You can use the | `sandbox.enabled` | `boolean` | `false` | Restrict shell commands, MCP/LSP servers, and built-in file/web tools to a sandboxed environment with limited file system and network access. Enable it from the `/sandbox` dialog or with `/sandbox enable`. | | `sandbox.auth.git` | `boolean` | `true` | Inject Git credentials into the sandbox so commands running inside it can authenticate with Git. Set to `false` to opt out. Renamed from `sandbox.gitAuth`; the old key has no migration and is ignored wherever it still appears. | | `sandbox.auth.gh` | `boolean` | `true` | Inject {% data variables.product.prodname_cli %} (`gh`) credentials into the sandbox so commands running inside it can authenticate with the {% data variables.product.prodname_cli %}. Set to `false` to opt out. Renamed from `sandbox.ghAuth`; the old key has no migration and is ignored wherever it still appears. | -| `sandbox.userPolicy.network.allowLocalNetwork` | `boolean` | `true` | Allow sandboxed commands to reach local network addresses (for example, local dev servers). Set to `false` to opt out. | +| `sandbox.userPolicy.network.allowLocalNetwork` | `boolean` | `true` | Allow sandboxed commands to reach local network addresses (for example, local dev servers). Set to `false` to opt out. On Windows hosts whose ProcessContainer backend supports it, enabling this setting also lets a sandboxed command reach the host's loopback address (for example, `localhost`), matching macOS and Linux behavior. Older Windows versions keep host loopback denied even with this setting enabled. | | `sandbox.userPolicy.network.proxy` | `object` | unset | Route sandboxed network traffic through an HTTP proxy. Fields: `url` (required), `username` (optional), `password` (optional). Configure it from the `/sandbox` dialog's **Network** tab, which masks the password field. The password itself is stored in the OS keychain rather than in `settings.json`, so it isn't editable via `/settings`. Enforcement differs by platform: on macOS the proxy is cooperative—{% data variables.copilot.copilot_cli_short %} sets `HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY` in the sandbox, so only programs that honor those variables use it; on Linux it is strictly enforced through a private network namespace that permits only the proxy endpoint (the proxy must have an IPv4 address and must not embed credentials); on Windows the proxy is not supported, so a policy that sets it is rejected and the sandboxed command fails with an error. | | `sandbox.userPolicy.network.allowedHosts` | `string[]` | `[]` | Hosts a sandboxed command is allowed to reach. Entries are exact hostnames, IP addresses, or `*.example.com` for strict subdomain matches (`*` matches every host). A non-empty list blocks any host that doesn't match. Configure from the `/sandbox` dialog's **Network** tab under **Host rules**. | | `sandbox.userPolicy.network.blockedHosts` | `string[]` | `[]` | Hosts a sandboxed command is denied from reaching, matched the same way as `allowedHosts`. `blockedHosts` always takes precedence over a matching `allowedHosts` entry, and denying a domain also denies its subdomains. Configure from the `/sandbox` dialog's **Network** tab under **Host rules**. | @@ -522,12 +525,14 @@ These settings apply across all your sessions and repositories. You can use the | `tabs.hide` | `string[]` | `[]` | Tab identifiers to hide. Accepted values: `"copilot"`, `"agents"`, `"issues"`, `"pull-requests"`, `"gists"` (matched case-insensitively). | | `tabs.sort` | `string[]` | `[]` | Order in which tabs are displayed. Tabs not listed keep their default relative order after the listed ones. Unknown identifiers are ignored. | | `taskbarPresence` | `boolean` | `true` | Show a live {% data variables.product.prodname_copilot_short %} session on the Windows taskbar (agent icon and hover card). Set to `false` to opt out. Startup-only; takes effect on the next launch. Windows only. | +| `terminalNotifications` | `boolean` | `false` | Prefer terminal-owned OSC 777 notifications on supported terminals (Ghostty, WezTerm) when desktop notifications are enabled, falling back to native OS notifications when unsupported or delivery fails. | | `terminalProgress` | `boolean` | `true` | Emit OSC 9;4 terminal progress indicators while the agent is working. Supported terminals include Windows Terminal, iTerm2, Ghostty, and ConEmu. | | `theme` | `"default"` \| `"github"` \| `"dim"` \| `"high-contrast"` \| `"colorblind"` | `"github"` | Color palette for terminal output. Managed by the `/settings` and `/theme` slash commands. `colorMode` is a deprecated alias for this setting. | | `toolSearch` | `boolean` | model- and feature-dependent | Controls tool search (deferred tool loading). Set `toolSearch: false` to opt out of tool search. | | `transcriptView` | `"default"` \| `"concise"` | `"default"` | Set to `"concise"` to group tool activity into expandable work summaries in the timeline. Set to `"default"` to show the full native transcript. | | `updateTerminalTitle` | `boolean` | `true` | Show the current intent in the terminal tab or window title. | -| `worktreeBaseRef` | `"head"` \| `"defaultBranch"` | `"head"` | Starting point for new worktrees created by `/worktree`, `/worktree new`, and `--worktree`. `"defaultBranch"` starts from the remote default branch instead of the current checkout. | +| `worktreeBaseRef` | `"head"` \| `"defaultBranch"` | `"head"` | Starting point for new worktrees created by `/worktree`, `/worktree new`, `/new worktree`, `/move`, and `--worktree`. `"defaultBranch"` starts from the remote default branch instead of the current checkout. `/fork worktree` always branches off `HEAD`, regardless of this setting. | +| `worktreePathTemplate` | `string` | unset | Where `/worktree`, `/move`, `/new`, and `--worktree` create worktrees—for example, `~/src/worktrees/{repo}/{branch}`. Supports the `{repoPath}`, `{repo}`, `{branch}`, and `{branchSlug}` placeholders. When unset, the default layout, `.worktrees/`, is used, with slashes in the branch name flattened to dashes. | > [!TIP] > Run `copilot help sandbox` for the full sandbox reference, including supported hosts and all `sandbox` settings keys. @@ -604,6 +609,8 @@ The local configuration file uses the same schema as the repository configuratio IT administrators can push baseline policy using Mobile Device Management (MDM) managed settings instead of requiring per-user configuration. These settings apply device-level defaults for supported keys and load before user settings. +Managed settings apply uniformly across every session-hosting mode—interactive, `-p`, `--acp`, `--ahp-host`, and `--server`—so enterprise MCP, permission, and plugin policy can't be bypassed by starting a session through a different entry point. + {% data variables.copilot.copilot_cli_short %} also loads server-managed settings at startup, in addition to MDM. Device-managed (MDM) and server-managed settings are resolved **per key**: MDM's value wins for any key it sets, and the server's value fills in keys MDM leaves unset. This lets an organization set some policy via MDM (for example, `permissions`) while still receiving other managed defaults (for example, `model`) from the server. Long-running sessions re-fetch and re-apply managed settings hourly, so policy changes—for example, an organization enabling `permissions.disableBypassPermissionsMode`—take effect without restarting the session. @@ -646,18 +653,19 @@ Only the following keys are supported in MDM managed settings. | Key | Description | |-----|-------------| | `allowedMcpServers` | Allowlist of MCP servers users may load, matched by `serverUrl`, `serverCommand`, or `serverName`. Trusted first-party servers (for example, the built-in {% data variables.product.github %} MCP server) are always exempt. Leaving this key unset allows all non-default servers; an empty array denies all of them. See [Managed MCP server allow/deny list](#managed-mcp-server-allowdeny-list). | +| `autoTier` | Set a default Auto routing tier (`"efficiency"`, `"balance"`, or `"intelligence"`) for sessions with `model` set to `auto`. A bare string strictly locks the tier, overriding user and repository settings and hiding it from `/settings`. Use `{"overridable": "TIER"}` instead to set an organization default that users and repositories may still override. `"fast"` is no longer selectable and falls back to `"balance"` with a warning. | | `deniedMcpServers` | Denylist of MCP servers that must never load, matched the same way as `allowedMcpServers`. A matching non-default server is blocked regardless of the allowlist—deny always wins. See [Managed MCP server allow/deny list](#managed-mcp-server-allowdeny-list). | | `enabledPlugins` | Enable or disable specific plugins | | `extraKnownMarketplaces` | Add trusted plugin marketplaces | | `forceLoginOrgs` | Pin sign-in to an approved set of {% data variables.product.github %} organizations (an array of organization logins, matched case-insensitively). {% data variables.product.prodname_copilot_short %} only runs for an account belonging to at least one listed organization; a personal account, an account that belongs only to some other enterprise, or BYOK/API-key authentication is refused with an actionable error. Set an empty array to turn the pin off without deleting the key. Deploy this key through the device channel (MDM plist/registry, or `managed-settings.json`) since it must be able to redirect a developer's first sign-in—the server-managed channel only reaches accounts that have already authenticated into the organization. This key fails closed: an unusable value, or a managed policy that can't be read on a known-managed device, blocks all sign-in until fixed. | | `forceRemoteSettingsRefresh` | Require a fresh server-managed settings fetch on startup, even when a fresh cached policy exists. The cached entry is still kept as a fallback if the fetch fails. The device (MDM) value takes precedence over a cached server value. | -| `model` | Set a default model for all users (overridden by the `--model` flag or a resumed-session model) | +| `model` | Set a default model for all users (overridden by the `--model` flag or a resumed-session model). `effortLevel` and `contextTier` set alongside `model` apply the same managed reasoning effort and context tier as the corresponding [repository settings](#repository-settings-githubcopilotsettingsjson) keys, but only when the managed model supports explicit effort/context options. | | `permissions` | Set managed permissions, including `disableBypassPermissionsMode` and `deny` / `ask` / `allow` rule arrays. See [Managed permission rules](#managed-permission-rules). | | `policyHelper` | Register an executable that supplies the lowest-priority managed-settings layer. Fields: `path` (required), plus optional `args`, `timeoutMs`, and `refreshIntervalMs`. If both a device (MDM) and a server policy register a `policyHelper`, the device registration wins. | | `remoteControl` | Control whether sessions on this device can be controlled from other devices. `mode` is `"enabled"`, `"disabled"`, or `"requireSSO"` (requires `githubDotComOrganizations` when set). | | `sandbox` | Set a sandbox policy floor that users cannot relax. Supported settings include `enabled`, `failIfUnavailable`, `allowBypass`, `addCurrentWorkingDirectory`, `sandboxMcpServers`, `sandboxLspServers`, `auth.git`, `auth.gh`, `allowDevToolAccess`, and the `userPolicy.*` filesystem and network rules. The managed value always takes precedence over a user's own value in the safer direction. Turning the sandbox on, requiring it to succeed, and sandboxing MCP and LSP servers cannot be turned off. Disabling bypass or credential injection cannot be re-enabled. Filesystem allow lists can only be narrowed, and denied paths can only be added to. `failIfUnavailable` can only be set by an administrator and blocks the session when the sandbox cannot be established. For the settings users can set themselves, see [User settings](#user-settings-copilotsettingsjson) or run `copilot help sandbox`. | | `shellShortcut` | Force-enable or force-disable the `$` interactive shell shortcut for all users. A managed value always overrides the user's own `shellShortcut` setting. | -| `strictKnownMarketplaces` | Restrict plugins to known marketplaces | +| `strictKnownMarketplaces` | Restrict plugins to an allowlist of known marketplaces (a JSON array of marketplace specs). The allowlist also governs built-in marketplaces once set—an empty array (`[]`) hides and blocks every marketplace, including built-ins, not just user- or repository-added ones. | | `telemetry` | Push baseline OpenTelemetry export configuration: `enabled`, `endpoint`, `protocol`, `headers`, `resourceAttributes`, `captureContent`, `lockCaptureContent`, and `serviceName`. See [AUTOTITLE](/copilot/reference/copilot-cli-reference/cli-command-reference#opentelemetry-monitoring). | > [!NOTE] diff --git a/content/copilot/reference/copilot-cli-reference/cli-plugin-reference.md b/content/copilot/reference/copilot-cli-reference/cli-plugin-reference.md index dbb7495184be..635ec20ba8d9 100644 --- a/content/copilot/reference/copilot-cli-reference/cli-plugin-reference.md +++ b/content/copilot/reference/copilot-cli-reference/cli-plugin-reference.md @@ -28,8 +28,8 @@ You can use the following commands in the terminal to manage plugins for {% data | `copilot plugin uninstall NAME` (aliases `remove`, `rm`) | Remove a plugin | | `copilot plugin list` | List installed plugins | | `copilot plugin update NAME` | Update a named plugin. Use `--all` to update all installed plugins at once. | -| `copilot plugin enable NAME` | Enable a previously disabled plugin | -| `copilot plugin disable NAME` | Disable a plugin without uninstalling it | +| `copilot plugin enable NAME` | Enable a previously disabled plugin. The change persists to configuration and applies to future sessions. This works for marketplace installs and direct installs (from `owner/repo`, a URL, or a local path) alike. | +| `copilot plugin disable NAME` | Disable a plugin without uninstalling it. A `--plugin-dir` mount stays read-only since it has no persisted activation to change. | | `copilot plugin marketplace add SPECIFICATION` | Register a marketplace. The marketplace's own name, from its `marketplace.json` manifest, becomes its registration key—there is no option to set a custom local name. | | `copilot plugin marketplace list` | List registered marketplaces | | `copilot plugin marketplace browse NAME` | Browse marketplace plugins | @@ -111,9 +111,9 @@ In interactive mode, run `/plugin marketplace update [NAME]` (alias `/plugin mar ## `plugin.json` -All plugins consist of a plugin directory containing a manifest file named `plugin.json`. Agent Plugins 1.0 requires the manifest at the plugin root. Legacy plugins support the alternative locations listed in [File locations](#file-locations). See [AUTOTITLE](/copilot/how-tos/copilot-cli/customize-copilot/plugins-creating). +All plugins consist of a plugin directory containing a manifest file named `plugin.json`. Agent Plugins requires the manifest at the plugin root. A root `plugin.json` that targets Agent Plugins takes precedence over `.plugin/plugin.json` and `.claude-plugin/plugin.json` per spec §5.1. Legacy plugins support the alternative locations listed in [File locations](#file-locations). See [AUTOTITLE](/copilot/how-tos/copilot-cli/customize-copilot/plugins-creating). -{% data variables.copilot.copilot_cli_short %} supports both the legacy plugin manifest and the Agent Plugins 1.0 manifest. The exact `$schema` value `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json` opts a plugin into Agent Plugins 1.0 semantics. A manifest without this value uses the legacy format and loads as before. +{% data variables.copilot.copilot_cli_short %} supports both the legacy plugin manifest and the Agent Plugins manifest. {% data variables.copilot.copilot_cli_short %} recognizes the canonical `$schema` values for Agent Plugins (Open Plugin Spec) v1.0.0 (`https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`) and v1.1.0 (`https://agent-plugins.org/schemas/1.1.0/plugin.schema.json`), opting a plugin into Agent Plugins semantics. A manifest without one of these exact values uses the legacy format and loads as before. If a plugin declares an Agent Plugins version that {% data variables.copilot.copilot_cli_short %} doesn't support, the CLI rejects the plugin instead of silently falling back to legacy mode. A rejected plugin contributes no hooks, LSP servers, MCP servers, skills, commands, agents, rules, or extension directories. ### Agent Plugins 1.0 manifest fields @@ -123,7 +123,7 @@ The following fields are allowed: | Field | Type | Required | Description | |---------------|----------|----------|-------------| -| `$schema` | string | Yes | Must be `https://agent-plugins.org/schemas/1.0.0/plugin.schema.json`. | +| `$schema` | string | Yes | Must be a recognized Agent Plugins `$schema` URL (v1.0.0 or v1.1.0). Unsupported Agent Plugins versions are rejected. | | `name` | string | Yes | Plugin name. See [Name constraints](#name-constraints). | | `version` | string | No | Version string. Semantic Versioning is recommended. | | `description` | string | No | Brief description. | @@ -152,9 +152,9 @@ Agent Plugins 1.0 defines two portable component types: * Skills in immediate subdirectories of `skills/` that contain a `SKILL.md` file. * MCP servers in `mcp.json` at the plugin root. -These locations are fixed and cannot be configured in `plugin.json`. The root `mcp.json` must declare `https://agent-plugins.org/schemas/1.0.0/mcp.schema.json` in its `$schema` field. The CLI accepts `stdio`, `streamable-http`, and `sse` MCP transport names. +These locations are fixed and cannot be configured in `plugin.json`. Skills load only from `skills/`—there is no root `SKILL.md` fallback (legacy plugins fall back to a root `SKILL.md` when no `skills/` directory exists). The root `mcp.json` must declare a recognized Agent Plugins `$schema` version (matching the same version as `plugin.json`) in its `$schema` field. The top-level envelope is closed, and each server entry is validated against its transport schema; invalid server entries are skipped individually while valid entries still load. The CLI accepts `stdio`, `streamable-http`, and `sse` MCP transport names. -For `stdio` servers, the CLI provides `PLUGIN_ROOT` and `PLUGIN_DATA` in the subprocess environment. It expands `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` in the server's `args`, `env` values, and `cwd`. `PLUGIN_DATA` points to a persistent, writable directory for the installed plugin. +For `stdio` servers, the CLI provides `PLUGIN_ROOT` and `PLUGIN_DATA` in the subprocess environment. It expands `${PLUGIN_ROOT}` and `${PLUGIN_DATA}` (plus the `CLAUDE_PLUGIN_DATA` and `COPILOT_PLUGIN_DATA` aliases) in the server's `args`, `env` values, and `cwd`. `PLUGIN_DATA` points to a persistent, writable directory for the installed plugin. Remote `http`, `sse`, and `streamable-http` server config values are passed through literally, with no placeholder or environment-variable expansion. Agent Plugins 1.0 does not define portable agents, hooks, commands, rules, or LSP servers. These remain client-specific. Client-specific manifest data belongs in `extensions`, keyed by reverse-domain namespace. Client-specific files belong in a top-level directory with the same namespace. Clients ignore namespaces they do not support. @@ -367,12 +367,12 @@ Both the `github` and `url` source types accept an optional `sha` field to pin i |----------------------|------| | Installed plugins | `~/.copilot/installed-plugins/MARKETPLACE/PLUGIN-NAME` (installed via a marketplace) and `~/.copilot/installed-plugins/_direct/SOURCE-ID/` (installed directly) | | Marketplace cache | Platform cache directory: `~/.cache/copilot/marketplaces/` (Linux), `~/Library/Caches/copilot/marketplaces/` (macOS). Overridable with `COPILOT_CACHE_HOME`. | -| Plugin manifest | Agent Plugins 1.0: `plugin.json` at the plugin root. Legacy plugins: `.plugin/plugin.json`, `plugin.json`, `.github/plugin/plugin.json`, or `.claude-plugin/plugin.json` (checked in this order). | +| Plugin manifest | Agent Plugins (v1.0.0 or v1.1.0): `plugin.json` at the plugin root. A root manifest targeting Agent Plugins takes precedence over `.plugin/plugin.json` and `.claude-plugin/plugin.json` per spec §5.1. Legacy plugins: `.plugin/plugin.json`, `plugin.json`, `.github/plugin/plugin.json`, or `.claude-plugin/plugin.json` (checked in this order). | | Marketplace manifest | `marketplace.json`, `.plugin/marketplace.json`, `.github/plugin/marketplace.json`, or `.claude-plugin/marketplace.json` (checked in this order) | | Agents | Legacy plugins: `agents/` (default, overridable in manifest). | -| Skills | Agent Plugins 1.0: `skills/` (fixed). Legacy plugins: `skills/` (default, overridable in manifest). | +| Skills | Agent Plugins: `skills/` (fixed, no root `SKILL.md` fallback). Legacy plugins: `skills/` (default, overridable in manifest), falling back to a root `SKILL.md` when no `skills/` directory exists. | | Hooks configuration | Legacy plugins: `hooks.json` or `hooks/hooks.json`. | -| MCP configuration | Agent Plugins 1.0: `mcp.json`. Legacy plugins: `.mcp.json`, `.github/mcp.json`, or the `mcpServers` manifest field. | +| MCP configuration | Agent Plugins: `mcp.json`. Legacy plugins: `.mcp.json`, `.github/mcp.json`, or the `mcpServers` manifest field. | | LSP configuration | Legacy plugins: `lsp.json` or `.github/lsp.json`. | | Plugin data | For Agent Plugins 1.0 MCP servers, `${PLUGIN_DATA}` (also available as `${COPILOT_PLUGIN_DATA}` and `${CLAUDE_PLUGIN_DATA}`) points to a persistent, writable directory unique to each installed plugin. Use this for plugin-specific runtime data instead of paths inside the installed-plugins cache directory. | diff --git a/content/copilot/reference/hooks-reference.md b/content/copilot/reference/hooks-reference.md index a7a3acf7e112..047d988b1d01 100644 --- a/content/copilot/reference/hooks-reference.md +++ b/content/copilot/reference/hooks-reference.md @@ -290,6 +290,18 @@ Each hook event delivers a JSON payload to the hook handler. Two payload formats } ``` +**Output:** + +```typescript +{ + additionalContext?: string; +} +``` + +Only `additionalContext` is consumed for `sessionStart` (command and HTTP variants). Return `{}` or empty for no action. + +When multiple `sessionStart` hooks run, successful hooks that return a non-empty string `additionalContext` contribute in execution order, separated by exactly `"\n\n"`. An empty or whitespace-only string does not erase already-accumulated context; if every hook returns only empty or whitespace-only strings, the last one is kept. The combined string (including separators) is bounded by the same 10 MiB hook-output limit—a contribution that would cross it is dropped, the previously accumulated context is kept, and a size-only warning is logged and raised in the session. + ### `sessionEnd` / `SessionEnd` > [!NOTE] @@ -551,6 +563,20 @@ Tools with no Claude equivalent keep their runtime names. } ``` +**Output:** + +```typescript +{ + additionalContext?: string; +} +``` + +If `additionalContext` is returned, it is prepended to the subagent's first user message, giving hooks a way to inject project-specific context, policies, or instructions into every subagent invocation. + +When multiple `subagentStart` hooks run, they accumulate the same way as `sessionStart`: successful hooks with a non-empty string `additionalContext` contribute in execution order joined by `"\n\n"`, empty or whitespace-only strings don't erase already-accumulated context, and the combined string is bounded by the 10 MiB hook-output limit (an over-limit contribution is dropped, the prior context is kept, and a size-only warning is logged and raised in the session). + +**Matcher:** Supports an optional `matcher` field that filters by agent name. The value is treated as a regular expression wrapped as `^(?:matcher)$` and tested against `agentName`. The pattern must match the **entire** agent name, not just a substring. If the pattern is not a valid regular expression, the hook is skipped entirely (it will not fire for any agent). + ### `subagentStop` / `SubagentStop` Fires when a subagent completes normally, before returning results to the parent. `stopReason` is currently always `"end_turn"`. This hook fires before large-response spill handling, so `response` (or `last_assistant_message` in the {% data variables.product.prodname_vscode_shortname %} compatible format) carries the full final subagent response text. 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 be79f8ae57ec..d4d540cf1cae 100644 --- a/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md +++ b/data/reusables/copilot/copilot-cloud-agent-non-auto-models.md @@ -1,6 +1,7 @@ * {% data variables.copilot.copilot_claude_opus_47 %} * {% 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_gemini_35_flash %} * {% data variables.copilot.copilot_gemini_36_flash %} diff --git a/data/tables/copilot/model-comparison.yml b/data/tables/copilot/model-comparison.yml index 37d8fc3448e4..e809ca6b35ac 100644 --- a/data/tables/copilot/model-comparison.yml +++ b/data/tables/copilot/model-comparison.yml @@ -114,6 +114,11 @@ excels_at: Complex problem-solving challenges, sophisticated reasoning further_reading: '[Claude Sonnet 5 model card](https://www-cdn.anthropic.com/9e6a1044980d8c4ed85669faf9c2a8342e2e9f1e/Claude%20Sonnet%205%20System%20Card.pdf)' +- name: Claude Sonnet 5.5 + task_area: General-purpose coding and agent tasks + excels_at: Efficient task completion with fewer steps, tokens, and tool calls + further_reading: 'Coming soon' + # Google - name: Gemini 3.5 Flash task_area: Fast help with simple or repetitive tasks diff --git a/data/tables/copilot/model-release-status.yml b/data/tables/copilot/model-release-status.yml index e83b05141cab..2cee9f7eab8d 100644 --- a/data/tables/copilot/model-release-status.yml +++ b/data/tables/copilot/model-release-status.yml @@ -105,6 +105,10 @@ provider: 'Anthropic' release_status: 'GA' +- name: 'Claude Sonnet 5.5' + provider: 'Anthropic' + release_status: 'GA' + # Google models - name: 'Gemini 3.5 Flash' diff --git a/data/tables/copilot/model-supported-clients.yml b/data/tables/copilot/model-supported-clients.yml index 2d2807e3a9fc..60a7eb2cbf74 100644 --- a/data/tables/copilot/model-supported-clients.yml +++ b/data/tables/copilot/model-supported-clients.yml @@ -104,6 +104,15 @@ xcode: true jetbrains: true +- name: Claude Sonnet 5.5 + dotcom: true + cli: true + vscode: true + vs: true + eclipse: true + xcode: true + jetbrains: true + - name: Gemini 3.5 Flash dotcom: false cli: true diff --git a/data/tables/copilot/model-supported-plans.yml b/data/tables/copilot/model-supported-plans.yml index 32887443a1a2..1452069b07a9 100644 --- a/data/tables/copilot/model-supported-plans.yml +++ b/data/tables/copilot/model-supported-plans.yml @@ -82,6 +82,13 @@ business: true enterprise: true +- name: Claude Sonnet 5.5 + pro: true + pro_plus: true + max: true + business: true + enterprise: true + - name: Gemini 3.5 Flash pro: true pro_plus: true diff --git a/data/tables/copilot/models-and-pricing.yml b/data/tables/copilot/models-and-pricing.yml index b68d523a6e4e..795f366a8493 100644 --- a/data/tables/copilot/models-and-pricing.yml +++ b/data/tables/copilot/models-and-pricing.yml @@ -313,6 +313,15 @@ output: $10.00 cache_write: $2.50 +- model: Claude Sonnet 5.5 + provider: anthropic + release_status: GA + category: Versatile + input: $2.00 + cached_input: $0.20 + output: $10.00 + cache_write: $2.50 + - model: Claude Opus 4.8 (fast mode) (preview) provider: anthropic release_status: GA diff --git a/data/variables/copilot.yml b/data/variables/copilot.yml index 996425dd1e0a..42d046597d72 100644 --- a/data/variables/copilot.yml +++ b/data/variables/copilot.yml @@ -201,6 +201,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' # Gemini: copilot_gemini: 'Gemini' copilot_gemini_flash: 'Gemini 2.0 Flash' diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9d823c65d72a..c4df4449bac7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ -// Stub layout kept so Next.js enables App Router mode, which relaxes -// the "global CSS only in _app" restriction needed by transpilePackages. -// All routing is handled by the Pages Router (src/pages/). +// Stub layout enables App Router mode, relaxing the "global CSS only in _app" +// restriction for transpilePackages. +// The Pages Router still handles all routing through src/pages. import type { ReactNode } from 'react' export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/src/archives/lib/is-archived-version.ts b/src/archives/lib/is-archived-version.ts index 8b08bacd856c..d2e44173e21b 100644 --- a/src/archives/lib/is-archived-version.ts +++ b/src/archives/lib/is-archived-version.ts @@ -8,14 +8,12 @@ type IsArchivedInfo = { } export function isArchivedVersion(req: ExtendedRequest): IsArchivedInfo { - // if this is an assets path, use the referrer - // if this is a docs path, use the req.path + // Asset requests carry the archive version in the Referrer, not req.path. const pathToCheck = patterns.assetPaths.test(req.path) ? req.get('referrer') : req.path return isArchivedVersionByPath(pathToCheck || '') } export function isArchivedVersionByPath(pathToCheck: string): IsArchivedInfo { - // ignore paths that don't have an enterprise version number if ( !( patterns.getEnterpriseVersionNumber.test(pathToCheck) || @@ -25,12 +23,10 @@ export function isArchivedVersionByPath(pathToCheck: string): IsArchivedInfo { return {} } - // extract enterprise version from path, e.g. 2.16 const requestedVersion = pathToCheck.includes('enterprise-server@') ? pathToCheck.match(patterns.getEnterpriseServerNumber)?.[1] : pathToCheck.match(patterns.getEnterpriseVersionNumber)?.[1] - // bail if the request version is not deprecated if (!requestedVersion || !deprecated.includes(requestedVersion)) { return {} } diff --git a/src/archives/lib/old-versions-utils.ts b/src/archives/lib/old-versions-utils.ts index 6804f8551d3d..880aaeed49d6 100644 --- a/src/archives/lib/old-versions-utils.ts +++ b/src/archives/lib/old-versions-utils.ts @@ -7,67 +7,55 @@ const latestNewVersion = `enterprise-server@${latest}` const oldVersions = ['dotcom'].concat(supported) const newVersions = Object.keys(allVersions) -// Utility functions for converting between old version paths and new version paths. -// See lib/path-utils.ts for utility functions based on new paths. -// Examples: -// OLD /github/category/article to NEW /free-pro-team@latest/github/category/article -// OLD /enterprise/2.21/user/github/category/article to NEW /enterprise-server@2.21/github/category/article -// OLD /enterprise/user/github/category/article to NEW /enterprise-server@/github/category/article +// Converts legacy version paths to versioned paths. +// See lib/path-utils.ts for utilities based on versioned paths. +// /github/category/article becomes /free-pro-team@latest/github/category/article. +// /enterprise/2.21/user/github/category/article becomes +// /enterprise-server@2.21/github/category/article. +// /enterprise/user/github/category/article becomes +// /enterprise-server@/github/category/article. -// Given a new version like enterprise-server@2.21, -// return an old version like 2.21. -// Fall back to latest GHES version if one can't be found, -// for example, if the new version is private-instances@latest. +// Unknown enterprise version names fall back to the latest GHES release. +// Example: private-instances@latest maps to the latest GHES release. export function getOldVersionFromNewVersion(newVersion: string) { return newVersion === nonEnterpriseDefaultVersion ? 'dotcom' : oldVersions.find((oldVersion) => newVersion.includes(oldVersion)) || latest } -// Given an old version like 2.21, -// return a new version like enterprise-server@2.21. -// Fall back to latest GHES version if one can't be found. +// Unknown legacy enterprise versions fall back to the latest versioned GHES path. export function getNewVersionFromOldVersion(oldVersion: string) { return oldVersion === 'dotcom' ? nonEnterpriseDefaultVersion : newVersions.find((newVersion) => newVersion.includes(oldVersion)) || latestNewVersion } -// Given an old path like /enterprise/2.21/user/github/category/article, -// return an old version like 2.21. export function getOldVersionFromOldPath(oldPath: string) { - // We should never be calling this function on a path that starts with a new version, - // so we can assume the path either uses the old /enterprise format or it's dotcom. + // Callers pass legacy enterprise paths or dotcom paths, not enterprise-server@ paths. if (!patterns.enterprise.test(oldPath)) return 'dotcom' const ghesNumber = oldPath.match(patterns.getEnterpriseVersionNumber) return ghesNumber ? ghesNumber[1] : latest } -// Given an old path like /en/enterprise/2.21/user/github/category/article, -// return a new path like /en/enterprise-server@2.21/github/category/article. +// /en/enterprise/2.21/user/github/category/article becomes +// /en/enterprise-server@2.21/github/category/article. +// Paths can already contain a versioned segment after currentVersion renders. +// Example: /en/enterprise/private-instances@latest/admin/category/article keeps +// private-instances@latest. export function getNewVersionedPath(oldPath: string, languageCode = '') { - // It's possible a new version has been injected into an old path - // via syntax like: /en/enterprise/{{ currentVersion }}/admin/category/article - // which could resolve to /en/enterprise/private-instances@latest/admin/category/article, - // in which case the new version is the `private-instances@latest` segment. - // Get the second or third segment depending on whether there is a lang code. const pathParts = oldPath.split('/') const possibleVersion = languageCode ? pathParts[3] : pathParts[2] let newVersion = newVersions.includes(possibleVersion) ? possibleVersion : '' - // If no new version was found, assume path contains an old version, like 2.21 if (!newVersion) { const oldVersion = getOldVersionFromOldPath(oldPath) newVersion = getNewVersionFromOldVersion(oldVersion) } - // Remove /?/enterprise?/?/user? if present. - // This leaves only the part of the string that starts with the product. - // Example: /github/category/article + // patterns.oldEnterprisePath leaves the product path, such as /github/category/article. const restOfString = oldPath.replace(patterns.oldEnterprisePath, '') - // Add the language and new version to the product part of the string return path.posix.join('/', languageCode, newVersion, restOfString) } diff --git a/src/archives/middleware/archived-asset-redirects.ts b/src/archives/middleware/archived-asset-redirects.ts index 5dac6e511bd3..c2579ba1358b 100644 --- a/src/archives/middleware/archived-asset-redirects.ts +++ b/src/archives/middleware/archived-asset-redirects.ts @@ -2,24 +2,15 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest } from '@/types' -// When we archive old versions, we take a snapshot of rendered pages, -// which includes whatever bundles it used at the time. -// Sometimes those archived versions don't include all static assets -// it might refer to. -// This middleware is a chance to redirect to new assets that we can -// use instead. -// Yes, not all legacy assets *can* be redirected to something we have -// today. But for those that we can, this is the middleware to do it. -// And the reason we don't host a copy of these old files is because -// we strive to make the files in the repo only files that we actually -// use and refer to in the non-archived content. +// Archived rendered pages can reference static assets that no longer exist in the repo. +// Redirect legacy assets we can map instead of hosting unused files. -// Note that, we also have `archived-enterprise-versions-assets.ts` -// but that one assumes the whole path refers to a prefix which is -// considered archived. E.g. /en/enterprise-server@2.9/foo/bar.css +// archived-enterprise-versions-assets.ts handles whole archived path prefixes, such as +// /en/enterprise-server@2.9/foo/bar.css. const REDIRECTS: Record = { - // Example: https://docs.github.com/en/enterprise-server@2.22/authentication/connecting-to-github-with-ssh + // One archived source is + // https://docs.github.com/en/enterprise-server@2.22/authentication/connecting-to-github-with-ssh. '/assets/images/octicons/search.svg': '/assets/images/octicons/search-24.svg', } export default function archivedAssetRedirects( diff --git a/src/archives/middleware/archived-enterprise-versions-assets.ts b/src/archives/middleware/archived-enterprise-versions-assets.ts index 5e80dacc376e..3c8c213b6bec 100644 --- a/src/archives/middleware/archived-enterprise-versions-assets.ts +++ b/src/archives/middleware/archived-enterprise-versions-assets.ts @@ -10,28 +10,17 @@ import { createLogger } from '@/observability/logger' const logger = createLogger(import.meta.url) -// This module handles requests for the CSS and JS assets for -// deprecated GitHub Enterprise versions by routing them to static content in -// one of the docs-ghes- repos. -// See also ./archived-enterprise-versions.ts for non-CSS/JS paths +// Proxies archived CSS and JS assets from docs-ghes- repositories. +// archived-enterprise-versions.ts handles non-asset paths. export default async function archivedEnterpriseVersionsAssets( req: ExtendedRequest, res: Response, next: NextFunction, ) { - // Only match asset paths - // This can be true on /enterprise/2.22/_next/static/foo.css - // or /_next/static/foo.css if (!patterns.assetPaths.test(req.path)) return next() - // The URL is either in the format - // /enterprise/2.22/_next/static/foo.css, - // /enterprise-server@, - // or /_next/static/foo.css. - // If the URL is prefixed with the enterprise version and release number - // or if the Referrer contains the enterprise version and release number, - // then we'll fetch it from the docs-ghes- repo. + // Versioned and bare asset paths still require an archived Referrer before proxying. if ( !( patterns.getEnterpriseVersionNumber.test(req.path) || @@ -43,20 +32,10 @@ export default async function archivedEnterpriseVersionsAssets( return next() } - // Now we know the URL is definitely not /_next/static/foo.css - // So it's probably /enterprise/2.22/_next/static/foo.css and we - // should see if we might find this in the proxied backend. - // But `isArchivedVersion()` will only return truthy if the - // Referrer header also indicates that the request for this static - // asset came from a page const { isArchived, requestedVersion } = isArchivedVersion(req) if (!isArchived || !requestedVersion) return next() - // If this looks like a Next.js chunk or build manifest request from an archived page, - // just return 204 No Content instead of trying to proxy it. - // This suppresses noise from hydration requests that don't affect - // content viewing since archived pages render fine server-side. - // Only target specific problematic asset types, not all _next/static assets. + // Send 204 for chunks, _buildManifest.js, and _ssgManifest.js; archive pages render without them. if ( (req.path.includes('/_next/static/chunks/') || req.path.includes('/_buildManifest.js') || @@ -65,18 +44,15 @@ export default async function archivedEnterpriseVersionsAssets( ) { archivedCacheControl(res) setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL) - return res.sendStatus(204) // No Content - silently ignore + return res.sendStatus(204) } - // In all of the `docs-ghes- - // These will thus be requested, with a Referrer header that - // forces us to give it a chance, but it'll find it can't find it - // but we mustn't return a 404 yet, because that - // /_next/static/styles.css will probably still succeed because the 404 - // page is not that of the archived enterprise version. + // Fall through on proxy misses; 404 pages request /_next/static/styles.css from archived pages. return next() } } diff --git a/src/archives/middleware/archived-enterprise-versions.ts b/src/archives/middleware/archived-enterprise-versions.ts index 6a678383e7c8..cae98add1a4f 100644 --- a/src/archives/middleware/archived-enterprise-versions.ts +++ b/src/archives/middleware/archived-enterprise-versions.ts @@ -24,22 +24,19 @@ import { ExtendedRequest } from '@/types' const logger = createLogger(import.meta.url) const OLD_PUBLIC_AZURE_BLOB_URL = 'https://githubdocs.azureedge.net' -// Old Azure Blob Storage `enterprise` container. +// Old Azure Blob Storage enterprise container. const OLD_AZURE_BLOB_ENTERPRISE_DIR = `${OLD_PUBLIC_AZURE_BLOB_URL}/enterprise` -// Old Azure Blob storage `github-images` container with -// the root directory of 'enterprise'. +// Old Azure Blob Storage github-images container rooted at enterprise. const OLD_GITHUB_IMAGES_ENTERPRISE_DIR = `${OLD_PUBLIC_AZURE_BLOB_URL}/github-images/enterprise` const OLD_DEVELOPER_SITE_CONTAINER = `${OLD_PUBLIC_AZURE_BLOB_URL}/developer-site` -// This is the new repo naming convention we use for each archived enterprise -// version. E.g. https://github.github.com/docs-ghes-2.10 +// Archived enterprise repositories use https://github.github.com/docs-ghes-2.10. const ENTERPRISE_GH_PAGES_URL_PREFIX = 'https://github.github.com/docs-ghes-' type ArchivedRedirects = { [url: string]: string | null } -// These files are huge so lazy-load them. But note that the -// `readJsonFileLazily()` function will, at import-time, check that -// the path does exist. +// Lazy-load the large redirect files. +// readCompressedJsonFileFallbackLazily verifies the path at import time. const archivedRedirects = readCompressedJsonFileFallbackLazily( './src/redirects/lib/static/archived-redirects-from-213-to-217.json', ) as () => ArchivedRedirects @@ -51,51 +48,23 @@ const archivedFrontmatterValidURLS = readCompressedJsonFileFallbackLazily( './src/redirects/lib/static/archived-frontmatter-valid-urls.json', ) as () => ArchivedFrontmatterURLs -// Combine all the things you need to make sure the response is -// aggressively cached. const cacheAggressively = (res: Response) => { archivedCacheControl(res) - // This sets a custom Fastly surrogate key so that this response - // won't get updated in every deployment. - // Essentially, this sets a surrogate key such that Fastly - // doesn't do soft-purges on these responses on every - // automated deployment. + // Manual surrogate keys avoid Fastly soft purges on every automated deployment. setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL) } -// The way `got` does retries: -// -// sleep = 1000 * Math.pow(2, retry - 1) + Math.random() * 100 -// -// So, it means: -// -// 1. ~1000ms -// 2. ~2000ms -// 3. ~4000ms -// -// ...if the limit we set is 3. -// Our own timeout, in @/frame/middleware/timeout.ts defaults to 10 seconds. -// So there's no point in trying more attempts than 3 because it would -// just timeout on the 10s. (i.e. 1000 + 2000 + 4000 + 8000 > 10,000) +// Got sleeps about 1s, 2s, then 4s for three retries. +// A fourth retry would exceed MAX_REQUEST_TIMEOUT, which defaults to 10 seconds in production. const retryConfiguration = { limit: 3 } -// According to our Datadog metrics, the *average* time for the -// the 'archive_enterprise_proxy' metric is ~70ms (excluding spikes) -// which is much less than 3000ms. -// We have observed errors of timeout, in production, when it was -// set to 500ms and then 1500ms. Let's be more conservative here to -// avoid unnecessary error reporting during occasional slow responses. +// Datadog reports archive_enterprise_proxy averages about 70ms excluding spikes. +// Production timed out at 500ms and 1500ms, so 3000ms avoids noise from slow responses. const timeoutConfiguration = { response: 3000 } -// Monitoring thresholds for logging response times -// Log warnings when responses exceed half the timeout threshold -const WARN_RESPONSE_THRESHOLD = timeoutConfiguration.response / 2 // 1500ms -// Log info for responses that are noticeably slow but not concerning -const SLOW_RESPONSE_THRESHOLD = 500 // ms - -// This module handles requests for deprecated GitHub Enterprise versions -// by routing them to static content in -// one of the docs-ghes- repos. +const WARN_RESPONSE_THRESHOLD = timeoutConfiguration.response / 2 +// Log successful responses slower than 500ms. +const SLOW_RESPONSE_THRESHOLD = 500 export default async function archivedEnterpriseVersions( req: ExtendedRequest, @@ -109,14 +78,14 @@ export default async function archivedEnterpriseVersions( const redirectCode = pathLanguagePrefixed(req.path) ? 301 : 302 - // Redirects for releases 3.0+ if (deprecatedWithFunctionalRedirects.includes(requestedVersion)) { const redirectTo = req.context ? getRedirect(req.path, req.context) : undefined if (redirectTo) { if (redirectCode === 302) { - languageCacheControl(res) // call first to get `vary` + // languageCacheControl sets vary; archivedCacheControl extends the cache duration. + languageCacheControl(res) } - archivedCacheControl(res) // call second to extend duration + archivedCacheControl(res) return res.safeRedirect(redirectCode, redirectTo) } @@ -124,11 +93,7 @@ export default async function archivedEnterpriseVersions( try { redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), { retry: retryConfiguration, - // This is allowed to be different compared to the other requests - // we make because downloading the `redirects.json` once is very - // useful because it caches so well. - // And, as of 2021 that `redirects.json` is 10MB so it's more likely - // to time out. + // Cache misses use a 1-second time-to-first-byte limit; body transfer may take longer. timeout: { response: 1000 }, })) as Record } catch (err) { @@ -143,13 +108,14 @@ export default async function archivedEnterpriseVersions( const newRedirectTo = redirectJson[withoutLanguage] if (newRedirectTo && newRedirectTo !== withoutLanguage) { if (redirectCode === 302) { - languageCacheControl(res) // call first to get `vary` + // languageCacheControl sets vary; archivedCacheControl extends the cache duration. + languageCacheControl(res) } - archivedCacheControl(res) // call second to extend duration + archivedCacheControl(res) return res.safeRedirect(redirectCode, `/${language}${newRedirectTo}`) } } - // For releases 2.13 and lower, redirect language-prefixed URLs like /en/enterprise/2.10 -> /enterprise/2.10 + // Earlier releases redirect /en/enterprise/2.10 to /enterprise/2.10. if ( req.path.startsWith('/en/') && versionSatisfiesRange(requestedVersion, `<${firstVersionDeprecatedOnNewSite}`) @@ -158,30 +124,21 @@ export default async function archivedEnterpriseVersions( return res.safeRedirect(redirectCode, req.baseUrl + req.path.replace(/^\/en/, '')) } - // Redirects for releases 2.13 - 2.17 if ( versionSatisfiesRange(requestedVersion, `>=${firstVersionDeprecatedOnNewSite}`) && versionSatisfiesRange(requestedVersion, `<=${lastVersionWithoutArchivedRedirectsFile}`) ) { const [language, withoutLanguagePath] = splitByLanguage(req.path) - // `archivedRedirects` is a callable because it's a lazy function - // and memoized so calling it is cheap. - + // archivedRedirects is lazy and memoized, so calling it here is cheap. const newPath = withoutLanguagePath && archivedRedirects()[withoutLanguagePath] - // Some entries in the lookup exists purely for the sake of injecting - // language. - // E.g. '/enterprise/2.15/user' - // URLs like this only need to redirect the original `req.path` - // didn't already have a language + // Null entries inject /en when the original request has no language prefix. if (newPath !== undefined && (newPath || !language)) { const redirect = `/${language || 'en'}${newPath || withoutLanguagePath}` cacheAggressively(res) return res.safeRedirect(redirectCode, redirect) } } - // Redirects for 2.18 - 3.0. Starting with 2.18, we updated the archival - // script to create a redirects.json file if ( versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`) && !deprecatedWithFunctionalRedirects.includes(requestedVersion) @@ -190,11 +147,7 @@ export default async function archivedEnterpriseVersions( try { redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), { retry: retryConfiguration, - // This is allowed to be different compared to the other requests - // we make because downloading the `redirects.json` once is very - // useful because it caches so well. - // And, as of 2021 that `redirects.json` is 10MB so it's more likely - // to time out. + // Cache misses use a 1-second time-to-first-byte limit; body transfer may take longer. timeout: { response: 1000 }, })) as Record } catch (err) { @@ -205,15 +158,13 @@ export default async function archivedEnterpriseVersions( throw err } - // make redirects found via redirects.json redirect with a 301 if (redirectJson[req.path]) { res.set('x-robots-tag', 'noindex') cacheAggressively(res) return res.safeRedirect(redirectCode, redirectJson[req.path]) } } - // Short-circuit requests that will never resolve on the upstream - // GitHub Pages repos, avoiding unnecessary network requests. + // Short-circuit impossible archive paths to avoid unnecessary upstream requests. const earlyNotFound = getEarlyNotFoundReason(req.path, requestedVersion) if (earlyNotFound) { statsd.increment('middleware.archived_early_not_found', 1, [ @@ -224,9 +175,7 @@ export default async function archivedEnterpriseVersions( return res.status(404).type('text').send('Page not found') } - // Requests without a language prefix for versions > 2.17 will always - // 404 upstream (the archive repos store pages under /en/, /zh/, etc.). - // Skip the fetch and let downstream middleware handle the redirect. + // Archive repos after 2.17 require language prefixes; redirects handle unlanguaged paths. if ( versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`) && !pathLanguagePrefixed(req.path) @@ -235,7 +184,6 @@ export default async function archivedEnterpriseVersions( return next() } - // Retrieve the page from the archived repo const doGet = () => fetchWithRetry( getProxyPath(req.path, requestedVersion), @@ -265,14 +213,13 @@ export default async function archivedEnterpriseVersions( }) } - // Warn on 404s, which are expected for missing archived pages. - // Everything else is a genuine upstream failure. + // Missing archived pages are expected 404s; other upstream failures need error logs. if (r.status !== 200) { let upstreamBody: string | undefined try { upstreamBody = await readBodyWithTimeout(r, () => r.text(), timeoutConfiguration.response) } catch { - // A body we cannot read should not change how we handle the error. + // Ignore unreadable bodies so the original upstream status controls error handling. } const level = r.status === 404 ? 'warn' : 'error' logger[level]('Failed to fetch archived enterprise content', { @@ -286,7 +233,7 @@ export default async function archivedEnterpriseVersions( }) } - // Log successful responses with timing for monitoring trends + // Log slow successful responses for monitoring trends. if (r.status === 200 && responseTime > SLOW_RESPONSE_THRESHOLD) { logger.info('Archived enterprise content response', { version: requestedVersion, @@ -303,7 +250,7 @@ export default async function archivedEnterpriseVersions( ) res.set('x-robots-tag', 'noindex') - // make stubbed redirect files (which exist in versions <2.13) redirect with a 301 + // Stubbed redirect files in releases before 2.13 return a static redirect target. const staticRedirect = body.match(patterns.staticRedirect) if (staticRedirect) { cacheAggressively(res) @@ -314,15 +261,12 @@ export default async function archivedEnterpriseVersions( cacheAggressively(res) - // Releases 3.2 and higher contain image asset paths with the - // old Azure Blob Storage URL. These need to be rewritten to - // the new archived enterprise repo URL. + // Releases 3.2 through 3.9 contain old Azure Blob image URLs that need archive URLs. if ( versionSatisfiesRange(requestedVersion, `>=${firstReleaseStoredInBlobStorage}`) && versionSatisfiesRange(requestedVersion, `<=3.9`) ) { - // `x-host` is a custom header set by Fastly. - // GLB automatically deletes the `x-forwarded-host` header. + // Fastly sets x-host, and GLB removes x-forwarded-host. const host = req.get('x-host') || req.get('x-forwarded-host') || req.get('host') const modifiedBody = body .replaceAll( @@ -337,11 +281,7 @@ export default async function archivedEnterpriseVersions( return res.send(modifiedBody) } - // Releases 3.1 and lower were previously hosted in the - // help-docs-archived-enterprise-versions repo. Only the images - // were stored in the old Azure Blob Storage `github-images` container. - // The image paths all need to be updated to reference the images in the - // new archived enterprise repo's root assets directory. + // Releases before 3.2 need github-images Azure Blob paths rewritten to archive root assets. if (versionSatisfiesRange(requestedVersion, `<${firstReleaseStoredInBlobStorage}`)) { let modifiedBody = body.replaceAll( `${OLD_GITHUB_IMAGES_ENTERPRISE_DIR}/${requestedVersion}`, @@ -352,12 +292,11 @@ export default async function archivedEnterpriseVersions( `${OLD_DEVELOPER_SITE_CONTAINER}/${requestedVersion}`, `${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/developer`, ) - // Update all hrefs to add /developer to the path modifiedBody = modifiedBody.replaceAll( `="/enterprise/${requestedVersion}`, `="/enterprise/${requestedVersion}/developer`, ) - // The changelog is the only thing remaining on developer.github.com + // The changelog remains on developer.github.com. modifiedBody = modifiedBody.replaceAll( 'href="/changes', 'href="https://developer.github.com/changes', @@ -369,46 +308,35 @@ export default async function archivedEnterpriseVersions( `="${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/assets`, ) - // Fix broken hrefs on the 2.16 landing page + // The 2.16 landing page has hrefs missing the version segment. if (requestedVersion === '2.16' && req.path === '/en/enterprise/2.16') { modifiedBody = modifiedBody.replaceAll('ref="/en/enterprise', 'ref="/en/enterprise/2.16') } - // Remove the search results container from the page + // The empty search results container blocks clicks on page links. modifiedBody = modifiedBody.replaceAll('
', '') return res.send(modifiedBody) } - // In all releases, some assets were incorrectly scraped and contain - // deep relative paths. For example, releases 3.4+ use the webp format - // for images. The URLs for those images were never rewritten to pull - // from the Azure Blob Storage container. This may be due to not - // updating our scraping tool to handle the new image types. There - // are additional images in older versions that also have a relative path. - // We want to update the URLs in the format - // "../../../../../../assets/" to prefix the assets directory with the - // new archived enterprise repo URL. + // Deep relative asset paths like "../../../../../../assets/" need archive repo prefixes. let modifiedBody = body.replaceAll( /="(\.\.\/)*assets/g, `="${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/assets`, ) - // Fix broken hrefs on the 2.16 landing page + // The 2.16 landing page has hrefs missing the version segment. if (requestedVersion === '2.16' && req.path === '/en/enterprise/2.16') { modifiedBody = modifiedBody.replaceAll('ref="/en/enterprise', 'ref="/en/enterprise/2.16') } - // Remove the search results container from the page, which removes a white - // box that prevents clicking on page links + // The empty search results container blocks clicks on page links. modifiedBody = modifiedBody.replaceAll('
', '') return res.send(modifiedBody) } - // In releases 2.13 - 2.17, we lost access to frontmatter redirects - // during the archival process. This workaround finds potentially - // relevant frontmatter redirects in currently supported pages + // Releases 2.13 through 2.17 need supported-page frontmatter redirects after data loss. if ( versionSatisfiesRange(requestedVersion, `>=${firstVersionDeprecatedOnNewSite}`) && versionSatisfiesRange(requestedVersion, `<=${lastVersionWithoutArchivedRedirectsFile}`) @@ -433,62 +361,39 @@ function getProxyPath(reqPath: string, requestedVersion: string) { `/enterprise/${requestedVersion}/developer`, ) - // This was the last release supported on developer.github.com + // Developer pages keep the developer-site path layout from the archived release. if (isDeveloperPage) { const enterprisePath = `/enterprise/${requestedVersion}` const newReqPath = reqPath.replace(enterprisePath, '') return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath } - // Releases 2.18 and higher + // Releases 2.18 and later store redirects.json at the repo root and pages at /index.html. if (versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`)) { const newReqPath = reqPath.includes('redirects.json') ? `/${reqPath}` : `${reqPath}/index.html` return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath } - // Releases 2.13 - 2.17 - // redirect.json files don't exist for these versions + // Releases 2.13 through 2.17 lack redirects.json files. if (versionSatisfiesRange(requestedVersion, `>=2.13`)) { return `${ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + reqPath}/index.html` } - // Releases 2.12 and lower + // Releases 2.12 and earlier omit the /enterprise/ path prefix. const enterprisePath = `/enterprise/${requestedVersion}` const newReqPath = reqPath.replace(enterprisePath, '') return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath } -// Module-level global cache object. -// Gets populated lazily inside getFallbackRedirect(). +// Caches fallback redirect lookups across requests. const fallbackRedirectLookups = new Map() +// archived-frontmatter-valid-urls.json maps valid destinations to acceptable source URLs. +// getFallbackRedirect inverts that structure once, so lookups avoid scanning every destination. +// Example source /enterprise/2.13/other/old/thing redirects to destination +// /enterprise/2.13/foo/bar. +// The JSON omits language prefixes, so lookups strip the request language and add it back. function getFallbackRedirect(req: ExtendedRequest) { - // The file `lib/redirects/static/archived-frontmatter-valid-urls.json` which - // we depend on here, is structured like this: - // - // { - // "/enterprise/2.13/foo/bar": [ - // "/enterprise/2.13/other/old/thing", - // "/enterprise/2.13/more/redirectable/url", - // "/enterprise/2.13/etc/etc" - // ], - // ... - // - // The keys are valid URLs that it can redirect to. I.e. these are - // URLs that we definitely know are valid and will be found - // in one of the docs-ghes- repos. - // The array values are possible URLs we deem acceptable redirect - // sources. - // But to avoid an unnecessary, O(n), loop every time, we turn this - // structure around to become: - // - // { - // "/enterprise/2.13/other/old/thing": "/enterprise/2.13/foo/bar", - // "/enterprise/2.13/more/redirectable/url": "/enterprise/2.13/foo/bar", - // "/enterprise/2.13/etc/etc": "/enterprise/2.13/foo/bar", - // ... - // - // Now potential lookups are fast. if (!fallbackRedirectLookups.size) { for (const [destination, sources] of Object.entries(archivedFrontmatterValidURLS())) { for (const source of sources) { @@ -497,14 +402,6 @@ function getFallbackRedirect(req: ExtendedRequest) { } } - // But before we proceed, remember that the - // file lib/redirects/static/archived-frontmatter-valid-urls.json never - // contains a language prefix. - // E.g. only `/enterprise/2.13/foo/bar` but the requested URL can be - // `/en/enterprise/2.13/foo/bar`, `/pt/enterprise/2.13/foo/bar`, - // or just `/enterprise/2.13/foo/bar`. - // Whatever it is, pop the language prefix, operate, and put it back - // again. In the end, it always has to have a language prefix. const [language, withoutLanguage] = splitPathByLanguage(req.path) const fallback = fallbackRedirectLookups.get(withoutLanguage) if (fallback) { @@ -523,43 +420,34 @@ function splitByLanguage(uri: string) { return [language, withoutLanguage] } -// Regex to extract any language-like prefix from the path, including -// "cn" which was the old Chinese language code used in archives ≤3.2. +// Matches language-like path prefixes, including the old Chinese cn code from archives through 3.2. const archiveLanguagePrefixRegex = new RegExp(`^/(${Object.keys(allLanguages).join('|')}|cn)(/|$)`) -// Detects request paths that will never resolve on the upstream GitHub -// Pages archive repos, so we can 404 immediately without making a -// network request. Returns a short reason string, or null if the -// request looks plausible. +// Identifies request paths that cannot resolve on upstream GitHub Pages archive repos. +// Returning a reason lets callers log and skip the network request. function getEarlyNotFoundReason(reqPath: string, version: string): string | null { - // Double slashes in the path never resolve (e.g. ".../about-2fa//index.html") + // Double slashes never resolve, such as /about-2fa//index.html. if (reqPath.includes('//')) { return 'double-slash' } - // A duplicated "/developer/developer/" segment means a broken crawler URL - // from the old developer.github.com site. + // Duplicated /developer/developer/ segments come from broken developer.github.com crawler URLs. if (reqPath.includes('/developer/developer/')) { return 'developer-developer' } - // Check if the language in the path actually exists in this version's - // archive. Each language has a `firstArchivedVersion` indicating when - // it was first included in the GHES archives. + // firstArchivedVersion records when each archive language became available. const langMatch = reqPath.match(archiveLanguagePrefixRegex) if (langMatch) { const lang = langMatch[1] - // "cn" was the old Chinese language code; those archives are ancient - // and effectively dead traffic. Always 404. + // cn was the old Chinese language code; always 404 it as dead archive traffic. if (lang === 'cn') { return 'language-not-in-version' } - const langDef = allLanguages[lang] if (langDef?.firstArchivedVersion) { - // 404 if the requested version is older than when this language - // was first archived (e.g. /zh/ on v3.0 → 404 because zh started in 3.3) + // 404 languages before firstArchivedVersion, such as /zh/ on 3.0 because zh starts in 3.3. if (!versionSatisfiesRange(version, `>=${langDef.firstArchivedVersion}`)) { return 'language-not-in-version' } diff --git a/src/archives/scripts/warmup-remotejson.ts b/src/archives/scripts/warmup-remotejson.ts index 978bcdd4cdfd..4bfd658e58b4 100755 --- a/src/archives/scripts/warmup-remotejson.ts +++ b/src/archives/scripts/warmup-remotejson.ts @@ -1,20 +1,7 @@ -// [start-readme] -// -// This calls a function directly that is used by our archived enterprise -// middleware. Namely, the `getRemoteJSON` function. That function is -// able to use the disk to cache responses quite aggressively. So when -// it's been run once, with the same disk, next time it can draw from disk -// rather than having to rely on network. -// -// We have this script to avoid excessive network fetches in production -// where, due to production deploys restarting new Node services, we -// can't rely on in-memory caching often enough. -// -// The list of URLs hardcoded in here is based on analyzing the URLs that -// were logged as tags in Datadog for entries that couldn't rely on -// in-memory cache. -// -// [end-readme] +// Warms getRemoteJSON's disk cache for archived redirects.json files. +// Production deploys restart Node services often enough that in-memory cache misses repeat. +// Production reuses these entries only when it starts from the same warmed cache directory. +// URLs come from Datadog tags for redirects.json requests that missed the in-memory cache. import { program } from 'commander' import semver, { SemVer } from 'semver' diff --git a/src/archives/tests/deprecated-enterprise-versions.ts b/src/archives/tests/deprecated-enterprise-versions.ts index 3515fb7bb587..ab9a668521ca 100644 --- a/src/archives/tests/deprecated-enterprise-versions.ts +++ b/src/archives/tests/deprecated-enterprise-versions.ts @@ -79,21 +79,19 @@ describe('enterprise deprecation', () => { const { $: $2, res } = await getDOM(`${guidesPath}/${firstLink}`) expect(res.statusCode).toBe(200) - // this test assumes the Installation guide is the first link on the guides page + // The test follows the first link, which is the Installation guide. expect($2('h2').text()).toBe('Installing and configuring GitHub Enterprise') }) }) -// Starting with the deprecation of 3.0, it's the first time we deprecate -// enterprise versions since redirects is a *function* rather than a -// lookup in a big object. +// Enterprise 3.0 redirects use getRedirect plus redirects.json instead of a static object. describe('recently deprecated redirects', () => { test('basic enterprise 3.0 redirects', async () => { const res = await get('/enterprise/3.0') expect(res.statusCode).toBe(302) expect(res.headers.location).toBe('/en/enterprise-server@3.0') expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') @@ -104,7 +102,7 @@ describe('recently deprecated redirects', () => { const res = await get('/en/enterprise/3.0') expect(res.statusCode).toBe(301) expect(res.headers.location).toBe('/en/enterprise-server@3.0') - // 301 redirects are safe to cache aggressively + // 301 redirects can cache aggressively. expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -116,13 +114,12 @@ describe('recently deprecated redirects', () => { ) expect(res.statusCode).toBe(302) expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') expect(res.headers.vary).toContain('x-user-language') - // This is based on - // https://github.com/github/docs-ghes-3.0/blob/main/redirects.json + // Matches https://github.com/github/docs-ghes-3.0/blob/main/redirects.json. expect(res.headers.location).toBe( '/en/enterprise-server@3.0/get-started/learning-about-github/githubs-products', ) diff --git a/src/article-api/lib/get-all-toc-items.ts b/src/article-api/lib/get-all-toc-items.ts index 985bb2667c1c..404a5b1ec905 100644 --- a/src/article-api/lib/get-all-toc-items.ts +++ b/src/article-api/lib/get-all-toc-items.ts @@ -15,17 +15,12 @@ interface TocItem extends LinkData { childTocItems?: TocItem[] } -/** - * Recursively gathers all TOC items from a page and its descendants. - * This mirrors the behavior of getTocItems() in the generic-toc middleware - * but works with the page.children frontmatter property. - */ +// Mirrors getTocItems() in src/frame/middleware/context/generic-toc.ts for frontmatter children. export async function getAllTocItems( page: Page, context: Context, options: { - /** Only recurse into children whose resolved path starts with this prefix. - * Prevents cross-product traversal (e.g. /en/rest listing /enterprise-admin). */ + // Prevents cross-product traversal, such as /en/rest listing /enterprise-admin. basePath?: string } = {}, ): Promise { @@ -41,8 +36,7 @@ export async function getAllTocItems( ) const pathname = pagePermalink ? pagePermalink.href : `/${languageCode}` - // On the first call, set basePath to this page's path so recursion - // stays within the same product section. + // Keeps recursive children within the first page's product section. const basePath = options.basePath ?? pathname const resolvedChildren = pageWithChildren.children @@ -69,7 +63,6 @@ export async function getAllTocItems( const category = childPage.category || [] - // Only recurse if the child is within the same product section const withinSection = href.startsWith(basePath) const childTocItems = withinSection && childPage.children && childPage.children.length > 0 @@ -83,14 +76,10 @@ export async function getAllTocItems( return items } -/** - * Flattens nested TOC items into a single array. - * Only includes leaf nodes (items without children) or all items based on options. - */ export function flattenTocItems( tocItems: TocItem[], options: { - excludeParents?: boolean // If true, only include items without children + excludeParents?: boolean } = {}, ): LinkData[] { const { excludeParents = true } = options @@ -101,9 +90,7 @@ export function flattenTocItems( for (const item of items) { const hasChildren = item.childTocItems && item.childTocItems.length > 0 - // Include this item if it's a leaf or if we're including parents - // Deduplicate by href - needed when a page lists both individual - // articles and their parent group as children (e.g., bespoke landing pages) + // Bespoke landing pages can list both articles and their parent group. if (!hasChildren || !excludeParents) { if (!seen.has(item.href)) { seen.add(item.href) @@ -125,13 +112,7 @@ export function flattenTocItems( return result } -/** - * Check whether a string contains markdown link syntax that would need - * processing by the unified pipeline (e.g. link rewriting, AUTOTITLE). - * - * Use this to short-circuit expensive rendering when the text is - * Liquid-only and contains no markdown that needs transformation. - */ +// Liquid-only properties can skip the full unified pipeline. function hasMarkdownLinks(text: string): boolean { return text.includes('[') && text.includes('](/') } @@ -141,11 +122,7 @@ const RAW_PROP_MAP = { intro: 'rawIntro', } as const -/** - * Fast-path rendering for page properties. Renders Liquid only, skipping - * the full unified pipeline. Falls back to page.renderProp() when the - * Liquid output contains markdown links that need rewriting. - */ +// Falls back to page.renderProp() when Liquid output still has markdown links. async function renderPropFast( page: PageWithChildren, prop: keyof typeof RAW_PROP_MAP, diff --git a/src/article-api/lib/get-link-data.ts b/src/article-api/lib/get-link-data.ts index 45749ec06a0e..de2d1955297b 100644 --- a/src/article-api/lib/get-link-data.ts +++ b/src/article-api/lib/get-link-data.ts @@ -1,14 +1,6 @@ import type { Context, Page } from '@/types' import type { LinkData } from '@/article-api/transformers/types' -/** - * Resolves link data (title, href, intro) for a given href and page - * - * This helper is used by landing page transformers to build link lists. - * It resolves the page from an href (relative or absolute), renders its title - * and intro, and - * returns the canonical permalink. - */ export async function getLinkData( href: string, languageCode: string, diff --git a/src/article-api/lib/graphql-helpers.ts b/src/article-api/lib/graphql-helpers.ts index dc07ef55ca18..27c585cdbd69 100644 --- a/src/article-api/lib/graphql-helpers.ts +++ b/src/article-api/lib/graphql-helpers.ts @@ -2,7 +2,6 @@ import type { Context, Page } from '@/types' import { renderContent } from '@/content-render/index' import matter from '@gr2m/gray-matter' -// Returns the part of the page markdown before the auto-generated marker. export async function extractManualContent(page: Page, context: Context): Promise { if (!page.markdown) return '' diff --git a/src/article-api/lib/load-template.ts b/src/article-api/lib/load-template.ts index 3e17398756ed..bd23040a2767 100644 --- a/src/article-api/lib/load-template.ts +++ b/src/article-api/lib/load-template.ts @@ -5,8 +5,6 @@ import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -// Loads a Liquid template file from src/article-api/templates, for use by -// transformers. export function loadTemplate(templateName: string): string { const templatePath = join(__dirname, '../templates', templateName) return readFileSync(templatePath, 'utf8') diff --git a/src/article-api/lib/normalize-markdown.ts b/src/article-api/lib/normalize-markdown.ts index 373db5dd0ef4..f0163a1c9ec1 100644 --- a/src/article-api/lib/normalize-markdown.ts +++ b/src/article-api/lib/normalize-markdown.ts @@ -1,25 +1,10 @@ -/** - * Post-processing for transformer-produced markdown that is about to be sent - * to the client (via the `.md` URL suffix, `Accept: text/markdown`, or the - * article-body API). Kept intentionally small so the same rules apply to - * every transformer's output without each one having to opt in. - */ - -/** - * Collapse runs of 3+ consecutive newlines down to 2 (i.e. at most one blank - * line between blocks). Transformers that render conditional sections often - * leave behind multiple blank lines when sections are empty; the rendered - * markdown is otherwise valid but visually noisy in the `.md` output. - */ +// Centralizes cleanup for transformer output returned by .md URLs and Accept: text/markdown. +// The article-body API uses the same path, so every transformer gets the same rules. +// Empty conditional sections often leave visually noisy blank lines in markdown output. export function collapseBlankLines(content: string): string { return content.replace(/\n{3,}/g, '\n\n') } -/** - * Apply every normalization step that should run on transformer-produced - * markdown before it leaves the server. Centralized so new rules (e.g. - * trailing-whitespace stripping) can be added in one place. - */ export function normalizeRenderedMarkdown(content: string): string { return collapseBlankLines(content) } diff --git a/src/article-api/lib/resolve-path.ts b/src/article-api/lib/resolve-path.ts index 922aa6c1a6c9..593aea61634f 100644 --- a/src/article-api/lib/resolve-path.ts +++ b/src/article-api/lib/resolve-path.ts @@ -2,13 +2,6 @@ import findPage from '@/frame/lib/find-page' import { allVersionKeys } from '@/versions/lib/all-versions' import type { Context, Page } from '@/types' -/** - * Resolves an href to a Page object from the context. - * - * Normalizes various href formats (relative, absolute, with/without language - * prefix) to canonical paths, then delegates to findPage for lookup with - * redirect support and English fallback. - */ export function resolvePath( href: string, languageCode: string, @@ -30,27 +23,22 @@ export function resolvePath( return undefined } -// Lazily yields candidate paths in priority order, stopping at first match. +// Yields candidate paths in priority order, so callers can stop at the first match. function* candidates(href: string, lang: string, pathname: string) { const langPrefix = `/${lang}/` const cleanPathname = pathname.replace(/\/$/, '') if (href.startsWith(langPrefix)) { - // Already has language prefix — use as-is yield href } else if (href.startsWith('/')) { - // Leading slash without lang prefix — try relative to pathname first, - // then as a direct path with lang prefix yield `${cleanPathname}${href}` yield `${langPrefix.slice(0, -1)}${href}` } else { - // Relative path — try relative to pathname, then with lang prefix yield `${cleanPathname}/${href}` yield `${langPrefix}${href}` } - // Versioned fallback: try inserting each version slug for - // enterprise-only pages that don't exist on FPT. + // Enterprise-only pages can lack an FPT path, so try each version slug. const suffix = href.startsWith(langPrefix) ? href.slice(langPrefix.length).replace(/\/$/, '') : href.replace(/^\//, '').replace(/\/$/, '') diff --git a/src/article-api/lib/strip-html-comments.ts b/src/article-api/lib/strip-html-comments.ts index 43342f4b881d..911649880a17 100644 --- a/src/article-api/lib/strip-html-comments.ts +++ b/src/article-api/lib/strip-html-comments.ts @@ -1,10 +1,9 @@ -// HTML also closes a comment with --!>, and treats and as empty comments. -// An unclosed or --!>, and treats and as empty comments. +// Unclosed \n' -// Main entrypoint into this module. -// Walks every directory under targetDirectory, adding and removing Markdown -// files and keeping the index.md children and versions frontmatter in sync. export async function updateContentDirectory({ targetDirectory, sourceContent, @@ -79,15 +76,12 @@ export async function updateContentDirectory({ await updateMarkdownFiles(targetDirectory, sourceContent, frontmatter, indexOrder) } -// Remove markdown files that are no longer in the source data async function removeMarkdownFiles( targetDirectory: string, sourceFiles: string[], autogeneratedType: string | undefined, ): Promise { const autogeneratedFiles = await getAutogeneratedFiles(targetDirectory, autogeneratedType) - // If the first array contains items that the second array does not, - // it means that a Markdown page was deleted from the OpenAPI schema const filesToRemove = difference(autogeneratedFiles, sourceFiles) if (filesToRemove.length > 0) { logger.info('Removing stale markdown files', { @@ -100,8 +94,6 @@ async function removeMarkdownFiles( } } -// Gets a list of all files under targetDirectory that have the -// `autogenerated` frontmatter set to `autogeneratedType`. async function getAutogeneratedFiles( targetDirectory: string, autogeneratedType: string | undefined, @@ -124,9 +116,6 @@ async function getAutogeneratedFiles( ).filter(Boolean) as string[] } -// The `sourceContent` object contains the new content and target file -// path for the Markdown files. Ex: -// { : { data: , content: } } async function updateMarkdownFiles( targetDirectory: string, sourceContent: SourceContent, @@ -137,14 +126,12 @@ async function updateMarkdownFiles( await updateMarkdownFile(file, newContent.data, newContent.content) } await updateDirectory(targetDirectory, frontmatter, { indexOrder }) - // The pipelines should not touch directories they do not own, so this - // call updates only the index.md file in the parent directory. + // Update only the parent index because pipelines must not touch sibling directories. await updateDirectory(path.dirname(targetDirectory), frontmatter, { rootDirectoryOnly: true }) } -// If the Markdown file already exists on disk, we update only the content -// and the versions frontmatter, so writers can hand-edit the other fields. -// If it does not exist, we create it. +// Existing autogenerated pages keep writer-edited frontmatter except versions. +// New pages use source frontmatter because no writer edits exist yet. async function updateMarkdownFile( file: string, sourceData: FrontmatterData, @@ -154,7 +141,7 @@ async function updateMarkdownFile( if (existsSync(file)) { const { data, content } = matter(await readFile(file, 'utf-8')) - // Double check that the comment delimiter is only used once + // Multiple delimiters make the writer-owned and generated content split unsafe. const matcher = new RegExp(commentDelimiter, 'g') const matches = content.match(matcher) if (matches && matches.length > 1) { @@ -181,9 +168,7 @@ async function updateMarkdownFile( delimiterMissing: isDelimiterMissing, }) - // Create a new object so that we don't mutate the original data const newData = { ...data } - // Only modify the versions property when a file already exists newData.versions = sourceData.versions const targetContent = manuallyCreatedContent + commentDelimiter + sourceContent const newFileContent = appendVersionComment(matter.stringify(targetContent, newData)) @@ -198,10 +183,7 @@ async function updateMarkdownFile( } } -// Recursively walks through the directory structure and updates the -// index.md files to match the disk. Before calling this function -// ensure that the Markdown files have been updated and any files -// that need to be deleted have been removed. +// Call after Markdown updates and deletions because child index files mirror disk state. async function updateDirectory( directory: string, frontmatter: FrontmatterData, @@ -225,8 +207,7 @@ async function updateDirectory( const indexFile = `${directory}/index.md` const { data, content } = await getIndexFileContents(indexFile, frontmatter, shortTitle) - // We need to re-get the directory contents because a recursive call - // may have removed a directory since the initial directory read. + // Recursive calls may remove child directories, so read the directory again before syncing. const { directoryContents, childDirectories, directoryFiles } = await getDirectoryInfo(directory) const { childrenOnDisk, indexChildren } = getChildrenToCompare( @@ -262,12 +243,8 @@ async function updateDirectory( await writeFile(indexFile, matter.stringify(content, dataUpdatedChildren)) } -// Takes the children properties from the index.md file and the -// files/directories on disk and normalizes them to be comparable -// against each other. -// Children properties include a leading slash except when the -// index.md file is the root index.md file. We also want to -// remove the file extension from the files on disk. +// Root index children omit the leading slash; other index.md children include it. +// Disk entries include extensions, so normalize both sides before comparing them. function getChildrenToCompare( indexFile: string, directoryContents: string[], @@ -291,21 +268,8 @@ function getChildrenToCompare( return { childrenOnDisk, indexChildren } } -// Adds and removes children properties to the index.md file. -// There are three possible scenarios that we want to handle: -// -// 1. If the lib/config.json file for the pipeline defines a sort -// order for the index file we're currently processing, then -// we want to use that sort order. Currently, the config files -// only defined a startsWith parameter. This property defines -// the order of the first items in the index files children -// property. All other items are sorted and appended to list. -// -// 2. If no config is defined and the index file is an -// autogenerated file, we sort all the children alphabetically. -// -// 3. If the index file is not autogenerated, we leave the ordering -// as is and append new children to the end. +// Autogenerated indexes use config startsWith ordering first, then alphabetical entries. +// Manual indexes keep existing order and append new children to the end. function updateIndexChildren( data: FrontmatterData, childUpdates: ChildUpdates, @@ -317,17 +281,13 @@ function updateIndexChildren( const childPrefix = rootIndex ? '' : '/' const children = [...(data.children || [])] - // remove the '/' prefix used in index.md children .map((item) => item.replace(childPrefix, '')) .filter((item) => !itemsToRemove.includes(item)) children.push(...itemsToAdd) const orderedIndexChildren: string[] = [] - // Only used for tests. During testing, the content directory is - // in a temp directory so the paths are not relative to - // the current working directory. This gets the relative path - // from the full path to the index file. + // Tests run content in a temp directory, so config keys need repo-relative index paths. const indexRelativePath = process.env.TEST_OS_ROOT_DIR ? indexFile.replace(`${process.env.TEST_OS_ROOT_DIR}/`, '') : indexFile @@ -340,24 +300,19 @@ function updateIndexChildren( orderedIndexChildren.push(...indexOrderConfig.startsWith, ...sortableChildren) } } else if (isAutogenerated) { - // always sort autogenerated index files that have no override config orderedIndexChildren.push(...children) orderedIndexChildren.sort() } else { - // just leave the children in the order they are in the index file - // so they can be manually sorted + // Manual index files keep writer-defined ordering. orderedIndexChildren.push(...children) } const updatedData = { ...data } - // add the '/' prefix back to the children updatedData.children = orderedIndexChildren.map((item) => `${childPrefix}${item}`) return updatedData } -// Gets the contents of the index.md file from disk if it exists, -// or returns default frontmatter for a new one. async function getIndexFileContents( indexFile: string, frontmatter: FrontmatterData, @@ -379,9 +334,6 @@ async function getIndexFileContents( return existsSync(indexFile) ? matter(await readFile(indexFile, 'utf-8')) : indexFileContent } -// Builds the index.md versions frontmatter by consolidating -// the versions from each Markdown file in the directory + the -// index.md files in any subdirectories of directory. async function getIndexFileVersions( directory: string, files: string[], @@ -414,42 +366,22 @@ async function getIndexFileVersions( return await convertVersionsToFrontmatter(versionArray) } -/* Takes a list of versions in the format: -[ - 'free-pro-team@latest', - 'enterprise-cloud@latest', - 'enterprise-server@3.3', - 'enterprise-server@3.4', - 'enterprise-server@3.5', - 'enterprise-server@3.6', - 'enterprise-server@3.7' -] -and returns the frontmatter equivalent JSON: -{ - fpt: '*', - ghec: '*', - ghes: '*' -} -*/ +// Converts applicable versions to versions frontmatter. +// Example: free-pro-team@latest, enterprise-cloud@latest, and every supported GHES release +// become fpt: *, ghec: *, and ghes: *. export async function convertVersionsToFrontmatter( versions: string[], ): Promise<{ [key: string]: string }> { const frontmatterVersions: { [key: string]: string } = {} const numberedReleases: { [key: string]: { availableReleases: (string | undefined)[] } } = {} - // Currently, only GHES is numbered. Number releases have to be - // handled differently because they use semantic versioning. + // GHES uses semantic version ranges because it has numbered releases. for (const version of versions) { const docsVersion = allVersions[version] if (!docsVersion.hasNumberedReleases) { frontmatterVersions[docsVersion.shortName] = '*' } else { - // Each version that has numbered releases in allVersions - // has a string for the number (currentRelease) and an array - // of all of the available releases (e.g. ['3.3', '3.4', '3.5']) - // This creates an array of the applicable releases in the same - // order as the available releases array. This is used to track when - // a release is no longer supported. + // Track supported GHES releases by position so gaps become explicit ranges later. const i = docsVersion.releases.indexOf(docsVersion.currentRelease) if (!numberedReleases[docsVersion.shortName]) { const availableReleases: (string | undefined)[] = Array(docsVersion.releases.length).fill( @@ -465,15 +397,13 @@ export async function convertVersionsToFrontmatter( } } - // Create semantic versions for numbered releases for (const key of Object.keys(numberedReleases)) { const availableReleases = numberedReleases[key].availableReleases const versionContinuity = checkVersionContinuity(availableReleases) if (availableReleases.every(Boolean)) { frontmatterVersions[key] = '*' } else if (!versionContinuity) { - // If there happens to be version gaps, just enumerate each version - // using syntax like =3.x || =3.x + // Gapped releases must enumerate each supported version, such as =3.3 || =3.5. const semVer = availableReleases .filter(Boolean) .map((release) => `=${release}`) @@ -501,14 +431,11 @@ export async function convertVersionsToFrontmatter( return sortedFrontmatterVersions } -// This is uncommon, but we potentially could have the case where an -// article was versioned for say 3.2, not for 3.3, and then again -// versioned for 3.4. This will result in a custom semantic version range +// A gap between supported versions, such as 3.2 and 3.4 without 3.3, needs a custom range. function checkVersionContinuity(versions: (string | undefined)[]): boolean { const availableVersions = [...versions] - // values at the beginning or end of the array are not gaps but normal - // starts and ends of version ranges + // Missing values at the ends mark normal range boundaries, not gaps. while (!availableVersions[0]) { availableVersions.shift() } diff --git a/src/automated-pipelines/tests/rendering.ts b/src/automated-pipelines/tests/rendering.ts index f8dfc17ca55f..9e729afc102f 100644 --- a/src/automated-pipelines/tests/rendering.ts +++ b/src/automated-pipelines/tests/rendering.ts @@ -24,19 +24,17 @@ describe('autogenerated docs render', () => { const autogeneratedPages = pageList.filter((page: Page) => page.autogenerated) test('all automated pages', async () => { - // Each page should render with 200 OK. Also, check for duplicate - // heading IDs on each page. + // Each page must render with 200 OK and unique heading IDs. const errors = ( await Promise.all( autogeneratedPages.map(async (page: Page) => { const url = page.permalinks[0].href - // Some autogenerated pages can be very slow and might fail. - // So we allow a few retries to avoid false positives. + // Slow autogenerated pages get retries to avoid false-positive failures. const res = await get(url, { retries: 3 }) if (res.statusCode !== 200) { return `${res.statusCode} status error on ${url}` } - // Using `xmlMode: true` is marginally faster + // xmlMode is faster for this duplicate-ID scan. const $ = load(res.body, { xmlMode: true }) const headingIDs = $('body') .find('h2, h3, h4, h5, h6') @@ -64,10 +62,8 @@ describe('autogenerated docs render', () => { const ghappsPath: string = JSON.parse( readFileSync('src/github-apps/lib/config.json', 'utf-8'), ).targetDirectory - // Right now only the rest and codeqlcli pages get their frontmatter updated automatically. - // The apps pages do not get their frontmatter auto-updated since they apply to all versions and they are - // single pages. The apps pages are also nested inside of the rest pages. So we want to filter out only - // rest pages and the codeql cli pages for this test. + // Only REST and CodeQL CLI pages get automated version frontmatter updates. + // GitHub Apps pages apply to all versions and nest under REST, so exclude them. const filesWithAutoUpdatedVersions = autogeneratedPages.filter( (page: Page) => (!page.fullPath.startsWith(ghappsPath) && page.fullPath.startsWith(restPath)) || diff --git a/src/automated-pipelines/tests/update-markdown.ts b/src/automated-pipelines/tests/update-markdown.ts index a617287046ff..827d90d27228 100644 --- a/src/automated-pipelines/tests/update-markdown.ts +++ b/src/automated-pipelines/tests/update-markdown.ts @@ -70,10 +70,8 @@ const indexOrder: IndexOrder = { } describe('automated content directory updates', () => { - // Before all tests, copy the content directory fixture - // to the operating systems temp directory. We'll be modifying - // that temp directory during the tests and comparing the directory - // structure and contents after running updateContentDirectory. + // Tests mutate a temp copy of src/automated-pipelines/tests/fixtures/content, then compare + // the resulting file tree and frontmatter after updateContentDirectory runs. beforeAll(async () => { process.env.TEST_OS_ROOT_DIR = tempDirectory mkdirSync(`${tempContentDirectory}`, { recursive: true }) @@ -81,10 +79,7 @@ describe('automated content directory updates', () => { recursive: true, }) - // The updateContentDirectory uses relative paths to the content directory - // because outside of testing it only runs in the docs-internal repo. - // Because of that, we need to update the content paths to use the - // full file path. + // Temp fixtures need absolute paths because this test runs outside the repo content root. const contentDataFullPath: { [key: string]: ContentItem } = {} for (const key of Object.keys(newContentData)) { contentDataFullPath[path.join(targetDirectory, key)] = newContentData[key] @@ -127,7 +122,6 @@ describe('automated content directory updates', () => { }) test('rest/actions index file is updated as expected', async () => { - // workflows added and artifacts removed const actionsIndex = matter( await readFile(`${tempDirectory}/content/rest/actions/index.md`, 'utf8'), ) diff --git a/src/codeql-cli/scripts/convert-markdown-for-docs.ts b/src/codeql-cli/scripts/convert-markdown-for-docs.ts index 2462ba5d927e..7bdf8bcd34bb 100644 --- a/src/codeql-cli/scripts/convert-markdown-for-docs.ts +++ b/src/codeql-cli/scripts/convert-markdown-for-docs.ts @@ -68,7 +68,6 @@ export async function convertContentToDocs( visit(ast, 'heading', (rawNode) => { const node = rawNode as unknown as MdNode - // A level 1 heading is the article title. if (node.depth === 1) { frontmatter.title = node.children[0].value } @@ -78,15 +77,12 @@ export async function convertContentToDocs( node.children[0].value = node.children[0].value.split('{#')[0].trim() } - // Works around secondary options sitting at the wrong heading level - // in the source rst files. - // Everything after the "Synopsis", "Description", and "Options" - // headings moves up one level, so h4 becomes h3. + // Headings after Primary options sit one level too deep in source rst, so shift them up. if (secondaryOptions) { node.depth = Math.max(1, Math.min(6, node.depth - 1)) } - // This needs to be assigned after node.depth is modified above + // Capture depth after secondary options shift changes node.depth. depth = node.depth if (node.children[0].value === LAST_PRIMARY_HEADING && node.children[0].type === 'text') { secondaryOptions = true @@ -98,8 +94,7 @@ export async function convertContentToDocs( const node = rawNode as unknown as MdNode if (node.type !== 'heading' && node.type !== 'paragraph') return false - // The first paragraph after the "Description" heading - // becomes the intro frontmatter. + // The first paragraph after Description becomes intro frontmatter. if (node.children[0]?.value === 'Description' && node.children[0]?.type === 'text') { currentNodeIsDescription = true } @@ -125,10 +120,7 @@ export async function convertContentToDocs( node.meta = 'copy' } - // The start of a secondary options section, for example - // "Output format options." - // The rst file gives these no heading level, so nest them one level - // under `depth`, the last heading level seen by the walk above. + // Secondary labels "Output format options." lack depth; depth+1 nests under last heading. if (node.type === 'text' && node.value && node.value.includes(HEADING_BEGIN)) { node.value = node.value.replace(HEADING_BEGIN, '') // Ancestors run root first, so the last one is the parent. @@ -136,8 +128,7 @@ export async function convertContentToDocs( ancestors[ancestors.length - 1].depth = Math.max(1, Math.min(6, depth + 1)) } - // Keywords like [Plumbing] come from the source code comments - // and should not render in the docs. + // Source code keywords like [Plumbing] do not belong in docs output. if (node.type === 'text' && node.value) { for (const keyword of removeKeywords) { if (node.value.includes(keyword)) { @@ -146,8 +137,7 @@ export async function convertContentToDocs( } } - // Subsections under the level 2 headings are commands - // starting with `-` or `<`, so render them as inline code. + // Level 2 command headings start with - or <, so render them as inline code. if ( node.type === 'text' && ancestors[ancestors.length - 1].type === 'heading' && @@ -161,13 +151,7 @@ export async function convertContentToDocs( node.value = node.value.replace(END_SECTION, '') } - // Links to other CodeQL CLI docs, which need to become Markdown links. - // Pandoc converts the rst links to this shape: - // `codeql test run`{.interpreted-text role="doc"} - // giving a link title of `codeql test run` and a relative path of - // `test-run`. The rest can be dropped. - // The inline code tag is one node and the {.interpreted-text} string - // is another. + // Pandoc emits "codeql test run" as inline code plus role marker; convert to link. if (node.type === 'text' && node.value.includes('{.interpreted-text')) { const paragraph = ancestors[ancestors.length - 1].children const docRoleTagChild = paragraph.findIndex( @@ -189,7 +173,7 @@ export async function convertContentToDocs( node.value = node.value.replace(/\n/g, ' ').replace('{.interpreted-text role="doc"}', '') - // A link to the file being converted would be circular. + // Links to the file being converted would be circular. const currentFileBaseName = currentFileName.replace('.md', '') if (currentFileBaseName && linkPath === currentFileBaseName) { link.type = 'text' @@ -202,15 +186,12 @@ export async function convertContentToDocs( } } - // Collect aka.ms links to resolve after the tree walk. + // Resolve aka.ms redirects after the tree walk, because visit callbacks cannot await. if (node.type === 'link' && node.url.includes('aka.ms')) { akaMsLinkMatches.push(node) } - // Example links like https://containers.GHEHOSTNAME should not be - // checked by the link checker, so render them as inline code. - // The Java program that generates the rst files should do this instead. - // See https://github.com/syntax-tree/mdast#inlinecode + // Render https://containers.GHEHOSTNAME example links as inline code so the link checker skips them. if (node.type === 'link' && node.url.startsWith('https://containers')) { // Strip the double quotes from the nodes either side. const nodeBefore = ancestors[ancestors.length - 1].children[0] @@ -237,12 +218,11 @@ export async function convertContentToDocs( }, ) - // Convert all aka.ms links to the docs.github.com relative path + // aka.ms redirects supply the docs.github.com relative path. await Promise.all( akaMsLinkMatches.map(async (node: MdNode) => { const url = await getRedirect(node.url) - // These are already Markdown links in the ast, - // so only the url and the link text need updating. + // Existing Markdown links only need AUTOTITLE text and the resolved URL. if (node.children[0]) { node.children[0].value = 'AUTOTITLE' } diff --git a/src/codeql-cli/scripts/sync.ts b/src/codeql-cli/scripts/sync.ts index 479bd83de914..ec4390385eba 100755 --- a/src/codeql-cli/scripts/sync.ts +++ b/src/codeql-cli/scripts/sync.ts @@ -30,10 +30,7 @@ async function main() { for (const file of markdownFiles) { const sourceContent = await readFile(file, 'utf8') - // The source content is missing a "Primary Options" heading directly - // under "Options". - // Adding a node to the AST is fiddly when it is not a child of the - // previous heading, so append the heading to the raw Markdown instead. + // Source Markdown lacks a Primary Options heading under Options; raw text avoids AST insertion. const matchHeading = '## Options\n' const primaryHeadingSourceContent = sourceContent.replace( matchHeading, diff --git a/src/codeql-queries/scripts/generate-code-quality-query-list.ts b/src/codeql-queries/scripts/generate-code-quality-query-list.ts index a0348201000a..eb422526288c 100644 --- a/src/codeql-queries/scripts/generate-code-quality-query-list.ts +++ b/src/codeql-queries/scripts/generate-code-quality-query-list.ts @@ -1,41 +1,13 @@ -/** - * This script generates a block of Markdown that can be saved as a reusable. - * The reusable lists all the code quality queries for one programming language, with categories, as a Markdown table. - * - * To be able to execute this script, you need to have the CodeQL CLI installed. - * To do that, you need two things: - * - * 1. The directory where the github/codeql repo is cloned - * 2. The path to the executable `codeql` file. - * - * The directory where the github/codeql repo is cloned is needed because - * that's how it looks up files. You can set it up like this: - * - * cd /tmp - * git clone git@github.com:github/codeql.git - * cd codeql - * pwd - * - * To install the codeql executable, use `gh` like this: - * - * gh extension install github/gh-codeql - * gh codeql set-channel nightly - * gh codeql version - * - * Note that when you run the `gh codeql version` command, it will tell you - * where the executable is installed. For example: - * - * /Users/peterbe/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-20231204/codeql - * - * If you've git cloned github/codeql in /tmp/ now you can execute this script. - * For example, to generate the Markdown - * for Python: - * - * npm run generate-code-quality-query-list -- \ - * --codeql-path ~/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-20231204/codeql \ - * --codeql-dir /tmp/codeql python | tee /tmp/python.md - * less /tmp/python.md - */ +// Generates reusable Markdown listing code quality queries for one language, with categories. +// Requires a local github/codeql clone and a CodeQL CLI executable. +// Set up the clone with git clone git@github.com:github/codeql.git /tmp/codeql. +// Install the CLI with gh extension install github/gh-codeql, then gh codeql set-channel nightly. +// Run gh codeql version to find the installed codeql path. +// Example: +// npm run generate-code-quality-query-list -- \ +// --codeql-path ~/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-*/codeql \ +// --codeql-dir /tmp/codeql python | tee /tmp/python.md +// Inspect the generated Markdown with less /tmp/python.md. import fs from 'fs' import { execFileSync } from 'child_process' @@ -122,7 +94,7 @@ async function main(options: Options, language: string) { const categories = getCategories(tags || '') const url = getDocsLink(language, id) - // Only include queries that have categories + // Category-less queries have no code quality docs row. if (categories.length) { queries[id] = { url, name, categories, severity: severity || 'N/A' } } else { @@ -135,8 +107,7 @@ async function main(options: Options, language: string) { } function decorate(query: Query): QueryExtended { - // Determine primary category for sorting - // Prefer 'maintainability' over 'reliability' + // Maintainability outranks reliability for table sorting. const primaryCategory = query.categories.includes('maintainability') ? 'maintainability' : query.categories.includes('reliability') @@ -151,7 +122,7 @@ async function main(options: Options, language: string) { const entries = Object.values(queries).map(decorate) - // Sort by primary category (maintainability first), then alphabetically by name + // Sort by primary category, then alphabetically by name. entries.sort((a, b) => { if (a.primaryCategory === 'maintainability' && b.primaryCategory !== 'maintainability') return -1 @@ -170,25 +141,23 @@ async function main(options: Options, language: string) { function printQueries(options: Options, queries: QueryExtended[]) { const markdown: string[] = [] markdown.push('{% rowheaders %}') - markdown.push('') // blank line + markdown.push('') const header = ['Query name', 'Category', 'Severity'] markdown.push(`| ${header.join(' | ')} |`) markdown.push(`| ${header.map(() => '---').join(' | ')} |`) for (const query of queries) { const markdownLink = `[${query.name}](${query.url})` - // Capitalize first letter of category for display const categoryDisplay = query.categories .map((cat) => cat.charAt(0).toUpperCase() + cat.slice(1)) .join(', ') - // Capitalize first letter of severity for display const severityDisplay = query.severity.charAt(0).toUpperCase() + query.severity.slice(1) const row = [markdownLink, categoryDisplay, severityDisplay] markdown.push(`| ${row.join(' | ')} |`) } - markdown.push('') // blank line + markdown.push('') markdown.push('{% endrowheaders %}') - markdown.push('') // always end with a blank line + markdown.push('') if (options.outputFile === 'stdout') { console.log(markdown.join('\n')) @@ -203,9 +172,7 @@ function getMetadata(options: Options, queryFile: string): QueryMetadata { }) const parsed = JSON.parse(metadataJson) - // Extract severity from various possible locations in the metadata - // CodeQL metadata can have @problem.severity in the query file, which may be - // represented in different ways in the JSON output from `codeql resolve metadata` + // CodeQL emits severity through several metadata shapes, depending on the query source. const severity = parsed.problem?.severity || // Nested: { problem: { severity: "error" } } parsed['@problem']?.severity || // Nested with @: { "@problem": { severity: "error" } } @@ -215,7 +182,7 @@ function getMetadata(options: Options, queryFile: string): QueryMetadata { parsed['@severity'] // With @: { "@severity": "error" } if (options.verbose) { - // On first query only, show all available keys to help debug + // Verbose mode logs metadata keys once to avoid noisy output. if (!getMetadata.shownKeys) { console.log(chalk.yellow('Available metadata keys:'), Object.keys(parsed)) if (parsed.problem) { @@ -240,24 +207,15 @@ function getMetadata(options: Options, queryFile: string): QueryMetadata { } } -// Add a property to track if we've shown keys getMetadata.shownKeys = false -/** - * - * @param language 'cpp' - * @param queryId 'external-entity-expansion' - * @returns https://codeql.github.com/codeql-query-help/cpp/cpp-external-entity-expansion/ - */ +// Example: cpp and external-entity-expansion become +// https://codeql.github.com/codeql-query-help/cpp/cpp-external-entity-expansion/ function getDocsLink(language: string, queryId: string) { return `https://codeql.github.com/codeql-query-help/${language}/${queryId.replaceAll('/', '-')}/` } -/** - * - * @param tags 'maintainability readability reliability external/cwe/cwe-1078 external/cwe/cwe-670 security' - * @returns ['maintainability', 'reliability'] - */ +// Example tags with maintainability and reliability return those categories in source order. function getCategories(tags: string) { const categories: string[] = [] for (const tag of tags.split(/\s+/g)) { diff --git a/src/codeql-queries/scripts/generate-code-scanning-query-list.ts b/src/codeql-queries/scripts/generate-code-scanning-query-list.ts index 8dfa75149b2c..e9bafc2b98dd 100644 --- a/src/codeql-queries/scripts/generate-code-scanning-query-list.ts +++ b/src/codeql-queries/scripts/generate-code-scanning-query-list.ts @@ -1,59 +1,23 @@ -/** - * This script generates a block of Markdown that can be saved as a reusable. - * The reusable lists all the queries for one programming language, with CWEs, as a Markdown table. - * - * To be able to execute this script, you need to have the CodeQL CLI installed. - * To do that, you need two things: - * - * 1. The directory where the github/codeql repo is clone - * 2. The path to the executable `codeql` file. - * - * The directory where the github/codeql repo is cloned is needed because - * that's how it looks up files. You can set it up like this: - * - * cd /tmp - * git clone git@github.com:github/codeql.git - * cd codeql - * pwd - * - * To install the codeql executable, use `gh` like this: - * - * gh extension install github/gh-codeql - * gh codeql set-channel nightly - * gh codeql version - * - * Note that when you run the `gh codeql version` command, it will tell you - * where the executable is installed. For example: - * - * /Users/peterbe/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-20231204/codeql - * - * Finally, you need to install `@github/cocofix`. This is a private package, - * so you first need to get the `DOCS_BOT_PAT_BASE` PAT from the vault and - * store it in the environment variable `DOCS_BOT_PAT_BASE`. - * Then run the following command from the root of this repo: - * - * ```sh - * npm i --no-save '--@github:registry=https://npm.pkg.github.com' '--//npm.pkg.github.com/:_authToken=${DOCS_BOT_PAT_BASE}' @github/cocofix - * ``` - * - * If you've git cloned github/codeql in /tmp/ now you can execute this script. - * For example, to generate the Markdown - * for Python: - * - * npm run generate-code-scanning-query-list -- \ - * --codeql-path ~/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-20231204/codeql \ - * --codeql-dir /tmp/codeql python | tee /tmp/python.md - * less /tmp/python.md - */ +// Generates reusable Markdown listing CodeQL code scanning queries for one language, with CWEs. +// Requires a local github/codeql clone and a CodeQL CLI executable. +// Set up the clone with git clone git@github.com:github/codeql.git /tmp/codeql. +// Install the CLI with gh extension install github/gh-codeql, then gh codeql set-channel nightly. +// Run gh codeql version to find the installed codeql path. +// Also requires @github/cocofix, installed locally with DOCS_BOT_PAT_BASE from the vault: +// npm i --no-save '--@github:registry=https://npm.pkg.github.com' \ +// '--//npm.pkg.github.com/:_authToken=${DOCS_BOT_PAT_BASE}' @github/cocofix +// Example: +// npm run generate-code-scanning-query-list -- \ +// --codeql-path ~/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-*/codeql \ +// --codeql-dir /tmp/codeql python | tee /tmp/python.md +// Inspect the generated Markdown with less /tmp/python.md. import fs from 'fs' import { execFileSync } from 'child_process' import chalk from 'chalk' import { program } from 'commander' -// We don't want to introduce a global dependency on @github/cocofix, so we install it by hand -// as described above and suppress the import warning. -// eslint-disable-next-line import/no-unresolved -- @github/cocofix is installed manually +// eslint-disable-next-line import/no-unresolved -- @github/cocofix stays manual to avoid a global dependency import { getSupportedQueries } from '@github/cocofix/dist/querySuites' import type { Language } from 'codeql-ts' @@ -150,8 +114,7 @@ async function main(options: Options, language: string) { const url = getDocsLink(language, id) const autofixSupport = autofixSupportedQueryIds.includes(id) ? 'default' : 'none' - // Only include queries that have CWEs, since the other queries deal with code scanning - // metadata and metrics (e.g. counting lines of code or number of files) and have no docs link + // CWE-less queries cover metadata or metrics and have no docs link. if (cwes.length) { if (!(id in queries)) { queries[id] = { url, name, packs: [], cwes, autofixSupport } @@ -178,8 +141,7 @@ async function main(options: Options, language: string) { const entries = Object.values(queries).map(decorate) - // Spec: "Queries that are both in Default and Extended should come first, - // in alphabetical order. Followed by the queries that are in Extended only." + // Default-and-Extended queries sort before Extended-only queries; each group sorts by name. entries.sort((a, b) => { if (a.inDefault && !b.inDefault) return -1 else if (!a.inDefault && b.inDefault) return 1 @@ -196,7 +158,7 @@ async function main(options: Options, language: string) { function printQueries(options: Options, queries: QueryExtended[]) { const markdown: string[] = [] markdown.push('{% rowheaders %}') - markdown.push('') // blank line + markdown.push('') const header = [ 'Query name', 'Related CWEs', @@ -218,9 +180,9 @@ function printQueries(options: Options, queries: QueryExtended[]) { const row = [markdownLink, query.cwes.join(', '), defaultIcon, extendedIcon, autofixIcon] markdown.push(`| ${row.join(' | ')} |`) } - markdown.push('') // blank line + markdown.push('') markdown.push('{% endrowheaders %}') - markdown.push('') // always end with a blank line + markdown.push('') if (options.outputFile === 'stdout') { console.log(markdown.join('\n')) @@ -237,21 +199,13 @@ function getMetadata(options: Options, queryFile: string): QueryMetadata { return parsed } -/** - * - * @param language 'cpp' - * @param queryId 'external-entity-expansion' - * @returns https://codeql.github.com/codeql-query-help/cpp/cpp-external-entity-expansion/ - */ +// Example: cpp and external-entity-expansion become +// https://codeql.github.com/codeql-query-help/cpp/cpp-external-entity-expansion/ function getDocsLink(language: string, queryId: string) { return `https://codeql.github.com/codeql-query-help/${language}/${queryId.replaceAll('/', '-')}/` } -/** - * - * @param tags 'maintainability readability external/cwe/cwe-1078 external/cwe/cwe-670 security' - * @returns ['1078', '670'] - */ +// Example tags with external/cwe/cwe-1078 and external/cwe/cwe-670 return 1078 and 670. function getCWEs(tags: string) { const cwes: string[] = [] for (const tag of tags.split(/\s+/g)) { diff --git a/src/color-schemes/components/BrandThemeProvider.tsx b/src/color-schemes/components/BrandThemeProvider.tsx index 4c1387cfdb35..78a1192538f6 100644 --- a/src/color-schemes/components/BrandThemeProvider.tsx +++ b/src/color-schemes/components/BrandThemeProvider.tsx @@ -3,7 +3,7 @@ import { ThemeProvider } from '@primer/react-brand' import { getBrandColorMode, type BrandColorMode } from '@/color-schemes/lib/get-brand-color-mode' -// Brand reads `colorMode="auto"` as "snapshot the OS on mount" rather than +// Brand reads colorMode="auto" as "snapshot the OS on mount" rather than // "inherit", so this only ever passes a concrete mode. export const BrandThemeProvider = ({ children }: PropsWithChildren) => { // Seeded to match SSR; reading the DOM here would break hydration. @@ -11,7 +11,7 @@ export const BrandThemeProvider = ({ children }: PropsWithChildren) => { useEffect(() => { setColorMode(getBrandColorMode()) - // colorModeScript re-stamps when the OS flips under `auto`. + // colorModeScript re-stamps when the OS flips under auto. const observer = new MutationObserver(() => setColorMode(getBrandColorMode())) observer.observe(document.documentElement, { attributes: true, @@ -20,8 +20,7 @@ export const BrandThemeProvider = ({ children }: PropsWithChildren) => { return () => observer.disconnect() }, []) - // Brand spreads rest props after its own attribute, so `data-color-mode={undefined}` - // drops it from the wrapper div; the prop still feeds brand's context. + // Brand spreads rest props last, so undefined removes the wrapper attribute but keeps context. return ( {children} diff --git a/src/color-schemes/components/useTheme.ts b/src/color-schemes/components/useTheme.ts index b51c1daff79d..c6078196be94 100644 --- a/src/color-schemes/components/useTheme.ts +++ b/src/color-schemes/components/useTheme.ts @@ -62,8 +62,8 @@ function filterMode(mode = ''): CssColorMode | undefined { } } -// `?? {}` rather than a default parameter: a default only covers `undefined`, and -// the cookie can carry an explicit `null` (`{"light_theme":null}`). +// Use ?? {} because a default parameter covers undefined, but the cookie can carry +// explicit null, for example {"light_theme":null}. function filterTheme( theme?: { name?: string; color_mode?: string } | null, ): SupportedTheme | undefined { @@ -102,6 +102,8 @@ export function getComponentTheme(cookieValue = ''): ComponentColorTheme { } } +// setTimeout(0) defers cookie reads until after Primer React's effect, which otherwise +// overrides the cookie color mode and reverts the page to auto. export function useTheme() { const [theme, setTheme] = useState({ css: defaultCSSTheme, @@ -109,10 +111,6 @@ export function useTheme() { }) useEffect(() => { - // setTimeout(0) defers this past Primer React's own useEffect, - // which otherwise overrides the cookie's color mode and reverts the page to auto. - // Primer's migration to CSS variables should remove the need for this. - // https://github.com/primer/react/issues/2229 setTimeout(() => { const cookieValue = Cookies.get(COLOR_MODE_COOKIE_NAME) const css = getCssTheme(cookieValue) diff --git a/src/color-schemes/lib/color-mode-script.ts b/src/color-schemes/lib/color-mode-script.ts index bf5833843821..b9a4097cc4c6 100644 --- a/src/color-schemes/lib/color-mode-script.ts +++ b/src/color-schemes/lib/color-mode-script.ts @@ -1,21 +1,19 @@ import { COLOR_MODE_COOKIE_NAME } from '@/frame/lib/constants' import { CssColorMode, SupportedTheme, defaultCSSTheme } from '@/color-schemes/components/useTheme' -// A tiny script that runs synchronously in the document , before the -// browser's first paint. It reads the `color_mode` cookie (set by github.com, -// not HttpOnly) and writes the matching `data-color-mode`, `data-light-theme`, -// and `data-dark-theme` attributes onto the element. Without this, the -// page first paints with the SSR default theme and only switches to the user's -// real theme after the React bundle hydrates, causing a visible flash. +// This script runs synchronously in the document head before first paint. It reads the +// color_mode cookie from github.com, which is not HttpOnly, and writes data-color-mode, +// data-light-theme, and data-dark-theme attributes on html. Without it, the page paints +// with the SSR default theme before React hydrates and switches to the user's theme. // -// `data-color-mode` is always concrete, never `auto` — @primer/react-brand has -// no `auto` palette — and follows the effective theme, because a `light` mode -// can carry a dark day theme. See src/color-schemes/README.md. +// data-color-mode stays concrete, never auto, and follows the effective theme because +// @primer/react-brand lacks an auto palette and light mode can carry a dark day theme. +// See src/color-schemes/README.md. // -// The output is identical for every request, so the HTML stays shared-cacheable -// in our CDN. The validation allowlists and defaults are derived from the same -// enums used by `useTheme`, so they can't drift, and `helmet.ts` hashes this -// exact string for the CSP `script-src` allowance (no nonce, no unsafe-inline). +// The generated output is identical across requests, so CDN caches can share the HTML. +// useTheme supplies the validation allowlists and defaults so they cannot drift. +// helmet.ts hashes this exact string for the CSP script-src allowance, with no nonce +// and no unsafe-inline. const modes = JSON.stringify(Object.values(CssColorMode)) const themes = JSON.stringify(Object.values(SupportedTheme)) const defaults = JSON.stringify(defaultCSSTheme) diff --git a/src/color-schemes/lib/get-brand-color-mode.ts b/src/color-schemes/lib/get-brand-color-mode.ts index cd317a6e9766..9bacbe6864c5 100644 --- a/src/color-schemes/lib/get-brand-color-mode.ts +++ b/src/color-schemes/lib/get-brand-color-mode.ts @@ -1,7 +1,7 @@ export type BrandColorMode = 'light' | 'dark' -// Brand's palette follows 's `data-color-mode`, resolved to a concrete mode -// before first paint — not PRC's `resolvedColorScheme`, which is the THEME. +// Brand's palette follows html data-color-mode, resolved to a concrete mode before first +// paint, not Primer React's resolvedColorScheme, which tracks the theme. export function getBrandColorMode(): BrandColorMode { if (typeof document === 'undefined') return 'light' // SSR fallback return document.documentElement.getAttribute('data-color-mode') === 'dark' ? 'dark' : 'light' diff --git a/src/color-schemes/tests/color-mode-script.ts b/src/color-schemes/tests/color-mode-script.ts index ad6ce2fcb158..7ac8cb9c1f53 100644 --- a/src/color-schemes/tests/color-mode-script.ts +++ b/src/color-schemes/tests/color-mode-script.ts @@ -3,17 +3,15 @@ import { describe, expect, test } from 'vitest' import { colorModeScript } from '../lib/color-mode-script' import { getCssTheme, SupportedTheme } from '../components/useTheme' -// The inline script runs before any bundle loads, so it reimplements -// `useTheme`'s validation instead of importing it. These tests assert the two -// stay in sync. +// The inline script runs before any bundle loads, so it reimplements useTheme validation +// instead of importing it. These tests assert the two stay in sync. function runScript( rawCookie: string, { prefersDark = false, matchMedia = true, legacyListener = false } = {}, ) { const attrs: Record = {} const listeners: Array<(event: { matches: boolean }) => void> = [] - // `matches` reads this through a getter, so `flipSystemPreference` changes - // what an already-registered handler sees. + // matches reads os through a getter, so flipSystemPreference changes what handlers see. const os = { prefersDark } const subscribe = (handler: (event: { matches: boolean }) => void) => { listeners.push(handler) @@ -61,8 +59,7 @@ function cookieFor(value: object) { function expectMatchesGetCssTheme(rawCookie: string, cookieValue: string, prefersDark = false) { const css = getCssTheme(cookieValue) - // Primitives select on the (mode, theme) pair, so the effective theme has to - // land on the attribute for the resolved mode. + // Primer primitives use mode and theme together, so resolved mode gets the effective theme. const mode = css.colorMode === 'auto' ? (prefersDark ? 'dark' : 'light') : css.colorMode const theme = mode === 'dark' ? css.darkTheme : css.lightTheme const resolved = theme.startsWith('dark') ? 'dark' : 'light' @@ -111,7 +108,7 @@ describe('colorModeScript', () => { }) test('survives an explicitly null theme without discarding the mode', () => { - // A default parameter covers `undefined`, not `null`. + // A default parameter covers undefined, not null. const value = { color_mode: 'dark', light_theme: null } expectMatchesGetCssTheme(cookieFor(value), JSON.stringify(value)) expect(runScript(cookieFor(value)).attrs['data-color-mode']).toBe('dark') @@ -159,7 +156,7 @@ describe('colorModeScript', () => { }) test('falls back to the deprecated addListener when addEventListener is absent', () => { - // Pre-14 Safari exposes only `addListener`, so this branch is live. + // Older Safari exposes only addListener, so this branch is live. const run = runScript(cookieFor({ color_mode: 'auto' }), { legacyListener: true }) expect(run.attrs['data-color-mode']).toBe('light') expect(run.listeners).toHaveLength(1) @@ -172,8 +169,7 @@ describe('colorModeScript', () => { }) test('still writes the attributes when matchMedia is unavailable', () => { - // The script's DOM block sits in a try/catch, so an unguarded matchMedia - // call would leave with no attributes at all. + // Guard matchMedia so the DOM try/catch still writes html attributes when it is unavailable. const { attrs } = runScript(cookieFor({ color_mode: 'auto' }), { matchMedia: false }) expect(attrs['data-color-mode']).toBe('light') expect(attrs['data-color-mode-preference']).toBe('auto') @@ -211,8 +207,7 @@ describe('colorModeScript', () => { dark_theme: { name: 'dark_high_contrast', color_mode: 'dark' }, }), ) - // Resolved dark by the DAY theme, so data-dark-theme carries that, not the - // separately configured night theme. + // The resolved day theme supplies data-dark-theme, not the separately configured night theme. expect(attrs['data-color-mode']).toBe('dark') expect(attrs['data-dark-theme']).toBe('dark_dimmed') }) @@ -237,10 +232,9 @@ describe('colorModeScript', () => { expect(runScript(value, { prefersDark: true }).attrs['data-color-mode']).toBe('dark') }) + // html classifies dark themes with startsWith("dark"); Primer React checks includes("dark"). + // A theme name like high_contrast_dark would split those classifications. test('every supported theme classifies the same under both operators', () => { - // must classify a theme's lightness the same way @primer/react does - // for its own wrapper: `startsWith('dark')` here, `includes('dark')` there. - // A name like `high_contrast_dark` would split them. for (const name of Object.values(SupportedTheme)) { expect(`${name} startsWith:${name.startsWith('dark')}`).toBe( `${name} startsWith:${name.includes('dark')}`, diff --git a/src/color-schemes/tests/get-brand-color-mode.ts b/src/color-schemes/tests/get-brand-color-mode.ts index 1653ffcacc9c..13a57486370d 100644 --- a/src/color-schemes/tests/get-brand-color-mode.ts +++ b/src/color-schemes/tests/get-brand-color-mode.ts @@ -21,7 +21,7 @@ describe('getBrandColorMode', () => { }) test.each([ - // Brand has no `auto` mode, so anything not `dark` has to render light. + // Brand has no auto mode, so anything not dark renders light. ['auto', 'light'], ['nonsense', 'light'], [null, 'light'], diff --git a/src/content-linter/lib/diff-files.ts b/src/content-linter/lib/diff-files.ts index a029dcb02dca..f53773a0f7d8 100644 --- a/src/content-linter/lib/diff-files.ts +++ b/src/content-linter/lib/diff-files.ts @@ -1,24 +1,9 @@ import fs from 'fs' -// The reason we're not manually doing a spawned subprocess -// of `git diff --name-only ...` or something here is because that stuff -// is unpredictable in GitHub Actions because of how it does `git clone`. -// So we rely on environment variables instead. - +// GitHub Actions checkouts make spawned git diff output unpredictable, so use +// DIFF_FILES for a space-separated list or DIFF_FILE for a file containing that list. export function getDiffFiles(): string[] { - // Instead of testing every single file possible, if there's - // an environment variable called `DIFF_FILES` or one called - // `DIFF_FILE` then use that. - // If `DIFF_FILES` is set, it's expected to be a space separated - // string. If `DIFF_FILE` is set, it's expected to be a text file - // which contains a space separated string. const diffFiles: string[] = [] - // Setting an environment variable called `DIFF_FILES` is optional. - // But if and only if it's set, we will respect it. - // And if it set, turn it into a cleaned up Set so it's made available - // every time we use it. - // Alternatively, you can put all the files change changed into a - // text file and do `export DIFF_FILE=files-that-changed.txt` if (process.env.DIFF_FILES) { diffFiles.push(...process.env.DIFF_FILES.trim().split(/\s+/g)) } else if (process.env.DIFF_FILE) { diff --git a/src/content-linter/lib/helpers/get-lintable-yml.ts b/src/content-linter/lib/helpers/get-lintable-yml.ts index 5d47264f507a..6908dcd3955b 100755 --- a/src/content-linter/lib/helpers/get-lintable-yml.ts +++ b/src/content-linter/lib/helpers/get-lintable-yml.ts @@ -4,39 +4,19 @@ import fs from 'fs/promises' import dataSchemas from '@/data-directory/lib/data-schemas/index' import ajv from '@/tests/lib/validate-json-schema' -// AJV already has a built-in way to extract out properties -// with a specific keyword using a custom validator function. -// The intended purpose of the validator function is to perform -// validation of course, but we are overloading it here to extract -// the `lintable` properties and their parent path in the schema. +// AJV custom validators can collect data values whose schema properties use lintable. -// mdDict contains the extracted `lintable` properties -// and their parent path in the schema. -// -// For example, assuming all items in `bar` are lintable, -// in this yaml file: -// -// foo: -// bar: -// - item 1 -// - item 2 -// -// mdDict will be populated with: -// -// { '/foo/bar/0': 'item 1', '/foo/bar/1': 'item 2' } +// mdDict keeps each lintable value next to its schema instance path. +// Example: foo.bar values item 1 and item 2 become /foo/bar/0 and /foo/bar/1 entries. const mdDict = new Map() const lintableData: string[] = Object.keys(dataSchemas) -// To redefine a custom keyword, you must remove it -// then re-add it with the new definition. The default -// ajv instance defines the `lintable` keyword without -// a custom validator function. +// Remove lintable before redefining it because the shared AJV instance already defines it. ajv.removeKeyword('lintable') ajv.addKeyword({ keyword: 'lintable', type: 'string', - // For docs on defining validate see - // https://ajv.js.org/keywords.html#define-keyword-with-validate-function + // AJV validate keyword docs: https://ajv.js.org/keywords.html#define-keyword-with-validate-function validate: ( _compiled: boolean, data: string, @@ -49,17 +29,8 @@ ajv.addKeyword({ errors: false, }) -// We do want to validate the value of each `lintable` -// property when running the content linter test. -// Because we have multiple rules, we can't write a single -// validator function that will work for all `lintable` -// properties. So we extract the `lintable` properties -// out of the schema and run those values through each -// linter rule. -// We need to know how to correlate each extracted property -// back to the location in the original schema file, -// so we also need the parent path of the `lintable` -// property in the schema. +// The content linter validates lintable data values with multiple rules, so this extracts +// each value with its schema path instead of validating it inside AJV. export async function getLintableYml(dataFilePath: string): Promise | null> { const matchingDataPath = lintableData.find( (ref) => dataFilePath === ref || dataFilePath.startsWith(ref), @@ -74,16 +45,12 @@ export async function getLintableYml(dataFilePath: string): Promise, dataFilePath: string): Map { const keys = Array.from(mdDictMap.keys()) for (const key of keys) { diff --git a/src/content-linter/lib/helpers/liquid-utils.ts b/src/content-linter/lib/helpers/liquid-utils.ts index 76c43b0b5147..16bdffa3fce1 100644 --- a/src/content-linter/lib/helpers/liquid-utils.ts +++ b/src/content-linter/lib/helpers/liquid-utils.ts @@ -35,13 +35,10 @@ export function getPositionData( token: TopLevelToken, lines: string[], ): { lineNumber: number; column: number; length: number } { - // Liquid indexes are 0-based, but we want to - // covert to the system used by Markdownlint + // Liquid offsets are 0-based, but markdownlint reports 1-based positions. const begin = token.begin + 1 const end = token.end + 1 - // Account for the newline character at the end - // of each line that is not represented in the - // `lines` array + // Add one character per newline because lines exclude newline characters. const lineLengths = lines.map((line) => line.length + 1) let count = begin @@ -54,18 +51,9 @@ export function getPositionData( return { lineNumber, column: count, length: end - begin } } -/* When looking for unused Liquid `ifversion` tags, there - * are a few ways content can be updated to remove - * deprecated conditional statements. This function is - * specific to tags in a statement that are removed along - * with the content in the statement. For example: - * - * {% ifversion < 1.0 %}This is removed{% endif %} - * - * Returns an array of error objects in the format expected - * by Markdownlint: - * [ { lineNumber: 1, column: 1, deleteCount: 3, }] - */ +// ifversion statements whose tags and content are deleted together need markdownlint +// delete ranges for each touched line. +// Example: {% ifversion < 1.0 %}This is removed{% endif %}. export function getContentDeleteData( token: TopLevelToken, tokenEnd: number, @@ -74,8 +62,7 @@ export function getContentDeleteData( const { lineNumber, column } = getPositionData(token, lines) const errorInfo: Array<{ lineNumber: number; column: number; deleteCount: number }> = [] let begin = column - 1 - // Subtract one from end of next token tag. The end of the - // current tag is one position before that. + // tokenEnd is the next tag's start, except an endif uses its own end. const length = tokenEnd - token.begin if (lines[lineNumber - 1].slice(begin).length >= length) { @@ -106,14 +93,9 @@ export function getContentDeleteData( return errorInfo } -// This function returns all ifversion conditional statement tags -// and filters out any `if` conditional statements (including the -// related elsif, else, and endif tags). -// Docs doesn't use the standard `if` tag for versioning, instead the -// `ifversion` tag is used. -// Returns TagToken array since we filter to only Tag tokens +// Docs versioning reads ifversion tags, so skip regular if subtrees and case statements. export function getLiquidIfVersionTokens(content: string): TagToken[] { - // Include 'case' and 'endcase' so we can filter out `else` tags that belong to case statements + // Include case and endcase so else tags inside case statements do not look like ifversion tags. const IFVERSION_TAG_NAMES = ['if', 'ifversion', 'elsif', 'else', 'endif', 'case', 'endcase'] const tokens = getLiquidTokens(content) .filter((token): token is TagToken => token.kind === TokenKind.Tag) @@ -123,13 +105,12 @@ export function getLiquidIfVersionTokens(content: string): TagToken[] { let inCaseStatement = false const ifVersionTokens: TagToken[] = [] for (const token of tokens) { - // Filter out `if` statements and their related tags (supports nesting) + // Skip regular if statements and their related tags, including nested ones. if (token.name === 'if') { ifDepth++ continue } - // While we're inside a regular if subtree, `endif` can close either - // `if` or `ifversion`, so count nested `ifversion` tags too. + // A regular if subtree can contain ifversion tags, and endif can close either one. if (ifDepth > 0 && token.name === 'ifversion') { ifDepth++ continue @@ -139,7 +120,7 @@ export function getLiquidIfVersionTokens(content: string): TagToken[] { continue } if (ifDepth > 0) continue - // Filter out `case` statements and their related tags (including `else`) + // Skip case statements and their related tags, including else. if (token.name === 'case') { inCaseStatement = true continue @@ -155,24 +136,17 @@ export function getLiquidIfVersionTokens(content: string): TagToken[] { } export function getSimplifiedSemverRange(release: string): string { - // Liquid conditionals only use the format > or < but not - // >= or <=. Not sure exactly why. - // if startswith >, we'll check to see if the release number - // is in the deprecated list, meaning the > case can be removed - // or changed to '*'. + // Liquid conditionals use > and <, so only the lower bound needs deprecation checks. const releaseStrings = release.split(' ') const releaseToCheckIndex = releaseStrings.indexOf('>') + 1 const releaseToCheck = releaseStrings[releaseToCheckIndex] - // If the release is not part of a range and the release number - // is deprecated, return '*' to indicate all ghes releases. + // A deprecated single lower bound covers all GHES releases, so return *. if (deprecated.includes(releaseToCheck) && releaseStrings.length === 2) { return '*' } - // When the release is a range and the lower range (e.g., `ghes > 3.12`) - // is now deprecated, return an empty string. - // Otherwise, return the release as-is. + // If the lower bound in a range, such as ghes > 3.12, is deprecated, remove it. const newRelease = deprecated.includes(releaseToCheck) ? release.replace(`> ${releaseToCheck}`, '') : release diff --git a/src/content-linter/lib/helpers/print-annotations.ts b/src/content-linter/lib/helpers/print-annotations.ts index 0076e2d784cb..01740144a75e 100644 --- a/src/content-linter/lib/helpers/print-annotations.ts +++ b/src/content-linter/lib/helpers/print-annotations.ts @@ -1,6 +1,4 @@ -// Meant to be used by the code that runs the linter, but only within Actions -// workflows. When it works, it posts all the annotations as inline comments -// on the pull request. +// GitHub Actions workflows parse these strings into pull request annotations. interface LintFlaw { ruleNames: string[] @@ -12,6 +10,8 @@ interface LintFlaw { [key: string]: unknown } +// Annotations also accept endLine to group one error across consecutive lines: +// https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message export function printAnnotationResults( results: Record, { @@ -32,10 +32,6 @@ export function printAnnotationResults( const bits = [`file=${file}`] if (flaw.lineNumber) { bits.push(`line=${flaw.lineNumber}`) - // Note: it's possible to use a endLine property - // if you can "lump" together the same error description on - // consecutive lines. - // See https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-error-message } if (flaw.ruleDescription) { @@ -54,9 +50,7 @@ export function printAnnotationResults( annotation += ` ${flaw.context}` } - // Why console.log and not `core.error()` (from @actions/core)? - // Because, this way you can debug this more easily on your own - // terminal. + // Logging the annotation string keeps local debugging independent of @actions/core. console.log(annotation) } } diff --git a/src/content-linter/lib/helpers/rule-utils.ts b/src/content-linter/lib/helpers/rule-utils.ts index 99d42c902d29..89281cf944e9 100644 --- a/src/content-linter/lib/helpers/rule-utils.ts +++ b/src/content-linter/lib/helpers/rule-utils.ts @@ -4,13 +4,10 @@ interface LintFlaw { errorDetail?: string } -/** - * Gets all rule names from a flaw, including sub-rules from search-replace errors - */ +// Search-replace errors encode sub-rule names in errorDetail. export function getAllRuleNames(flaw: LintFlaw): string[] { const ruleNames = [...flaw.ruleNames] - // Extract sub-rule name from search-replace error details if (flaw.ruleNames.includes('search-replace') && flaw.errorDetail) { const match = flaw.errorDetail.match(/^([^:]+):/) if (match) { diff --git a/src/content-linter/lib/helpers/utils.ts b/src/content-linter/lib/helpers/utils.ts index 3e051e20d28d..6240f7e53759 100644 --- a/src/content-linter/lib/helpers/utils.ts +++ b/src/content-linter/lib/helpers/utils.ts @@ -3,15 +3,14 @@ import matter from '@gr2m/gray-matter' import type { RuleParams, RuleErrorCallback, MarkdownToken } from '@/content-linter/types' -// Adds an error object with details conditionally via the onError callback export function addFixErrorDetail( onError: RuleErrorCallback, lineNumber: number, expected: string, actual: string, - // Using flexible type to accommodate different range formats from various linting rules + // Accept the range shapes emitted by different linting rules. range: [number, number] | number[] | null, - // Using unknown for fixInfo as markdownlint-rule-helpers accepts various fix info structures + // markdownlint-rule-helpers accepts several fix info shapes. fixInfo: unknown, ): void { addError(onError, lineNumber, `Expected: ${expected}`, ` Actual: ${actual}`, range, fixInfo) @@ -31,8 +30,7 @@ export function forEachInlineChild( export function getRange(line: string, content: string): [number, number] | null { if (content.length === 0) { - // This function assumes that the content is something. If it's an - // empty string it can never produce a valid range. + // Empty content cannot produce a valid markdownlint range. throw new Error('invalid content (empty)') } const startColumnIndex = line.indexOf(content) @@ -40,16 +38,12 @@ export function getRange(line: string, content: string): [number, number] | null } export function isStringQuoted(text: string): boolean { - // String starts with either a single or double quote - // ends with either a single or double quote - // and optionally ends with a question mark or exclamation point - // because that punctuation can exist outside of the quoted string + // Match quotes around the full string, with optional ? or ! outside the quote. return /^['"].*['"][?!]?$/.test(text) } export function isStringPunctuated(text: string): boolean { - // String ends with a period, question mark, or exclamation point, optionally - // followed by a single or double quote. + // Match sentence punctuation with an optional closing quote. return /^.*[.?!]['"]?$/.test(text) } @@ -63,15 +57,11 @@ export function quotePrecedesLinkOpen(text: string | undefined): boolean { return text.endsWith('"') || text.endsWith("'") } -// Lines is an array of strings read from a -// Markdown file a split around new lines. -// This is the format we get from Markdownlint. -// Returns null if the lines do not contain frontmatter properties. +// markdownlint passes files as line arrays, and gray-matter needs a string. export function getFrontmatter(lines: string[]): Record | null { const fmString = lines.join('\n') const { data } = matter(fmString) - // If there is no frontmatter or the frontmatter contains - // no keys, matter will return an empty object. + // gray-matter returns an empty object when frontmatter is absent or empty. if (Object.keys(data).length === 0) return null return data } diff --git a/src/content-linter/scripts/disable-rules.ts b/src/content-linter/scripts/disable-rules.ts index 9d07356915ce..a9d84d4a30d1 100755 --- a/src/content-linter/scripts/disable-rules.ts +++ b/src/content-linter/scripts/disable-rules.ts @@ -1,10 +1,5 @@ -// Disables markdownlint rules in markdown files with same-line comments. This is -// useful when introducing a new rule that causes many failures. The comments -// can be fixed and removed while updating the file later. -// -// Usage: -// -// src/content-linter/scripts/disable-rules.ts no-generic-link-text +// Add same-line markdownlint disables when a new rule creates many failures. +// Run as src/content-linter/scripts/disable-rules.ts no-generic-link-text. import fs from 'fs' import { spawn } from 'child_process' @@ -19,7 +14,6 @@ if (process.argv[3] === '--verbose' || process.argv[3] === '-v') { verbose = true } -// Cleanup from previous run if (fs.existsSync('markdown-violations.json')) { fs.unlinkSync('markdown-violations.json') } diff --git a/src/content-linter/scripts/find-unsed-variables.ts b/src/content-linter/scripts/find-unsed-variables.ts index d12abb38a519..ee50cdb8d74d 100644 --- a/src/content-linter/scripts/find-unsed-variables.ts +++ b/src/content-linter/scripts/find-unsed-variables.ts @@ -1,21 +1,11 @@ -/** - * @purpose Writer tool - * @description Look for mentions of variables in Liquid syntax across all pages - * - * For example, - * - * --- - * title: '{% data variables.product.prodname_mobile %} is cool' - * shortTitle: '{% data variables.product.prodname_mobile %}' - * --- - * - * This also mentions {% data variables.product.prodname_ios %} - * - * So in this case, we *know* that `prodname_mobile` and - * `prodname_ios` inside `data/variables/product.yml` is definitely used. - * So that variable won't be mentioned as unused. - * - */ +// @purpose Writer tool +// @description Look for mentions of variables in Liquid syntax across all pages +// +// Liquid references in content, reusables, and title, shortTitle, or intro frontmatter mark +// data variables as used; other frontmatter fields are not scanned. +// For example, {% data variables.product.prodname_mobile %} in title or +// {% data variables.product.prodname_ios %} in content keeps data/variables/product.yml keys +// out of the unused report. import fs from 'fs' import { load } from 'js-yaml' diff --git a/src/content-linter/scripts/generate-docs.ts b/src/content-linter/scripts/generate-docs.ts index c70ced85f2ba..7e6e37a97203 100644 --- a/src/content-linter/scripts/generate-docs.ts +++ b/src/content-linter/scripts/generate-docs.ts @@ -48,7 +48,6 @@ function main() { ghRules.sort((a, b) => a.ruleId.localeCompare(b.ruleId)) ghdRules.sort((a, b) => a.ruleId.localeCompare(b.ruleId)) - // Add rules in order: MD rules, then GH rules, then GHD rules, then search-replace rules for (const { row } of mdRules) { markdown.push(row) } diff --git a/src/content-linter/scripts/lint-content.ts b/src/content-linter/scripts/lint-content.ts index b58caf6b09bb..2add4134be37 100755 --- a/src/content-linter/scripts/lint-content.ts +++ b/src/content-linter/scripts/lint-content.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Run the Docs content linter, specifying paths and optional rules - */ +// @purpose Writer tool +// @description Run the Docs content linter, specifying paths and optional rules import fs from 'fs' import path from 'path' import { execSync } from 'child_process' @@ -81,13 +79,13 @@ interface FormattedResult { errorContext?: string context?: string fixable?: boolean - // Index signature allows additional properties from LintError that may vary by rule + // Individual lint rules can add their own result properties. [key: string]: unknown } type FormattedResults = Record -// Config that applies to all rules in all environments (CI, reports, precommit). +// Applies to all rules in CI, reports, and precommit. export const globalConfig = { excludePaths: ['content/contributing/', 'data/llms-txt/'], } @@ -138,16 +136,16 @@ const { const ALL_CONTENT_DIR = ['content', 'data'] +// main casts local LintError values before applyFixes because markdownlint types the same +// fields as non-null, and applyFixes only reads lineNumber and fixInfo. main() async function main() { if (!isOptionsValid()) return - // Get the updated paths after validation (invalid paths will have been filtered out) const validatedPaths = program.opts().paths - // With no paths and no --summary-by-rule, fall back to the files changed - // in the local git checkout. + // With no paths and no --summary-by-rule, lint files changed in the local checkout. const files = getFilesToLint( (summaryByRule && ALL_CONTENT_DIR) || validatedPaths || getChangedFiles(), ) @@ -168,20 +166,17 @@ async function main() { const { config, configuredRules } = getMarkdownLintConfig(errorsOnly, rules) - // Run Markdownlint for content directory const resultContent = (await markdownlint.promises.markdownlint({ files: files.content, config: config.content, customRules: configuredRules.content, })) as LintResults - // Run Markdownlint for data directory const resultData = (await markdownlint.promises.markdownlint({ files: files.data, config: config.data, customRules: configuredRules.data, })) as LintResults - // Run Markdownlint for content directory (frontmatter only) const resultFrontmatter = await markdownlint.promises.markdownlint({ frontMatter: null, files: files.content, @@ -189,7 +184,6 @@ async function main() { customRules: configuredRules.frontMatter, }) - // Run Markdownlint on "lintable" Markdown strings in a YML file const resultYml: LintResults = {} for (const ymlFile of files.yml) { const lintableYml = await getLintableYml(ymlFile) @@ -204,9 +198,7 @@ async function main() { for (const [key, value] of Object.entries(resultYmlFile)) { if ((value as LintError[]).length) { const errors = (value as LintError[]).map((error) => { - // Autofixing would require us to write the changes back to the YML - // file which Markdownlint doesn't support. So we don't support - // autofixing for YML files at this time. + // markdownlint cannot write fixes back into lintable YAML strings. if (error.fixInfo) delete error.fixInfo error.isYamlFile = true return error @@ -216,13 +208,10 @@ async function main() { } } - // There are no collisions when assigning the results to the new object - // because the keys are filepaths and the individual runs of Markdownlint - // are in separate directories (content and data). + // Content and data paths cannot collide because they live in separate directories. const results: LintResults = Object.assign({}, resultContent, resultData, resultYml) - // Merge in the results for frontmatter tests, which could be - // in a file that already exists as a key in the `results` object. + // Frontmatter results can share file keys with content results. for (const [key, value] of Object.entries(resultFrontmatter)) { if (results[key]) results[key].push(...(value as LintError[])) else results[key] = value as LintError[] @@ -236,9 +225,6 @@ async function main() { continue } const content = fs.readFileSync(file, 'utf8') - // The local LintError type intentionally allows null for fields that - // markdownlint types as non-null, so cast to markdownlint's own type at - // this boundary. applyFixes only reads lineNumber and fixInfo. const applied = applyFixes(content, results[file] as unknown as MarkdownlintLintError[]) if (content !== applied) { countFixedFiles++ @@ -247,16 +233,13 @@ async function main() { } } - // The results don't yet contain severity information and are - // in the format received directly from Markdownlint. + // markdownlint results need repo-specific severity before output. const formattedResults = getFormattedResults(results, isPrecommit) - // If we applied fixes, it's important that we don't count those that - // might now be entirely fixed. + // When --fix runs, ignore files whose remaining issues were fully fixed. const errorFileCount = getErrorCountByFile(formattedResults, fix) const warningFileCount = getWarningCountByFile(formattedResults, fix) - // Used for a temporary way to allow us to see how many errors currently - // exist for each rule in the content directory. + // summaryByRule helps decide which warning rules can become errors. if (summaryByRule && (errorFileCount > 0 || warningFileCount > 0 || countFixedFiles > 0)) { reportSummaryByRule(results, config) } else if (errorFileCount > 0 || warningFileCount > 0 || countFixedFiles > 0) { @@ -274,17 +257,13 @@ async function main() { printAnnotationResults(formattedResults, { skippableRules: [], skippableFlawProperties: [ - // As of Feb 2024, we don't support reporting flaws for lines - // and columns numbers of YAML files. YAML files consist of one - // or more Markdown strings that can themselves constitute an - // entire "file." + // YAML lint strings can span a whole virtual file, so line and column data misleads. 'isYamlFile' as string, ] as string[], }) } const end = Date.now() - // Ensure previous console logging is not truncated console.log('\n') const took = end - start if (warningFileCount > 0 || errorFileCount > 0) { @@ -319,7 +298,7 @@ async function main() { if (isPrecommit) { if (errorFileCount) { - console.log('') // Just for some whitespace before the box message + console.log('') console.log( boxen( 'GIT COMMIT IS ABORTED. Please fix the errors before committing.\n\n' + @@ -338,7 +317,7 @@ async function main() { .filter(([, fileResults]) => fileResults.some((flaw) => flaw.fixable)) .map(([file]) => file) if (fixableFiles.length) { - console.log('') // Just for some whitespace before the next message + console.log('') console.log( `Content linting found ${fixableFiles.length} ${pluralize(fixableFiles, 'file')} ` + 'that can be automatically fixed.\nTo apply the fixes run this command and re-add the changed files:\n', @@ -359,7 +338,6 @@ async function main() { } } -// Using unknown[] to accept arrays of any type (errors, warnings, files, etc.) function pluralize( things: unknown[] | number, word: string, @@ -372,14 +350,8 @@ function pluralize( return word } -// Parse filepaths and directories, only allowing -// Markdown file types for now. Snippets of Markdown -// in .yml files that are defined as `lintable` in -// their associated JSON schema are also linted. -// Certain rules cannot run on data files or yml -// (e.g., heading linters) so we need to separate the -// list of data files from all other files to run -// through markdownlint individually +// getFilesToLint separates content Markdown, data Markdown, and lintable YAML because +// each group gets different markdownlint rules. function getFilesToLint(inputPaths: string[]): FileList { const fileList: FileList = { length: 0, @@ -391,8 +363,7 @@ function getFilesToLint(inputPaths: string[]): FileList { const root = path.resolve(languages.en.dir) const contentDir = path.join(root, 'content') const dataDir = path.join(root, 'data') - // The path passed to Markdownlint is what is displayed in the error report, - // so normalize it and make it relative if it's absolute. + // markdownlint reports the path it receives, so pass repo-relative paths. for (const rawPath of inputPaths) { const absPath = path.resolve(rawPath) if (fs.statSync(rawPath).isDirectory()) { @@ -412,9 +383,7 @@ function getFilesToLint(inputPaths: string[]): FileList { fileList.data.push(absPath) } } - // If it's a file but it's not part of the content or the data - // directory, it's probably a file passed in by computing changed files - // from the git diff. + // Changed-file lists can include code, so ignore paths outside content and data. } } @@ -451,38 +420,22 @@ function getFilesToLint(inputPaths: string[]): FileList { return fileList } -/** - * Return true if a directory is or is a sub-directory of a parent. - * For example: - * - * isInDir('/foo/bar', '/foo') => true - * isInDir('/foo/some-sub-directory', '/foo') => true - * isInDir('/foo/some-file.txt', '/foo') => true - * isInDir('/foo', '/foo') => true - * isInDir('/foo/barring', '/foo/bar') => false - */ +// Match path segments, so /foo/bar matches /foo but /foo/barring does not match /foo/bar. function isInDir(child: string, parent: string): boolean { - // The simple reason why you can't use `parent.startsWith(child)` - // is because the parent might be `/path/to/data` and the child - // might be `/path/to/data-files`. const parentSplit = parent.split(path.sep) const childSplit = child.split(path.sep) return parentSplit.every((dir: string, i: number) => dir === childSplit[i]) } -// This is a function used during development to -// see how many errors we have per rule. This helps -// to identify rules that can be upgraded from -// warning severity to error. +// reportSummaryByRule helps identify warning rules that can become errors. function reportSummaryByRule(results: LintResults, config: LintConfig): void { const ruleCount: Record = {} - // populate the list of rules with 0 occurrences for (const rule of Object.keys(config.content)) { if ((config.content[rule] as { severity?: string }).severity === 'error') continue ruleCount[rule] = 0 } - // the default property is not actually a rule + // default is a config key, not a rule name. delete ruleCount.default for (const key of Object.keys(results)) { @@ -497,16 +450,14 @@ function reportSummaryByRule(results: LintResults, config: LintConfig): void { } } -// Filter out the files with one or more results and format each result. -// Results are sorted by severity per file, with errors listed first then -// warnings. +// Keep only files with results, then list errors before warnings in each file. function getFormattedResults( allResults: LintResults, isInPrecommitMode: boolean, ): FormattedResults { const output: FormattedResults = {} const filteredResults = Object.entries(allResults) - // Each result key always has an array value, but it may be empty + // Empty result arrays would print blank file sections in verbose output. .filter(([, results]) => results.length) for (const [key, fileResults] of filteredResults) { if (verbose) { @@ -544,8 +495,7 @@ function getCountBySeverity( ): number { return Object.values(results).filter((fileResults: FormattedResult[]) => fileResults.some((result: FormattedResult) => { - // If --fix was applied, we don't want to know about files that - // no longer have errors or warnings. + // After --fix, ignore files whose errors or warnings disappeared. return result.severity === severityLookup && (!fixed || !result.fixable) }), ).length @@ -559,10 +509,7 @@ function formatResult(object: LintError, isInPrecommitMode: boolean): FormattedR const ruleName = object.ruleNames[1] || object.ruleNames[0] const ruleConfig = allConfig[ruleName] as Config | undefined - // Skip rules that aren't in our config. This can happen when using - // / comments - // without specifying rule names, which re-enables ALL markdownlint rules - // including ones we don't use (like line-length/MD013). + // Bare markdownlint-enable comments can re-enable unconfigured rules, such as MD013. if (!ruleConfig) { return null } @@ -588,7 +535,6 @@ function formatResult(object: LintError, isInPrecommitMode: boolean): FormattedR }, formattedResult) } -// Get a list of changed and staged files in the local git repo function getChangedFiles() { const changedFiles = execSync(`git diff --diff-filter=d --name-only`) .toString() @@ -603,8 +549,7 @@ function getChangedFiles() { return [...changedFiles, ...stagedFiles] } -// Summarizes the list of rules we have available to run with their -// short name, long name, and description. +// listRules prints short names, long names, and descriptions for CLI help. function listRules() { let ruleList = '' for (const rule of allRules) { @@ -614,9 +559,7 @@ function listRules() { return ruleList } -// Some rules can't be run on data files, since those Markdown files are -// partials included in full Markdown files. Those rules have the property -// `partial-markdown-files` set to false. +// Data Markdown files are partials, so rules with partial-markdown-files false skip them. function getMarkdownLintConfig( filterErrorsOnly: boolean, runRules: string[] | undefined, @@ -638,8 +581,7 @@ function getMarkdownLintConfig( const customRule = (customConfig as Record)[ruleName] ? (getCustomRule(ruleName) as MarkdownlintRule) : undefined - // search-replace is handled differently than other rules because - // it has nested metadata and rules. + // search-replace has nested metadata and pseudo-rules. if ( filterErrorsOnly && getSeverity(ruleConfig, isPrecommit) !== 'error' && @@ -650,7 +592,6 @@ function getMarkdownLintConfig( if (runRules && !shouldIncludeRule(ruleName, runRules)) continue - // There are a subset of rules run on just the frontmatter in files if ((githubDocsFrontmatterConfig as Record)[ruleName]) { config.frontMatter[ruleName] = ruleConfig if (customRule) configuredRules.frontMatter.push(customRule) @@ -665,9 +606,7 @@ function getMarkdownLintConfig( for (const searchRule of ruleConfig.rules) { const searchRuleSeverity = getSeverity(searchRule, isPrecommit) if (filterErrorsOnly && searchRuleSeverity !== 'error') continue - // The frontmatter pass lints the whole file, so a rule with - // applyToFrontmatter must run there and nowhere else, or every match - // gets reported twice. + // applyToFrontmatter runs only in the frontmatter pass, or every match reports twice. if (searchRule.applyToFrontmatter) { frontmatterSearchReplaceRules.push(searchRule) } else { @@ -714,17 +653,13 @@ function getMarkdownLintConfig( return { config, configuredRules } } -// Return the severity value of a rule but keep in mind it could be -// running as a precommit hook, which means the severity could be -// deliberately different. +// Precommit can lower or raise a rule's normal severity. function getSeverity(ruleConfig: Config, isInPrecommitMode: boolean): string { return isInPrecommitMode ? ruleConfig.precommitSeverity || ruleConfig.severity : ruleConfig.severity } -// Gets a custom rule function from the name of the rule -// in the configuration file function getCustomRule(ruleName: string): Rule | MarkdownlintRule { const rule = customRules.find((r) => r.names.includes(ruleName)) if (!rule) @@ -734,20 +669,17 @@ function getCustomRule(ruleName: string): Rule | MarkdownlintRule { return rule } -// Check if a rule should be included based on user-specified rules -// Handles both short names (e.g., GHD047, MD001) and long names (e.g., table-column-integrity, heading-increment) +// Accept both short rule IDs and long rule names. export function shouldIncludeRule(ruleName: string, runRules: string[]) { if (runRules.includes(ruleName)) { return true } - // For custom rules, check if any of the rule's names (short or long) are in the runRules list const customRule = customRules.find((rule) => rule.names.includes(ruleName)) if (customRule) { return customRule.names.some((name) => runRules.includes(name)) } - // For built-in markdownlint rules, check if any of the rule's names are in the runRules list const builtinRule = allRules.find((rule) => rule.names.includes(ruleName)) if (builtinRule) { return builtinRule.names.some((name: string) => runRules.includes(name)) @@ -756,24 +688,8 @@ export function shouldIncludeRule(ruleName: string, runRules: string[]) { return false } -/* - The severity of the search-replace custom rule is embedded in - each individual search rule. This function returns the severity - of the individual search rule. The name we define for each search - rule shows up in the errorDetail property of the error object. - The error object returned from Markdownlint has the following structure: - - { - lineNumber: 266, - ruleNames: [ 'search-replace' ], - ruleDescription: 'Custom rule', - ruleInformation: 'https://github.com/OnkarRuikar/markdownlint-rule-search-replace', - errorDetail: 'docs-domain: Catch occurrences of docs.github.com domain.', - errorContext: "column: 21 text:'docs.github.com'", - errorRange: [ 21, 15 ], - fixInfo: null - } -*/ +// markdownlint-rule-search-replace stores the pseudo-rule name before the colon in +// errorDetail, for example "docs-domain: Catch occurrences of docs.github.com domain." function getSearchReplaceRuleSeverity( ruleName: string, object: LintError, @@ -782,26 +698,25 @@ function getSearchReplaceRuleSeverity( const pluginRuleName = object.errorDetail?.split(':')[0].trim() const ruleConfig = allConfig[ruleName] as Config const rule = ruleConfig.rules?.find((r) => r.name === pluginRuleName) - if (!rule) return 'error' // Default to error if rule not found + if (!rule) return 'error' // Unknown search-replace sub-rules default to error severity. return isInPrecommitMode ? rule.precommitSeverity || rule.severity : rule.severity } function isOptionsValid() { - // paths should only contain existing files and directories const optionPaths = program.opts().paths || [] const validPaths = [] for (const filePath of optionPaths) { try { fs.statSync(filePath) - validPaths.push(filePath) // Keep track of valid paths + validPaths.push(filePath) } catch { if ('paths'.includes(filePath)) { console.warn('warning: did you mean --paths') } else { console.warn(`warning: the value '${filePath}' was not found. Skipping this path.`) } - // Keep going: one bad path should not abandon the rest. + // Keep going so one bad path does not abandon the rest. } } @@ -809,7 +724,6 @@ function isOptionsValid() { program.setOptionValue('paths', validPaths) } - // rules should only contain existing, correctly spelled rules const allRulesList = [...allRules.map((rule) => rule.names).flat(), ...Object.keys(allConfig)] const optionRules = program.opts().rules || [] for (const ruleName of optionRules) { @@ -825,7 +739,7 @@ function isOptionsValid() { } } - // Only return false if paths were specified but none are valid + // Bad paths fail only when none of the requested paths exist. return optionPaths.length === 0 || validPaths.length > 0 } diff --git a/src/content-linter/scripts/lint-report.ts b/src/content-linter/scripts/lint-report.ts index f77bde4d3df0..ada3d2079a16 100644 --- a/src/content-linter/scripts/lint-report.ts +++ b/src/content-linter/scripts/lint-report.ts @@ -7,7 +7,7 @@ import { getEnvInputs } from '@/workflows/get-env-inputs' import { createReportIssue, linkReports } from '@/workflows/issue-report' import { getAllRuleNames } from '@/content-linter/lib/helpers/rule-utils' -// GitHub issue body size limit is ~65k characters, so we'll use 60k as a safe limit +// GitHub issue bodies max out near 65k characters, so reports stop at 60k. const MAX_ISSUE_BODY_SIZE = 60000 // If the number of warnings exceeds this number, print a warning so we can give them attention @@ -33,7 +33,6 @@ function shouldIncludeInReport(flaw: LintFlaw): boolean { return true } - // Check if any rule name is in the include list that overrides severity const hasIncludedRule = allRuleNames.some((ruleName: string) => reportingConfig.includeRules.includes(ruleName), ) @@ -44,19 +43,8 @@ function shouldIncludeInReport(flaw: LintFlaw): boolean { return false } -// [start-readme] -// -// This script runs once a week via a scheduled GitHub Action to lint -// the entire content and data directories based on our -// markdownlint.js rules. -// -// If errors or warnings are found, it will open up a new issue in the -// docs-content repo with the label "broken content markdown report". -// -// The Content FR will go through the issue and update the content and -// data files accordingly. -// -// [end-readme] +// The weekly report turns content and data lint results into a docs-content issue for +// Content FR. program .description( @@ -77,15 +65,13 @@ async function main() { const { REPORT_REPOSITORY, REPORT_AUTHOR, REPORT_LABEL } = process.env const octokit = github() - // `GITHUB_TOKEN` is optional. If you need the token to post a comment - // or open an issue report, you might get cryptic error messages from Octokit. + // Validate GITHUB_TOKEN early because Octokit auth errors are cryptic. getEnvInputs(['GITHUB_TOKEN']) core.info(`Creating issue for configured lint rules...`) const parsedResults = JSON.parse(lintResults) - // Keep track of warnings so we can print an alert when they exceed a manageable number let totalWarnings = 0 const filteredResults: Record = {} diff --git a/src/content-linter/scripts/pretty-print-results.ts b/src/content-linter/scripts/pretty-print-results.ts index 1bc20181b91f..5e319f14fbf3 100644 --- a/src/content-linter/scripts/pretty-print-results.ts +++ b/src/content-linter/scripts/pretty-print-results.ts @@ -39,8 +39,7 @@ export function prettyPrintResults( console.log(chalk.bold(file)) console.log('') - // It's very possible that the same file has multiple flaws of the - // same rule but on different line numbers. + // Keep repeated rule failures together without losing line-number order within each group. const sorted = [...flaws] .sort((a, b) => a.lineNumber - b.lineNumber) .sort((a, b) => a.ruleDescription.localeCompare(b.ruleDescription)) @@ -160,7 +159,7 @@ function chalkFunColors(text: string): string { function indentWrappedString(str: string, startingIndent: number): string { const NEW_LINE_PADDING = ' '.repeat(16) - const width = process.stdout.columns || 80 // Use terminal width, default to 80 if not available + const width = process.stdout.columns || 80 // Default to 80 columns when stdout is not a TTY. let indentedString = '' let currentLine = '' let isFirstLine = true diff --git a/src/content-linter/style/github-docs.ts b/src/content-linter/style/github-docs.ts index 2c6a8e89809b..9f1fe8edb959 100644 --- a/src/content-linter/style/github-docs.ts +++ b/src/content-linter/style/github-docs.ts @@ -162,7 +162,7 @@ const githubDocsConfig = { 'partial-markdown-files': true, 'yml-files': true, }, - // GHD044 removed - octicon aria-labels are now auto-generated + // GHD044 stays unused because octicon aria-labels are auto-generated. 'code-annotation-comment-spacing': { // GHD045 severity: 'error', @@ -315,8 +315,7 @@ export const githubDocsFrontmatterConfig = { }, } -// Configures rules from the `github/markdownlint-github` repo -// created by the accessibility team. +// Rules from github/markdownlint-github come from the accessibility team. const githubMarkdownlintConfig = { 'no-default-alt-text': { severity: 'error', @@ -330,8 +329,7 @@ const githubMarkdownlintConfig = { }, } -// Configures rules from the open-source Markdownlint extension -// search-replace: +// search-replace rule docs: // https://www.npmjs.com/package/markdownlint-rule-search-replace export const searchReplaceConfig = { 'search-replace': { @@ -345,7 +343,7 @@ export const searchReplaceConfig = { precommitSeverity: 'warning', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Critical for content quality - prevents placeholders in titles, intros, etc. + applyToFrontmatter: true, // Catch placeholders in titles, intros, and similar metadata. }, { name: 'docs-domain', @@ -355,7 +353,7 @@ export const searchReplaceConfig = { severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Should not appear in frontmatter + applyToFrontmatter: true, // Catch this domain in frontmatter. }, { name: 'help-domain', @@ -365,25 +363,21 @@ export const searchReplaceConfig = { severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Should not appear in frontmatter + applyToFrontmatter: true, // Catch this domain in frontmatter. }, { name: 'developer-domain', message: 'Catch occurrences of developer.github.com domain.', - // Do not match developer.github.com/changes or - // developer.github.com/enterprise/[0-9] or - // developer.github.com/enterprise/{{something}} (e.g. liquid). - // There are occurrences that will likely always remain in the content. + // Allow /changes, /enterprise/3.17, and /enterprise/{{ currentVersion }} paths. searchPattern: '/developer\\.github\\.com(?!\\/(changes|enterprise\\/([0-9]|{))).*/g', searchScope: 'all', severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Should not appear in frontmatter + applyToFrontmatter: true, // Catch this domain in frontmatter. }, { - // Catches usage of old liquid data reusable syntax. For example: - // {{ site.data.variables.product_releases }} + // Catches deprecated site.data syntax, such as {{ site.data.variables.product_releases }}. name: 'deprecated liquid syntax: site.data', message: 'Catch occurrences of deprecated liquid data syntax.', searchPattern: '/{{\\s*?site\\.data\\.([a-zA-Z0-9-_]+(?:\\.[a-zA-Z0-9-_]+)+)\\s*?}}/g', @@ -391,12 +385,10 @@ export const searchReplaceConfig = { severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Can appear in frontmatter strings + applyToFrontmatter: true, // Can appear in frontmatter strings. }, { - // Catches usage of old octicon variable syntax. For example: - // - {{ octicon-plus }} - // - {{ octicon-plus An example label }} + // Catches octicon- syntax, such as {{ octicon-plus An example label }}. name: 'deprecated liquid syntax: octicon-', message: 'The octicon liquid syntax used is deprecated. Use this format instead `octicon "" aria-label=""`', @@ -404,7 +396,7 @@ export const searchReplaceConfig = { severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Can appear in frontmatter strings + applyToFrontmatter: true, // Can appear in frontmatter strings. }, ], }, diff --git a/src/content-linter/tests/category-pages.ts b/src/content-linter/tests/category-pages.ts index e12311db0328..7a81b6e21783 100644 --- a/src/content-linter/tests/category-pages.ts +++ b/src/content-linter/tests/category-pages.ts @@ -43,35 +43,28 @@ describe.skip('category pages', () => { const productIndices = walk(contentDir, walkOptions) const productNames = productIndices.map((index) => path.basename(path.dirname(index))) - // Combine those to fit vitest's `.each` usage const productTuples = zip(productNames, productIndices) as [string, string][] - // Use a regular for...of loop to generate the `describe(...)` blocks - // otherwise, if one of them has no categories, the tests will fail. + // describe.each fails when a product has no categories, so generate describes imperatively. for (const tuple of productTuples) { const [, productIndex] = tuple const productDir = path.dirname(productIndex) - // Get links included in product index page. - // Each link corresponds to a product subdirectory (category). - // Example: "getting-started-with-github" - // Note: We need to read this synchronously here because vitest's describe.each - // can't asynchronously define tests + // Vitest must define describe.each cases synchronously. + // Children include category slugs such as getting-started-with-github. const contents = fs.readFileSync(productIndex, 'utf8') const data = getFrontmatterData(contents) const children: string[] = data.children const categoryLinks = children - // Only include category directories, not standalone category files like content/actions/quickstart.md + // Skip standalone category files such as content/actions/quickstart.md. .filter((link) => fs.existsSync(getPath(productDir, link, 'index'))) const categoryPaths = categoryLinks.map((link) => getPath(productDir, link, 'index')) - // Make them relative for nicer display in test names const categoryRelativePaths = categoryPaths.map((p) => path.relative(contentDir, p)) - // Combine those to fit vitest's `.each` usage const categoryTuples = zip(categoryRelativePaths, categoryPaths, categoryLinks) as [ string, string, @@ -95,7 +88,6 @@ describe.skip('category pages', () => { beforeAll(async () => { const categoryDir = path.dirname(indexAbsPath) - // Get child article links included in each subdir's index page const indexContents = await fs.promises.readFile(indexAbsPath, 'utf8') const parsed = matter(indexContents) if (!parsed.data) throw new Error('No frontmatter') @@ -123,7 +115,6 @@ describe.skip('category pages', () => { const productIndexContents = await fs.promises.readFile(productIndex, 'utf8') const productIndexData = getFrontmatterData(productIndexContents) - // Save the index title for later testing indexTitle = productIndexData.title.includes('{') ? await renderContent(productIndexData.title, req.context, { textOnly: true }) : productIndexData.title @@ -143,7 +134,7 @@ describe.skip('category pages', () => { const articleContents = await fs.promises.readFile(articlePath, 'utf8') const articleData = getFrontmatterData(articleContents) - // Do not include subcategories nor hidden pages in list of published articles + // Published article lists omit subcategories and hidden pages. if (articleData.subcategory || articleData.hidden) return null // ".../content/github/{category}/{article}.md" => "/{article}" @@ -164,7 +155,7 @@ describe.skip('category pages', () => { const articleContents = await fs.promises.readFile(articlePath, 'utf8') const availableArticleData = getFrontmatterData(articleContents) - // Do not include subcategories nor hidden pages in list of available articles + // Available article lists omit subcategories and hidden pages. if (availableArticleData.subcategory || availableArticleData.hidden) return null // ".../content/github/{category}/{article}.md" => "/{article}" @@ -234,8 +225,7 @@ describe.skip('category pages', () => { }) function getPath(productDir: string, link: string, filename: string) { - // Handle absolute /content/ paths for cross-product children - // The link parameter contains the child path from frontmatter + // Absolute /content/ links resolve from contentDir instead of productDir. if (link.startsWith('/content/')) { const absolutePath = link.slice('/content/'.length) if (filename === 'index') { diff --git a/src/content-linter/tests/integration/lint-cli.ts b/src/content-linter/tests/integration/lint-cli.ts index bbc7a701c9c5..b92fe79d9efb 100644 --- a/src/content-linter/tests/integration/lint-cli.ts +++ b/src/content-linter/tests/integration/lint-cli.ts @@ -1,9 +1,6 @@ -// End-to-end tests for the lint-content script, run via npm and checked by -// their output. They cover argument parsing, file discovery, rule filtering, -// and exit codes. -// -// Test files are written to content/test-integration/ because the linter only -// processes files under content/ or data/. +// End-to-end lint-content tests run through npm, so they cover argument parsing, +// file discovery, rule filtering, and exit codes. +// Test files live under content/test-integration/ so these cases exercise content-root inputs. import { execSync } from 'child_process' import { beforeEach, afterEach, describe, test, expect } from 'vitest' @@ -61,7 +58,7 @@ TODOCS This placeholder should definitely be detected. const { output, exitCode } = await runLinter(`--paths "${testFile}" --rules search-replace`) - // This MUST work - if it doesn't, the linter is completely broken + // This failure means lint-content did not detect the fixture error. expect(exitCode).toBe(1) expect(output).toContain('todocs-placeholder') expect(output).toContain('ERROR') @@ -70,8 +67,7 @@ TODOCS This placeholder should definitely be detected. describe('Default linter behavior', () => { test('should verify default rule execution behavior', async () => { - // This test verifies that all rules run by default when no --rules are specified - // It serves as regression protection against the TODOCS bug where no rules would run + // Guards against the TODOCS regression where default runs skipped all rules. const testFile = path.join(testContentDir, 'default-behavior-test.md') const testContent = `--- title: Test Article diff --git a/src/content-linter/tests/lint-files.ts b/src/content-linter/tests/lint-files.ts index dd6a6f916721..0aab67ad7d02 100755 --- a/src/content-linter/tests/lint-files.ts +++ b/src/content-linter/tests/lint-files.ts @@ -20,50 +20,19 @@ const fbvDir = path.join(rootDir, 'data/features') const languageCodes = Object.keys(languages) -// This is a string that contributors can use in markdown and yaml files as a placeholder. -// If any placeholders slip through, this test will flag them. +// Contributors use TODOCS as a placeholder; this test catches leftovers in Markdown and YAML. const placeholder = 'TODOCS' const placeholderRegex = new RegExp(`\\b${placeholder}\\b`, 'gi') -// WARNING: Complicated RegExp below! -// -// Things matched by this RegExp: -// - [link text](link-url) -// - [link text] (link-url) -// - [link-definition-ref]: link-url -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [link text](#link-url) -// - [link text] (#link-url) -// - [link-definition-ref]: #link-url -// - [link text](/link-url) -// - [link-definition-ref]: /link-url -// - [link text](https://link-url) -// - [link-definition-ref]: https://link-url -// - [link text](mailto:mail-url) -// - [link-definition-ref]: mailto:mail-url -// - [link text](tel:phone-url) -// - [link-definition-ref]: tel:phone-url -// - [link text]({{ site.data.variables.product_url }}) -// - [link-definition-ref]: {{ site.data.variables.product_url }} -// - [link text][link-definition-ref]: other text -// - [link text][link-definition-ref] (other text) -// - etc. -// +// Matches relative Markdown link targets, including definitions and space-before-target links. +// Examples: "[Billing](billing/usage)" and "[Billing]: billing/usage". +// Excludes anchors, root-relative paths, external URLs, tel/mailto URLs, and Liquid targets. +// Examples: "[Email](mailto:docs@example.com)" and "[Phone](tel:555-0100)". const relativeArticleLinkRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?!\/|#|https?:\/\/|tel:|mailto:|\{[%{]\s*)[^)\s]+(?:(?:\s*[%}]\})?\)|\s+|$)/gm -// Things matched by this RegExp: -// - [link text](/en/github/blah) -// - [link text] (https://docs.github.com/ja/github/blah) -// - [link-definition-ref]: http://help.github.com/es/github/blah -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [Node.js](https://nodejs.org/en/) -// - etc. -// +// Matches docs URLs with hard-coded language prefixes such as /en/github/overview. +// Excludes external non-docs URLs such as https://nodejs.org/en/. const languageLinkRegex = new RegExp( `(?=^|[^\\]]\\s*)\\[[^\\]]+\\](?::\\n?[ \\t]+|\\s*\\()(?:(?:https?://(?:help|docs|developer)\\.github\\.com)?/(?:${languageCodes.join( '|', @@ -71,79 +40,36 @@ const languageLinkRegex = new RegExp( 'gm', ) -// Things matched by this RegExp: -// - [link text](/enterprise/2.19/admin/blah) -// - [link text] (https://docs.github.com/enterprise/11.10.340/admin/blah) -// - [link-definition-ref]: http://help.github.com/enterprise/2.8/admin/blah -// -// Things intentionally NOT matched by this RegExp: -// - [link text](https://someservice.com/enterprise/1.0/blah) -// - [link text](/github/site-policy/enterprise/2.2/admin/blah) +// Matches docs URLs with hard-coded Enterprise Server versions such as /enterprise/2.19/admin. +// Excludes non-docs external URLs and current versioning paths under /github/site-policy/enterprise/. const versionLinkRegEx = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:(?:https?:\/\/(?:help|docs|developer)\.github\.com)?\/enterprise\/\d+(\.\d+)+(?:\/[^)\s]*)?)(?:\)|\s+|$)/gm -// Things matched by this RegExp: -// - [link text](/early-access/github/blah) -// - [link text] (https://docs.github.com/early-access/github/blah) -// - [link-definition-ref]: http://help.github.com/early-access/github/blah -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [Node.js](https://nodejs.org/early-access/) -// - etc. -// +// Matches docs URLs that leak Early Access paths such as /early-access/github/overview. +// Excludes external non-docs URLs such as https://nodejs.org/early-access/. const earlyAccessLinkRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:(?:https?:\/\/(?:help|docs|developer)\.github\.com)?\/early-access(?:\/[^)\s]*)?)(?:\)|\s+|$)/gm -// - [link text](https://docs.github.com/github/blah) -// - [link text] (https://help.github.com/github/blah) -// - [link-definition-ref]: http://developer.github.com/v3/ -// - [link text](//docs.github.com) -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [link text](/github/blah) -// - [link text[(https://developer.github.com/changes/2018-02-22-protected-branches-required-signatures/) -// - etc. -// +// Matches hard-coded docs domains such as docs.github.com, help.github.com, +// and developer.github.com. +// Excludes root-relative links and developer.github.com/changes URLs. const domainLinkRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:https?:)?\/\/(?:help|docs|developer)\.github\.com(?!\/changes\/)[^)\s]*(?:\)|\s+|$)/gm -// Things matched by this RegExp: -// - ![image text](/assets/images/early-access/github/blah.gif) -// - ![image text] (https://docs.github.com/assets/images/early-access/github/blah.gif) -// - [image-definition-ref]: http://help.github.com/assets/images/early-access/github/blah.gif -// - [link text](/assets/images/early-access/github/blah.gif) -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [Node.js](https://nodejs.org/assets/images/early-access/blah.gif) -// - etc. -// +// Matches docs image links under /assets/images/early-access. +// Excludes external non-docs URLs such as https://nodejs.org/assets/images/early-access/. const earlyAccessImageRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:(?:https?:\/\/(?:help|docs|developer)\.github\.com)?\/assets\/images\/early-access(?:\/[^)\s]*)?)(?:\)|\s+|$)/gm -// Things matched by this RegExp: -// - ![image text](/assets/early-access/images/github/blah.gif) -// - ![image text] (https://docs.github.com/images/early-access/github/blah.gif) -// - [image-definition-ref]: http://help.github.com/assets/early-access/github/blah.gif -// - [link text](/early-access/assets/images/github/blah.gif) -// - [link text](/early-access/images/github/blah.gif) -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [Node.js](https://nodejs.org/assets/early-access/images/blah.gif) -// - etc. -// +// Matches misplaced Early Access image paths, including /assets/early-access/images. +// Excludes external non-docs URLs such as https://nodejs.org/assets/early-access/images/. const badEarlyAccessImageRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:(?:https?:\/\/(?:help|docs|developer)\.github\.com)?\/(?:(?:assets|images)\/early-access|early-access\/(?:assets|images))(?:\/[^)\s]*)?)(?:\)|\s+|$)/gm -// {{ site.data.example.pizza }} +// Matches old site.data Liquid variables such as {{ site.data.example.pizza }}. const oldVariableRegex = /{{\s*?site\.data\..*?}}/g -// - {{ octicon-plus }} -// - {{ octicon-plus An example label }} -// +// Matches old octicon Liquid variables such as {{ octicon-plus An example label }}. const oldOcticonRegex = /{{\s*?octicon-([a-z-]+)(\s[\w\s\d-]+)?\s*?}}/g const relativeArticleLinkErrorText = 'Found unexpected relative article links:' const languageLinkErrorText = 'Found article links with hard-coded language codes:' @@ -158,8 +84,6 @@ const oldVariableErrorText = const oldOcticonErrorText = 'Found octicon variables with the old {{ octicon-name }} syntax. Use {% octicon "name" %} instead!' -// Also test the "data/variables/" YAML files - const yamlWalkOptions = { globs: ['**/*.yml'], directories: false, @@ -168,26 +92,20 @@ const yamlWalkOptions = { let ymlToLint -// compile lists of all the files we want to lint - -// data/variables const variableYamlAbsPaths = walk(variablesDir, yamlWalkOptions).sort() const variableYamlRelPaths = variableYamlAbsPaths.map((p) => slash(path.relative(rootDir, p))) const variableYamlTuples = zip(variableYamlRelPaths, variableYamlAbsPaths) -// data/glossaries const glossariesYamlAbsPaths = walk(glossariesDir, yamlWalkOptions).sort() const glossariesYamlRelPaths = glossariesYamlAbsPaths.map((p) => slash(path.relative(rootDir, p))) const glossariesYamlTuples = zip(glossariesYamlRelPaths, glossariesYamlAbsPaths) -// data/features (feature-based versioning) const FbvYamlAbsPaths = walk(fbvDir, yamlWalkOptions).sort() const FbvYamlRelPaths = FbvYamlAbsPaths.map((p) => slash(path.relative(rootDir, p))) const fbvTuples = zip(FbvYamlRelPaths, FbvYamlAbsPaths) -// Put all the yaml files together ymlToLint = ([] as Array<[string | undefined, string | undefined]>).concat( - variableYamlTuples, // These "tuples" not tested independently; they are only tested as part of ymlToLint. + variableYamlTuples, glossariesYamlTuples, fbvTuples, ) @@ -196,8 +114,7 @@ function formatLinkError(message: string, links: string[]) { return `${message}\n - ${links.join('\n - ')}` } -// Returns `content` if its a string, or `content.description` if it can. -// Used for getting the nested `description` key in glossary files. +// Glossary YAML stores text directly or under a description key. function getContent(content: unknown) { if (typeof content === 'string') return content if ( @@ -212,15 +129,11 @@ function getContent(content: unknown) { const diffFiles = getDiffFiles() -// If it is present and not empty, use it. In most cases it is empty. +// DIFF_FILES or DIFF_FILE narrows YAML linting to the listed files. if (diffFiles.length > 0) { - // It's faster to do this once and then re-use over and over in the - // .filter() later on. + // Reuse a Set because every YAML tuple checks both relative and absolute paths. const only = new Set( - // If the environment variable encodes all the names - // with quotation marks, strip them. - // E.g. Turn `"foo" "bar"` into ['foo', 'bar'] - // Note, this assumes no possible file contains a space. + // Strip quotes from CI tokens such as "foo" "bar"; filenames with spaces are unsupported. diffFiles.map((name) => { if (/^['"]/.test(name) && /['"]$/.test(name)) { return name.slice(1, -1) @@ -237,7 +150,7 @@ if (diffFiles.length > 0) { } if (ymlToLint.length === 0) { - // This is to make sure the file has at least once `describe`. + // Keep Vitest happy when diff filtering leaves no YAML files. describe('deliberately do nothing', () => { test('void', () => {}) }) @@ -247,12 +160,11 @@ if (ymlToLint.length === 0) { describe.each(ymlToLint)( '%s', (yamlRelPath: string | undefined, yamlAbsPath: string | undefined) => { - let dictionary: unknown // YAML structure varies by file type (variables, glossaries, features) + // YAML structure varies by variables, glossaries, and features files. + let dictionary: unknown let isEarlyAccess: boolean let fileContents: string - // This variable is used to determine if the file was parsed successfully. - // When `load()` fails to parse the file, it is overwritten with the error message. - // `false` is intentionally chosen since `null` and `undefined` are valid return values. + // Use false as the parse sentinel because null and undefined are valid YAML values. let dictionaryError: unknown = false beforeAll(async () => { @@ -295,7 +207,7 @@ if (ymlToLint.length === 0) { }) test('must not leak Early Access doc URLs', async () => { - // Only execute for docs that are NOT Early Access + // Early Access docs can link to Early Access docs. if (!isEarlyAccess) { const matches = [] @@ -314,7 +226,7 @@ if (ymlToLint.length === 0) { }) test('must not leak Early Access image URLs', async () => { - // Only execute for docs that are NOT Early Access + // Early Access docs can link to Early Access images. if (!isEarlyAccess) { const matches = [] @@ -333,8 +245,7 @@ if (ymlToLint.length === 0) { }) test('must have correctly formatted Early Access image URLs', async () => { - // Execute for ALL docs (not just Early Access) to ensure non-EA docs - // are not leaking incorrectly formatted EA image URLs + // Check all YAML files because non-Early-Access docs can leak bad image paths. const matches = [] for (const [key, content] of Object.entries(dictionary as Record)) { diff --git a/src/content-linter/tests/lint-frontmatter-links.ts b/src/content-linter/tests/lint-frontmatter-links.ts index f13643ecad07..29bd45eef0b7 100644 --- a/src/content-linter/tests/lint-frontmatter-links.ts +++ b/src/content-linter/tests/lint-frontmatter-links.ts @@ -41,8 +41,6 @@ describe('front matter', () => { return customErrorMessage } - // Test content with .featuredLinks front matter - const pagesWithFeaturedLinks = pageList.filter((page) => page.featuredLinks) test.each(pagesWithFeaturedLinks)( '$relativePath .featuredLinks have pristine links', @@ -51,8 +49,7 @@ describe('front matter', () => { const trouble = [] for (const links of Object.values(page.featuredLinks!)) { - // Some thing in `.featuredLinks` are not arrays. - // For example `popularHeading`. So just skip them. + // .featuredLinks includes scalars such as popularHeading, so only check arrays. if (!Array.isArray(links)) continue trouble.push( @@ -68,8 +65,9 @@ describe('front matter', () => { }, ) - // Test content with .introLinks front matter - + // Intro links can include conditional absolute CTA URLs such as try_ghec_for_free: + // https://github.com/account/enterprises/new on /en/enterprise-cloud@latest/admin. + // checkURL only handles docs-relative URLs. const pagesWithIntroLinks = pageList.filter((page) => page.introLinks) test.each(pagesWithIntroLinks)('$relativePath .introLinks have pristine links', async (page) => { const redirectsContext = { redirects, pages } @@ -79,14 +77,8 @@ describe('front matter', () => { const links = Array.isArray(linksRaw) ? linksRaw : [linksRaw] trouble.push( ...links - // At the present, we're not able to check when the URI - // contains an `elsif` Liquid tag. So just skip them. + // Skip URIs with elsif Liquid because checkURL cannot resolve conditional targets. .filter((uri) => !containsLiquidElseIf(uri)) - // On /en/enterprise-cloud@latest/admin we have, - // - // try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/new{% endif %}' - // - // Ignore those too. .filter((uri) => !uri.includes('https://')) .map((uri, i) => checkURL(uri, i, redirectsContext)) .filter((item): item is NonNullable => Boolean(item)), diff --git a/src/content-linter/tests/site-data-references.ts b/src/content-linter/tests/site-data-references.ts index c0828043b187..37b73929eb5f 100644 --- a/src/content-linter/tests/site-data-references.ts +++ b/src/content-linter/tests/site-data-references.ts @@ -5,25 +5,17 @@ import { describe, expect, test, vi } from 'vitest' import patterns from '@/frame/lib/patterns' import { getDataByLanguage, getDeepDataByLanguage } from '@/data-directory/lib/get-data' -// Given syntax like {% data foo.bar %} or {% indented_data_reference foo.bar spaces=3 %}, -// the following regex returns just the dotted path: foo.bar +// Extracts dotted data paths from data and indented_data_reference Liquid tags. -// Note this regex allows nonstandard whitespace between terms; it does not enforce a single space. -// In other words, it will allow {%data foo.bar %} or {% data foo.bar %}. -// We should enforce a single space someday, but the content will need a lot of cleanup first, and -// we should have a more purpose-driven validation test for that instead of enforcing it here. +// Content cleanup needs a purpose-built test before this rejects nonstandard Liquid spacing. const getDataPathRegex = /{%\s*?(?:data|indented_data_reference)\s+?(\S+?)\s*?(?:spaces=\d\d?\s*?)?%}/ const rawLiquidPattern = /{%\s*raw\s*%}.*?{%\s*endraw\s*%}/gs +// Strip raw Liquid blocks so examples inside {% raw %} do not count as real references. +// Example: "{% raw %}{% data reusables.foo %}{% endraw %}" returns no references. const getDataReferences = (content: string): string[] => { - // When looking for things like `{% data reusables.foo %}` in the - // content, we first have to exclude any Liquid that isn't real. - // E.g. - // {% raw %} - // Here's an example: {% data reusables.foo.bar %} - // {% endraw %} const withoutRawLiquidBlocks = content.replace(rawLiquidPattern, '') const refs = withoutRawLiquidBlocks.match(patterns.dataReference) || [] return refs.map((ref: string) => ref.replace(getDataPathRegex, '$1')) @@ -59,7 +51,7 @@ describe('data references', () => { }) }) -// object is the allVariables object with dynamic keys, value is the nested object we're searching for +// Search allVariables by object identity because getDataByLanguage returns the nested value only. function getFilenameByValue(object: Record, value: unknown): string | undefined { return Object.keys(object).find((key) => object[key] === value) } diff --git a/src/content-linter/tests/unit/code-annotation-comment-spacing.ts b/src/content-linter/tests/unit/code-annotation-comment-spacing.ts index 37f76e5fe8fc..0d19056b3d45 100644 --- a/src/content-linter/tests/unit/code-annotation-comment-spacing.ts +++ b/src/content-linter/tests/unit/code-annotation-comment-spacing.ts @@ -50,7 +50,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(3) - // Check first error (JavaScript comment) expect(errors[0].lineNumber).toBe(5) expect(errors[0].errorDetail).toContain("Comment must have exactly one space after '//'") expect(errors[0].fixInfo).toEqual({ @@ -60,7 +59,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '// This should fail the content linter', }) - // Check second error (Python/Shell comment) expect(errors[1].lineNumber).toBe(8) expect(errors[1].errorDetail).toContain("Comment must have exactly one space after '#'") expect(errors[1].fixInfo).toEqual({ @@ -70,7 +68,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '# This should also fail', }) - // Check third error (SQL comment) expect(errors[2].lineNumber).toBe(11) expect(errors[2].errorDetail).toContain("Comment must have exactly one space after '--'") expect(errors[2].fixInfo).toEqual({ @@ -102,7 +99,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(3) - // Check first error (JavaScript comment) expect(errors[0].lineNumber).toBe(5) expect(errors[0].errorDetail).toContain( "Comment must have exactly one space after '//', found multiple spaces", @@ -114,7 +110,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '// This has too many spaces', }) - // Check second error (Python/Shell comment) expect(errors[1].lineNumber).toBe(8) expect(errors[1].errorDetail).toContain( "Comment must have exactly one space after '#', found multiple spaces", @@ -126,7 +121,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '# This also has too many', }) - // Check third error (SQL comment) expect(errors[2].lineNumber).toBe(11) expect(errors[2].errorDetail).toContain( "Comment must have exactly one space after '--', found multiple spaces", @@ -159,7 +153,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(2) - // Check first error (indented JavaScript comment without space) expect(errors[0].lineNumber).toBe(6) expect(errors[0].errorDetail).toContain("Comment must have exactly one space after '//'") expect(errors[0].fixInfo).toEqual({ @@ -169,7 +162,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: ' // Missing space in indented comment', }) - // Check second error (indented comment with multiple spaces) expect(errors[1].lineNumber).toBe(9) expect(errors[1].errorDetail).toContain( "Comment must have exactly one space after '#', found multiple spaces", diff --git a/src/content-linter/tests/unit/ctas-schema.ts b/src/content-linter/tests/unit/ctas-schema.ts index 9fa8139057b7..686fcff65475 100644 --- a/src/content-linter/tests/unit/ctas-schema.ts +++ b/src/content-linter/tests/unit/ctas-schema.ts @@ -59,9 +59,8 @@ describe(ctasSchema.names.join(' - '), () => { ` const result = await runRule(ctasSchema, { strings: { markdown } }) const errors = result.markdown - expect(errors.length).toBe(2) // Should have errors for 'Trial' and 'Button' + expect(errors.length).toBe(2) - // Check that both expected errors are present (order may vary) const errorMessages = errors.map((error) => error.errorDetail) expect(errorMessages.some((msg) => msg.includes('Invalid value for ref_type: "Trial"'))).toBe( true, @@ -79,15 +78,14 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n ` const result = await runRule(ctasSchema, { strings: { markdown } }) const errors = result.markdown - expect(errors.length).toBe(1) // Should detect and try to convert the old CTA format + expect(errors.length).toBe(1) expect(errors[0].fixInfo).toBeDefined() - // The extracted URL should not include the curly brace from the Liquid tag. const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() expect(fixedUrl).not.toContain('{') expect(fixedUrl).not.toContain('}') - expect(fixedUrl).toContain('ref_product=ghec') // Should have converted old format correctly + expect(fixedUrl).toContain('ref_product=ghec') }) test('old CTA format autofix preserves original URL structure', async () => { @@ -99,11 +97,10 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n expect(errors.length).toBe(1) expect(errors[0].fixInfo).toBeDefined() - // The fixed URL should not introduce extra slashes const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() - expect(fixedUrl).toMatch(/^https:\/\/github\.com\?ref_product=/) // Should not have github.com/? - expect(fixedUrl).not.toMatch(/github\.com\/\?/) // Should not contain extra slash before query + expect(fixedUrl).toMatch(/^https:\/\/github\.com\?ref_product=/) + expect(fixedUrl).not.toMatch(/github\.com\/\?/) }) test('mixed parameter scenarios - new format takes precedence over old', async () => { @@ -115,13 +112,12 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n expect(errors.length).toBe(1) expect(errors[0].fixInfo).toBeDefined() - // Should preserve existing new format parameters, only convert old ones not already covered const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() - expect(fixedUrl).toContain('ref_product=copilot') // Preserved from new format - expect(fixedUrl).toContain('ref_type=trial') // Preserved from new format - expect(fixedUrl).not.toContain('ref_cta=') // Old parameter removed - expect(fixedUrl).not.toContain('ref_loc=') // Old parameter removed + expect(fixedUrl).toContain('ref_product=copilot') + expect(fixedUrl).toContain('ref_type=trial') + expect(fixedUrl).not.toContain('ref_cta=') + expect(fixedUrl).not.toContain('ref_loc=') }) test('hash fragment preservation during conversion', async () => { @@ -135,7 +131,7 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() - expect(fixedUrl).toContain('#pricing') // Hash fragment preserved + expect(fixedUrl).toContain('#pricing') expect(fixedUrl).toContain('ref_product=copilot') }) @@ -150,11 +146,11 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() - expect(fixedUrl).toContain('utm_source=docs') // UTM preserved - expect(fixedUrl).toContain('utm_campaign=trial') // UTM preserved - expect(fixedUrl).toContain('other_param=value') // Other params preserved - expect(fixedUrl).toContain('ref_product=copilot') // New CTA params added - expect(fixedUrl).not.toContain('ref_cta=') // Old CTA params removed + expect(fixedUrl).toContain('utm_source=docs') + expect(fixedUrl).toContain('utm_campaign=trial') + expect(fixedUrl).toContain('other_param=value') + expect(fixedUrl).toContain('ref_product=copilot') + expect(fixedUrl).not.toContain('ref_cta=') }) test('multiple query parameter types handled correctly', async () => { @@ -163,8 +159,8 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n ` const result = await runRule(ctasSchema, { strings: { markdown } }) const errors = result.markdown - expect(errors.length).toBe(1) // Only old format conversion error + expect(errors.length).toBe(1) expect(errors[0].errorDetail).toContain('old parameter format') - expect(errors[0].fixInfo).toBeDefined() // Should have autofix + expect(errors[0].fixInfo).toBeDefined() }) }) diff --git a/src/content-linter/tests/unit/frontmatter-children.ts b/src/content-linter/tests/unit/frontmatter-children.ts index 388550747f31..32fa9eca920f 100644 --- a/src/content-linter/tests/unit/frontmatter-children.ts +++ b/src/content-linter/tests/unit/frontmatter-children.ts @@ -10,7 +10,7 @@ const NO_CHILDREN = 'src/content-linter/tests/fixtures/frontmatter-children/no-c const ruleName = frontmatterChildren.names[1] -// Configure the test fixture to not split frontmatter and content +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(ruleName, () => { diff --git a/src/content-linter/tests/unit/frontmatter-content-type.ts b/src/content-linter/tests/unit/frontmatter-content-type.ts index 84e5a5ed83e3..ec084e4c7385 100644 --- a/src/content-linter/tests/unit/frontmatter-content-type.ts +++ b/src/content-linter/tests/unit/frontmatter-content-type.ts @@ -6,19 +6,16 @@ import { resetCache, } from '@/content-linter/lib/linting-rules/frontmatter-content-type' -// Disable frontMatter stripping so the rule can parse frontmatter itself +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } -// Helper: build a Markdown string with valid frontmatter function md(fmLines: string[], body = 'Some content.'): string { return ['---', ...fmLines, '---', '', body].join('\n') } -// Use the fixture content directory so the qualifying-products scan is -// hermetic and won't break if the real content/ layout changes. -// The fixture tree includes: -// content/copilot/{how-tos,concepts,tutorials,reference,get-started,getting-started,responsible-use} → qualifies -// content/actions/{category,using-workflows} → does NOT qualify +// Fixture content keeps qualifying product scans independent of the real content tree. +// content/copilot has how-tos, concepts, tutorials, reference, get-started, +// getting-started, and responsible-use; content/actions lacks required dirs. const FIXTURE_ROOT = 'src/fixtures/fixtures' describe('GHD065 - frontmatter-content-type', () => { @@ -32,14 +29,11 @@ describe('GHD065 - frontmatter-content-type', () => { process.env.ROOT = savedRoot }) - // Clear the qualifying-products cache between tests so that each - // test starts with a fresh filesystem scan. + // Reset the qualifying-products cache so each test scans the fixture filesystem. beforeEach(() => { resetCache() }) - // Passing cases - test('file with correct contentType matching directory passes', async () => { const strings = { 'content/copilot/how-tos/test-file.md': md([ @@ -69,7 +63,7 @@ describe('GHD065 - frontmatter-content-type', () => { }) test('file with contentType "get-started" in getting-started directory passes', async () => { - // Some products use "getting-started" instead of "get-started" as directory name + // Some products use getting-started instead of get-started as the directory name. const strings = { 'content/copilot/getting-started/test-file.md': md([ 'title: Getting Started', @@ -98,8 +92,7 @@ describe('GHD065 - frontmatter-content-type', () => { }) test('file outside qualifying product is not checked', async () => { - // actions in fixtures has non-EDI subdirs (category/, using-workflows/), - // so it does NOT qualify and the rule should skip it entirely. + // The actions fixture only has category and using-workflows, so the rule skips it. const strings = { 'content/actions/category/test-file.md': md(['title: Test', 'versions:', " fpt: '*'"]), } @@ -117,8 +110,6 @@ describe('GHD065 - frontmatter-content-type', () => { expect(errors).toEqual([]) }) - // Failing cases - test('missing contentType in qualifying product triggers error', async () => { const strings = { 'content/copilot/tutorials/test-file.md': md(['title: Tutorial', 'versions:', " fpt: '*'"]), diff --git a/src/content-linter/tests/unit/frontmatter-hero-image.ts b/src/content-linter/tests/unit/frontmatter-hero-image.ts index 5130cbbd4e02..c6f7085d1701 100644 --- a/src/content-linter/tests/unit/frontmatter-hero-image.ts +++ b/src/content-linter/tests/unit/frontmatter-hero-image.ts @@ -118,7 +118,6 @@ describe(frontmatterHeroImage.names.join(' - '), () => { }) test('all valid hero images pass', async () => { - // Test each valid hero image (extensionless) const validImages = [ "heroImage: '/assets/images/banner-images/hero-1'", "heroImage: '/assets/images/banner-images/hero-2'", diff --git a/src/content-linter/tests/unit/frontmatter-landing-carousels.ts b/src/content-linter/tests/unit/frontmatter-landing-carousels.ts index 2aaeeefd3f63..6b5ddd0e3301 100644 --- a/src/content-linter/tests/unit/frontmatter-landing-carousels.ts +++ b/src/content-linter/tests/unit/frontmatter-landing-carousels.ts @@ -19,7 +19,7 @@ const PRIORITY_VALIDATION = const ruleName = frontmatterLandingCarousels.names[1] -// Configure the test fixture to not split frontmatter and content +// Disable frontmatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(ruleName, () => { @@ -64,7 +64,7 @@ describe(ruleName, () => { files: [DUPLICATE_CAROUSELS], ...fmOptions, }) - expect(result[DUPLICATE_CAROUSELS]).toHaveLength(1) // Only duplicate error since all paths are valid + expect(result[DUPLICATE_CAROUSELS]).toHaveLength(1) expect(result[DUPLICATE_CAROUSELS][0].errorDetail).toContain( "Found duplicate articles in carousel 'recommended': /article-one", ) @@ -91,10 +91,10 @@ describe(ruleName, () => { expect(result[VALID_LANDING]).toEqual([]) }) + // /article-one exists in src/fixtures/fixtures/content/article-one.md and + // src/content-linter/tests/fixtures/landing-carousels/article-one.md. + // Absolute resolution wins. test('absolute paths are prioritized over relative paths', async () => { - // /article-one exists both as src/fixtures/fixtures/content/article-one.md - // and as src/content-linter/tests/fixtures/landing-carousels/article-one.md. - // The absolute resolution wins. const result = await runRule(frontmatterLandingCarousels, { files: [ABSOLUTE_PRIORITY], ...fmOptions, @@ -121,8 +121,7 @@ describe(ruleName, () => { }) test('mixed valid and invalid absolute paths are handled correctly', async () => { - // This test has both a valid absolute path (/article-one) and an invalid one (/nonexistent-absolute) - // It should fail because of the invalid path, proving our absolute path resolution is working + // Include one valid absolute path so the error isolates /nonexistent-absolute. const result = await runRule(frontmatterLandingCarousels, { files: [PRIORITY_VALIDATION], ...fmOptions, diff --git a/src/content-linter/tests/unit/frontmatter-schema.ts b/src/content-linter/tests/unit/frontmatter-schema.ts index 8fa299079eb2..7315cbbd2ce2 100644 --- a/src/content-linter/tests/unit/frontmatter-schema.ts +++ b/src/content-linter/tests/unit/frontmatter-schema.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { frontmatterSchema } from '../../lib/linting-rules/frontmatter-schema' -// Configure the test fixture to not split frontmatter and content +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(frontmatterSchema.names.join(' - '), () => { diff --git a/src/content-linter/tests/unit/frontmatter-search-replace.ts b/src/content-linter/tests/unit/frontmatter-search-replace.ts index 788f41102dbf..e8240f54acfe 100644 --- a/src/content-linter/tests/unit/frontmatter-search-replace.ts +++ b/src/content-linter/tests/unit/frontmatter-search-replace.ts @@ -21,7 +21,7 @@ describe('search-replace rule in frontmatter', () => { const todosErrors = errors.filter((e) => e.errorDetail && /TODOCS/.test(e.errorDetail)) expect(todosErrors.length).toBe(1) - expect(todosErrors[0].lineNumber).toBe(2) // title: TODOCS + expect(todosErrors[0].lineNumber).toBe(2) }) test('multiple TODOCS in frontmatter are all detected', async () => { @@ -48,9 +48,9 @@ describe('search-replace rule in frontmatter', () => { const todosErrors = errors.filter((e) => e.errorDetail && /TODOCS/.test(e.errorDetail)) expect(todosErrors.length).toBe(3) - expect(todosErrors[0].lineNumber).toBe(2) // title: TODOCS - expect(todosErrors[1].lineNumber).toBe(3) // shortTitle: TODOCS - expect(todosErrors[2].lineNumber).toBe(4) // intro: TODOCS + expect(todosErrors[0].lineNumber).toBe(2) + expect(todosErrors[1].lineNumber).toBe(3) + expect(todosErrors[2].lineNumber).toBe(4) }) test('domain rules work in frontmatter', async () => { @@ -79,9 +79,9 @@ describe('search-replace rule in frontmatter', () => { (e) => e.errorDetail && /docs-domain|help-domain|developer-domain/.test(e.errorDetail), ) expect(domainErrors.length).toBe(3) - expect(domainErrors[0].lineNumber).toBe(2) // docs domain in title - expect(domainErrors[1].lineNumber).toBe(3) // help domain in shortTitle - expect(domainErrors[2].lineNumber).toBe(4) // developer domain in intro + expect(domainErrors[0].lineNumber).toBe(2) + expect(domainErrors[1].lineNumber).toBe(3) + expect(domainErrors[2].lineNumber).toBe(4) }) test('deprecated liquid syntax in frontmatter is detected', async () => { @@ -109,7 +109,7 @@ describe('search-replace rule in frontmatter', () => { (e) => e.errorDetail && /site\.data|octicon/.test(e.errorDetail), ) expect(deprecatedErrors.length).toBe(2) - expect(deprecatedErrors[0].lineNumber).toBe(2) // site.data syntax - expect(deprecatedErrors[1].lineNumber).toBe(3) // octicon syntax + expect(deprecatedErrors[0].lineNumber).toBe(2) + expect(deprecatedErrors[1].lineNumber).toBe(3) }) }) diff --git a/src/content-linter/tests/unit/frontmatter-versions-whitespace.ts b/src/content-linter/tests/unit/frontmatter-versions-whitespace.ts index d81bf4350f8d..e82874f25c01 100644 --- a/src/content-linter/tests/unit/frontmatter-versions-whitespace.ts +++ b/src/content-linter/tests/unit/frontmatter-versions-whitespace.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '@/content-linter/lib/init-test' import { frontmatterVersionsWhitespace } from '@/content-linter/lib/linting-rules/frontmatter-versions-whitespace' -// Configure the test fixture to not split frontmatter and content +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } interface ValidTestCase { diff --git a/src/content-linter/tests/unit/image-alt-text-end-punctuation.ts b/src/content-linter/tests/unit/image-alt-text-end-punctuation.ts index 956692c95240..6d0b2f15bd09 100644 --- a/src/content-linter/tests/unit/image-alt-text-end-punctuation.ts +++ b/src/content-linter/tests/unit/image-alt-text-end-punctuation.ts @@ -54,13 +54,11 @@ describe(imageAltTextEndPunctuation.names.join(' - '), () => { const markdown = [ '# Heading', '', - // Completely empty + // The incorrect-alt-text-length rule owns empty alt text. '![](/images/this-is-ok.png)', ].join('\n') const result = await runRule(imageAltTextEndPunctuation, { strings: { markdown } }) const errors = result.markdown - // This rule is not concerned with empty alt text. The - // incorrect-alt-text-length rule catches that instead. expect(errors.length).toBe(0) }) }) 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 885c7f470e80..b63b6674828b 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 @@ -34,13 +34,11 @@ describe(imageAltTextExcludeStartWords.names.join(' - '), () => { const markdown = [ '# Heading', '', - // Completely empty + // The incorrect-alt-text-length rule owns empty alt text. '![](/images/this-is-ok.png)', ].join('\n') const result = await runRule(imageAltTextExcludeStartWords, { strings: { markdown } }) const errors = result.markdown - // This rule is not concerned with empty alt text. The - // incorrect-alt-text-length rule catches that instead. expect(errors.length).toBe(0) }) }) diff --git a/src/content-linter/tests/unit/image-alt-text-length.ts b/src/content-linter/tests/unit/image-alt-text-length.ts index 5990c3d10416..5490d13bbf49 100644 --- a/src/content-linter/tests/unit/image-alt-text-length.ts +++ b/src/content-linter/tests/unit/image-alt-text-length.ts @@ -31,14 +31,13 @@ describe(incorrectAltTextLength.names.join(' - '), () => { const markdown = [ '# Heading', '', - // Completely empty + // Empty alt text has no valid range. '![](/images/this-is-ok.png)', ].join('\n') const result = await runRule(incorrectAltTextLength as Rule, { strings: { markdown } }) const errors = result.markdown expect(errors.length).toBe(1) expect(errors[0].lineNumber).toBe(3) - // Because you can't get a valid range when it's entirely empty expect(errors[0].errorRange).toEqual(null) }) }) diff --git a/src/content-linter/tests/unit/internal-links-no-lang.ts b/src/content-linter/tests/unit/internal-links-no-lang.ts index 66d8eaf970bb..b1c6cc3c1ca1 100644 --- a/src/content-linter/tests/unit/internal-links-no-lang.ts +++ b/src/content-linter/tests/unit/internal-links-no-lang.ts @@ -24,12 +24,11 @@ describe(internalLinksNoLang.names.join(' - '), () => { }) test('internal links with no hardcoded language codes pass', async () => { const markdown = [ - // This is caught by the internal-links-slashes rule + // The internal-links-slash rule owns relative links without a slash. '[Internal Link Fail Docs](en/docs)', - // a // means the link is external + // Protocol-relative URLs count as external links. 'These are the [Docs](//ja/actions) we need.', 'This is the [actions Docs](/actions)', - // Starts with a path segment that is not a language code '[Enterprise](/enterprise/overview)', ].join('\n') const result = await runRule(internalLinksNoLang as Rule, { strings: { markdown } }) diff --git a/src/content-linter/tests/unit/internal-links-old-version.ts b/src/content-linter/tests/unit/internal-links-old-version.ts index 39e4e4590de6..03e02d6d6508 100644 --- a/src/content-linter/tests/unit/internal-links-old-version.ts +++ b/src/content-linter/tests/unit/internal-links-old-version.ts @@ -22,9 +22,9 @@ describe(internalLinksOldVersion.names.join(' - '), () => { test('links without old hardcoded versions pass', async () => { const markdown = [ - // External links with enterprise in them + // External links with enterprise paths stay external. '[External link](https://someservice.com/enterprise/1.0/admin/yes)', - // Current versioning links are excluded from this test + // Current versioning paths stay valid. '[New versioning](/github/site-policy/enterprise/2.2/yes)', ].join('\n') const result = await runRule(internalLinksOldVersion as Rule, { strings: { markdown } }) diff --git a/src/content-linter/tests/unit/internal-links-slash.ts b/src/content-linter/tests/unit/internal-links-slash.ts index 14350e4851fc..5bd6969c3fef 100755 --- a/src/content-linter/tests/unit/internal-links-slash.ts +++ b/src/content-linter/tests/unit/internal-links-slash.ts @@ -34,9 +34,9 @@ describe(internalLinksSlash.names.join(' - '), () => { const markdown = [ 'Hello [GitHub Actions](/actions/index.md)', '- "[Actions](/actions/index.md)"', - // Not a relative page link + // Anchors stay outside relative page link checks. '[Anchor on page](#anchor-on-page)', - // Not internal links + // External URLs stay outside internal link checks. '[External Link](https://git-scm.com/)', '[External link](http://example.com)', '[External Link](mailto:email@example.com)', diff --git a/src/content-linter/tests/unit/journey-tracks.ts b/src/content-linter/tests/unit/journey-tracks.ts index bdee809e765a..a3c793e83846 100644 --- a/src/content-linter/tests/unit/journey-tracks.ts +++ b/src/content-linter/tests/unit/journey-tracks.ts @@ -23,9 +23,7 @@ describe('journey-tracks-liquid', () => { }) test('invalid liquid syntax fails', async () => { - // Using inline content instead of a fixture file to avoid CI conflicts. - // Malformed Liquid syntax in fixture files causes other rules (like liquid-versioning) - // to crash when they try to parse the same file during content linting. + // Keep malformed Liquid inline because fixture-wide runs let other rules parse it and crash. const invalidLiquidContent = `--- title: Journey with Liquid Syntax layout: journey-landing @@ -49,7 +47,7 @@ This journey landing page has invalid liquid syntax in journeyTracks. strings: { 'test-invalid-liquid.md': invalidLiquidContent }, ...fmOptions, }) - expect(result['test-invalid-liquid.md']).toHaveLength(2) // title and description both have invalid liquid + expect(result['test-invalid-liquid.md']).toHaveLength(2) expect(result['test-invalid-liquid.md'][0].ruleDescription).toMatch(/liquid syntax/i) expect(result['test-invalid-liquid.md'][1].ruleDescription).toMatch(/liquid syntax/i) }) diff --git a/src/content-linter/tests/unit/link-punctuation.ts b/src/content-linter/tests/unit/link-punctuation.ts index c12ebdaa3fce..701398ee69f4 100644 --- a/src/content-linter/tests/unit/link-punctuation.ts +++ b/src/content-linter/tests/unit/link-punctuation.ts @@ -8,8 +8,7 @@ describe(linkPunctuation.names.join(' - '), () => { const markdown = [ '[This should pass](./image.png)', '[AUTOTITLE](./image.png)', - // These are not necessarily good descriptions, but they are valid - // per the requirements of the rule + // The rule allows imperfect descriptions when their punctuation is valid. "[A link with end quote'](./image.png)", '["A link with start quote](./image.png)', '[A link with a question mark?](./image.png)', diff --git a/src/content-linter/tests/unit/lint-report-exclusions.ts b/src/content-linter/tests/unit/lint-report-exclusions.ts index e9fe1bd8c6d3..8050062efec6 100644 --- a/src/content-linter/tests/unit/lint-report-exclusions.ts +++ b/src/content-linter/tests/unit/lint-report-exclusions.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest' import { getAllRuleNames } from '../../lib/helpers/rule-utils' -// Use static config objects for testing to avoid Commander.js conflicts +// Static config objects avoid Commander.js conflicts in tests. const globalConfig = { excludePaths: ['content/contributing/'], } @@ -26,28 +26,25 @@ describe('content linter configuration', () => { }) test('simulates path exclusion logic', () => { - // Simulate the cleanPaths function logic from lint-content.ts + // Mirror cleanPaths excludePaths prefix checks from lint-content.ts. function isPathExcluded(filePath: string): boolean { return globalConfig.excludePaths.some((excludePath) => filePath.startsWith(excludePath)) } - // Files in contributing directory should be excluded expect(isPathExcluded('content/contributing/README.md')).toBe(true) expect(isPathExcluded('content/contributing/how-to-contribute.md')).toBe(true) expect(isPathExcluded('content/contributing/collaborating-on-github-docs/file.md')).toBe(true) - // Files outside contributing directory should not be excluded expect(isPathExcluded('content/actions/README.md')).toBe(false) expect(isPathExcluded('content/copilot/getting-started.md')).toBe(false) expect(isPathExcluded('data/variables/example.yml')).toBe(false) - // Edge case: partial matches should not be excluded expect(isPathExcluded('content/contributing-guide.md')).toBe(false) }) }) describe('report filtering (lint-report.ts)', () => { - // Helper function that matches the actual logic in lint-report.ts + // Mirror lint-report.ts so config tests use the same rule-name extraction. function shouldIncludeInReport(flaw: LintFlaw): boolean { const allRuleNames = getAllRuleNames(flaw) @@ -55,7 +52,6 @@ describe('content linter configuration', () => { return true } - // Check if any rule name is in the include list that overrides severity const hasIncludedRule = allRuleNames.some((ruleName: string) => reportingConfig.includeRules.includes(ruleName), ) @@ -97,7 +93,6 @@ describe('content linter configuration', () => { ruleNames: ['expired-content'], } - // Should be included because expired-content is in includeRules expect(shouldIncludeInReport(expiredContentWarning)).toBe(true) }) @@ -108,8 +103,6 @@ describe('content linter configuration', () => { errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.', } - // Should extract 'todocs-placeholder' as a rule name and check against includeRules - // This will depend on your actual includeRules configuration const result = shouldIncludeInReport(searchReplaceFlaw) expect(typeof result).toBe('boolean') }) @@ -118,10 +111,9 @@ describe('content linter configuration', () => { const searchReplaceFlawNoDetail = { severity: 'warning', ruleNames: ['search-replace'], - // no errorDetail + // errorDetail deliberately absent. } - // Should not throw an error and return false (warning not in includeSeverities) expect(shouldIncludeInReport(searchReplaceFlawNoDetail)).toBe(false) }) @@ -153,29 +145,20 @@ describe('content linter configuration', () => { }) describe('integration between systems', () => { + // Path-excluded files never reach report filtering, so keep the two filters independent. test('path exclusions happen before report filtering', () => { - // This is a conceptual test - in practice, files excluded by globalConfig.excludePaths - // never reach the reporting stage, so they never get filtered by reportingConfig - - // Files in excluded paths should never be linted at all const isExcluded = (path: string) => globalConfig.excludePaths.some((excludePath) => path.startsWith(excludePath)) expect(isExcluded('content/contributing/some-file.md')).toBe(true) - - // If a file is excluded at the path level, it doesn't matter what the reportingConfig says - // because the file will never be processed for linting in the first place }) test('configurations are independent', () => { - // globalConfig handles what gets linted expect(globalConfig.excludePaths).toBeDefined() - // reportingConfig handles what gets reported expect(reportingConfig.includeSeverities).toBeDefined() expect(reportingConfig.includeRules).toBeDefined() - // They should not overlap or depend on each other expect(globalConfig).not.toHaveProperty('includeSeverities') expect(reportingConfig).not.toHaveProperty('excludePaths') }) diff --git a/src/content-linter/tests/unit/liquid-data-tags.ts b/src/content-linter/tests/unit/liquid-data-tags.ts index faa423e1e504..339495e74e42 100644 --- a/src/content-linter/tests/unit/liquid-data-tags.ts +++ b/src/content-linter/tests/unit/liquid-data-tags.ts @@ -24,7 +24,7 @@ describe(liquidDataReferencesDefined.names.join(' - '), () => { const markdown = [ 'Hello {% data variables.empty %}', '{% data variables.no-file %}', - // Variables even when they exist can't be nested + // Existing variables cannot be nested. '{% data variables.location.foo.bar %}', '{% data reusables.gated-features.empty %}', '{% data reusables.no-file %}', diff --git a/src/content-linter/tests/unit/liquid-ifversion-versions.ts b/src/content-linter/tests/unit/liquid-ifversion-versions.ts index 7a3c831d3b8d..17319eb7b650 100644 --- a/src/content-linter/tests/unit/liquid-ifversion-versions.ts +++ b/src/content-linter/tests/unit/liquid-ifversion-versions.ts @@ -86,8 +86,7 @@ describe(liquidIfversionVersions.names.join(' - '), () => { }) test('ifversion all shortnames and an almost oldest ghes', async () => { - // Note that this will mean version will not catch the oldest version - // of ghes, so something is actually excluded by the ifversion tag. + // The oldest ghes remains excluded, so the ifversion tag still changes content. const markdown = [ ...placeholderAllVersionsFm, `{% ifversion ghec or fpt or ghes >${supported.at(-1)} %}{% endif %}`, @@ -101,7 +100,7 @@ describe(liquidIfversionVersions.names.join(' - '), () => { }) test.skip('ifversion using feature based version with all versions', async () => { - // That `features/them-and-all.yml` uses all versions. + // features/them-and-all.yml covers all versions. const markdown = [...placeholderAllVersionsFm, `{% ifversion them-and-all %}{% endif %}`].join( '\n', ) @@ -114,7 +113,7 @@ describe(liquidIfversionVersions.names.join(' - '), () => { }) test.skip('ifversion using feature based version extended with shortname all versions', async () => { - // That `features/volvo.yml` contains `fpt:'*', ghec:'*'`. + // features/volvo.yml contains fpt: "*" and ghec: "*". const markdown = ` {% ifversion volvo or ghes %}{% endif %} ` @@ -152,7 +151,6 @@ describe(liquidIfversionVersions.names.join(' - '), () => { const result = await runRule(liquidIfversionVersions, { strings: { markdown }, }) - // No crash; zero errors expected for valid ifversion usage const errors = result.markdown expect(errors.length).toBe(0) }) diff --git a/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts b/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts index 511761be7d64..d442c6d4633d 100644 --- a/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts +++ b/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts @@ -119,7 +119,6 @@ describe(liquidQuotedConditionalArg.names.join(' - '), () => { ].join('\n') const result = await runRule(liquidQuotedConditionalArg, { strings: { markdown } }) const errors = result.markdown - // Only the standalone quoted arg (line 9) should be flagged expect(errors.length).toBe(1) expect(errors[0].lineNumber).toBe(9) }) diff --git a/src/content-linter/tests/unit/liquid-syntax.ts b/src/content-linter/tests/unit/liquid-syntax.ts index a503b869def0..ebf24833cb58 100644 --- a/src/content-linter/tests/unit/liquid-syntax.ts +++ b/src/content-linter/tests/unit/liquid-syntax.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { frontmatterLiquidSyntax, liquidSyntax } from '../../lib/linting-rules/liquid-syntax' -// Configure the test fixture to not split frontmatter and content +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(frontmatterLiquidSyntax.names.join(' - '), () => { @@ -76,7 +76,7 @@ describe(liquidSyntax.names.join(' - '), () => { '---', '{% data reusables.foo.bar %}', '{% if true %}Permission statement{% endif %}', - // Not correct, but not caught by this rule. See liquid-ifversion-tags. + // The liquid-ifversion-tags rule owns invalid ifversion names. '{% ifversion ghhes %}bla{%endif%}', ].join('\n') const result = await runRule(liquidSyntax, { strings: { markdown } }) diff --git a/src/content-linter/tests/unit/liquid-versioning.ts b/src/content-linter/tests/unit/liquid-versioning.ts index 0d142a66b43d..8304924ae625 100644 --- a/src/content-linter/tests/unit/liquid-versioning.ts +++ b/src/content-linter/tests/unit/liquid-versioning.ts @@ -20,9 +20,8 @@ describe(liquidIfTags.names.join(' - '), () => { test('if tags with version names fail', async () => { const markdown = [ '{% if ghes %}', - // Valid test fixture feature name + // volvo is a feature-based version in fixture data. '{% if volvo %}', - // None of the args should contain a version name '{% if something and ghes %}', ] const result = await runRule(liquidIfTags, { strings: { markdown: markdown.join('\n') } }) @@ -54,11 +53,10 @@ describe(liquidIfVersionTags.names.join(' - '), () => { '{% ifversion ghec > 3.7 %}', '{% ifversion ghes !== 3.7 %}', '{% ifversion ghec === 3.7 %}', - // < 2.9 is not in the currently supported list + // 2.9 falls outside supported GHES releases. '{% ifversion ghes < 2.9 %}', - // Incorrect syntax '{% ifversion ghec or ifversion fpt %}', - // Typo: should be `not ghec` + // no ghec is an invalid spelling of not ghec. '{% ifversion no ghec %}', ] const result = await runRule(liquidIfVersionTags, { diff --git a/src/content-linter/tests/unit/rai-app-card-structure.ts b/src/content-linter/tests/unit/rai-app-card-structure.ts index ef1f838dacc1..516c4664a585 100644 --- a/src/content-linter/tests/unit/rai-app-card-structure.ts +++ b/src/content-linter/tests/unit/rai-app-card-structure.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { raiAppCardStructure } from '../../lib/linting-rules/rai-app-card-structure' -// A minimal valid RAI card with all required H2s, H3s, and reusables. function validCard(): string { return [ '---', @@ -98,8 +97,6 @@ function validCard(): string { } describe(raiAppCardStructure.names.join(' - '), () => { - // Happy path and filtering - test('valid RAI card produces zero errors', async () => { const markdown = validCard() const result = await runRule(raiAppCardStructure, { strings: { markdown } }) @@ -122,8 +119,6 @@ describe(raiAppCardStructure.names.join(' - '), () => { expect(errors.length).toBe(0) }) - // One negative test per validator, to prove each code path fires - test('missing a required H2 section reports an error', async () => { const markdown = validCard() .split('\n') diff --git a/src/content-linter/tests/unit/search-replace.ts b/src/content-linter/tests/unit/search-replace.ts index 2cc4cd9f17d5..17ff5b290a4e 100644 --- a/src/content-linter/tests/unit/search-replace.ts +++ b/src/content-linter/tests/unit/search-replace.ts @@ -76,13 +76,13 @@ describe(searchReplace.names.join(' - '), () => { const result = await runRule(searchReplace, { strings: { markdown }, ruleConfig: searchReplaceConfig['search-replace'], - markdownlintOptions: { frontMatter: null }, // Include frontmatter in linting + markdownlintOptions: { frontMatter: null }, }) const errors = result.markdown expect(errors.length).toBe(3) - expect(errors[0].lineNumber).toBe(2) // title: TODOCS - expect(errors[1].lineNumber).toBe(3) // shortTitle: TODOCS - expect(errors[2].lineNumber).toBe(4) // intro: TODOCS + expect(errors[0].lineNumber).toBe(2) + expect(errors[1].lineNumber).toBe(3) + expect(errors[2].lineNumber).toBe(4) }) test('TODOCS placeholder in both frontmatter and content', async () => { @@ -98,14 +98,14 @@ describe(searchReplace.names.join(' - '), () => { const result = await runRule(searchReplace, { strings: { markdown }, ruleConfig: searchReplaceConfig['search-replace'], - markdownlintOptions: { frontMatter: null }, // Include frontmatter in linting + markdownlintOptions: { frontMatter: null }, }) const errors = result.markdown expect(errors.length).toBe(4) - expect(errors[0].lineNumber).toBe(2) // title: TODOCS - expect(errors[1].lineNumber).toBe(3) // intro: TODOCS - expect(errors[2].lineNumber).toBe(6) // content TODOCS - expect(errors[3].lineNumber).toBe(7) // content TODOCS + expect(errors[0].lineNumber).toBe(2) + expect(errors[1].lineNumber).toBe(3) + expect(errors[2].lineNumber).toBe(6) + expect(errors[3].lineNumber).toBe(7) }) test('TODOCS placeholder in frontmatter is not caught with default frontmatter handling', async () => { @@ -123,17 +123,13 @@ describe(searchReplace.names.join(' - '), () => { const result = await runRule(searchReplace, { strings: { markdown }, ruleConfig: searchReplaceConfig['search-replace'], - // Default frontmatter handling (frontmatter is stripped from content) }) const errors = result.markdown - // When using default frontmatter handling (frontmatter is stripped from content), - // this unit test only tests the search-replace rule in isolation on the content portion. - // Frontmatter linting happens separately in the actual linting system. + // Default frontmatter handling strips frontmatter, so this only tests Markdown content. expect(errors.length).toBe(0) }) test('TODOCS in frontmatter is detected when frontmatter is included in content', async () => { - // This test shows that search-replace works on frontmatter when it's included in content const frontmatterOnly = [ '---', 'title: TODOCS', @@ -142,24 +138,21 @@ describe(searchReplace.names.join(' - '), () => { '---', ].join('\n') - // When frontmatter is treated as content, search-replace works const result = await runRule(searchReplace, { strings: { markdown: frontmatterOnly }, ruleConfig: searchReplaceConfig['search-replace'], - markdownlintOptions: { frontMatter: null }, // Include frontmatter in content + markdownlintOptions: { frontMatter: null }, }) const errors = result.markdown - // Finds all 3 TODOCS in frontmatter when frontmatter is included in content expect(errors.length).toBe(3) - expect(errors[0].lineNumber).toBe(2) // title: TODOCS - expect(errors[1].lineNumber).toBe(3) // shortTitle: TODOCS - expect(errors[2].lineNumber).toBe(4) // intro: TODOCS + expect(errors[0].lineNumber).toBe(2) + expect(errors[1].lineNumber).toBe(3) + expect(errors[2].lineNumber).toBe(4) }) test('TODOCS placeholder found in documentation about TODOCS usage', async () => { - // This test verifies that the TODOCS rule detects instances in documentation files - // The actual exclusion happens in the reporting layer, not in the rule itself + // content/contributing docs are path-excluded before this rule detects TODOCS placeholders. const markdown = [ '---', 'title: Using the TODOCS placeholder to leave notes', @@ -182,15 +175,13 @@ describe(searchReplace.names.join(' - '), () => { }) const errors = result.markdown - // The rule should find TODOCS in frontmatter because markdownlint-disable doesn't apply there - // However, since we're testing the actual behavior, let's check what we get const frontmatterErrors = errors.filter((e) => e.lineNumber <= 6) const contentErrors = errors.filter((e) => e.lineNumber > 6) - // The markdownlint-disable comment should suppress content errors + // markdownlint-disable suppresses content errors, not frontmatter errors. expect(contentErrors.length).toBe(0) - // Frontmatter errors depend on the configuration - this test documents current behavior + // frontMatter: null keeps frontmatter in content, so these TODOCS errors appear. expect(frontmatterErrors.length).toBeGreaterThanOrEqual(0) }) }) 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 dcbb9947a0d7..14137e8709e8 100644 --- a/src/content-linter/tests/unit/table-column-integrity-simple.ts +++ b/src/content-linter/tests/unit/table-column-integrity-simple.ts @@ -167,8 +167,7 @@ describe(tableColumnIntegrity.names.join(' - '), () => { }) test('File paths with pipes are handled correctly (regression test)', async () => { - // This test catches the specific issue from content/actions/tutorials/build-and-test-code/python.md - // where the old regex /[^\\]\|/ was consuming characters before pipes and miscounting columns + // content/actions/tutorials/build-and-test-code/python.md exposed /[^\\]\|/ pipe miscounts. const markdown = [ '| Directory | Ubuntu | macOS |', '|-----------|--------|-------|', @@ -182,7 +181,6 @@ describe(tableColumnIntegrity.names.join(' - '), () => { }) test('Complex file paths with multiple characters before pipes', async () => { - // Additional test to ensure the lookbehind regex works with various characters before pipes const markdown = [ '| Pattern | Linux Path | Windows Path |', '|---------|------------|--------------|', diff --git a/src/content-linter/tests/unit/third-party-actions-reusable.ts b/src/content-linter/tests/unit/third-party-actions-reusable.ts index 6227dff9a08f..b485ebbbcbff 100644 --- a/src/content-linter/tests/unit/third-party-actions-reusable.ts +++ b/src/content-linter/tests/unit/third-party-actions-reusable.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { thirdPartyActionsReusable } from '../../lib/linting-rules/third-party-actions-reusable' -// Configure the test figure to not split frontmatter and content +// Keep frontmatter in params.lines so disclaimer lookback uses source line offsets. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(thirdPartyActionsReusable.names.join(' - '), () => { diff --git a/src/content-linter/types.ts b/src/content-linter/types.ts index 43f1b3cdf9b2..b5ae84e1f8de 100644 --- a/src/content-linter/types.ts +++ b/src/content-linter/types.ts @@ -1,4 +1,3 @@ -// Interfaces for content linter rule parameters and callbacks export interface MarkdownToken { type: string tag?: string @@ -12,9 +11,9 @@ export interface MarkdownToken { export interface RuleParams { name: string // file path - lines: string[] // array of lines from the file - frontMatterLines: string[] // array of frontmatter lines - tokens?: MarkdownToken[] // markdown tokens (when using markdownit parser) + lines: string[] + frontMatterLines: string[] + tokens?: MarkdownToken[] // present only when the rule uses the markdownit parser config?: { [key: string]: unknown // rule-specific configuration } diff --git a/src/content-pipelines/config.yml b/src/content-pipelines/config.yml index e2b9719e25a3..7a364ca70798 100644 --- a/src/content-pipelines/config.yml +++ b/src/content-pipelines/config.yml @@ -1,19 +1,17 @@ -# Content pipelines configuration +# Content pipelines sync source docs into allowed target articles with the +# content-pipeline-update agent. # -# Each entry defines a content pipeline that syncs docs from an external -# repository and uses the content-pipeline-update agent to update content articles. +# Run a pipeline manually with: +# npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli # -# The update.ts script reads this file so you can run: -# $ npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli -# -# The workflow matrix in .github/workflows/content-pipelines.yml only needs `id`; -# everything else is read from this file. -# -# `exclusions` lists source topics the agent should skip during gap analysis. -# Use an empty list ([]) when nothing should be excluded. Example: -# exclusions: -# - Internal debugging commands -# - Experimental telemetry flags +# The workflow matrix entry needs only id. +# Later workflow steps and update.ts read the other fields from this file. +# exclusions lists source topics the agent skips during gap analysis. +# Use [] when none. +# Example: +# exclusions: +# - Internal debugging commands +# - Experimental telemetry flags # copilot-cli: name: Copilot CLI @@ -55,13 +53,3 @@ gh-stack: The source uses "sh" code fences and title-case headings; use "shell" and sentence case instead. Write "pull request" rather than "PR". Do not remove the public preview reusable near the top of the article; it has no counterpart in the source docs. - -# TODO -# mcp-server: -# name: GitHub MCP Server -# source-repo: github/github-mcp-server -# source-path: docs -# # TBD — update this list as articles are created -# target-articles: [] -# exclusions: [] -# content-mapping: "" diff --git a/src/content-pipelines/scripts/update.ts b/src/content-pipelines/scripts/update.ts index f8b0f67ccc71..3fbb0fddd777 100644 --- a/src/content-pipelines/scripts/update.ts +++ b/src/content-pipelines/scripts/update.ts @@ -1,20 +1,15 @@ -// [start-readme] +// Clones an external source repository, detects changed docs, and runs the +// content-pipeline-update Copilot agent to update reference articles. // -// This script clones an external source repository, detects whether its docs -// have changed since the last processed commit, and if so runs the -// content-pipeline-update Copilot agent to update our reference articles. -// -// The workflow (.github/workflows/content-pipelines.yml) calls this script in CI. -// You can also run it locally for testing and iteration: +// .github/workflows/content-pipelines.yml calls this script in CI. +// Run it locally with: // // npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli // npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli --dry-run // npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli --full-scan // -// Defaults (source-repo, source-path, target-articles) are read from -// src/content-pipelines/config.yml. You can override any value via CLI flags. -// -// [end-readme] +// src/content-pipelines/config.yml supplies source-repo, source-path, and +// target-articles defaults. CLI flags override them. import { execSync, execFileSync } from 'child_process' import fs from 'fs' @@ -149,8 +144,7 @@ async function main(): Promise { const repoUrl = `https://github.com/${SOURCE_REPO}.git` try { - // execFileSync passes the token as an argument instead of embedding it in the URL, - // where it would leak into error messages and logs. + // Use http.extraHeader for token, not clone URL; Git includes clone URLs in errors and logs. const args = ['clone'] if (token) { args.push( @@ -200,9 +194,7 @@ async function main(): Promise { diff = '(diff unavailable)' } - // Empty means no doc files changed. - // A leading "(" means the diff itself failed, - // so fall through and run the agent anyway. + // Empty output means no doc files changed; a leading "(" means diff failed, so run the agent. if (!nameStatus.startsWith('(') && !nameStatus.trim()) { console.log( `No changes in ${SOURCE_PATH} between ${storedSha.slice(0, 7)} and ${currentSha.slice(0, 7)}. Skipping agent run.`, @@ -222,8 +214,7 @@ async function main(): Promise { diff, ].join('\n') } else { - // Initial run or full scan, so list every source doc. - // Incremental runs get this inventory from git diff --name-status instead. + // Initial and full scans list all docs; incremental scans use git diff --name-status. const sourceDocs = path.join(sourceDir, SOURCE_PATH) let fileList: string try { diff --git a/src/content-pipelines/state/.gitignore b/src/content-pipelines/state/.gitignore index 425c0aa18d88..2e5cc2dc37a4 100644 --- a/src/content-pipelines/state/.gitignore +++ b/src/content-pipelines/state/.gitignore @@ -1,4 +1,4 @@ # This directory stores the last-processed commit SHA for each content pipeline. -# SHA files are created and updated by the content-pipelines workflow. -# Diff files (.diff) are ephemeral and should not be committed. +# The content-pipelines workflow creates and updates SHA files. +# Diff files are ephemeral and must not be committed. *.diff diff --git a/src/content-pipelines/state/copilot-cli.sha b/src/content-pipelines/state/copilot-cli.sha index 649c47c84294..ec8fc78723b7 100644 --- a/src/content-pipelines/state/copilot-cli.sha +++ b/src/content-pipelines/state/copilot-cli.sha @@ -1 +1 @@ -c619492f08c4ca46107b70a0633a3e9f8b3adbf9 +1ba5557551d36124e81c7e860dc99b09aa16a000 diff --git a/src/content-render/index.ts b/src/content-render/index.ts index 2333de8a5084..376b8f8d980a 100644 --- a/src/content-render/index.ts +++ b/src/content-render/index.ts @@ -15,14 +15,12 @@ interface RenderOptions { const globalCache = new Map() -// parse multiple times because some templates contain more templates. :] export async function renderContent( template = '', context: Context = {} as Context, options: RenderOptions = {}, ): Promise { - // If called with a falsy template, it can't ever become something - // when rendered. We can exit early to save some pointless work. + // Falsy templates cannot render into content, so skip Liquid and unified work. if (!template) return template let cacheKey: string | null = null if (options && options.cache) { @@ -42,8 +40,7 @@ export async function renderContent( try { template = await renderLiquid(template, context) if (context.markdownRequested) { - // Skip the remark pipeline when there are no internal links to rewrite, - // since link rewriting is the only transformation the pipeline performs. + // Skip remark without internal links; link rewriting is the only markdownRequested transformation. if (!/\]\(\s* 1) { @@ -87,13 +77,11 @@ function handleIndent(tagToken: TagToken, text: string): string { return text } -// When a reusable has multiple lines, and the input line is a blockquote, -// keep the blockquote character on every successive line. +// Multiline reusables in blockquotes need the quote marker on every line. const blockquoteRegexp = /^\n?([ \t]*>[ \t]?)/ function handleBlockquote(tagToken: TagToken, text: string): string { if (text.split('\n').length <= 1) return text - // If the line with the liquid tag starts with a blockquote... const { input, content } = tagToken if (!content) return text const inputLine = input.split('\n').find((line) => line.includes(content)) diff --git a/src/content-render/liquid/engine.ts b/src/content-render/liquid/engine.ts index 11418f9cfd9a..1d39d62062ab 100644 --- a/src/content-render/liquid/engine.ts +++ b/src/content-render/liquid/engine.ts @@ -38,33 +38,18 @@ for (const tag of codeTabTags) { engine.registerTag('prompt', promptTag) -/** - * Like the `size` filter, but specifically for - * getting the number of keys in an object - */ engine.registerFilter('obj_size', (input: Record | null | undefined): number => { if (!input) return 0 return Object.keys(input).length }) -/** - * Returns the version number of a GHES version string - * ex: enterprise-server@2.22 => 2.22 - */ engine.registerFilter('version_num', (input: string): string => { return input.split('@')[1] }) -/** - * Render a string that itself contains Liquid. - * - * Values interpolated with `{{ }}` are not given a second Liquid pass, so - * `{% data %}` or `{% ifversion %}` stored in a data file would otherwise be - * printed literally. This filter lets data files keep using Liquid instead of - * hardcoding product names or version logic. - * - * Usage: {{ row.action | render_liquid }} - */ +// Values interpolated with {{ }} do not get a second Liquid pass. +// Use render_liquid when data values contain {% data %} or {% ifversion %}. +// Example: {{ row.action | render_liquid }} interface FilterScope { context: { environments: Record diff --git a/src/content-render/liquid/error-handling.ts b/src/content-render/liquid/error-handling.ts index c37e1a4b2ec2..9749fa812515 100644 --- a/src/content-render/liquid/error-handling.ts +++ b/src/content-render/liquid/error-handling.ts @@ -1,5 +1,5 @@ -// If 'THROW_ON_EMPTY' is set and it's value is '0' or 'false' it becomes -// false. Or true if it's 'true' or '1'. +// THROW_ON_EMPTY is false for 0 or false and true for 1 or true. +// Without it, CI and non-production throw. export const THROW_ON_EMPTY: boolean = Boolean( process.env.THROW_ON_EMPTY ? JSON.parse(process.env.THROW_ON_EMPTY) diff --git a/src/content-render/liquid/ifversion.ts b/src/content-render/liquid/ifversion.ts index 38c724517a18..aca1a3c214b0 100644 --- a/src/content-render/liquid/ifversion.ts +++ b/src/content-render/liquid/ifversion.ts @@ -42,17 +42,16 @@ const supportedOperatorsRegex = new RegExp(`[${supportedOperators.join('')}]`) const releaseRegex = /\d\d?\.\d\d?/ const notRegex = /(?:^|\s)not\s/ -// This module supports a new tag we can use for docs versioning specifically. It extends the -// native Liquid `if` block tag. It has special handling for statements like {% ifversion ghes < 3.0 %}, -// using semver to evaluate release numbers instead of doing standard number comparisons, which -// don't work the way we want because they evaluate 3.2 > 3.10 = true. +// This tag extends Liquid's if block for docs versions. +// Semver compares GHES releases so 3.10 sorts after 3.2. export default class Ifversion extends Tag { tagToken: TagToken branches: Branch[] elseTemplates: Template[] currentVersionObj: VersionObj | null = null - // The following is verbatim from https://github.com/harttle/liquidjs/blob/v9.22.1/src/builtin/tags/if.ts + // This constructor copies LiquidJS if.ts verbatim to keep if, elsif, and else behavior. + // https://github.com/harttle/liquidjs/blob/v9.22.1/src/builtin/tags/if.ts constructor(tagToken: TagToken, remainTokens: TopLevelToken[], liquid: Liquid) { super(tagToken, remainTokens, liquid) @@ -85,8 +84,9 @@ export default class Ifversion extends Tag { stream.start() } - // The following is _mostly_ verbatim from https://github.com/harttle/liquidjs/blob/v9.22.1/src/builtin/tags/if.ts - // The additions here are the handleNots(), handleOperators(), and handleVersionNames() calls. + // Render mostly mirrors LiquidJS if.ts. + // Docs-specific additions are handleNots, handleOperators, and handleVersionNames. + // https://github.com/harttle/liquidjs/blob/v9.22.1/src/builtin/tags/if.ts *render(ctx: Context, emitter: Emitter): Generator { const r = this.liquid.renderer @@ -97,13 +97,10 @@ export default class Ifversion extends Tag { resolvedBranchCond = this.handleNots(resolvedBranchCond) - // Resolve special operators in the conditional, if any. - // This will replace syntax like `fpt or ghes < 3.0` with `fpt or true` or `fpt or false`. + // Version operators resolve before Liquid evaluates the rest of the condition. resolvedBranchCond = this.handleOperators(resolvedBranchCond) - // Replace syntax like `fpt or ghec` with `true or false` based on the current - // version. Only done for the Markdown API, where the version names would - // otherwise be undefined. + // Markdown API requests resolve version names here because Liquid has no version variables. if ((ctx.environments as IfversionEnvironments).markdownRequested) { resolvedBranchCond = this.handleVersionNames(resolvedBranchCond) } @@ -125,21 +122,17 @@ export default class Ifversion extends Tag { const notIndex = condArray.findIndex((el: string) => el === 'not') - // E.g., ['not', 'fpt'] + // Example: ['not', 'fpt'] const condParts = condArray.slice(notIndex, notIndex + 2) - // E.g., 'fpt' const versionToEvaluate = condParts[1] - // If the current version is the version being evaluated in the conditional, - // that is negated and resolved to false. If it's NOT the version being - // evaluated, that resolves to true. + // not fpt resolves to false for FPT and true for every other version. const resolvedBoolean = !(versionToEvaluate === this.currentVersionObj!.shortName) - // Replace syntax like `not fpt` with `true` or `false`. resolvedBranchCond = resolvedBranchCond.replace(condParts.join(' '), String(resolvedBoolean)) - // Run this function recursively until we've resolved all the nots. + // Recursion resolves every not operator in the condition. if (notRegex.test(resolvedBranchCond)) { return this.handleNots(resolvedBranchCond) } @@ -150,19 +143,19 @@ export default class Ifversion extends Tag { handleOperators(resolvedBranchCond: string): string { if (!supportedOperatorsRegex.test(resolvedBranchCond)) return resolvedBranchCond - // If this conditional contains multiple parts using `or` or `and`, get only the conditional with operators. + // Only the version comparison segment gets replaced; Liquid evaluates and/or around it. const condArray = resolvedBranchCond.split(' ') const operatorIndex = condArray.findIndex((el: string) => supportedOperators.find((op: string) => el === op), ) - // E.g., ['ghes', '<', '3.1'] + // Example: ['ghes', '<', '3.1'] const condParts = condArray.slice(operatorIndex - 1, operatorIndex + 2) const [versionShortName, operator, releaseToEvaluate] = condParts - // Make sure the operator is supported and the release number matches `\d\d?\.\d\d?` + // ifversion accepts supported operators and one- or two-digit release parts. const syntaxError = !supportedOperators.includes(operator as IfversionSupportedOperator) || !releaseRegex.test(releaseToEvaluate) @@ -182,25 +175,22 @@ export default class Ifversion extends Tag { let resolvedBoolean: boolean if (operator === '!=') { - // If this is the current plan, compare the release numbers. (Our semver package doesn't handle !=.) - // If it's not the current version, it's always true. + // The semver helper lacks !=, so current plans compare releases and others stay true. resolvedBoolean = versionShortName === this.currentVersionObj!.shortName ? releaseToEvaluate !== currentRelease : true } else { - // If this is the current plan, evaluate the operator using semver. - // If it's not the current plan, it's always false. + // Non-current plans resolve false because their release comparisons cannot match. resolvedBoolean = versionShortName === this.currentVersionObj!.shortName ? versionSatisfiesRange(currentRelease!, `${operator}${releaseToEvaluate}`) : false } - // Replace syntax like `fpt or ghes < 3.0` with `fpt or true` or `fpt or false`. resolvedBranchCond = resolvedBranchCond.replace(condParts.join(' '), String(resolvedBoolean)) - // Run this function recursively until we've resolved all the special operators. + // Recursion resolves every version comparison in the condition. if (supportedOperatorsRegex.test(resolvedBranchCond)) { return this.handleOperators(resolvedBranchCond) } diff --git a/src/content-render/liquid/indented-data-reference.ts b/src/content-render/liquid/indented-data-reference.ts index b5b7f589d73b..092592668051 100644 --- a/src/content-render/liquid/indented-data-reference.ts +++ b/src/content-render/liquid/indented-data-reference.ts @@ -14,15 +14,9 @@ interface LiquidScope { } } -// This class supports a tag that expects two parameters, a data reference and `spaces=NUMBER`: -// -// {% indented_data_reference foo.bar spaces=NUMBER %} +// indented_data_reference renders a data reference with spaces=NUMBER prepended to every line. // Example: {% indented_data_reference reusables.pages.wildcard-dns-warning spaces=3 %} -// -// This tag renders the given data reference with the specified number of spaces -// prepended to each line. This results in correct formatting when the data -// reference is used inside a block element (like a list or nested list) without -// affecting the formatting when the reference is used elsewhere via {{ site.data.foo.bar }}. +// Use it inside Markdown blocks, such as nested lists, without changing site.data rendering. const IndentedDataReference = { markup: '', @@ -33,8 +27,7 @@ const IndentedDataReference = { }, async render(scope: LiquidScope): Promise { - // obfuscate first legit space, remove all other spaces, then restore legit space - // this way we can support spaces=NUMBER as well as spaces = NUMBER + // Preserve the separator space so spaces=NUMBER and spaces = NUMBER parse the same way. const input = this.markup .replace(/\s/, 'REALSPACE') .replace(/\s/g, '') @@ -42,7 +35,7 @@ const IndentedDataReference = { const [dataReference, spaces] = input.split(' ') - // if no spaces are specified, default to 2 + // The tag defaults to spaces=2. const numSpaces: string = spaces ? spaces.replace(/spaces=/, '') : '2' assert(parseInt(numSpaces) || numSpaces === '0', '"spaces=NUMBER" must include a number') diff --git a/src/content-render/liquid/octicon.ts b/src/content-render/liquid/octicon.ts index aacb8927e791..81ed01f1109f 100644 --- a/src/content-render/liquid/octicon.ts +++ b/src/content-render/liquid/octicon.ts @@ -5,16 +5,13 @@ const OptionsSyntax = /([a-zA-Z-]+)="([\w\s-]+)"*/g const Syntax = new RegExp(`"(?[a-zA-Z-]+)"(?(?:\\s${OptionsSyntax.source})*)`) const SyntaxHelp = 'Syntax Error in tag \'octicon\' - Valid syntax: octicon "" ' -/** - * Uses the octicons library to render the chosen icon. Also - * supports passing attributes like `width="64"`. - * - * If no aria-label is provided, a default one will be auto-generated - * based on the icon name (e.g., "check icon", "git-branch icon"). - * - * {% octicon "check" %} - * {% octicon "check" width="64" aria-label="Example label" %} - */ +// The octicon tag renders a Primer Octicon and forwards attributes such as width="64". +// Without aria-label, the tag derives one from the icon name, such as check icon. +// Example: {% octicon "check" %} +// Example: {% octicon "check" width="64" aria-label="Example label" %} +// trashcan, duplicate, and clippy stay compatible with Primer's renamed icons. +// https://github.com/primer/octicons/releases/tag/v12.0.0 +// https://github.com/primer/octicons/blob/main/CHANGELOG.md#1500 const Octicon = { icon: '', options: {} as Record, @@ -26,10 +23,7 @@ const Octicon = { } this.icon = match.groups.icon - // Breaking change in octicons 12 - // https://github.com/primer/octicons/releases/tag/v12.0.0 if (this.icon === 'trashcan') this.icon = 'trash' - // https://github.com/primer/octicons/blob/main/CHANGELOG.md#1500 if (this.icon === 'duplicate') this.icon = 'copy' if (this.icon === 'clippy') this.icon = 'paste' @@ -39,7 +33,6 @@ const Octicon = { let optionsMatch: RegExpExecArray | null while ((optionsMatch = OptionsSyntax.exec(match.groups.options))) { - // Pull out the key/value ([0] is the whole input) const [, key, value] = optionsMatch this.options[key] = value @@ -53,7 +46,7 @@ const Octicon = { throw new Error(`Octicon ${this.icon} does not exist`) } - // Replace non-alphanumeric characters with spaces and append " icon" + // The default aria-label keeps icon-only output accessible. if (!this.options['aria-label']) { const defaultLabel = `${this.icon.toLowerCase().replace(/[^a-z0-9]+/gi, ' ')} icon` this.options['aria-label'] = defaultLabel diff --git a/src/content-render/liquid/post.ts b/src/content-render/liquid/post.ts index e618d580151c..54787a1ec382 100644 --- a/src/content-render/liquid/post.ts +++ b/src/content-render/liquid/post.ts @@ -1,4 +1,3 @@ -// used below to remove extra newlines in TOC lists const endLine: string = '\r?\n' const blankLine: string = '\\s*?[\r\n]*' const startNextLine: string = '[^\\S\r\n]*?[-\\*] foo - // - // - bar if (template.includes('')) { template = template.replace(blankLineInList, '$1$2') } return template } +// Liquid statements can leave triple newlines that break Markdown list numbering. function cleanUpExtraEmptyLines(template: string): string { - // this removes any extra newlines left by (now resolved) liquid - // statements so that extra space doesn't mess with list numbering template = template.replace(/(\r?\n){3}/g, '\n\n') return template } diff --git a/src/content-render/liquid/prompt.ts b/src/content-render/liquid/prompt.ts index 1df9e1bcd28a..062ae75c3e80 100644 --- a/src/content-render/liquid/prompt.ts +++ b/src/content-render/liquid/prompt.ts @@ -1,4 +1,4 @@ -// Defines {% prompt %}…{% endprompt %} to wrap its content in and append the Copilot icon. +// The prompt tag wraps content in code and appends Copilot links with responsive labels. import octicons from '@primer/octicons' import type { TagToken, TopLevelToken } from 'liquidjs' @@ -32,9 +32,9 @@ export const Prompt: LiquidTag = { const promptParam: string = encodeURIComponent(contentString) const href: string = `https://github.com/copilot?prompt=${promptParam}` - // Use murmur hash for deterministic ID (avoids hydration mismatch) + // Deterministic IDs prevent hydration mismatches. const promptId: string = generatePromptId(contentString) - // Show long text on larger screens and short text on smaller screens (set via accessibility.scss) + // accessibility.scss shows the long label on large screens and short label on small screens. const promptLabelLong: string = 'Run this prompt in Copilot Chat' const promptLabelShort: string = 'Run prompt' return [ diff --git a/src/content-render/liquid/tool.ts b/src/content-render/liquid/tool.ts index 922893032e37..47118cef29d7 100644 --- a/src/content-render/liquid/tool.ts +++ b/src/content-render/liquid/tool.ts @@ -3,53 +3,18 @@ import { allPlatforms } from '@/tools/lib/all-platforms' export const tags: string[] = Object.keys(allTools).concat(allPlatforms).concat(['rowheaders']) -// The trailing newline is important. Without it, the line immediately after -// the `` will be considered part of the previous block, which means the Markdown following the `` will not be rendered to HTML correctly. For example: -// -//
Here's some stuff
-// And *here* us also some stuff. -// -// Another **sentence** here. -// -// Will yield: -// -//
Here's some stuff
-// And *here* us also some stuff. -// -//

Another sentence here.

-// -// when rendering this template with unified. -// If you instead inject an extra newline after the ``, you -// go from: -// -//
Here's some stuff
-// -// And *here* us also some stuff. -// -// Another **sentence** here. -// -// which yields: -// -//
Here's some stuff
-// -//

And here us also some stuff.

-// -//

Another sentence here.

-// -// The Tool Liquid tags are a little bit fragile because we hope and assume -// that the author of the Liquid+Markdown *don't* do this: -// -// {% vscode %}Bla bla.{% endvscode %}Next stuff here... -// +// The trailing newline keeps Markdown after outside the HTML block so unified renders it. +// Tool tags require content after the closing tag to start on a new line. +// Example: \nText stays in the HTML block; \n\nText renders as Markdown. const template = '
{{ output }}
\n' export const Tool = { type: 'block' as const, tagName: '', - // Liquid template objects don't have TypeScript definitions + // Liquid does not publish TypeScript definitions for template objects. templates: [] as unknown[], - // tagToken and remainTokens are Liquid internal types without TypeScript definitions + // Liquid internal types do not cover tagToken or remainTokens. parse(tagToken: unknown, remainTokens: unknown) { const token = tagToken as { name: string; getText: () => string } this.tagName = token.name @@ -58,7 +23,6 @@ export const Tool = { const stream = this.liquid.parser.parseStream(remainTokens) stream .on(`tag:end${this.tagName}`, () => stream.stop()) - // tpl is a Liquid template object without TypeScript definitions .on('template', (tpl: unknown) => this.templates.push(tpl)) .on('end', () => { throw new Error(`tag ${token.getText()} not closed`) @@ -66,7 +30,7 @@ export const Tool = { stream.start() }, - // scope is a Liquid scope object, Generator yields/returns Liquid template values - no TypeScript definitions available + // Liquid does not type scope or generator template values. *render(scope: unknown): Generator { const output = yield this.liquid.renderer.renderTemplates(this.templates, scope) return yield this.liquid.parseAndRender(template, { diff --git a/src/content-render/scripts/add-content-type.ts b/src/content-render/scripts/add-content-type.ts index f6286ea7b4a2..0f5925e55768 100644 --- a/src/content-render/scripts/add-content-type.ts +++ b/src/content-render/scripts/add-content-type.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Auto-populate the `contentType` frontmatter property based on the directory location of the content file - */ +// @purpose Writer tool +// @description Auto-populate the `contentType` frontmatter property based on the directory location of the content file import fs from 'fs' import path from 'path' @@ -54,8 +52,7 @@ async function main() { if (file.includes('early-access')) return false if (!options.paths) return true return options.paths.some((p: string) => { - // Allow either a full content path like "content/foo/bar.md" - // or a top-level directory name like "copilot" + // Accept full content paths like content/foo/bar.md or top-level dirs like copilot. if (!p.startsWith('content')) { p = path.join('content', p) } @@ -130,7 +127,7 @@ function processFile(filePath: string, scriptOptions: ScriptOptions) { frontmatter.stringify( content, data, - // lineWidth is a js-yaml option passed through gray-matter, not in gray-matter's type definitions + // gray-matter passes lineWidth to js-yaml, but its types omit it. { lineWidth: -1 } as unknown as Parameters[2], ), ) @@ -144,38 +141,31 @@ function processFile(filePath: string, scriptOptions: ScriptOptions) { } function determineContentType(relativePath: string): string { - // The split path array will be structured like: - // [ 'copilot', 'how-tos', 'troubleshoot', 'index.md' ] - // where the content type we want is in slot 1. + // For copilot/how-tos/troubleshoot/index.md, pathSegments[1] is the content type. const pathSegments = relativePath.split(path.sep) const topLevelDirectory = pathSegments[0] const derivedContentType = pathSegments[1] - // There is only one content/index.md, and it's the homepage. + // content/index.md is the only homepage. if (topLevelDirectory === 'index.md') return 'homepage' - // SPECIAL HANDLING FOR RAI - // If a directory name includes a responsible-use string, assume the 'rai' type. + // Responsible-use directories map to the rai content type. if (derivedContentType.includes(RESPONSIBLE_USE_STRING)) { return RAI_TYPE } - // Allow 'getting-started' as an alternative directory name for 'get-started'. + // getting-started directories map to get-started. if (derivedContentType === 'getting-started') { return 'get-started' } - // When the content directory matches any of the allowed - // content type values (such as 'get-started', - // 'concepts', 'how-tos', 'reference', and 'tutorials'), - // immediately return it. We're satisfied. + // Directories matching contentTypesEnum map to their content type. if (contentTypesEnum.includes(derivedContentType)) { return derivedContentType } - // There is only one content//index.md file per doc set. - // This index.md is always a landing page. + // Product index.md files are landing pages. if (derivedContentType === 'index.md') { return LANDING_TYPE } diff --git a/src/content-render/scripts/all-documents/cli.ts b/src/content-render/scripts/all-documents/cli.ts index 3e4893ef9793..5b304222c4f2 100644 --- a/src/content-render/scripts/all-documents/cli.ts +++ b/src/content-render/scripts/all-documents/cli.ts @@ -1,43 +1,14 @@ -/** - * You specify one or more languages and versions, and this script - * will output a JSON file with the metadata needed. - * You run it with: - * - * npm run all-documents -- -o /tmp/all-documents.json - * - * By default, it will do free-pro-team, enterprise-cloud, and whatever - * the latest enterprise-server is. You can specify versions with: --version - * For example: - * - * npm run all-documents -- -v free-pro-team@latest -v ghes-3.12 - * - * By default it will include all languages, but you can specify - * with --language - * - * npm run all-documents -- -l en -l de - * - * For debugging purposes, because there are so *many* documents you can - * apply a filter by URL matching, for example: - * - * npm run all-documents -- -f get-started/using-github - * - * This will only include documents whose URL contains the string - * 'get-started/using-github'. - * - * If you don't specify an output file (the --output flag or -o for short), - * it will print all the JSON to stdout. - * - * By default the fields set to include are: title, shortTitle, intro, url. - * You can instead specify the fields you only want. For example - * - * npm run all-documents -- --field url --field title - * - * Now the JSON will look like this: - * - * ... - * {"title": "Some title", "url": "/some-url"} - * ... - */ +// Generates JSON metadata for documents. +// Run npm run all-documents -- -o /tmp/all-documents.json. +// Defaults to all languages, free-pro-team, enterprise-cloud, latest enterprise-server, +// fields title, shortTitle, intro, and url, and output file all-documents.json. +// Use --version for versions such as free-pro-team@latest and ghes-3.12. +// Use --language for languages such as en and de. +// Use --filter to include only documents whose URL contains the given string. +// Use --field to choose output fields, such as url and title. +// Filter example: npm run all-documents -- -f get-started/using-github. +// Field example: npm run all-documents -- --field url --field title. +// Example field output: {"title":"Some title","url":"/some-url"}. import { writeFileSync, statSync } from 'fs' @@ -47,7 +18,7 @@ import { languageKeys } from '@/languages/lib/languages-server' import { allVersions } from '@/versions/lib/all-versions' import { allDocuments, POSSIBLE_FIELDS, type AllDocument } from './lib' -// E.g. enteprise-server@3.12, free-pro-team@latest, etc +// Version flags accept enterprise-server@3.12 and free-pro-team@latest. const fullVersions = Object.keys(allVersions) const defaultVersions: string[] = [] const shortAlias = new Map() diff --git a/src/content-render/scripts/cta-builder.ts b/src/content-render/scripts/cta-builder.ts index 96ca90b65f00..3cd26ab9597b 100644 --- a/src/content-render/scripts/cta-builder.ts +++ b/src/content-render/scripts/cta-builder.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Create a properly formatted Call-to-Action URL with tracking parameters - */ +// @purpose Writer tool +// @description Create a properly formatted Call-to-Action URL with tracking parameters import { Command } from 'commander' import readline from 'readline' import chalk from 'chalk' @@ -92,7 +90,7 @@ program.action(() => { interactiveBuilder() }) -// Only run CLI when script is executed directly, not when imported +// Avoid parsing CLI arguments when tests import this module. if (import.meta.url === `file://${process.argv[1]}`) { program.parse() } @@ -106,7 +104,7 @@ async function selectFromOptions( console.log(chalk.yellow(`\n${message} (${paramName}):`)) for (let index = 0; index < options.length; index++) { const option = options[index] - const letter = String.fromCharCode(97 + index) // 97 is 'a' in ASCII + const letter = String.fromCharCode(97 + index) // 97 is the ASCII code for a. console.log(chalk.white(` ${letter}. ${option}`)) } @@ -115,7 +113,7 @@ async function selectFromOptions( const answer = await promptFn('Enter the letter of your choice: ') if (!answer) continue - const letterIndex = answer.toLowerCase().charCodeAt(0) - 97 // Convert letter to index + const letterIndex = answer.toLowerCase().charCodeAt(0) - 97 if (letterIndex >= 0 && letterIndex < options.length && answer.length === 1) { return options[letterIndex] @@ -124,7 +122,7 @@ async function selectFromOptions( const validLetters = options.map((_, index) => String.fromCharCode(97 + index)).join(', ') console.log(chalk.red(`Invalid choice. Please enter one of: ${validLetters}`)) - // Safety: prevent infinite loops in automated scenarios + // Cap invalid answers for automated runs; empty answers reprompt without counting. if (++attempts > 50) { throw new Error('Too many invalid attempts. Please restart the tool.') } @@ -145,7 +143,7 @@ async function confirmChoice( if (lower === 'n' || lower === 'no') return false console.log(chalk.red('Please enter y or n')) - // Safety: prevent infinite loops in automated scenarios + // Cap invalid answers for automated runs; empty answers reprompt without counting. if (++attempts > 50) { throw new Error('Too many invalid attempts. Please restart the tool.') } @@ -176,7 +174,6 @@ interface AjvError { params: AjvErrorParams } -// Process AJV validation errors into readable messages function formatValidationErrors(ctaParams: CTAParams, errors: AjvError[]): string[] { const errorMessages: string[] = [] for (const error of errors) { @@ -198,7 +195,6 @@ function formatValidationErrors(ctaParams: CTAParams, errors: AjvError[]): strin return errorMessages } -// Full validation using AJV schema (consistent across all commands) function validateCTAParams(params: CTAParams): { isValid: boolean; errors: string[] } { const isValid = validateCTASchema(params) const ajvErrors = validateCTASchema.errors || [] @@ -234,7 +230,7 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin const newParams: CTAParams = {} - // Preserve any new-style params that are already on the URL. + // Keep CTA params that already pass the schema. for (const [key, value] of url.searchParams.entries()) { for (const param of Object.keys(ctaSchema.properties)) { if (key === param && key in ctaSchema.properties) { @@ -277,7 +273,7 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin } } - // Build new URL - preserve all existing parameters except old ref_ parameters + // Keep existing query parameters except ref_cta, ref_loc, and ref_page. const newUrl = new URL(url.toString()) newUrl.searchParams.delete('ref_cta') @@ -290,15 +286,12 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin } } - // The URL constructor may add a slash before the question mark in - // "github.com?foo", but we don't want that. First, check if original - // URL had trailing slash before query params. + // URL serializes github.com?foo as github.com/?foo; preserve the original slash shape. const urlBeforeQuery = oldUrl.split('?')[0] const hadTrailingSlash = urlBeforeQuery.endsWith('/') let finalUrl = newUrl.toString() - // Remove unwanted trailing slash if original didn't have one. if (!hadTrailingSlash && finalUrl.includes('/?')) { finalUrl = finalUrl.replace('/?', '?') } @@ -321,19 +314,19 @@ function inferProductFromUrl(url: string, refCta: string): string { try { hostname = new URL(url).hostname.toLowerCase() } catch { - // Fallback if url isn't valid: leave hostname empty + // Invalid URLs fall back to ref_cta or the default product. } if (hostname === 'desktop.github.com' || refCta.includes('desktop')) { return 'desktop' } - // Hostname contains 'copilot' (e.g., copilot.github.com), or refCta mentions copilot + // GitHub subdomains containing copilot and ref_cta values containing copilot map to copilot. if ( (hostname.includes('copilot') && hostname.endsWith('.github.com')) || refCta.toLowerCase().includes('copilot') ) { return 'copilot' } - // Hostname contains 'enterprise' (e.g. enterprise.github.com), or refCta mentions GHEC + // GitHub subdomains containing enterprise and ref_cta values containing GHEC map to ghec. if ( (hostname.includes('enterprise') && hostname.endsWith('.github.com')) || refCta.includes('GHEC') @@ -344,8 +337,7 @@ function inferProductFromUrl(url: string, refCta: string): string { } function inferStyleFromContext(refLoc: string): string { - // If location suggests it's in a button context, return button - // Otherwise default to text for inline links + // Button-like ref_loc values map to button; everything else defaults to text. const isButton = buttonKeywords.some((keyword) => refLoc.toLowerCase().includes(keyword)) return isButton ? 'button' : 'text' } @@ -393,7 +385,6 @@ async function interactiveBuilder(): Promise { ) } - // Optional parameters (properties not in required array) console.log(chalk.white(`\nOptional parameters:\n`)) const allProperties = Object.keys(ctaSchema.properties) @@ -458,7 +449,6 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise< const result = convertOldCTAUrl(options.url) if (options.quiet) { - // In quiet mode, only output the new URL console.log(result.newUrl) return } @@ -469,7 +459,6 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise< console.log(chalk.white('\nNew URL:')) console.log(chalk.cyan(result.newUrl)) - // Validate the converted URL using shared validation function try { const newParams = extractCTAParams(result.newUrl) const validation = validateCTAParams(newParams) @@ -507,7 +496,7 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise< } } - // The convert command doesn't use readline, so script should exit naturally + // The convert command opens no readline handle, so Node exits after logging. } async function validateUrl(options: { url?: string }): Promise { @@ -531,7 +520,6 @@ async function validateUrl(options: { url?: string }): Promise { return } - // Validate against schema using shared validation function const validation = validateCTAParams(ctaParams) if (validation.isValid) { @@ -595,7 +583,6 @@ async function buildProgrammaticCTA(options: { const validation = validateCTAParams(params) if (!validation.isValid) { - // Output validation errors to stderr and exit with error code for (const error of validation.errors) { console.error(`Validation error: ${error}`) } diff --git a/src/content-render/scripts/liquid-tags.ts b/src/content-render/scripts/liquid-tags.ts index e24fdf6cfc73..5f9aaac84914 100644 --- a/src/content-render/scripts/liquid-tags.ts +++ b/src/content-render/scripts/liquid-tags.ts @@ -1,7 +1,5 @@ -/* - * @purpose Writer tool - * @description Expand and restore Liquid data references in content files - */ +// @purpose Writer tool +// @description Expand and restore Liquid data references in content files // Usage: npm run liquid-tags -- expand --paths content/pull-requests/about.md // Usage: npm run liquid-tags -- restore --paths content/pull-requests/about.md @@ -38,23 +36,20 @@ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } -// Regex pattern to match expanded content blocks const EXPANDED_PATTERN = /(.+?)/gs -// Validates and normalizes the incoming dataPath to prevent path traversal -// and ensure the final resolved path remains within the expected root. +// Reject absolute, traversal, empty, and unsafe data paths before resolving under data root. function getDataFilePath(type: 'reusable' | 'variable', dataPath: string): string { if (path.isAbsolute(dataPath)) { throw new Error(`Invalid ${type} data path: absolute paths are not allowed: ${dataPath}`) } - // Disallow path traversal and empty segments const segments = dataPath.split(/[\\/]/) if (segments.some((segment) => segment === '..' || segment === '')) { throw new Error(`Invalid ${type} data path: contains disallowed segments: ${dataPath}`) } - // Restrict allowed characters to a conservative safe set + // Restrict data paths to filename characters used by reusables and variables. if (!/^[A-Za-z0-9_.\-/]+$/.test(dataPath)) { throw new Error(`Invalid ${type} data path: contains disallowed characters: ${dataPath}`) } @@ -147,11 +142,11 @@ function getAllowedTypes(options: ExpandOptions): Array<'reusable' | 'variable'> async function expandReferences(options: ExpandOptions): Promise { const { paths, verbose, markers, shallow } = options - // markers will be true by default, false when --no-markers is used + // --no-markers sets markers to false; missing flag leaves it true. const withMarkers = markers !== false - const recursive = !shallow // Recursive by default unless --shallow is specified + const recursive = !shallow // Omitting --shallow enables recursive expansion. const allowedTypes = getAllowedTypes(options) - const maxIterations = 10 // Safety limit for recursive expansion + const maxIterations = 10 // Stop recursive expansion after 10 passes to avoid circular references. if (paths.length === 0) { console.error(chalk.red('Error: No paths provided. Use --paths option.')) @@ -204,7 +199,6 @@ async function expandReferences(options: ExpandOptions): Promise { hasRemainingRefs = remainingRefs.length > 0 if (shallow) { - // Shallow mode: show remaining references and break if (hasRemainingRefs) { console.log( chalk.yellow( @@ -296,10 +290,10 @@ async function restoreReferences(options: ExpandOptions): Promise { console.log(chalk.dim(' Use --verbose to see details of the edits')) } - // Update data files with the edited content before restoring + // Write edited expanded blocks back to data files before restoring Liquid tags. const updatedDataFiles = updateDataFiles(filePath, verbose, false, allowedTypes) - // Automatically restore any updated data files back to liquid tags + // Restore updated data files so nested references return to Liquid tags too. if (updatedDataFiles.length > 0) { if (verbose) console.log(chalk.blue(' Restoring updated data files back to liquid tags...')) @@ -324,7 +318,7 @@ async function restoreReferences(options: ExpandOptions): Promise { } } - // Always restore the main file content regardless of edits + // Restore the main file even when no data file changed. const restoredContent = restoreFileContent(content, verbose, allowedTypes) if (restoredContent !== content) { @@ -414,12 +408,10 @@ async function detectContentEdits( if (!allowedTypes || allowedTypes.includes(refType)) { try { - // Load the original content from data files const originalContent = loadDataValue(refType, dataPath.trim()) if (originalContent !== null) { - // Compare against the original content directly, not re-resolved - // This avoids nested resolution issues that cause false positives + // Compare direct data file content to avoid false positives from nested resolution. const currentContent = resolvedContent.trim() if (currentContent !== originalContent.trim()) { @@ -458,7 +450,7 @@ function loadDataValue(type: 'reusable' | 'variable', dataPath: string): string if (type === 'reusable') { const content = fs.readFileSync(targetPath, 'utf8') - // Remove any frontmatter if present (same as resolveReusable) + // Strip reusable frontmatter before comparing content, matching resolveReusable. const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') return contentWithoutFrontmatter.trim() } else { @@ -478,7 +470,7 @@ function loadDataValue(type: 'reusable' | 'variable', dataPath: string): string return typeof current === 'string' ? current.trim() : String(current).trim() } } catch { - // Silently return null for any errors + // Unreadable data returns null so callers can treat it as unverifiable. } return null } @@ -561,7 +553,7 @@ function extractDataUpdates( const refType = type as 'reusable' | 'variable' if (!allowedTypes || allowedTypes.includes(refType)) { - // Check if this content was actually changed before including it + // Compare expanded blocks with their source before updating data files. try { const originalContent = loadDataValue(refType, dataPath.trim()) if (originalContent !== null && resolvedContent.trim() !== originalContent.trim()) { @@ -572,7 +564,7 @@ function extractDataUpdates( }) } } catch { - // If we can't verify, assume it was changed to be safe + // Keep blocks on unexpected errors; unreadable files return null from loadDataValue. updates.push({ type: refType, path: dataPath.trim(), @@ -619,19 +611,18 @@ function applyDataUpdates( } else { console.log(chalk.green(` Updated: ${targetPath}`)) } - return targetPath // Return path even in dry run + return targetPath // Dry runs return the target path so callers can report it. } try { if (type === 'reusable') { - // For reusables, replace entire file content if (contents.length > 1) { console.log( chalk.yellow(` Warning: Multiple content blocks found for ${dataPath}, using first one`), ) } - // Preserve original file's newline behavior + // Preserve a trailing newline from the original reusable file. const originalContent = fs.readFileSync(targetPath, 'utf8') const hasTrailingNewline = originalContent.endsWith('\n') const newContent = @@ -642,12 +633,11 @@ function applyDataUpdates( console.log(chalk.green(` Updated: ${type}s.${dataPath}`)) } } else { - // For variables, update YAML structure const yamlContent = fs.readFileSync(targetPath, 'utf8') const data = load(yamlContent) as Record const pathParts = dataPath.split('.') - const propertyPath = pathParts.slice(1) // Skip the file name + const propertyPath = pathParts.slice(1) let current: Record = data for (let i = 0; i < propertyPath.length - 1; i++) { @@ -665,7 +655,7 @@ function applyDataUpdates( } current[finalKey] = contents[0] - // Preserve original file's newline behavior for YAML + // Preserve a trailing newline from the original YAML file. const hasTrailingNewline = yamlContent.endsWith('\n') const yamlOutput = dump(data) const finalYaml = @@ -692,13 +682,13 @@ function findLiquidReferences( const references: LiquidReference[] = [] const types = allowedTypes || ['reusable', 'variable'] - // Pattern to match {% data reusables.path %} and {% data variables.path %} + // Match data references for reusables and variables. const liquidPattern = /{%\s*data\s+(reusables|variables)\.([^%]+)\s*%}/g let match while ((match = liquidPattern.exec(content)) !== null) { const [original, type, dataPath] = match - const refType = type.slice(0, -1) as 'reusable' | 'variable' // Remove 's' from end + const refType = type.slice(0, -1) as 'reusable' | 'variable' if (types.includes(refType)) { references.push({ @@ -745,7 +735,7 @@ async function resolveReusable(reusablePath: string, verbose?: boolean): Promise try { const content = fs.readFileSync(filePath, 'utf-8') - // Remove any frontmatter if present + // Strip reusable frontmatter before inserting its body. const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') return contentWithoutFrontmatter.trim() } catch (error: unknown) { @@ -781,8 +771,8 @@ async function resolveVariable(variablePath: string, verbose?: boolean): Promise const yamlContent = fs.readFileSync(filePath, 'utf-8') const data = load(yamlContent) as Record - // Navigate through the key path to find the value - const [, ...keyPath] = pathParts // Skip filename, get remaining path + // Variable paths start with the file name; remaining segments address YAML keys. + const [, ...keyPath] = pathParts let value: unknown = data for (const key of keyPath) { if (value && typeof value === 'object' && key in value) { diff --git a/src/content-render/scripts/move-by-content-type.ts b/src/content-render/scripts/move-by-content-type.ts index e7773d92d881..1b4d395e5078 100644 --- a/src/content-render/scripts/move-by-content-type.ts +++ b/src/content-render/scripts/move-by-content-type.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Move files to the relevant directory based on `contentType` frontmatter - */ +// @purpose Writer tool +// @description Move files to the relevant directory based on `contentType` frontmatter import { program } from 'commander' import fs from 'fs/promises' @@ -16,8 +14,7 @@ const CONTENT_TYPES = contentTypesEnum.filter( (type) => type !== 'homepage' && type !== 'other' && type !== 'landing', ) -// The number of path segments at the product level (e.g., "content//..."). -// Used when determining whether a target directory is a deeper subdirectory. +// Three segments identify content//index.md and top-level content-type directories. const PRODUCT_LEVEL_PATH_SEGMENTS = 3 const contentTypeToDir = (contentType: string): string => { @@ -31,10 +28,10 @@ function shouldSkipIndexFile(filePath: string): boolean { const parts = relativePath.split(path.sep) const contentIndex = parts.indexOf('content') - // Skip product-level index.md: content/product/index.md + // Keep product-level index.md files in place. if (parts.length === contentIndex + PRODUCT_LEVEL_PATH_SEGMENTS) return true - // Skip content-type-level index.md that's already in place: content/product/content-type/index.md + // Keep content-type index.md files that already sit at content/product/content-type/index.md. if (parts.length === contentIndex + 4) { const parentDir = parts[parts.length - 2] if (validContentTypeDirs.has(parentDir)) return true @@ -52,18 +49,16 @@ function calculateTarget(filePath: string, contentType: string, productDir: stri const targetContentType = contentTypeToDir(contentType) if (targetContentType === 'how-tos') { - // Preserve subdirectory structure for how-tos + // How-to pages keep their product subdirectory structure. const pathAfterProduct = parts.slice(contentIndex + 2, -1) if (pathAfterProduct[0] === 'how-tos') { - // Already in how-tos, no change return { targetDir: path.dirname(filePath), targetPath: filePath } } else { - // Move to how-tos preserving structure const targetDir = path.join(productDir, targetContentType, ...pathAfterProduct) return { targetDir, targetPath: path.join(targetDir, fileName) } } } else { - // Flatten to content-type directory + // Other content types flatten into their content-type directory. const targetDir = path.join(productDir, targetContentType) return { targetDir, targetPath: path.join(targetDir, fileName) } } @@ -81,7 +76,6 @@ program .description('Reorganize content files into subdirectories based on their contentType property') .argument('[paths...]', 'Content paths to process') .action(async (paths: string[]) => { - // Gather files. const filesToProcess: string[] = [] if (paths?.length > 0) { for (const p of paths) { @@ -102,8 +96,8 @@ program const filesToMove: FileMove[] = [] const skipped: Array<{ file: string; reason: string }> = [] - const targetDirs = new Set() // Relative paths of all target directories - const subdirTargets = new Set() // Subdirectories receiving index.md files + const targetDirs = new Set() + const subdirTargets = new Set() const productDirs = new Set() const productsWithRai = new Set() @@ -111,7 +105,6 @@ program const relativePath = path.relative(process.cwd(), filePath) try { - // Skip certain index.md files if (path.basename(filePath) === 'index.md' && shouldSkipIndexFile(filePath)) { continue } @@ -129,7 +122,7 @@ program const parts = relativePath.split(path.sep) const contentIndex = parts.indexOf('content') - // Skip all landing pages - they should only be product-level index.md and don't move + // Landing pages belong at product-level index.md files; this script does not move them. if (contentType === 'landing') { console.log(chalk.gray(`→ Skipping ${relativePath}: landing pages don't move`)) continue @@ -166,7 +159,7 @@ program console.log(chalk.yellow(`⚠ Skipping ${relativePath}: Target file already exists`)) continue } catch { - // Good, doesn't exist + // Missing target means the move can proceed. } filesToMove.push({ filePath, targetDir, targetPath, contentType }) @@ -174,7 +167,6 @@ program const relativeTargetDir = path.relative(process.cwd(), targetDir) targetDirs.add(relativeTargetDir) - // Track subdirectories that will receive index.md files if ( path.basename(filePath) === 'index.md' && relativeTargetDir.split(path.sep).length > PRODUCT_LEVEL_PATH_SEGMENTS @@ -195,7 +187,6 @@ program console.log(chalk.white('Ensuring standard content-type directories exist...\n')) - // Add standard content-type directories for each affected product if (paths?.length > 0) { for (const p of paths) { const fullPath = path.resolve(process.cwd(), p) @@ -237,10 +228,10 @@ program await fs.access(indexPath) console.log(chalk.gray(`- Skipping ${dirPath}/index.md (already exists)`)) } catch { - // Only create placeholders for top-level content-type directories (not subdirectories) + // Create placeholders only for top-level content-type directories. if (dirPath.split(path.sep).length > PRODUCT_LEVEL_PATH_SEGMENTS) continue - // Skip if an index.md will be moved here + // Moved index.md files become the placeholder for their target directory. if (subdirTargets.has(dirPath)) { console.log(chalk.gray(`- Skipping ${dirPath}/index.md (will be moved)`)) continue @@ -249,8 +240,6 @@ program const contentTypeName = path.basename(dirPath) const title = titleMap[contentTypeName] || contentTypeName - // Determine the correct contentType for this placeholder - // Map directory name back to contentType enum value const placeholderContentType = contentTypeName === 'responsible-use' ? 'rai' : contentTypeName @@ -316,7 +305,7 @@ contentType: ${placeholderContentType} const moved: Array<{ file: string; from: string; to: string }> = [] - // Categorize files by type for correct move order + // Move regular files and index.md files in separate groups to avoid path conflicts. const regularFiles = filesToMove.filter((f) => path.basename(f.filePath) !== 'index.md') const topLevelIndexFiles = filesToMove.filter((f) => { if (path.basename(f.filePath) !== 'index.md') return false @@ -333,7 +322,7 @@ contentType: ${placeholderContentType} ) }) - // Move subdirectory index files first (copy only, delete later) + // Copy subdirectory index.md files first; delete sources after regular files move. const indexFilesToDeleteLater: string[] = [] for (const file of subdirIndexFiles) { try { @@ -341,7 +330,7 @@ contentType: ${placeholderContentType} const content = await fs.readFile(file.filePath, 'utf-8') const { data, content: body } = readFrontmatter(content) - // Clear children array because paths will be invalid in the new content-type directory structure + // Clear children because the new content-type directory structure invalidates child paths. if (data?.children) data.children = [] await fs.writeFile( @@ -526,7 +515,7 @@ contentType: ${placeholderContentType} if (!data) continue - // For how-tos, build children from subdirectories + // how-tos children point to subdirectories. if (path.basename(dirPath) === 'how-tos') { const entries = await fs.readdir(absoluteDirPath, { withFileTypes: true }) const subdirs = entries @@ -544,7 +533,7 @@ contentType: ${placeholderContentType} ) } } - // For others, sort with about-* first + // Other content types sort about-* pages first. else if (data.children && Array.isArray(data.children) && data.children.length > 0) { const sorted = [...data.children].sort((a, b) => { const aBasename = path.basename(a) diff --git a/src/content-render/scripts/move-content.ts b/src/content-render/scripts/move-content.ts index d0f02a9e0f14..7c4b35603053 100755 --- a/src/content-render/scripts/move-content.ts +++ b/src/content-render/scripts/move-content.ts @@ -1,25 +1,13 @@ -/** - * @purpose Writer tool - * @description Move or rename a file or a folder and automatically add redirects - */ -// [start-readme] -// -// Use this script to help you move or rename a single file or a folder. The script will move or rename the file or folder for you, update relevant `children` in the index.md file(s), and add a `redirect_from` to frontmatter in the renamed file(s). Note: You will still need to manually update the `title` if necessary. -// -// By default, the `move-content.ts` script will commit the changes it makes. If you don't want the script to run any git commands for you, run it with the `--no-git` flag. Note: In most cases it will be easier and safer to let the script run the git commands for you, since git can get confused when a file is both renamed and edited. -// -// To learn more about the script, you can run `npm run move-content --help`. -// -// To run the script for a file: -// - `npm run move-content PATH/TO/CURRENT-FILE.md PATH/TO/DESIRED-FILE-LOCATION-OR-NAME.md` -// -// To run the script for a folder: -// - `npm run move-content PATH/TO/CURRENT-FOLDER PATH/TO/DESIRED-FOLDER-LOCATION-OR-NAME` -// -// To undo the script, run the same command that you used to run the script, but add an `--undo` flag: -// - `npm run move-content --undo PATH/TO/OLD PATH/TO/NEW` -// -// [end-readme] +// @purpose Writer tool +// @description Move or rename a file or a folder and automatically add redirects +// Moves one file or folder, updates relevant children entries, and adds redirect_from. +// It does not update title frontmatter. +// By default, it runs git mv and git commit; pass --no-git to avoid git commands. +// Keeping git enabled records rename and edit commits separately. +// 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. import fs from 'fs' import path from 'path' @@ -45,7 +33,7 @@ interface PositionInfo { childGroupPositions: number[][] } -// This is so you can optionally run it again the test fixtures root. +// ROOT lets tests run against a fixture content root. const ROOT = process.env.ROOT || '.' const CONTENT_ROOT = path.resolve(path.join(ROOT, 'content')) @@ -99,7 +87,6 @@ async function main(opts: MoveOptions, nameTuple: string[]) { newPath = new_ } - // The file you're about to move needs to exist if (!fs.existsSync(oldPath)) { console.error(chalk.red(`${oldPath} does not exist.`)) process.exit(1) @@ -107,20 +94,11 @@ async function main(opts: MoveOptions, nameTuple: string[]) { let isFolder = fs.lstatSync(oldPath).isDirectory() - // Before validating, see if we need to fake that the newPath should be. - // This is to mimic how bash `mv` works where you can do: - // - // mv some/place/a/file.txt destin/ation/ - // - // which is implied to mean the same as; - // - // mv some/place/a/file.txt destin/ation/file.txt - // + // Emulate mv: moving path/file.md to an existing path/dir resolves to path/dir/file.md. if (undo) { if (isFolder) { const wouldBe = path.join(oldPath, path.basename(newPath)) - // We can't know if the `newPath` is a directory or file because - // whichever it is, it doesn't exist. + // For undo, infer a file move from the old folder plus the new file basename. if (fs.existsSync(wouldBe) && !fs.lstatSync(wouldBe).isDirectory()) { isFolder = false oldPath = wouldBe @@ -142,22 +120,19 @@ async function main(opts: MoveOptions, nameTuple: string[]) { process.exit(2) } - // This will exit non-zero if anything is wrong with these inputs validateFileInputs(oldPath, newPath, isFolder) const oldHref = makeHref(CONTENT_ROOT, undo ? newPath : oldPath) const newHref = makeHref(CONTENT_ROOT, undo ? oldPath : newPath) if (isFolder) { - // The folder must have an index.md file + // Folders can move only when they have an index.md landing file. const indexFilePath = path.join(oldPath, 'index.md') if (!fs.existsSync(indexFilePath)) { throw new Error(`${oldPath} does not have an index.md file`) } - // Gather individual files by walking `oldPath` recursively. const files = findFilesInFolder(oldPath, newPath, opts) - // First take care of the `git mv` (or regular rename) part. if (undo) { undoFolder(oldPath, newPath, files, opts) } else { @@ -172,10 +147,8 @@ async function main(opts: MoveOptions, nameTuple: string[]) { editFiles(files, false, opts) } } else { - // When it's just an individual file, it's easier. const files: FileTuple[] = [[oldPath, newPath, oldHref, newHref]] - // First take care of the `git mv` (or regular rename) part. moveFiles(files, opts) if (undo) { @@ -185,11 +158,9 @@ async function main(opts: MoveOptions, nameTuple: string[]) { } } - // Updating featuredLinks front matter actually doesn't care if - // the file is a folder or not. It just needs to know the old and new hrefs. + // featuredLinks updates need old and new hrefs, not whether the path is a file or folder. changeFeaturedLinks(oldHref, newHref) - // Update any links in ChildGroups on the homepage. changeHomepageLinks(oldHref, newHref, verbose) if (!undo) { @@ -205,8 +176,7 @@ async function main(opts: MoveOptions, nameTuple: string[]) { function validateFileInputs(oldPath: string, newPath: string, isFolder: boolean) { if (isFolder) { - // Make sure that only the last portion of the path is different - // and that all preceding are equal. + // Directory moves can change only the last path segment unless the destination base exists. const [oldBase, oldName] = splitDirectory(oldPath) const [newBase] = splitDirectory(newPath) if (oldBase !== newBase && !existsAndIsDirectory(newBase)) { @@ -333,9 +303,7 @@ function undoFolder(oldPath: string, newPath: string, files: FileTuple[], opts: } function getBasename(fileOrDirectory: string) { - // Note, can't use fs.lstatSync().isDirectory() because it's just a string - // at this point. It might not exist. - + // Infer file or directory names from path strings because the destination may not exist. if (fileOrDirectory.endsWith('index.md')) { return path.basename(path.dirname(fileOrDirectory)) } @@ -444,9 +412,9 @@ function addToChildren(newPath: string, positions: PositionInfo, opts: MoveOptio } } +// When git runs, commit pure renames before edits so later merges avoid complex three-way diffs. function moveFiles(files: FileTuple[], opts: MoveOptions) { const { verbose, git: useGit } = opts - // Before we do anything, assert that the files are valid for (const [oldPath] of files) { const fileContent = fs.readFileSync(oldPath, 'utf-8') const { errors } = fm(fileContent, { filepath: oldPath }) @@ -458,13 +426,6 @@ function moveFiles(files: FileTuple[], opts: MoveOptions) { if (errors.length > 0) throw new Error('There were more than 0 parse errors') } - // In the first loop, we exclusively perform the rename. No file edits! - // The reason is that we don't want lump renaming and edits in the same - // git commit. - // By having a dedicated git commit that purely renames (without changing - // any content) is best practice to avoid complex 3-way diffs that - // `git merge` does when you later have to merge in the latest `main` - // into your ongoing renaming branch. for (const [oldPath, newPath] of files) { if (verbose) { console.log(`Moving ${chalk.bold(oldPath)} to ${chalk.bold(newPath)}`) @@ -493,13 +454,10 @@ function moveFiles(files: FileTuple[], opts: MoveOptions) { } } +// editFiles keeps redirect_from edits in a separate commit from renames when git runs. function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) { const { verbose, git: useGit } = opts - // Second loop. This time our only job is to edit the `redirects_from` - // frontmatter key. - // See comment in the first loop above for why we're looping over the files - // two times. for (const [oldPath, newPath, oldHref] of files) { const fileContent = fs.readFileSync(newPath, 'utf-8') const { content, data } = readFrontmatter(fileContent) @@ -518,7 +476,7 @@ function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) } } - // Add contentType frontmatter to moved files + // Moved files get contentType from target paths. if (files.length > 0) { const filePaths = files.map(([, newPath]) => newPath) try { @@ -553,7 +511,6 @@ function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) function undoFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) { const { verbose, git: useGit } = opts - // First undo any edits to the file for (const [oldPath, newPath, oldHref] of files) { const fileContent = fs.readFileSync(newPath, 'utf-8') const { content, data } = readFrontmatter(fileContent) @@ -580,10 +537,9 @@ function undoFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) } } +// Regex replacement preserves YAML formatting and comments that serialization would lose. +// Homepage childGroup hrefs omit the leading slash. function changeHomepageLinks(oldHref: string, newHref: string, verbose: boolean) { - // Can't deserialize and serialize the Yaml because it would lose - // formatting and comments. So regex replace it. - // Homepage childGroup links do not have a leading '/', so we need to remove that. const homepageOldHref = oldHref.replace('/', '') const homepageNewHref = newHref.replace('/', '') const escapedHomepageOldHref = RegExp.escape(homepageOldHref) diff --git a/src/content-render/scripts/reusables-cli.ts b/src/content-render/scripts/reusables-cli.ts index d253cd6d2a36..4c84f3496d0a 100644 --- a/src/content-render/scripts/reusables-cli.ts +++ b/src/content-render/scripts/reusables-cli.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Find all content files that use a specific reusable - */ +// @purpose Writer tool +// @description Find all content files that use a specific reusable // Usage: npm run reusables -- --help // Usage: npm run reusables -- find used accounts/create-account.md // Usage: npm run reusables -- find unused accounts/create-account.md diff --git a/src/content-render/scripts/reusables-cli/find/potential-uses.ts b/src/content-render/scripts/reusables-cli/find/potential-uses.ts index c0827117caa9..af2886568cbe 100644 --- a/src/content-render/scripts/reusables-cli/find/potential-uses.ts +++ b/src/content-render/scripts/reusables-cli/find/potential-uses.ts @@ -63,7 +63,7 @@ export function findPotentialUses({ reusableCount += 1 for (const { filePath, fileContents } of allFileContents) { - // Skip the reusable file itself + // Do not report a reusable as a use of itself. if (filePath === reusableFilePath) continue const indices = findIndicesOfSubstringInString(reusableContents.trim(), fileContents) diff --git a/src/content-render/scripts/reusables-cli/find/unused.ts b/src/content-render/scripts/reusables-cli/find/unused.ts index 1f7bf29e8711..9906fb5b663e 100644 --- a/src/content-render/scripts/reusables-cli/find/unused.ts +++ b/src/content-render/scripts/reusables-cli/find/unused.ts @@ -33,7 +33,7 @@ export function findUnused({ absolute }: { absolute: boolean }) { args.startsWith('reusables.') ) { const reusableName = `${path.join('data', ...args.split(' ')[0].split('.'))}.md` - // Special cases where we don't want them to count as reusables. It's an example in a how-to doc + // Ignore how-to examples that use fake reusable names. if ( reusableName.includes('foo/bar.md') || reusableName.includes('foo/par.md') || diff --git a/src/content-render/scripts/reusables-cli/find/used.ts b/src/content-render/scripts/reusables-cli/find/used.ts index 6f56c31512d6..23e44a37d38f 100644 --- a/src/content-render/scripts/reusables-cli/find/used.ts +++ b/src/content-render/scripts/reusables-cli/find/used.ts @@ -26,7 +26,7 @@ export function findUsed(reusablePath: string, { absolute }: { absolute: boolean const filesWithReusables: FilesWithLineNumbers = [] for (const filePath of allFilePaths) { - // Skip the reusable file itself + // Do not report a reusable as a use of itself. if (filePath === reusableFilePath) continue const fileContents = fs.readFileSync(filePath, 'utf-8') diff --git a/src/content-render/scripts/reusables-cli/ignore-reusables.ts b/src/content-render/scripts/reusables-cli/ignore-reusables.ts index 9c9979f80f54..2460a9878523 100644 --- a/src/content-render/scripts/reusables-cli/ignore-reusables.ts +++ b/src/content-render/scripts/reusables-cli/ignore-reusables.ts @@ -1,5 +1,4 @@ -// List of reusables to ignore when checking for potential uses of reusables -// Make sure paths are relative to the root of the repo +// List repo-relative reusables excluded from potential-use checks. export const reusablesToIgnore = [ - 'data/reusables/copilot/trial-period.md', // Just a number, so it pops up in unrelated files + 'data/reusables/copilot/trial-period.md', // This numeric reusable matches unrelated files. ] diff --git a/src/content-render/scripts/reusables-cli/shared.ts b/src/content-render/scripts/reusables-cli/shared.ts index c04e24725d15..454e0477159e 100644 --- a/src/content-render/scripts/reusables-cli/shared.ts +++ b/src/content-render/scripts/reusables-cli/shared.ts @@ -73,12 +73,12 @@ export function getIndicesOfLiquidVariable(liquidVariable: string, fileContents: } export function resolveReusablePath(reusablePath: string): string { - // Try .md if extension is not provided + // Append .md when the reusable path has no extension. if (!reusablePath.endsWith('.md') && !reusablePath.endsWith('.yml')) { reusablePath += '.md' } - // Allow user to just pass the name of the file. If it's not ambiguous, we'll find it. + // Resolve a path fragment only when it matches exactly one reusable file. const allReusableFiles = getAllReusablesFilePaths() const foundPaths = [] for (const possiblePath of allReusableFiles) { @@ -130,13 +130,12 @@ export function findIndicesOfSubstringInString(substr: string, str: string): num } export function findSimilarSubStringInString(substr: string, str: string) { - // Take every sentence in the substr, lower case it, and compare it to every sentence in the str to get a similarity score + // Score each substring sentence against each corpus sentence by shared words. const substrSentences = substr.split('.').map((sentence) => sentence.toLowerCase()) const corpus = str.split('.').map((sentence) => sentence.toLowerCase()) let similarityScore = 0 - // Find how similar every two strings are based on the words they share for (const substrSentence of substrSentences) { for (const sentence of corpus) { const substrTokens = substrSentence.split(' ') diff --git a/src/content-render/scripts/update-filepaths.ts b/src/content-render/scripts/update-filepaths.ts index 7760d5c7b441..45bc147c26d8 100755 --- a/src/content-render/scripts/update-filepaths.ts +++ b/src/content-render/scripts/update-filepaths.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Update content filenames to match short titles - */ +// @purpose Writer tool +// @description Update content filenames to match short titles import fs from 'fs' import path from 'path' @@ -53,11 +51,12 @@ const estimateScriptMinutes = (numberOfFiles: number): string => { return estNum === 0 ? '<1' : estNum.toString() } +// main processes files sequentially because move-content must move files before directories, +// and deepest directories before parents. +// Async does not shorten this work because each path move depends on the ordered result. async function main(): Promise { const slugger = new GithubSlugger() const contentDir: string = path.join(process.cwd(), 'content') - // Filter to get all the content files we want to read in. - // Then sort them from longest > shortest so we can do the file moves in order. const filesToProcess: string[] = sortFiles(filterFiles(contentDir, options)) if (filesToProcess.length === 0) { @@ -71,11 +70,6 @@ async function main(): Promise { console.log(`Estimated time: ${estimate} min\n`) } - // Process files sequentially to maintain the correct order of operations. - // Files must be moved before directories, and directories must be moved - // from deepest to shallowest to avoid path conflicts during the move operations. - // The result is rather slow, but an asynchronous approach that ensures - // sequential processing would not be faster. for (const file of filesToProcess) { try { slugger.reset() @@ -110,24 +104,16 @@ async function processFile( stringToSlugify = await renderContent(stringToSlugify, context, { textOnly: true }) } - // Slugify the short title of each article. - // Where: shortTitle = Foo bar - // Returns: slug = foo-bar - // Fall back to title if shortTitle doesn't exist. + // Slug shortTitle, or title when shortTitle is absent, to get the target basename. const slug: string = slugger.slug(decode(stringToSlugify)) let basename: string if (isDirectory) { - // Where: content location = content/foobar/index.md - // Returns: basename = foobar basename = path.basename(path.dirname(file)) } else { - // Where: content location = content/foobar.md - // Returns: basename = foobar basename = path.basename(file, '.md') } - // If slug and basename already match, all set here. Return early. if (slug === basename) return null const newPath = isDirectory @@ -153,7 +139,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void { return } - // Call out to well-tested move-content script for the moving and redirect adding functions. + // move-content handles file moves, redirects, and children updates. const stdout = execFileSync( 'tsx', [ @@ -166,7 +152,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void { { encoding: 'utf8' }, ) - // Grab just the "Moving..." and "Renamed..." output from stdout; otherwise output is too noisy. + // Print only Moving or Renamed lines unless verbose; full move-content output is noisy. const moveMsg = stdout.split('\n').find((l) => l.startsWith('Moving') || l.startsWith('Renamed')) if (moveMsg && !options.verbose) { console.log(moveMsg, '\n') @@ -176,11 +162,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void { } function sortFiles(filesArray: string[]): string[] { - // The order of operations is important. - // We need to return an array so that the moving operations happens in this order: - // 1. Filepaths - // 2. Deepest subdirectory path - // 3. Shallowest subdirectory path (up to category level, e.g., content/product/category) + // Move files before directories, then deepest directories before parents. return filesArray.toSorted((a, b) => { if (!isDirectoryCheck(a) && isDirectoryCheck(b)) { return -1 @@ -194,7 +176,7 @@ function sortFiles(filesArray: string[]): string[] { if (isDirectoryCheck(a) && isDirectoryCheck(b)) { const aDepth = a.split(path.sep).length const bDepth = b.split(path.sep).length - return bDepth - aDepth // Deeper paths first + return bDepth - aDepth } return 0 @@ -203,21 +185,19 @@ function sortFiles(filesArray: string[]): string[] { function filterFiles(contentDir: string, scriptOptions: ScriptOptions) { return walkFiles(contentDir, ['.md']).filter((file: string) => { - // Never move readmes + // Keep README paths unchanged. if (file.endsWith('README.md')) return false - // Never move early access files + // Keep early access paths unchanged. if (file.includes('early-access')) return false - // Never move the homepage (content/index.md) + // Keep the homepage path unchanged. if (path.relative(contentDir, file) === 'index.md') return false - // Never move product landings (content/foo/index.md) + // Keep product landing paths unchanged. if (path.relative(contentDir, file).split(path.sep)[1] === 'index.md') return false - // If no specific paths are passed, we are done filtering. if (!scriptOptions.paths) return true return scriptOptions.paths.some((p: string) => { - // Allow either a full content path like "content/foo/bar.md" - // or a top-level directory name like "copilot" + // Accept full content paths like content/foo/bar.md or top-level dirs like copilot. if (!p.startsWith('content')) { p = path.join('content', p) } @@ -236,7 +216,7 @@ function determineProcessStatus( isDirectory: boolean, scriptOptions: ScriptOptions, ): boolean { - // A directory is never processed when dirs are excluded, whatever else is set. + // exclude-dirs prevents directory moves even when force is set. if (isDirectory && scriptOptions.excludeDirs) { return false } diff --git a/src/content-render/stylesheets/accessibility.scss b/src/content-render/stylesheets/accessibility.scss index 4f859c02cf71..2659cf3482de 100644 --- a/src/content-render/stylesheets/accessibility.scss +++ b/src/content-render/stylesheets/accessibility.scss @@ -1,23 +1,16 @@ -/* Accessibility fixes for tooltip text spacing and other a11y improvements */ - -/* Fix tooltip text spacing inheritance - Issue #11442 */ +// Tooltips inherit the user's custom text-spacing preferences for accessibility. .tooltipped { &::before, &::after { - /* Allow tooltips to inherit user's custom text spacing preferences */ letter-spacing: inherit !important; word-spacing: inherit !important; line-height: inherit !important; } - /* WCAG 1.4.13: Make tooltip content hoverable with the mouse pointer. - Primer's .tooltipped uses pointer-events:none on the ::after pseudo- - element and a 6px margin gap between the trigger and the tooltip. - This makes it impossible to hover the tooltip content itself. - - Fix: re-enable pointer-events and replace the directional margin with - a transparent border so the hover hit-area is contiguous while the - visual appearance is unchanged. */ + // WCAG 1.4.13 requires tooltip content to stay hoverable with the mouse pointer. + // Primer's .tooltipped sets pointer-events: none on ::after and leaves a 6px + // margin gap between the trigger and tooltip, so replace the margin with a + // transparent border and keep the visual appearance unchanged. &::after { pointer-events: auto !important; } @@ -43,16 +36,14 @@ } } -/* Enhanced focus indicators for high contrast mode */ @media (prefers-contrast: high) { .tooltipped { &:focus-visible::before, &:focus-visible::after { - // --color-focus-outset is defined nowhere in this app, which made the whole - // `outline` shorthand invalid at computed-value time — so the longhands reset - // and `outline-style: none` won, leaving high-contrast users with NO focus - // ring at all, in either colour mode. Brand's focus token, matching the same - // repair already made in annotate.scss. + // --color-focus-outset is undefined here. It invalidates the outline shorthand + // at computed-value time, resets the longhands, and lets outline-style: none + // remove the focus ring in both color modes. Use Brand's focus token to match + // annotate.scss. outline: var(--brand-borderWidth-thick, 2px) solid var(--brand-color-focus, #0377ff); outline-offset: 2px; @@ -60,8 +51,6 @@ } } -/* Responsive tooltip text for Copilot prompt links */ -/* Show long tooltip text on small screens and up (544px+) */ .copilot-prompt-long { display: none; visibility: hidden; @@ -72,7 +61,6 @@ } } -/* Show short tooltip text only on extra small screens (below 544px) */ .copilot-prompt-short { display: inline-block; visibility: visible; diff --git a/src/content-render/stylesheets/alerts.scss b/src/content-render/stylesheets/alerts.scss index 62366c9f0ca3..4d26e2de3deb 100644 --- a/src/content-render/stylesheets/alerts.scss +++ b/src/content-render/stylesheets/alerts.scss @@ -1,7 +1,5 @@ -// Largely identical styling from the monolith, -// but the color names match the Primer variables -// and we had to directly state a few props instead of using variables -// that are only in the monolith. +// This mirrors monolith styling, but color names match Primer variables. +// Direct properties replace variables that exist only in the monolith. $colors: "default", "muted", "subtle", "accent", "success", "attention", "severe", @@ -9,9 +7,8 @@ $colors: .ghd-alert { padding: var(--base-size-8, 0.5rem) var(--base-size-16, 1rem); - // Docs 2026: brand-align the callout container — rounded corners + brand - // border-radius token. The colored left border is set per-type below; the - // callout *system* redesign (Note/Warning/Tip/Pro tip) is tracked separately. + // Brand callout work changes the container only: rounded corners and the Brand token. + // Per-type colors stay on the left border; Note, Warning, Tip, and Pro tip changes are separate. border-left: 0.25em solid var(--brand-color-border-default, var(--color-border-default)); border-radius: var(--brand-borderRadius-medium, 0.5rem); diff --git a/src/content-render/stylesheets/annotate.scss b/src/content-render/stylesheets/annotate.scss index bcc496501233..f00245290941 100644 --- a/src/content-render/stylesheets/annotate.scss +++ b/src/content-render/stylesheets/annotate.scss @@ -1,8 +1,5 @@ @import "src/frame/stylesheets/breakpoint-xxl.scss"; -/* Code annotations -----------------------------------------------------------------------------*/ - .annotate.beside { .annotate-beside { display: inherit; @@ -31,8 +28,8 @@ .annotate-header header { border-top-left-radius: 6px !important; border-top-right-radius: 6px !important; - // Brand's `subtle` border (#d2d9d4) is the match for Primer's - // --color-border-default (#d0d7de); brand's `default` is much darker (#b6bfb8). + // Brand's subtle border #d2d9d4 matches Primer --color-border-default #d0d7de; + // Brand's default #b6bfb8 is much darker. border-bottom: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-subtle, #d2d9d4); } @@ -98,14 +95,13 @@ border-color: var(--color-segmented-control-button-selected-border); } - // High contrast theme support @media (prefers-contrast: high) { border-color: var(--brand-color-border-subtle, #d2d9d4); &:hover { background: var(--brand-color-canvas-subtle, #f2f5f3); - // --color-border-emphasis is defined nowhere in this app, so this border - // was computing to currentColor. Brand's `default` is its strongest border. + // --color-border-emphasis is undefined here, so this border computes to currentColor. + // Brand's default border is its strongest border. border-color: var(--brand-color-border-default, #b6bfb8); } @@ -116,7 +112,7 @@ } &:focus-visible { - // --color-focus-outset is also undefined in this app; brand's focus token. + // --color-focus-outset is undefined here; use Brand's focus token. outline: var(--brand-borderWidth-thick, 2px) solid var(--brand-color-focus, #0377ff); outline-offset: 2px; diff --git a/src/content-render/stylesheets/article-section-framing.scss b/src/content-render/stylesheets/article-section-framing.scss index c6b132334f5b..f4b47059dc8a 100644 --- a/src/content-render/stylesheets/article-section-framing.scss +++ b/src/content-render/stylesheets/article-section-framing.scss @@ -1,27 +1,22 @@ -// Docs 2026 article section framing: the article body renders as stacked -// sections separated by single horizontal rules (Figma node 795:41405). +// Article body sections stack with single horizontal rules, matching Figma node 795:41405. // -// Scoped to `#article-contents[data-article-body]`. The id alone is NOT enough: -// AutomatedPage renders the same `#article-contents` wrapper, and it backs the -// GraphQL reference / changelog / breaking-changes / schema-previews pages, -// webhook events and payloads, audit-log events and the github-apps lists — all -// of which would pick up this framing. The attribute is set only by the pages -// this treatment was drawn for (ArticlePage and TocLanding), so auto-generated -// reference pages keep their own look. +// Scope this to #article-contents[data-article-body]. The id alone also wraps +// AutomatedPage, including GraphQL reference, changelog, breaking-changes, +// schema-previews, webhook events and payloads, audit-log events, and github-apps +// lists. ArticlePage and TocLanding set this attribute, so auto-generated reference +// pages keep their own look. #article-contents[data-article-body] { .markdown-body { position: relative; - // Vertical padding gives the first/last section breathing room from the - // top/bottom rules. There are deliberately NO vertical side rules at any - // width — sections are separated by horizontal rules alone, and the flexible - // gap columns either side of the content keep the text off the rails. + // Vertical padding separates the first and last sections from the top and bottom rules. + // No width draws vertical side rules; horizontal rules separate sections, and flexible + // gap columns keep text off the rails. padding-top: 2rem; padding-bottom: 2rem; - // Closing rule below the last section — the h2 rules only draw the TOP of - // each section, so without this the article would end without a divider. - // Spans the body column, like those rules. + // The h2 rules draw only the top of each section, so this rule closes the article. + // It spans the body column like the h2 rules. &::after { content: ""; position: absolute; @@ -33,18 +28,17 @@ pointer-events: none; } - // The Figma section headings have no underline — the section-box top rule is - // the only divider. Drop the @primer/css setext border under h2/h3. + // Figma section headings have no underline; the section-box top rule is the only divider. + // Drop the @primer/css setext border under h2 and h3. h2, h3 { border-bottom: 0; } - // Each top-level section (h2) is separated by a SINGLE horizontal rule with - // clear space either side of it: the 3.5rem heading margin is split by the - // rule into ~24px above and 2rem below. The rule spans the width of the - // article body and no further — it is not run out to the rails. The first - // h2's rule is suppressed — the hero divider already sits above it. + // Each top-level h2 has one horizontal rule with clear space on both sides. + // The 3.5rem heading margin splits into about 24px above the rule and 2rem below. + // The rule spans the article body only. The first h2 suppresses its rule because + // the hero divider already sits above it. h2 { position: relative; margin-top: 3.5rem; @@ -55,8 +49,7 @@ position: absolute; left: 0; right: 0; - // Sits 2rem above the heading, leaving that gap below the rule and the - // remainder of the heading margin above it. + // This leaves 2rem between the rule and heading, with the rest of the margin above. top: -2rem; border-bottom: var(--borderWidth-thin, 1px) solid var(--brand-color-border-muted, #e4ebe6); @@ -72,11 +65,9 @@ } } - // Journey-track pages render a full-width "Up next" band directly below the - // grid (ArticlePage drops the 24px wrapper/band margins on those pages so the - // band sits flush). The band carries its own full-width top border, which - // already closes the article, so suppress our own closing rule rather than - // stacking two lines. + // Journey-track pages render a full-width "Up next" band directly below the grid. + // ArticlePage removes the 24px wrapper and band margins there, so the band sits flush. + // The band's full-width top border already closes the article, so this avoids two lines. &[data-has-upnext] .markdown-body::after { display: none; } diff --git a/src/content-render/stylesheets/heading-links.scss b/src/content-render/stylesheets/heading-links.scss index 3b46e85c5936..9d97297a703c 100644 --- a/src/content-render/stylesheets/heading-links.scss +++ b/src/content-render/stylesheets/heading-links.scss @@ -10,8 +10,8 @@ // https://primer.style/design/foundations/icons/link-16 mask: url('data:image/svg+xml;charset=utf8,'); mask-size: cover; - // Brand has no `subtle` text step; `muted` is the closest analogue to - // Primer's --color-fg-subtle (#6e7781 -> #58635b). + // Brand has no subtle text step; muted is closest to Primer --color-fg-subtle. + // Primer #6e7781 maps to Brand #58635b. background-color: var(--brand-color-text-muted, #58635b); @media (forced-colors: active) { background-color: LinkText; diff --git a/src/content-render/stylesheets/markdown-overrides.scss b/src/content-render/stylesheets/markdown-overrides.scss index 0c4af7f5909f..7af2a92e180b 100644 --- a/src/content-render/stylesheets/markdown-overrides.scss +++ b/src/content-render/stylesheets/markdown-overrides.scss @@ -1,21 +1,7 @@ -// What might happens is that we have a DOM of -// -//
-//
Note
-//

Heading

-// ... -// -// When this is the case, by default, that first
that is the first -// gets the `margin-top: 0 !important` and not the first

. -// Generally, the reason this even exists is because

(and

) elements -// are given extra margin-top so as to divide the article into sections -// with some extra whitespace. That's fine, but we don't to start the -// top of the page with too much whitespace. That's why @primer/css -// has a solution for that. Just the problem that it fails then first -// element isn't actually a heading. -// Note we're also doing it for a possible

being the first element. +// Primer's markdown-body first-child reset can hit a hidden first child instead +// of the first h2 or h3. Those headings carry section spacing, but the page top +// must not start with it, so reset the first h2 or h3 directly. // See https://github.com/primer/css/issues/2303 -// See internal issue #2368 .markdown-body { > h2:first-of-type, > h3:first-of-type { @@ -23,9 +9,8 @@ } } -// Horizontal scroll gets flagged as an accessibility violation. -// Updates all code examples to only allow vertical scroll, and -// break aggressively. +// Horizontal scroll gets flagged as an accessibility violation, so code examples wrap +// aggressively and allow only vertical scrolling. .markdown-body { pre { overflow-x: hidden; @@ -38,97 +23,71 @@ } } -// Fix for permissions icon collision with bulleted lists -// When permissions/product statements contain lists that start immediately, -// the list bullets can visually collide with the icons in the flex layout. -// This adds proper spacing to prevent the collision while supporting RTL languages -// and avoiding effects on nested lists. -// See: https://github.com/github/docs-engineering/issues/5199 +// Lists that start immediately in permissions and product statements can collide with +// the icon in the flex layout. Inline spacing preserves right-to-left layouts and avoids +// changing nested lists. .permissions-statement, .product-statement { ul { margin-inline-start: 0; - padding-inline-start: 1rem; // Ensure proper spacing from icon (RTL-aware) + padding-inline-start: 1rem; } ul > li { - margin-inline-start: 0.5rem; // Additional spacing to prevent bullet collision (direct children only) + margin-inline-start: 0.5rem; } } -// A CTA button written on its own line in markdown — `` — becomes its own

, and that paragraph already carries the 16px -// rhythm margin. The `mt-3` utility then stacks a second 16px inside it, so the -// button ends up 32px below the preceding line but only 16px above the next one. -// Drop the utility when the button is alone in its paragraph and let the -// paragraph margin do the spacing, which puts the CTA on the same rhythm as -// every other block. `!important` is required because Primer's spacing -// utilities are themselves !important. -// -// `:only-child` is doing real work here — it is what keeps the two cases apart: -// - CTA callouts (`product:`/`permissions:` frontmatter) put the button after -// a
INSIDE the prose paragraph, so there is no paragraph margin above -// it and `mt-3` is the only thing separating it from the text. -// - The side-by-side Yes/No `.btn-outline` pairs are two buttons in one -// paragraph. -// Neither is an only child, so both keep their margin. +// A CTA button written alone in markdown, such as
, +// becomes its own p. The p already has a 16px rhythm margin, and mt-3 adds another +// 16px, leaving 32px below the preceding line but 16px above the next one. +// Drop mt-3 only when the button is alone in its p; Primer spacing utilities use +// !important too. +// :only-child keeps CTA callouts and side-by-side Yes/No buttons unchanged. Frontmatter +// product: and permissions: put the CTA after a br inside the prose p, so mt-3 supplies +// its only top spacing. Yes/No .btn-outline pairs have two buttons in one p. .markdown-body p > a.btn:only-child { margin-top: 0 !important; } -// @primer/css holds `.btn` at `white-space: nowrap`, which a button cannot -// honour and still stay inside a narrow column. The longest CTA label — "Set up -// a trial of GitHub Enterprise Cloud", 322px — is wider than the article column -// below a ~420px viewport and wider than the callout's text column below ~390px, -// so the button ran past the content edge and was clipped. +// @primer/css sets .btn to white-space: nowrap, which makes long CTA labels overflow +// narrow columns. The longest CTA label, "Set up a trial of GitHub Enterprise Cloud", +// measures 322px, wider than the article column below about 420px and the callout text +// column below about 390px, so it gets clipped. // -// Letting the label wrap fixes it with no breakpoint to guess at. An -// inline-block is shrink-to-fit — min(max-content, available) — so -// `white-space: normal` changes nothing until max-content exceeds the space -// available: at every width where the button already fits it still renders on -// one line, byte-identical. That also makes it self-correcting for longer -// translated labels and for the narrower column a callout gives the same button. +// Let labels wrap instead of guessing a breakpoint. inline-block shrink-to-fit, +// min(max-content, available), means white-space: normal changes nothing until +// max-content exceeds the available space. Buttons that fit still render on one line, +// and longer translations or narrower callout columns self-correct. .markdown-body a.btn, .permissions-statement a.btn, .product-statement a.btn { white-space: normal; - // Wrapping alone orphaned the trailing octicon on a line of its own: the - // space between the label and the icon is a valid break point, and the - // label filled the first line exactly. Laying the button out as a flex row - // instead lets the label wrap within itself and keeps the icon beside it, - // vertically centred. At widths where nothing wraps the result is within a - // pixel of the inline-block it replaces: same 17px left inset, same 21px - // right inset, same 32px height, still one line. The `gap` below covers the - // one thing that does change. + // Wrapping alone can orphan the trailing octicon because the label/icon space can break. + // inline-flex lets the label wrap inside itself and keeps the icon beside it, centered. + // When nothing wraps, this stays within a pixel of inline-block: 17px left inset, + // 21px right inset, 32px height, and one line. The gap below covers the one change. display: inline-flex; align-items: center; - // Flex layout eats the one thing that was separating the label from the icon. - // The markup is `Label {% octicon "link-external" %}`, and that - // literal space does survive Liquid and the markdown pipeline as a real text - // node — but a whitespace-only text node between two flex items is not itself - // a flex item, so no box is generated for it and the label ends up touching - // the icon. `gap` puts the space back. + // Flex removes the literal space between the label and icon. In + // Label {% octicon "link-external" %}, Liquid and markdown preserve the + // space as a text node, but a whitespace-only text node between flex items creates no + // box, so the label touches the icon. // - // 4px rather than the measured width of that space glyph, because a space is - // font- and locale-dependent — it measures differently on two machines here — - // while 4px is the value Primer itself already uses between a button's icon - // and its label. The button ends up a fraction of a pixel wider than it was - // rather than most of a space narrower, on a number the design system owns. + // Use 4px instead of the measured space width because the space changes by font and + // locale. Primer already uses 4px between a button icon and label, so this + // design-system value makes the button slightly wider rather than most of a space narrower. // - // Only the label/icon gap is restored. Primer's `.btn .octicon` also carries - // `margin-right: 4px`, which assumes a LEADING icon and so lands outside the - // trailing icon on these CTAs, giving them 21px of inset on the right against - // 17px on the left. That asymmetry is what ships today, so it stays — zeroing - // it would restyle every CTA on the site, which is a different change from - // keeping a long label inside its column. + // Restore only the label/icon gap. Primer .btn .octicon also has margin-right: 4px + // for leading icons, which lands outside these trailing CTA icons and gives 21px right + // inset against 17px left. Keep that asymmetry; zeroing it would restyle every CTA. gap: 4px; - // The octicon is a flex item now, and flex items shrink before their container - // overflows. Once the label wraps, the icon is the only thing left to give, so - // the 16px glyph was rendering at 11px in a 240px callout column. It is a - // fixed-size icon; the label is what should absorb a narrow column. + // The octicon is a flex item, and flex items shrink before their container overflows. + // Once the label wraps, the icon is the only thing left to shrink, so the 16px glyph + // rendered at 11px in a 240px callout column. Keep the icon fixed and let the label absorb width. .octicon { flex-shrink: 0; } diff --git a/src/content-render/stylesheets/octicon-table-optimization.scss b/src/content-render/stylesheets/octicon-table-optimization.scss index 23d1f9af5728..777b9d30f538 100644 --- a/src/content-render/stylesheets/octicon-table-optimization.scss +++ b/src/content-render/stylesheets/octicon-table-optimization.scss @@ -1,5 +1,5 @@ -// Octicon table optimization for pages with hundreds of repeated icons -// Uses CSS background images instead of inline SVGs to dramatically reduce HTML size +// Pages with hundreds of repeated octicons use CSS background images instead of inline SVGs +// to reduce HTML size. $octicon-check-path: "M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z"; $octicon-x-path: "M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z"; diff --git a/src/content-render/stylesheets/syntax-highlighting.scss b/src/content-render/stylesheets/syntax-highlighting.scss index 30b9b9efa5ae..e7b679732af0 100644 --- a/src/content-render/stylesheets/syntax-highlighting.scss +++ b/src/content-render/stylesheets/syntax-highlighting.scss @@ -8,10 +8,9 @@ from https://unpkg.com/highlight.js@9.15.8/styles/github.css .hljs { display: block; padding: 0.5em; - // The block's BASE text colour — the tokens below are prettylights, which has - // no Brand equivalent and stays on Primer deliberately, but this one is just - // "default text" and was painting Primer's #e6edf3 inside a Brand-framed code - // block. The `background` is inert here (markdown-overrides paints the `pre`). + // Use Brand's default text color for the block itself. The syntax tokens below stay + // on Primer prettylights because Brand has no equivalent. Inside .markdown-body, Primer + // paints the pre and makes pre code transparent, so this background is inert there. color: var(--brand-color-text-default); background: var(--color-canvas-subtle); } diff --git a/src/content-render/tests/annotate.ts b/src/content-render/tests/annotate.ts index c47a78d6fb76..05e220bd62d8 100644 --- a/src/content-render/tests/annotate.ts +++ b/src/content-render/tests/annotate.ts @@ -124,7 +124,6 @@ on: [push] \`\`\` ` - // Create a mock context with pages for AUTOTITLE resolution const mockPages: Record = { '/get-started/start-your-journey/hello-world': { href: '/get-started/start-your-journey/hello-world', @@ -141,7 +140,7 @@ on: [push] currentVersion: 'free-pro-team@latest', pages: mockPages, redirects: {}, - // Mock test object doesn't need all Context properties, using 'as unknown as' to bypass strict type checking + // AUTOTITLE resolution reads only these Context fields. } as unknown as Context const res = await renderContent(autotitleExample, mockContext) diff --git a/src/content-render/tests/collect-mini-toc.ts b/src/content-render/tests/collect-mini-toc.ts index ae8b6942ccc4..eaf110d5d833 100644 --- a/src/content-render/tests/collect-mini-toc.ts +++ b/src/content-render/tests/collect-mini-toc.ts @@ -62,7 +62,7 @@ describe('collect-mini-toc rehype plugin', () => { }) test('does not collect when collectMiniToc is not provided', async () => { - // Should not throw — plugin is a no-op without collectInto + // Without collectMiniToc, the plugin is a no-op. const result = await renderContent('## Heading') expect(result).toContain('Heading') }) diff --git a/src/content-render/tests/data.ts b/src/content-render/tests/data.ts index 85fbe23faa54..99365061b34e 100644 --- a/src/content-render/tests/data.ts +++ b/src/content-render/tests/data.ts @@ -42,9 +42,7 @@ describe('data tag', () => { currentPath: '/en/liquid-tags/good-data-variable', } const rendered = await page!.render(context) - // The test fixture contains: - // {% data variables.stuff.foo %} - // which we control the value of here in the test. + // good-data-variable.md uses {% data variables.stuff.foo %} from the test data directory. expect(rendered.includes('Foo')).toBeTruthy() }) test('should throw if the data tag is used with something unrecognized', async () => { diff --git a/src/content-render/tests/link-error-line-numbers.ts b/src/content-render/tests/link-error-line-numbers.ts index 36cd3d1f842e..734e2fa2d77c 100644 --- a/src/content-render/tests/link-error-line-numbers.ts +++ b/src/content-render/tests/link-error-line-numbers.ts @@ -54,9 +54,6 @@ More content here.` } catch (error) { expect(error).toBeInstanceOf(TitleFromAutotitleError) - // The broken link is on line 10 in the original file - // (3 lines of frontmatter + 1 blank line + 1 title + 1 blank + 1 content + 1 blank + 1 link line) - // The error message should reference the correct line number expect((error as TitleFromAutotitleError).message).toContain('/nonexistent/page') expect((error as TitleFromAutotitleError).message).toContain('could not be resolved') expect((error as TitleFromAutotitleError).message).toContain('(Line: 10)') diff --git a/src/content-render/tests/liquid-tags.ts b/src/content-render/tests/liquid-tags.ts index db28d494733b..5151423349d5 100644 --- a/src/content-render/tests/liquid-tags.ts +++ b/src/content-render/tests/liquid-tags.ts @@ -55,7 +55,8 @@ This uses {% data variables.product.prodname_dotcom %} in content. const expandedContent = await fs.readFile(testFile, 'utf8') expect(expandedContent).not.toBe(testContent) - expect(expandedContent).toContain('GitHub') // Should expand to actual fixture value + // The fixture data tag expands to GitHub. + expect(expandedContent).toContain('GitHub') }) test('restore command should complete successfully', async () => { diff --git a/src/content-render/tests/liquid.ts b/src/content-render/tests/liquid.ts index e38b32f68ba7..82e8053b10c4 100644 --- a/src/content-render/tests/liquid.ts +++ b/src/content-render/tests/liquid.ts @@ -8,10 +8,7 @@ import { allVersions } from '@/versions/lib/all-versions' import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases' import type { Context, ExtendedRequest, Page } from '@/types' -// Setup these variables so we don't need to manually update tests as GHES -// versions continually get deprecated. For example, if we deprecate GHES 3.0, -// oldestSupportedGhes will be 3.1, secondOldestSupportedGhes will be 3.2, and -// thirdOldestSupportedGhes will be 3.3. +// Derive GHES versions from supported releases so deprecations do not require test updates. const oldestSupportedGhes = enterpriseServerReleases.supported[enterpriseServerReleases.supported.length - 1] const secondOldestSupportedGhes = @@ -50,7 +47,7 @@ describe('liquid template parser', () => { vi.setConfig({ testTimeout: 60 * 1000 }) describe('short versions', () => { - // Create a fake req so we can test the shortVersions middleware + // shortVersionsMiddleware reads and mutates a request context. const req = { language: 'en', query: {} } as ExtendedRequest test('FPT works as expected when it is FPT', async () => { @@ -61,7 +58,7 @@ describe('liquid template parser', () => { } as Context contextualize(req) const output = await liquid.parseAndRender(shortVersionsTemplate, req.context) - // We should have TWO results because we are supporting two shortcuts + // FPT matches directly and through the fpt or ghes shortcut. expect(output.replace(/\s\s+/g, ' ').trim()).toBe( `I am FPT I am FTP or GHES < ${secondOldestSupportedGhes}`, ) @@ -70,7 +67,6 @@ describe('liquid template parser', () => { test('GHEC works as expected', async () => { req.context = { currentVersion: 'enterprise-cloud@latest', - // page: {}, allVersions, enterpriseServerReleases, } as Context @@ -144,13 +140,13 @@ describe('liquid template parser', () => { }) describe('feature versions', () => { - // Create a fake req so we can test the feature versions middleware + // featureVersionsMiddleware reads and mutates a request context. const req = { language: 'en', query: {} } as ExtendedRequest test('does not render in FPT because feature is not available in FPT', async () => { req.context = { currentVersion: 'free-pro-team@latest', - page: {} as Page, // it just has to be any truthy value + page: {} as Page, // featureVersionsMiddleware only checks that page is truthy. allVersions, enterpriseServerReleases, } as Context @@ -162,7 +158,7 @@ describe('liquid template parser', () => { test('renders in GHES because feature is available in GHES', async () => { req.context = { currentVersion: `enterprise-server@${enterpriseServerReleases.latest}`, - page: {} as Page, // it just has to be any truthy value + page: {} as Page, // featureVersionsMiddleware only checks that page is truthy. allVersions, enterpriseServerReleases, } as Context @@ -174,7 +170,7 @@ describe('liquid template parser', () => { test('renders in GHEC because feature is available in GHEC', async () => { req.context = { currentVersion: 'enterprise-cloud@latest', - page: {} as Page, // it just has to be any truthy value + page: {} as Page, // featureVersionsMiddleware only checks that page is truthy. allVersions, enterpriseServerReleases, } as Context diff --git a/src/content-render/tests/prompt-id.ts b/src/content-render/tests/prompt-id.ts index 71e046b0fd48..ff26162f4653 100644 --- a/src/content-render/tests/prompt-id.ts +++ b/src/content-render/tests/prompt-id.ts @@ -39,13 +39,13 @@ describe('generatePromptId', () => { }) test('generates deterministic IDs (regression test)', () => { - // These specific values ensure the hash function remains consistent + // Fixed hash outputs catch unintended murmurhash changes. expect(generatePromptId('hello world')).toBe('1730621824') expect(generatePromptId('test')).toBe('4180565944') }) test('handles prompts with code context (ref pattern)', () => { - // When ref= is used, the prompt includes referenced code + prompt text separated by newline + // ref= prompts include referenced code, a newline, then prompt text. const codeContext = 'function logPersonAge(name, age, revealAge) {\n if (revealAge) {\n console.log(name);\n }\n}' const promptText = 'Improve the variable names in this function' @@ -59,15 +59,15 @@ describe('generatePromptId', () => { }) test('handles very long prompts', () => { - // Real-world prompts can include entire code blocks (100+ lines) - const longCode = 'x\n'.repeat(500) // 500 lines + // Real prompts can include code blocks longer than 100 lines. + const longCode = 'x\n'.repeat(500) const id = generatePromptId(longCode) expect(typeof id).toBe('string') expect(id.length).toBeGreaterThan(0) }) test('handles prompts with backticks and template literals', () => { - // Prompts often include inline code with backticks + // Prompts can include inline code delimiters. const prompt = "In JavaScript I'd write: `The ${numCats === 1 ? 'cat is' : 'cats are'} hungry.`" const id = generatePromptId(prompt) expect(typeof id).toBe('string') @@ -75,7 +75,7 @@ describe('generatePromptId', () => { }) test('handles prompts with placeholders', () => { - // Content uses placeholders like NEW-LANGUAGE, OWNER/REPOSITORY + // Content uses placeholders like NEW-LANGUAGE and OWNER/REPOSITORY. const id1 = generatePromptId('What is NEW-LANGUAGE best suited for?') const id2 = generatePromptId('In OWNER/REPOSITORY, create a feature request') expect(id1).not.toBe(id2) @@ -84,7 +84,7 @@ describe('generatePromptId', () => { }) test('handles unicode and international characters', () => { - // May encounter non-ASCII characters in prompts + // Prompts can include non-ASCII text. const id1 = generatePromptId('Explique-moi le code en français') const id2 = generatePromptId('コードを説明してください') const id3 = generatePromptId('Объясните этот код') diff --git a/src/content-render/tests/render-changed-and-deleted-files.ts b/src/content-render/tests/render-changed-and-deleted-files.ts index 617089e59ea4..b61d0b715cc5 100644 --- a/src/content-render/tests/render-changed-and-deleted-files.ts +++ b/src/content-render/tests/render-changed-and-deleted-files.ts @@ -1,37 +1,13 @@ -/** - * To "debug" this test locally, you need to set at least one of these - * environment variables: - * - * - CHANGED_FILES - * - DELETED_FILES - * - RENAMED_FILES - * - * `CHANGED_FILES` and `DELETED_FILES` are whitespace-separated lists of - * paths to content files. `RENAMED_FILES` is a whitespace-separated list - * of `oldPath,newPath` pairs (as emitted by tj-actions/changed-files - * `all_old_new_renamed_files` output). For example: - * - * export CHANGED_FILES="content/get-started/index.md content/get-started/start-your-journey/hello-world.md" - * export RENAMED_FILES="content/old/path.md,content/new/path.md" - * - * If any of the paths in there, split by ' ', don't match real files, the - * test will fail before it even starts. Meaning, it will throw an error - * rather than failing an `expect(...)` assertion. - * - * Technically, the value is any whitespace. So you can actually use: - * - * export DELETED_FILES=`git diff --name-only main...` - * - * which will make the environment variable be newline-separated and that - * works too. - * - * So, for example, if you've made some deletions and some edits the - * staged files: - * - * export DELETED_FILES=`git diff --name-only --diff-filter=D main...` - * export CHANGED_FILES=`git diff --name-only --diff-filter=M main...` - * npm run test -- src/content-render/tests/render-changed-and-deleted-files.ts - */ +// To run this test locally, set CHANGED_FILES, DELETED_FILES, or RENAMED_FILES. +// CHANGED_FILES and DELETED_FILES contain whitespace-separated content paths. +// RENAMED_FILES contains oldPath,newPath pairs from tj-actions/changed-files. +// CHANGED_FILES paths must identify loaded pages or the test throws before expectations run. +// Newline-separated git diff output works because the parser accepts all whitespace. +// Example: +// export CHANGED_FILES="content/get-started/index.md content/actions/index.md" +// export RENAMED_FILES="content/old/path.md,content/new/path.md" +// export DELETED_FILES="$(git diff --name-only --diff-filter=D main...)" +// npm run test -- src/content-render/tests/render-changed-and-deleted-files.ts import path from 'path' @@ -52,10 +28,8 @@ function getDeletedContentFiles() { return getContentFiles(process.env.DELETED_FILES) } -// Parse `RENAMED_FILES` from tj-actions/changed-files `all_old_new_renamed_files` -// output. Each whitespace-separated entry is an `oldPath,newPath` pair. We return -// the OLD paths so they can be checked the same way deleted files are: the test -// will fail if the old URL 404s (i.e. no redirect was set up for the rename). +// RENAMED_FILES comes from tj-actions/changed-files all_old_new_renamed_files. +// Each oldPath,newPath entry adds the old path because old URLs must not return 404. function getRenamedOldContentFiles() { const raw = (process.env.RENAMED_FILES || '').split(/\s+/g).filter(Boolean) const oldPaths = raw.map((pair) => pair.split(',')[0]).filter(Boolean) @@ -64,7 +38,7 @@ function getRenamedOldContentFiles() { function getContentFiles(spaceSeparatedList: string | undefined): string[] { return (spaceSeparatedList || '').split(/\s+/g).filter((filePath) => { - // This filters out things like '', or `data/foo.md` or `content/something/README.md` + // Only content Markdown pages count; data files and content README files do not render. return ( filePath.endsWith('.md') && filePath.split(path.sep)[0] === 'content' && @@ -73,23 +47,18 @@ function getContentFiles(spaceSeparatedList: string | undefined): string[] { }) } -// If the list of changed pages is very large, this test can take a long time. -// It can also happen if some of the pages involves are infamously slow. -// For example guide pages because they involved a lot of processing -// to gather and preview linked data. +// Large changes and guide pages can render slowly because guides gather linked data. vi.setConfig({ testTimeout: 60 * 1000 }) describe('changed-content', () => { const changedContentFiles = getChangedContentFiles() - // `test.each` will throw if the array is empty, so we need to add a dummy - // when there are no changed files in the environment. + // test.each throws on an empty array, so EMPTY stands in when no files are present. const testFiles: Array = changedContentFiles.length ? changedContentFiles : [EMPTY] test.each(testFiles)('changed-content: %s', async (file: string | symbol) => { - // Necessary because `test.each` will throw if the array is empty if (file === EMPTY) return const page = pageList.find((p) => { @@ -98,7 +67,7 @@ describe('changed-content', () => { if (!page) { throw new Error(`Could not find page for ${file as string} in all loaded English content`) } - // Each version of the page should successfully render + // Every permalink must render because changed files can affect all versions. for (const { href } of page.permalinks) { const res = await get(href) if (!res.ok) { @@ -114,19 +83,16 @@ describe('changed-content', () => { }) describe('deleted-content', () => { - // Renamed files (status `R` from git) don't appear in `DELETED_FILES`, but - // the old path is just as gone from the user's perspective and needs a - // redirect. Treat the old path of each rename the same as a deleted file. + // RENAMED_FILES provides old paths separately because git status R paths skip DELETED_FILES. const deletedContentFiles = [...getDeletedContentFiles(), ...getRenamedOldContentFiles()] - // `test.each` will throw if the array is empty, so we need to add a dummy - // when there are no deleted files in the environment. + // test.each throws on an empty array, so EMPTY stands in when no files are present. const testFiles: Array = deletedContentFiles.length ? deletedContentFiles : [EMPTY] + // Deleted pages no longer have versions frontmatter, so this checks the versionless permalink. test.each(testFiles)('deleted-content: %s', async (file: string | symbol) => { - // Necessary because `test.each` will throw if the array is empty if (file === EMPTY) return const page = pageList.find((p) => { @@ -137,9 +103,6 @@ describe('deleted-content', () => { `The supposedly deleted file ${file as string} is still in list of loaded pages`, ) } - // You can't know what the possible permalinks were for a deleted page, - // because it's deleted so we can't look at its `versions` front matter. - // However, we always make sure all pages work in versionless. const indexmdSuffixRegex = new RegExp(`${path.sep}index\\.md$`) const mdSuffixRegex = /\.md$/ const relativePath = (file as string).split(path.sep).slice(1).join(path.sep) @@ -150,9 +113,7 @@ describe('deleted-content', () => { res.statusCode === 404 ? `The deleted or renamed file ${file as string} did not set up a redirect.` : '' - // Certain articles that are deleted and moved under a directory with the same article name - // should just route to the subcategory page instead of redirecting (docs content team confirmed). - // So, in this scenario, we'd get a 200 status code. + // Same-name subcategory moves return 200 instead of redirecting. expect(res.statusCode === 301 || res.statusCode === 200, error).toBe(true) }) }) diff --git a/src/content-render/tests/render-content.ts b/src/content-render/tests/render-content.ts index dc1cdbbf9576..939abf540582 100644 --- a/src/content-render/tests/render-content.ts +++ b/src/content-render/tests/render-content.ts @@ -4,8 +4,7 @@ import { describe, expect, test } from 'vitest' import { renderContent } from '@/content-render/index' import { EOL } from 'os' -// Use platform-specific line endings for realistic tests when templates have -// been loaded from disk +// Disk-loaded templates use platform line endings, so tests do too. const nl = (str: string): string => str.replace(/\n/g, EOL) describe('renderContent', () => { @@ -240,8 +239,8 @@ var a = 1 const html = await renderContent(template) const $ = load(html) const el = $('button.js-btn-copy') + // Copy buttons use a murmurhash ID that matches the paired pre element. expect(el.data('clipboard')).toBe(2967273189) - // Generates a murmurhash based ID that matches a

   })
 
   describe('wrap-code-terms ( in table code)', () => {
diff --git a/src/content-render/tests/render-to-hast.ts b/src/content-render/tests/render-to-hast.ts
index c50f67640d84..0bfddca3c574 100644
--- a/src/content-render/tests/render-to-hast.ts
+++ b/src/content-render/tests/render-to-hast.ts
@@ -4,11 +4,8 @@ import { renderContentToHast } from '@/content-render/index'
 import { renderUnified, renderUnifiedToHast } from '@/content-render/unified/index'
 import type { Context } from '@/types'
 
-// A corpus that exercises the parts of the pipeline most likely to differ
-// between "stringify the processed vfile" (today) and "stringify the hast tree
-// we stopped at" (the new hast path): headings (slug + anchor links), code
-// blocks (highlight + code-header), tables (several rewrite plugins), alerts,
-// raw inline HTML (rehype-raw), and images.
+// This corpus covers pipeline stages where vfile HTML and hast-derived HTML can diverge:
+// headings, highlighted code, tables, alerts, raw inline HTML, images, and blockquotes.
 const fixtures: Array<{ name: string; template: string }> = [
   { name: 'paragraph', template: 'Hello **world**, this is a [link](https://github.com).' },
   {
diff --git a/src/content-render/tests/table-accessibility-labels.ts b/src/content-render/tests/table-accessibility-labels.ts
index e17e246cf096..a69844db08c0 100644
--- a/src/content-render/tests/table-accessibility-labels.ts
+++ b/src/content-render/tests/table-accessibility-labels.ts
@@ -4,8 +4,7 @@ import { describe, expect, test } from 'vitest'
 import { renderContent } from '@/content-render/index'
 import { EOL } from 'os'
 
-// Use platform-specific line endings for realistic tests when templates have
-// been loaded from disk
+// Disk-loaded templates use platform line endings, so tests do too.
 const nl = (str: string) => str.replace(/\n/g, EOL)
 
 describe('table accessibility labels', () => {
@@ -170,7 +169,7 @@ Some additional context here.
     const tables = $('table')
     expect(tables.length).toBe(2)
     expect($(tables[0]).attr('aria-labelledby')).toBe('first-heading')
-    // Second table should not get the same heading since the first table is in between
+    // A prior table stops heading lookup, so the second table stays unlabeled.
     expect($(tables[1]).attr('aria-labelledby')).toBeUndefined()
   })
 
diff --git a/src/data-directory/lib/data-directory.ts b/src/data-directory/lib/data-directory.ts
index 05e7049d93a6..56da0f47a565 100644
--- a/src/data-directory/lib/data-directory.ts
+++ b/src/data-directory/lib/data-directory.ts
@@ -17,6 +17,9 @@ interface DataDirectoryResult {
   [key: string]: unknown
 }
 
+// dataDirectory uses setWith because lodash set creates arrays for numeric release-note paths.
+// Example: release-notes.enterprise-server.2-20.0 must stay an object path.
+// See https://lodash.com/docs#set.
 export default function dataDirectory(
   dir: string,
   opts: DataDirectoryOptions = {},
@@ -38,7 +41,6 @@ export default function dataDirectory(
 
   const data: DataDirectoryResult = {}
 
-  // find YAML and Markdown files in the given directory, recursively
   const filenames = walk(dir, { includeBasePath: true }).filter((filename: string) => {
     if (mergedOpts.ignorePatterns.some((pattern) => pattern.test(filename))) return false
 
@@ -51,7 +53,6 @@ export default function dataDirectory(
   ])
 
   for (const [filename, fileContent] of files) {
-    // derive `foo.bar.baz` object key from `foo/bar/baz.yml` filename
     const key = filenameToKey(path.relative(dir, filename))
     const extension = path.extname(filename).toLowerCase()
 
@@ -60,11 +61,6 @@ export default function dataDirectory(
       processedContent = mergedOpts.preprocess(fileContent)
     }
 
-    // Add this file's data to the global data object.
-    // Note we want to use `setWith` instead of `set` so we can customize the type during path creation.
-    // If we just use `set`, then e.g. `release-notes.enterprise-server.2-20.0` will be an Array but
-    // `release-notes.enterprise-server.3-0.0` will be an Object.
-    // See https://lodash.com/docs#set for an explanation.
     switch (extension) {
       case '.json':
         setWith(data, key, JSON.parse(processedContent), Object)
@@ -74,9 +70,7 @@ export default function dataDirectory(
         break
       case '.md':
       case '.markdown':
-        // Use `matter` to drop frontmatter, since localized reusable Markdown files
-        // can potentially have frontmatter, but we want to prevent the frontmatter
-        // from being rendered.
+        // Localized reusable Markdown can have frontmatter; strip it so content rendering hides it.
         setWith(data, key, matter(processedContent).content, Object)
         break
     }
diff --git a/src/data-directory/lib/data-schemas/ctas.ts b/src/data-directory/lib/data-schemas/ctas.ts
index 2f97602bed03..31c2c0238c80 100644
--- a/src/data-directory/lib/data-schemas/ctas.ts
+++ b/src/data-directory/lib/data-schemas/ctas.ts
@@ -1,13 +1,9 @@
-// This schema enforces the structure for CTA (Call-to-Action) URL parameters
-// Used to validate CTA tracking parameters in documentation links
-
 export default {
   type: 'object',
   additionalProperties: false,
   required: ['ref_product', 'ref_type', 'ref_style'],
   properties: {
-    // GitHub Product: The GitHub product the CTA leads users to
-    // Format: ref_product=copilot
+    // Example query parameter: ref_product=copilot.
     ref_product: {
       type: 'string',
       name: 'Product',
@@ -26,8 +22,7 @@ export default {
       ],
     },
 
-    // Type of CTA: The type of action the CTA encourages users to take
-    // Format: ref_type=trial
+    // Example query parameter: ref_type=trial.
     ref_type: {
       type: 'string',
       name: 'Type',
@@ -35,8 +30,7 @@ export default {
       enum: ['trial', 'purchase', 'engagement'],
     },
 
-    // CTA style: The way we are formatting the CTA in the docs
-    // Format: ref_style=button
+    // Example query parameter: ref_style=button.
     ref_style: {
       type: 'string',
       name: 'Style',
@@ -44,8 +38,7 @@ export default {
       enum: ['button', 'text'],
     },
 
-    // Type of plan (Optional): For links to sign up for or trial a plan, the specific plan we link to
-    // Format: ref_plan=business
+    // Example query parameter: ref_plan=business.
     ref_plan: {
       type: 'string',
       name: 'Plan',
diff --git a/src/data-directory/lib/data-schemas/features.ts b/src/data-directory/lib/data-schemas/features.ts
index 1b9aa310350b..de81e35ff109 100644
--- a/src/data-directory/lib/data-schemas/features.ts
+++ b/src/data-directory/lib/data-schemas/features.ts
@@ -15,7 +15,6 @@ interface FeatureVersionsSchema {
   additionalProperties: false
 }
 
-// Copy the properties from the frontmatter schema.
 const featureVersions: FeatureVersionsSchema = {
   type: 'object',
   properties: {
@@ -24,8 +23,7 @@ const featureVersions: FeatureVersionsSchema = {
   additionalProperties: false,
 }
 
-// Remove the feature versions properties.
-// We don't want to allow features within features! We just want pure versioning.
+// Each data/features file allows version gates but not nested feature gates.
 delete (featureVersions.properties.versions.properties as Record | undefined)
   ?.feature
 
diff --git a/src/data-directory/lib/data-schemas/glossaries-candidates.ts b/src/data-directory/lib/data-schemas/glossaries-candidates.ts
index cfe6393c32a6..ae86460d98ac 100644
--- a/src/data-directory/lib/data-schemas/glossaries-candidates.ts
+++ b/src/data-directory/lib/data-schemas/glossaries-candidates.ts
@@ -7,7 +7,8 @@ export interface TermSchema {
 export const term: TermSchema = {
   type: 'string',
   minLength: 1,
-  pattern: '^((?!\\*).)*$', // no asterisks allowed
+  // Reject asterisks in glossary terms.
+  pattern: '^((?!\\*).)*$',
 }
 
 export interface GlossaryCandidateItem {
diff --git a/src/data-directory/lib/data-schemas/index.ts b/src/data-directory/lib/data-schemas/index.ts
index bd157c2afb63..9a082301823a 100644
--- a/src/data-directory/lib/data-schemas/index.ts
+++ b/src/data-directory/lib/data-schemas/index.ts
@@ -12,16 +12,14 @@ function resolveSchemaPath(filename: string): string {
   const isTest = process.env.NODE_ENV === 'test'
 
   if (isTest) {
-    // Use relative paths that work for vitest and 4.x compatibility with
-    // dynamic imports in particular
+    // Vitest dynamic imports need relative schema paths.
     return `../lib/data-schemas/${filename}`
   } else {
-    // Use absolute paths that work for content linter and other contexts
+    // Content linter and other runtime contexts need absolute schema paths.
     return `@/data-directory/lib/data-schemas/${filename}`
   }
 }
 
-// Auto-discover table schemas from data/tables/ directory
 function loadTableSchemas(): DataSchemas {
   const tablesDir = path.join(process.cwd(), 'data/tables')
   const schemasDir = path.join(__dirname, 'tables')
@@ -43,7 +41,6 @@ function loadTableSchemas(): DataSchemas {
   return tableSchemas
 }
 
-// Manual schema registrations for non-table data
 const manualSchemas: DataSchemas = {
   'data/features': resolveSchemaPath('features.ts'),
   'data/variables': resolveSchemaPath('variables.ts'),
@@ -51,9 +48,8 @@ const manualSchemas: DataSchemas = {
   'data/code-languages.yml': resolveSchemaPath('code-languages.ts'),
   'data/glossaries/candidates.yml': resolveSchemaPath('glossaries-candidates.ts'),
   'data/glossaries/external.yml': resolveSchemaPath('glossaries-external.ts'),
-  // Tables in subdirectories of data/tables are not picked up by loadTableSchemas(),
-  // which only reads the top level, so the matrix is registered explicitly here.
-  // The matrix/ entry is a directory schema: every per-IDE file is validated against it.
+  // Register the matrix directory schema because loadTableSchemas reads only top-level files.
+  // The directory schema validates every per-IDE file.
   'data/tables/copilot/matrix': resolveSchemaPath('tables/copilot/matrix-ide.ts'),
   'data/tables/copilot/matrix-meta.yml': resolveSchemaPath('tables/copilot/matrix-meta.ts'),
 }
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/auto-model-selection.ts b/src/data-directory/lib/data-schemas/tables/copilot/auto-model-selection.ts
index 9b6ff927dd95..63b81d4f8cec 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/auto-model-selection.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/auto-model-selection.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in auto-model-selection.yml
-
 const autoModelSelectionSchema = {
   type: 'array',
   items: {
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/matrix-ide.ts b/src/data-directory/lib/data-schemas/tables/copilot/matrix-ide.ts
index e91ddcc37c4c..e2b85f6cdc9b 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/matrix-ide.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/matrix-ide.ts
@@ -1,28 +1,15 @@
-// Schema for the per-IDE files in data/tables/copilot/matrix/
-//
-// Registered as a directory schema in src/data-directory/lib/data-schemas/index.ts,
-// so every file added to that directory is validated against this shape.
+// The directory schema registration validates every data/tables/copilot/matrix/.yml file.
 
-// Deliberately not an enum. The vocabulary is defined once, as data, in
-// matrix-meta.yml, and is enforced against every IDE file by the
-// 'every support level used is defined in matrix-meta' invariant in
-// src/data-directory/tests/copilot-matrix.ts. Repeating the values here would
-// be a fourth copy that can drift from the data — which is exactly what the
-// schema this file replaces did: it was missing 'closing-down'.
+// supportLevel stays open because matrix-meta.yml owns the vocabulary and tests enforce it.
+// Repeating values here would create a fourth copy that can drift from data.
 const supportLevel = {
   type: 'string',
 }
 
-// Every version tracked here is 3-part, and that follows from what is tracked
-// rather than from convention: four of the six files track the Copilot
-// extension (marketplace versions are required to be x.y.z) and the two that
-// track the IDE itself, VS Code and Visual Studio, version that way natively.
-// Kept strict on purpose. It catches a dropped or added segment — the mistake
-// an updater reading release notes is most likely to make, and one the
-// cross-file invariants cannot see, since they only check that a version is
-// used consistently, not that it is real. If an IDE genuinely changes
-// versioning scheme, that is a deliberate decision: change this pattern and say
-// why in the PR.
+// All six matrix files use three-part versions: four track Copilot extension marketplace versions,
+// and VS Code and Visual Studio use three-part IDE versions natively.
+// Keep the pattern strict because cross-file tests catch consistency, not malformed versions.
+// Update this pattern if an IDE adopts a different version format.
 const VERSION_PATTERN = '^\\d+\\.\\d+\\.\\d+$'
 
 const copilotMatrixIdeSchema = {
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/matrix-meta.ts b/src/data-directory/lib/data-schemas/tables/copilot/matrix-meta.ts
index 8853dc696125..873642ba58e8 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/matrix-meta.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/matrix-meta.ts
@@ -1,7 +1,5 @@
-// Schema for data/tables/copilot/matrix-meta.yml
-//
-// Shared configuration for the Copilot IDE feature matrix. Per-IDE data lives in
-// data/tables/copilot/matrix/.yml and is validated by matrix-ide.ts.
+// matrix-meta.yml owns shared Copilot IDE matrix configuration.
+// Per-IDE data lives in data/tables/copilot/matrix/.yml and matrix-ide.ts validates it.
 
 const copilotMatrixMetaSchema = {
   type: 'object',
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 022eb8da25aa..6189c00e9ff6 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,5 +1,3 @@
-// This schema enforces the structure in model-comparison.yml
-
 const modelComparisonSchema = {
   type: 'object',
   additionalProperties: false,
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 ba31dc99efb6..84fc98abac18 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,5 +1,3 @@
-// This schema enforces the structure in model-deprecation-history.yml
-
 const modelDeprecationHistorySchema = {
   type: 'object',
   additionalProperties: false,
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 a00352c7735a..6918f92a8903 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,5 +1,3 @@
-// This schema enforces the structure in model-release-status.yml
-
 const modelsReleaseStatusSchema = {
   type: 'object',
   additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-supported-clients.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-supported-clients.ts
index ffb28af36dc2..84475d8305aa 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/model-supported-clients.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/model-supported-clients.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in model-supported-clients.yml
-
 const modelsSupportedClientsSchema = {
   type: 'object',
   additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-supported-plans.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-supported-plans.ts
index 401b46254091..1476e55a4774 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/model-supported-plans.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/model-supported-plans.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in model-supported-plans.yml
-
 const modelSupportedPlansSchema = {
   type: 'object',
   additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/models-and-pricing.ts b/src/data-directory/lib/data-schemas/tables/copilot/models-and-pricing.ts
index 96f8127cd22e..992153a91100 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/models-and-pricing.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/models-and-pricing.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in models-and-pricing.yml
-
 const modelsAndPricingSchema = {
   type: 'object',
   additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/repository-roles.ts b/src/data-directory/lib/data-schemas/tables/repository-roles.ts
index 6774b9739d4b..ab0a2af84e5c 100644
--- a/src/data-directory/lib/data-schemas/tables/repository-roles.ts
+++ b/src/data-directory/lib/data-schemas/tables/repository-roles.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in data/tables/repository-roles.yml
-
 const row = {
   type: 'object',
   additionalProperties: false,
@@ -9,13 +7,11 @@ const row = {
       type: 'string',
       lintable: true,
     },
-    // Liquid that renders non-empty when the row should be shown. When omitted,
-    // the row is shown on every version.
+    // Non-empty Liquid output limits the row to matching versions; omitting it renders everywhere.
     versions: {
       type: 'string',
     },
-    // Comma separated list of the roles that can perform the action. Roles left
-    // out render as no. May contain Liquid, so a single role can be conditional.
+    // Comma-separated roles can contain Liquid; omitted roles render as no.
     roles: {
       type: 'string',
     },
diff --git a/src/data-directory/lib/data-schemas/tables/rest-api-versions.ts b/src/data-directory/lib/data-schemas/tables/rest-api-versions.ts
index 046c02afe403..b0e5aef2741d 100644
--- a/src/data-directory/lib/data-schemas/tables/rest-api-versions.ts
+++ b/src/data-directory/lib/data-schemas/tables/rest-api-versions.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in data/tables/rest-api-versions.yml
-
 export default {
   type: 'object',
   additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts b/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts
index a298f709ef15..a7836014e0d5 100644
--- a/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts
+++ b/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in data/tables/supported-code-languages.yml
-
 export default {
   type: 'object',
   additionalProperties: false,
@@ -164,7 +162,7 @@ export default {
       type: 'object',
       additionalProperties: false,
       patternProperties: {
-        // Language names like C, C++, C#, Go, Java, JavaScript, etc.
+        // Matches language names like C, C++, C#, Go, Java, and JavaScript.
         '^[a-zA-Z+#]+$': {
           type: 'object',
           additionalProperties: false,
@@ -188,15 +186,15 @@ export default {
             },
             codeScanning: {
               type: 'string',
-              // Allow "supported", "not-supported", or custom text like "third-party [^1]"
+              // Accepts supported, not-supported, or custom text such as "third-party [^1]".
             },
             depGraph: {
               type: 'string',
-              // Allow "supported", "not-supported", or specific package managers like "npm, Yarn"
+              // Accepts supported, not-supported, or package managers such as "npm, Yarn".
             },
             depUpdates: {
               type: 'string',
-              // Allow "supported", "not-supported", or specific package managers
+              // Accepts supported, not-supported, or package managers.
             },
             actions: {
               type: 'string',
@@ -204,7 +202,7 @@ export default {
             },
             packages: {
               type: 'string',
-              // Allow "supported", "not-supported", or specific package managers
+              // Accepts supported, not-supported, or package managers.
             },
           },
         },
diff --git a/src/data-directory/lib/filename-to-key.ts b/src/data-directory/lib/filename-to-key.ts
index e46c27903709..b9eaf51fbaf7 100644
--- a/src/data-directory/lib/filename-to-key.ts
+++ b/src/data-directory/lib/filename-to-key.ts
@@ -3,14 +3,12 @@ import path from 'path'
 const leadingPathSeparator = new RegExp(`^${RegExp.escape(path.sep)}`)
 const windowsLeadingPathSeparator = new RegExp('^/')
 
-// all slashes in the filename. path.sep is OS agnostic (windows, mac, etc)
+// path.sep handles the current OS; the slash and backslash regexes handle paths from other systems.
 const pathSeparator = new RegExp(RegExp.escape(path.sep), 'g')
 const windowsPathSeparator = new RegExp('/', 'g')
 
-// handle MS Windows style double-backslashed filenames
 const windowsDoubleSlashSeparator = new RegExp('\\\\', 'g')
 
-// derive `foo.bar.baz` object key from `foo/bar/baz.yml` filename
 export default function filenameToKey(filename: string): string {
   const extension = new RegExp(`${RegExp.escape(path.extname(filename))}$`)
   const key = filename
diff --git a/src/data-directory/lib/get-data.ts b/src/data-directory/lib/get-data.ts
index 65b3d8d8bc91..85f7acf1bd09 100644
--- a/src/data-directory/lib/get-data.ts
+++ b/src/data-directory/lib/get-data.ts
@@ -20,14 +20,10 @@ interface FileSystemError extends Error {
   code?: string
 }
 
-// If you run `export DEBUG_JIT_DATA_READS=true` in your terminal,
-// next time it will mention every file it reads from disk.
+// Set DEBUG_JIT_DATA_READS=true to log every data file read from disk.
 const DEBUG_JIT_DATA_READS = Boolean(JSON.parse(process.env.DEBUG_JIT_DATA_READS || 'false'))
 
-// This is a list of files that we should always immediately fall back to
-// English for.
-// Having this is safer than trying to wrangle the translations to NOT
-// have them translated.
+// Product and Copilot paths belong in the English-only set; translations can change fixed names.
 const ALWAYS_ENGLISH_YAML_FILES = new Set([
   'data/variables/product.yml',
   'data/variables/copilot.yml',
@@ -37,17 +33,13 @@ const ALWAYS_ENGLISH_MD_FILES = new Set([
   'data/reusables/ssh/known_hosts.md',
 ])
 
-// Returns all the things inside a directory
 export const getDeepDataByLanguage = memoize(
   (dottedPath: string, langCode: string, dir: string | null = null): Record => {
     if (!(langCode in languages)) {
       throw new Error(`langCode '${langCode}' not a recognized language code`)
     }
 
-    // The `dir` argument is only used for testing purposes.
-    // For example, our unit tests that depend on using a fixtures root.
-    // If we don't allow those tests to override the `dir` argument,
-    // it'll be stuck from the first time `languages.ts` was imported.
+    // Tests pass a fixture root because languages-server.ts captures directories when it loads.
     if (dir === null) {
       dir = languages[langCode].dir
     }
@@ -55,8 +47,7 @@ export const getDeepDataByLanguage = memoize(
   },
 )
 
-// Doesn't need to be memoized because it's used by getDataKeysByLanguage
-// which is already memoized.
+// getDeepDataByLanguage caches each top-level path, so recursive reads need no extra cache.
 function getDeepDataByDir(dottedPath: string, dir: string): Record {
   const fullPath = ['data']
   const split = dottedPath.split(/\./g)
@@ -66,7 +57,8 @@ function getDeepDataByDir(dottedPath: string, dir: string): Record {
   const uiEnglish = getUIData('en')
   if (langCode === 'en') return uiEnglish as UIStrings
-  // Got to combine. Start with the English and put the translation on top.
-  // E.g.
-  //    english = {food: "Food", drink: "Drink"}
-  //    swedish = {food: "Mat"}
-  //    =>
-  //    combind = {food: "Mat", drink: "Drink"}
+  // Merge translations over English so missing localized UI keys fall back to English.
   const combined: Record = {}
   merge(combined, uiEnglish)
   merge(combined, getUIData(langCode))
   return combined as UIStrings
 })
 
-// Doesn't need to be memoized because it's used by another function
-// that is memoized.
+// getUIDataMerged memoizes results, so this reader needs no separate cache.
 const getUIData = (langCode: string): Record => {
   const fullPath = ['data', 'ui.yml']
   const { dir } = languages[langCode]
   return getYamlContent(dir, fullPath.join(path.sep)) as Record
 }
 
+// When translated data misses a dotted path, retry English.
+// lodash get returns undefined for the missing dotted path instead of ENOENT.
 export const getDataByLanguage = memoize((dottedPath: string, langCode: string): unknown => {
   if (!(langCode in languages))
     throw new Error(`langCode '${langCode}' not a recognized language code`)
@@ -116,32 +104,20 @@ export const getDataByLanguage = memoize((dottedPath: string, langCode: string):
   try {
     const value = getDataByDir(dottedPath, dir, languages.en.dir, langCode)
 
-    // What could happens is that a new key has only been added to
-    // the English data/ui.yml but hasn't been added to Japanese, but
-    // there nevertheless exists a Japanese `data/ui.yml`.
-    // Since getDataByDir() uses `get(dataObject, 'dott.ed.path')` it
-    // will return `undefined` if it's not present.
-    // If this happens, we can't rely on `err.code === 'ENOENT'` to
-    // fall back the English one. So we just start over using the English data.
     if (value === undefined && langCode !== 'en') {
       return getDataByDir(dottedPath, languages.en.dir)
     }
     return value
   } catch (error) {
     if (error instanceof Error && (error as YAMLException).mark && error.message) {
-      // It's a load() generated error!
-      // Remember, the file that we read might have been a .yml or a .md
-      // file. If it was a .md file, with corrupt front-matter that too
-      // would have caused a YAMLException
+      // Corrupt YAML files and Markdown frontmatter raise YAMLException, so translations fall back.
       if (langCode !== 'en') {
         if (DEBUG_JIT_DATA_READS) {
           logger.warn('Unable to parse Yaml in translation', { langCode, dottedPath, error })
         }
-        // Give it one more chance, but use English this time
         return getDataByDir(dottedPath, languages.en.dir)
       }
-      // Always throw English Yaml reading errors. Staff writers
-      // need to know early and explicitly that they are corrupt.
+      // Throw English YAML errors so staff writers see corrupt source data early.
       throw error
     }
 
@@ -150,6 +126,10 @@ export const getDataByLanguage = memoize((dottedPath: string, langCode: string):
   }
 })
 
+// getSmartSplit preserves dotted path segments such as version-3.4.
+// Release notes split normally because numeric paths such as 3-7/0.yml would combine incorrectly.
+// getDataByDir keeps {% data early-access.reusables.foo.bar %} under data/early-access.
+// That data lives at data/early-access/reusables/foo/bar.md.
 function getDataByDir(
   dottedPath: string,
   dir: string,
@@ -158,28 +138,10 @@ function getDataByDir(
 ): unknown {
   const fullPath = ['data']
 
-  // Using English here because it doesn't matter. We just want to
-  // figure out how to turn `foo.version-3.4.deeper.key' into
-  // `['foo', 'version-3.4', 'deeper', 'key']` here and we'll need
-  // any directory to do that and English is always the most up-to-date.
-  // We need the getSmartSplit() as long as there's a chance that a
-  // directory or file inside data/ might contain a dot in the name,
-  // however the exception is the file names in data/release-notes/**/*.yml
-  // because it contains files that are just numbers like 3-7/0.yml and
-  // that can cause problems inside getSmartSplit().
   const split = dottedPath.startsWith('release-notes')
     ? dottedPath.split('.')
     : getSmartSplit(dottedPath)
 
-  // For early-access data stuff, they're referred to as...
-  //
-  //   {% data early-access.reusables.foo.bar %}
-  //
-  // When we "merge" in the early-access data, we put the whole directory
-  // within the root `data/` so it exists, on disk, as
-  //
-  //   data/early-access/reusables/foo/bar.md
-  //
   if (split[0] === 'early-access') {
     fullPath.push(split.shift()!)
   }
@@ -233,24 +195,12 @@ function getDataByDir(
     const markdown = getMarkdownContent(dir, fullPath.join(path.sep), englishRoot)
     let { content } = matter(markdown)
     if (dir !== englishRoot) {
-      // If we're reading a translation, we need to replace the possible
-      // corruptions. For example `[AUTOTITLE"을](/foo/bar)`.
-      // To do this we'll need the English equivalent
+      // Translated reusables need English content to fix corruptions like [AUTOTITLE"을](/foo/bar).
       let englishContent = content
       try {
         englishContent = getMarkdownContent(englishRoot, fullPath.join(path.sep), englishRoot)
       } catch (error) {
-        // In some real but rare cases a reusable doesn't exist in English.
-        // At all.
-        // This can happen when the translation is really out of date.
-        // You might have an old `docs-internal.locale/content/**/*.md`
-        // file that mentions `{% data reusables.foo.bar %}`. And it's
-        // working fine, except none of that exists in English.
-        // If this is the case, we still want to executed the
-        // correctTranslatedContentStrings() function, but we can't
-        // genuinely give it the English equivalent content, which it
-        // sometimes uses to correct some Liquid tags. At least other
-        // good corrections might happen.
+        // Translated pages can reference reusables missing in English; other corrections still run.
         if ((error as FileSystemError).code !== 'ENOENT') {
           throw error
         }
@@ -263,9 +213,9 @@ function getDataByDir(
     return content
   }
 
-  // E.g. {% data ui.pages.foo.bar %}
+  // UI data references such as {% data ui.pages.foo.bar %} read from data/ui.yml.
   if (first === 'ui') {
-    const basename = split.shift() // i.e. 'ui'
+    const basename = split.shift()
     fullPath.push(`${basename}.yml`)
     const allData = getYamlContent(dir, fullPath.join(path.sep), englishRoot)
     return get(allData, split.join('.'))
@@ -292,7 +242,7 @@ function getSmartSplit(dottedPath: string): string[] {
       const next = split[i + 1]
       if (/\d$/.test(bit) && /^\d/.test(next)) {
         bits.push([bit, next].join('.'))
-        i++ // jump ahead one position in the loop
+        i++
       } else {
         bits.push(bit)
       }
@@ -301,36 +251,12 @@ function getSmartSplit(dottedPath: string): string[] {
   return bits
 }
 
-// The reason this is memoized, even though the parent caller function
-// (`getDataByLanguage`) is also memoized is because we might read
-// the same file for two different keys. E.g.
-//
-//    getDataByLanguage('variables.product.prodname_ghe_server', 'en')
-//    getDataByLanguage('variables.product.company_short', 'en')
-//
-// ...will actually depend on reading `data/variables/product.yml`. Twice.
-// Well, actually not twice because we cache the disk reading. So the outcome
-// becomes this:
-//
-//    1. getDataByLanguage('variables.product.prodname_ghe_server', 'en')
-//      -> cache MISS
-//        1.1. read and parse data/variables/product.yml
-//          -> cache MISS
-//    2. getDataByLanguage('variables.product.company_short', 'en')
-//      -> cache MISS
-//        2.1. read and parse data/variables/product.yml
-//          -> cache HIT    (Yay!)
-//
+// getDataByLanguage caches each dotted key, but different keys can read the same YAML file.
+// Cache YAML reads too, so product name variables share data/variables/product.yml.
 const getYamlContent = memoize(
   (root: string | undefined, relPath: string, englishRoot?: string): unknown => {
-    // Certain Yaml files we know we always want the English one
-    // no matter what the specified language is.
-    // For example, we never want `data/variables/product.yml` translated
-    // so we know to immediately fall back to the English one.
     if (ALWAYS_ENGLISH_YAML_FILES.has(relPath)) {
-      // This forces it to read from English. Later, when it goes
-      // into `getFileContent(...)` it will note that `root !== englishRoot`
-      // so it won't try to fall back.
+      // Passing englishRoot prevents getFileContent from treating this as a translation fallback.
       root = englishRoot
     }
     const fileContent = getFileContent(root, relPath, englishRoot)
@@ -338,13 +264,10 @@ const getYamlContent = memoize(
   },
 )
 
-// The reason why this is memoized, is the same as for getYamlContent() above.
+// Cache Markdown reads too because different dotted keys can hit the same file.
 const getMarkdownContent = memoize(
   (root: string | undefined, relPath: string, englishRoot?: string): string => {
-    // Certain reusables we never want to be pulled from the translations.
-    // For example, certain reusables don't contain any English prose. Just
-    // facts like numbers or hardcoded key words.
-    // If this is the case, forcibly always draw from the English files.
+    // SSH fingerprints and known_hosts contain facts, not prose, so they are meant to use English.
     if (ALWAYS_ENGLISH_MD_FILES.has(relPath)) {
       root = englishRoot
     }
@@ -364,13 +287,9 @@ const getFileContent = (
   try {
     return fs.readFileSync(filePath, 'utf-8')
   } catch (err) {
-    // It might fail because that particular data entry doesn't yet
-    // exist in a translation
     if ((err as FileSystemError).code === 'ENOENT') {
-      // If looking it up as a file fails, give it one more chance if the
-      // read was for a translation.
       if (englishRoot && root !== englishRoot) {
-        // We can try again but this time using the English files
+        // Missing translated data falls back to English when an English root is available.
         return getFileContent(englishRoot, relPath, englishRoot)
       }
     }
@@ -378,24 +297,15 @@ const getFileContent = (
   }
 }
 
+// Development bypasses caching because repeated sync reads stay cheap enough for debugging.
+// A benchmark sampled 10 common data files across 100 runs, with about 80% YAML files.
+// Median sync reads took 0.5 ms per 10 files, or 2.1 ms per 10 files with YAML parsing.
 function memoize(
   func: (...args: Args) => Return,
 ): (...args: Args) => Return {
   const cache = new Map()
   return (...args: Args) => {
     if (process.env.NODE_ENV === 'development') {
-      // It is very possible that certain files, when caching is disabled,
-      // are read multiple times in short succession. E.g. `product.yml`.
-      // So how expensive is it to read these files excessively?
-      // To answer that, we benchmarked it by sampling 10 files from the
-      // most common files that are used from `data/`. In fact, we ran 100
-      // runs of 10 *different* files. About 80% of them were `.yml` files.
-      // As a median, it takes **0.5ms to read 10 files from disk**
-      // all in a sync manner.
-      // Since most files coming through here is `.yml` files (e.g.
-      // product.yml and ui.yml) if you also do the `load()` of the
-      // read content, that number becomes **2.1ms to read and parse 10 files**.
-      // So in conclusion, not a lot of time.
       return func(...args)
     }
 
diff --git a/src/data-directory/middleware/data-tables.ts b/src/data-directory/middleware/data-tables.ts
index 8edf5262f938..bb3ed7c20a32 100644
--- a/src/data-directory/middleware/data-tables.ts
+++ b/src/data-directory/middleware/data-tables.ts
@@ -6,13 +6,12 @@ let tablesCache: Record | null = null
 
 const getTables = () => {
   if (!tablesCache) {
-    // Keep product-name-heavy reference tables in English only for now
+    // Product-name-heavy reference tables stay in English to avoid localized product names.
     tablesCache = getDeepDataByLanguage('tables', 'en')
   }
   return tablesCache
 }
 
-// Loads the YAML files under data/tables/ into req.context.
 export default async function dataTables(req: ExtendedRequest, res: Response, next: NextFunction) {
   if (!req.context) throw new Error('request not contextualized')
 
diff --git a/src/data-directory/scripts/deleted-features-pr-comment.ts b/src/data-directory/scripts/deleted-features-pr-comment.ts
index a601190d9c16..a88cc4565b36 100644
--- a/src/data-directory/scripts/deleted-features-pr-comment.ts
+++ b/src/data-directory/scripts/deleted-features-pr-comment.ts
@@ -1,12 +1,6 @@
-/**
- * This script is supposed to be used in Actions. When it's run in Actions
- * there will be an env var called GITHUB_REPOSITORY. If it's not there,
- * you can use this script as a CLI tool. For example:
- *
- *  export GITHUB_TOKEN=github_pat_blablabla
- *  npm run deleted-features-pr-comment -- github docs-internal main 2ba53b6a
- *
- */
+// Produces deleted-feature Markdown as an Actions output; without GITHUB_REPOSITORY, prints it.
+// Required: GITHUB_TOKEN.
+// CLI: npm run deleted-features-pr-comment -- github docs-internal main 2ba53b6a
 
 import { context as github_context, getOctokit } from '@actions/github'
 import { setOutput } from '@actions/core'
@@ -44,7 +38,6 @@ async function main(owner: string, repo: string, baseSHA: string, headSHA: strin
     throw new Error(`GITHUB_TOKEN environment variable not set`)
   }
   const octokit = getOctokit(GITHUB_TOKEN)
-  // get the list of file changes from the PR
   const response = await octokit.rest.repos.compareCommitsWithBasehead({
     owner,
     repo,
@@ -62,10 +55,10 @@ async function main(owner: string, repo: string, baseSHA: string, headSHA: strin
 
     console.warn(`Feature involved in this PR: ${filename}; Status: ${status}`)
     if (status === 'removed') {
-      // Bad
+      // Deleted feature files can stay referenced in translated content.
       oldFilenames.push(filename)
     } else if (status === 'renamed') {
-      // Also bad
+      // Renamed feature files can stay referenced by the old name in translated content.
       const previousFilename = file.previous_filename
       oldFilenames.push(previousFilename)
     } else {
diff --git a/src/data-directory/scripts/find-orphaned-features/find.ts b/src/data-directory/scripts/find-orphaned-features/find.ts
index 2398738b896c..43f7b73afde0 100644
--- a/src/data-directory/scripts/find-orphaned-features/find.ts
+++ b/src/data-directory/scripts/find-orphaned-features/find.ts
@@ -1,31 +1,8 @@
-/**
- * This script will loop over all pages, in all languages, and look at
- * the following:
- *
- *    1. `title` in frontmatter
- *    2. `intro` in frontmatter
- *    3. `shortTitle` in frontmatter (if present)
- *    4. the markdown body itself
- *    5. The `versions:` frontmatter key (if the page is in English)
- *
- * Then it will search out the features mentioned based on `data/features/*.yml`
- * It will make a Set of these (e.g. `dependabot-grouped-dependencies` and
- * `ghas-enablement-webhook`) and one by one pluck them away.
- *
- * After the pages, it will loop over the reusables in English, and do the
- * same search there. Once it's done the English, it loops over the
- * reusables in the translations (if they exist) and does the same search.
- *
- * Lastly, it will output the remaining features, as relative file paths.
- * For example, `data/features/havent-been-used-in-years.yml` so now you
- * know that file can be deleted.
- *
- * NOTE: A lot of translations have corrupted Liquid. So if we can't parse
- * the Liquid we fall back to string search. A regex will try to find
- * all `{% ifversion ... %}` (and `elsif`) and search for any features
- * mentioned inside that as a string.
- *
- */
+// Finds data/features/*.yml entries that no page, reusable, or variable references.
+// It scans title, intro, shortTitle, body, and English versions frontmatter across all pages.
+// It also scans English reusables and variables, then matching translated reusables.
+// Outputs remaining features as paths such as data/features/havent-been-used-in-years.yml.
+// If translated Liquid cannot parse, regex searches feature names in ifversion and elsif tags.
 
 import { strictEqual } from 'node:assert'
 import fs from 'fs'
@@ -118,12 +95,12 @@ function formatDelta(t0: Date, t1: Date) {
   return `${(ms / 1000).toFixed(1)} seconds`
 }
 
+// searchAndRemove scans translated reusables only when English has the same relative path.
+// English content lets correctTranslatedContentStrings repair Liquid before feature matching.
 function searchAndRemove(features: Set, pages: Page[], verbose = false) {
   for (const page of pages) {
     const content = page.markdown
-    // We actually never bother looking at the `versions:` frontmatter
-    // key in translations, so it doesn't matter if the translated
-    // frontmatter might have `versions: some-old-feature`.
+    // Only English versions frontmatter can mark a feature used.
     if (page.languageCode === 'en') {
       for (const [key, value] of Object.entries(page.versions)) {
         if (key === 'feature') {
@@ -144,19 +121,6 @@ function searchAndRemove(features: Set, pages: Page[], verbose = false)
     checkString(combined, features, { page, verbose, languageCode: page.languageCode })
   }
 
-  // Reusables are a bit special, as they are shared between languages.
-  // There'll always be a slight mismatch between files present on disk
-  // in English vs. translations.
-  // The translations never delete files, so there's often excess reusables
-  // on disk in translations. And the English might be ahead, meaning a file
-  // has been introduced in English but not yet translated.
-  // The code below loops over the English reusables, and takes note of the
-  // their relative paths and content. Then, we re-use the keys of that map
-  // to know which files, in the translations, to check. And when we read
-  // them in, we'll need the English equivalent content to be able to
-  // use the correctTranslatedContentStrings function.
-
-  // Check the English variable files.
   for (const filePath of getVariableFiles(path.join(languages.en.dir, 'data', 'variables'))) {
     const fileContent = fs.readFileSync(filePath, 'utf-8')
     checkString(fileContent, features, { filePath, verbose, languageCode: 'en' })
@@ -170,7 +134,7 @@ function searchAndRemove(features: Set, pages: Page[], verbose = false)
     englishReusables.set(relativePath, fileContent)
   }
   for (const language of Object.values(languages)) {
-    if (language.code === 'en') continue // Already did that in the loop above
+    if (language.code === 'en') continue
 
     for (const [relativePath, englishFileContent] of Array.from(englishReusables.entries())) {
       const filePath = path.join(language.dir, relativePath)
@@ -192,10 +156,7 @@ function searchAndRemove(features: Set, pages: Page[], verbose = false)
         })
       } catch (error) {
         if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
-          // That a reusable does *not* exist in a translation is
-          // perfectly expected. It means that English reusable was
-          // most likely added recently and the translation hasn't been
-          // translated yet.
+          // Missing translated reusables are expected when English has newer files.
           continue
         }
         throw error
@@ -243,10 +204,7 @@ function checkString(
   }: { page?: Page; filePath?: string; languageCode?: string; verbose?: boolean } = {},
 ) {
   try {
-    // The reason for the `noCache: true` is that we're going to be sending
-    // a LOT of different strings in and the cache will fill up rapidly
-    // when testing every possible string in every possible language for
-    // every page.
+    // Disable the Liquid token cache because scanning many different strings would fill it quickly.
     const tokens = getLiquidTokens(string, { noCache: true }).filter(
       (token): token is TagToken => token.kind === TokenKind.Tag,
     )
@@ -264,11 +222,10 @@ function checkString(
     }
   } catch (error) {
     if (error instanceof TokenizationError) {
-      // If it happens in English, it's a serious error
+      // English Liquid parse failures are source errors.
       if (languageCode === 'en') throw error
 
-      // The translation might, currently, have corrupted liquid
-      // So treat it as a string
+      // Translated Liquid can be corrupt, so regex search still catches feature references.
       if (verbose)
         console.log(
           `TokenizationError in ${page ? page.fullPath : filePath}. Treating ${page ? page.fullPath : filePath} as a string and using regex`,
diff --git a/src/data-directory/scripts/find-orphaned-tables.ts b/src/data-directory/scripts/find-orphaned-tables.ts
index a254863b9b6a..9ce6e3e74898 100644
--- a/src/data-directory/scripts/find-orphaned-tables.ts
+++ b/src/data-directory/scripts/find-orphaned-tables.ts
@@ -1,20 +1,6 @@
-// [start-readme]
-//
-// Print a list of all the YAML-powered table files in ./data/tables/ that
-// can't be found mentioned in any source file (content, data & code), along
-// with their paired schema files. Mirrors find-orphaned-assets.ts.
-//
-// Tables are referenced from Liquid like:
-//
-//     {% data tables.. %}
-//     {% for entry in tables.. %}
-//
-// so a table file `data/tables//.yml` is "used" if the string
-// `tables..` appears anywhere. A deeper reference such as
-// `tables...` also counts, because the file key is a
-// prefix of it.
-//
-// [end-readme]
+// Prints unreferenced YAML-powered table files under ./data/tables/ and paired schema files.
+// Both {% data tables.copilot.matrix-meta %} and
+// {% for level in tables.copilot.matrix-meta.supportLevels %} mark the table used.
 
 import fs from 'fs'
 import path from 'path'
@@ -28,17 +14,15 @@ import languages from '@/languages/lib/languages-server'
 const TABLES_DIR = 'data/tables'
 const SCHEMAS_DIR = 'src/data-directory/lib/data-schemas/tables'
 
-// Tables that are referenced dynamically (not via Liquid) and must never be
-// flagged as orphans. Add an entry here (the dotted key, e.g. `copilot.foo`)
-// if a table is loaded by code rather than mentioned in content.
+// EXCEPTIONS protects tables loaded dynamically by code rather than mentioned in content.
 const EXCEPTIONS = new Set([])
 
 export type TableFile = {
-  // Repo-relative path to the YAML file, e.g. data/tables/copilot/model-multipliers.yml
+  // Repo-relative YAML path, such as data/tables/copilot/model-multipliers.yml.
   yml: string
   // Repo-relative path to the paired schema, if it exists on disk.
   schema?: string
-  // Dotted key used in Liquid, e.g. copilot.model-multipliers
+  // Dotted Liquid key, such as copilot.model-multipliers.
   key: string
 }
 
@@ -72,9 +56,7 @@ type MainOptions = {
   excludeTranslations: boolean
 }
 
-// Given the table files and the contents of every source file, return the
-// tables whose Liquid key is never mentioned. Pulled out of main() so it can
-// be unit tested without touching the filesystem.
+// Exported for tests so orphan detection can run without filesystem reads.
 export function getOrphanedTables(
   tables: TableFile[],
   sourceContents: Iterable,
@@ -91,8 +73,7 @@ export function getOrphanedTables(
   return [...orphans.values()].sort((a, b) => a.yml.localeCompare(b.yml))
 }
 
-// Only parse argv and run when invoked directly (e.g. via `npm run
-// find-orphaned-tables`), not when imported by a test.
+// Guard main so tests can import getOrphanedTables; npm run find-orphaned-tables invokes it.
 if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
   program.parse(process.argv)
   main(program.opts())
@@ -108,10 +89,7 @@ async function main(opts: MainOptions) {
   const sourceFiles: string[] = [...englishFiles]
 
   if (!excludeTranslations) {
-    // Translations are often behind English. A table can still be referenced
-    // in a translation even when no English content references it, so we must
-    // search translations too. We only look at files that also exist in
-    // English, because translations rarely delete renamed/removed files.
+    // Search matching translations because translated content can still reference a table.
     const englishRelativeFiles = new Set(
       englishFiles.map((englishFile) => path.relative(languages.en.dir, englishFile)),
     )
@@ -133,9 +111,7 @@ async function main(opts: MainOptions) {
     }
   }
 
-  // Tables can also be referenced from code (e.g. table-rendering helpers), so
-  // search src and contributing as well. Searching more files only ever marks
-  // a table as used, never as an orphan, so it errs on the safe side.
+  // Search code because table-rendering helpers can reference tables without Liquid.
   for (const root of ['contributing', 'src']) {
     if (!fs.existsSync(root)) continue
     sourceFiles.push(
@@ -165,9 +141,7 @@ async function main(opts: MainOptions) {
 
   const orphanTables = getOrphanedTables(tables, readContents())
 
-  // Safety net: if every table looks orphaned, the detection is almost
-  // certainly broken (e.g. content wasn't checked out). Refuse to suggest
-  // deleting everything.
+  // If every table looks orphaned, detection is probably broken; refuse to list deletions.
   if (tables.length > 0 && orphanTables.length === tables.length) {
     console.error(
       'Every table was flagged as orphaned, which is almost certainly a bug. ' +
diff --git a/src/data-directory/tests/copilot-matrix.ts b/src/data-directory/tests/copilot-matrix.ts
index d12ec6ae8f8e..529344ab9c43 100644
--- a/src/data-directory/tests/copilot-matrix.ts
+++ b/src/data-directory/tests/copilot-matrix.ts
@@ -4,23 +4,14 @@ import { join } from 'path'
 import { load } from 'js-yaml'
 import { describe, expect, test } from 'vitest'
 
-// Cross-file invariants for the Copilot IDE feature matrix.
-//
-// The JSON schemas validate each file in isolation. These tests cover the
-// relationships *between* matrix-meta.yml and the per-IDE files, which is where
-// a hand edit — or, later, an automated changelog-driven update — is most
-// likely to introduce a silent error.
-//
-// "Silent" is the operative word: a missing or mistyped key does not raise an
-// error, it renders as ✗ (not supported) to customers.
+// JSON schemas validate each file in isolation. These tests cover cross-file matrix relationships.
+// Missing or mistyped keys silently render as ✗ (not supported) in customer-facing tables.
 
 const MATRIX_DIR = join(process.cwd(), 'data/tables/copilot/matrix')
 const META_PATH = join(process.cwd(), 'data/tables/copilot/matrix-meta.yml')
 
-// Stands for "supported since before we tracked versions". Some IDEs list it in
-// `versions` without putting it in a `versionGroup`, so it is the one version
-// allowed to have no detail table. Removing it is a customer-visible content
-// decision; until then it is excluded from the grouping invariant below.
+// Some IDEs use 0.0.0 for supported-before-tracking without a versionGroup.
+// Removing the sentinel is customer-visible, so the grouping invariant excludes it.
 const SENTINEL_VERSION = '0.0.0'
 
 type Ide = {
@@ -70,8 +61,7 @@ describe('copilot matrix meta', () => {
     expect(new Set(meta.featureOrder).size).toBe(meta.featureOrder.length)
   })
 
-  // A stale featureOrder entry that no IDE uses renders as a row of ✗ across
-  // every column of the summary table.
+  // A stale featureOrder entry renders as a row of ✗ across every summary-table column.
   test('every featureOrder entry is used by at least one IDE', () => {
     const used = new Set()
     for (const ide of Object.values(ides)) {
@@ -114,13 +104,9 @@ describe.each(ideFilenames)('copilot matrix: %s', (slug) => {
     ).toEqual([])
   })
 
-  // Only versions listed in a versionGroup are rendered as a detail table. A
-  // version in `versions` that is in no group is data customers cannot see —
-  // and the summary table reads `versions | first`, so if it is the newest one
-  // the page shows support data for a version with no detail table at all.
-  // This is the most likely mistake for an automated updater that appends to
-  // `versions` and forgets `versionGroups`, and checking only the newest
-  // version would miss a backfilled older one.
+  // Only versions listed in versionGroups render detail tables. The test skips the 0.0.0 sentinel.
+  // The summary table reads versions | first, so an ungrouped newest version has no detail table.
+  // Checking every version also catches backfilled older versions that automated updates miss.
   test('every version appears in at least one versionGroup', () => {
     const grouped = new Set(Object.values(ide.versionGroups).flat())
     const ungrouped = ide.versions.filter(
diff --git a/src/data-directory/tests/data-schemas.ts b/src/data-directory/tests/data-schemas.ts
index 7dd168ade55b..bc2b83a61f37 100644
--- a/src/data-directory/tests/data-schemas.ts
+++ b/src/data-directory/tests/data-schemas.ts
@@ -64,7 +64,6 @@ describe('YAML-powered tables', () => {
         const schemaPath = join(schemasDir, `${name}.ts`)
         expect(existsSync(schemaPath)).toBe(true)
 
-        // Also verify it's registered in the dataSchemas
         const dataKey = `data/tables/${yamlFile}`
         expect(dataSchemas[dataKey]).toBeDefined()
       }
diff --git a/src/data-directory/tests/find-orphaned-tables.ts b/src/data-directory/tests/find-orphaned-tables.ts
index b3ac92a04978..1dfc47015020 100644
--- a/src/data-directory/tests/find-orphaned-tables.ts
+++ b/src/data-directory/tests/find-orphaned-tables.ts
@@ -41,8 +41,6 @@ describe('getOrphanedTables', () => {
   })
 
   test('counts a deeper sub-key reference as using the table file', () => {
-    // A reference to `tables.copilot.copilot-matrix.ides` should mark the
-    // `copilot.copilot-matrix` file as used.
     const orphans = getOrphanedTables(
       [table('copilot.copilot-matrix')],
       ['{% for row in tables.copilot.copilot-matrix.ides %}'],
@@ -51,8 +49,6 @@ describe('getOrphanedTables', () => {
   })
 
   test('does not let a longer key falsely mark a shorter, unrelated table', () => {
-    // `tables.copilot.annual-subscriber-model-multipliers` must NOT mark
-    // `copilot.model-multipliers` as used.
     const orphans = getOrphanedTables(
       [table('copilot.model-multipliers')],
       ['{% data tables.copilot.annual-subscriber-model-multipliers %}'],
diff --git a/src/data-directory/tests/get-data.ts b/src/data-directory/tests/get-data.ts
index 7b3a0414f929..68e834bce35e 100644
--- a/src/data-directory/tests/get-data.ts
+++ b/src/data-directory/tests/get-data.ts
@@ -14,7 +14,7 @@ import { DataDirectory } from '@/tests/helpers/data-directory'
 describe('get-data', () => {
   let dd: DataDirectory
   const enDirBefore = languages.en.dir
-  // Only `en` is available in tests, so pretend we also have Japanese
+  // Only en is available in tests, so copy English metadata for Japanese fixtures.
   languages.ja = Object.assign({}, languages.en, {})
 
   beforeAll(() => {
@@ -77,12 +77,10 @@ describe('get-data', () => {
       const result = getDataByLanguage('variables.stuff.foo', 'en')
       expect(result).toBe('Foo')
     }
-    // Test that memoization doesn't go wrong
     {
       const result = getDataByLanguage('variables.stuff.bar', 'en')
       expect(result).toBe('Bar')
     }
-    // Test that unrecognized keys just return `undefined`
     {
       const result = getDataByLanguage('variables.stuff.neverheardof', 'en')
       expect(result).toBeUndefined()
@@ -94,12 +92,10 @@ describe('get-data', () => {
       const result = getDataByLanguage('variables.stuff.foo', 'ja')
       expect(result).toBe('フー')
     }
-    // Test fallback to English if not present in translation
     {
       const result = getDataByLanguage('variables.stuff.bar', 'ja')
       expect(result).toBe('Bar')
     }
-    // Test that unrecognized keys just return `undefined`
     {
       const result = getDataByLanguage('variables.stuff.neverheardof', 'ja')
       expect(result).toBeUndefined()
@@ -111,12 +107,10 @@ describe('get-data', () => {
       const result = getDataByLanguage('variables.stuff.key_non_existent', 'en')
       expect(result).toBeUndefined()
     }
-    // Test fallback to English if not present in translation
     {
       const result = getDataByLanguage('variables.stuff.key_non_existent', 'ja')
       expect(result).toBeUndefined()
     }
-    // Returns undefined if not only the key is missing but the whole file too
     {
       const result = getDataByLanguage('variables.notpresent.whatever', 'en')
       expect(result).toBeUndefined()
@@ -128,7 +122,6 @@ describe('get-data', () => {
       const result = getDataByLanguage('reusables.coolness', 'en')
       expect(result).toBe('This is *Markdown*')
     }
-    // Test that memoization doesn't go wrong
     {
       const result = getDataByLanguage('reusables.otherness', 'en')
       expect(result).toBe('**Also** Markdown')
@@ -140,7 +133,6 @@ describe('get-data', () => {
       const result = getDataByLanguage('reusables.coolness', 'ja')
       expect(result).toBe('これがマークダウンです')
     }
-    // Test translations fall back to English if file doesn't exist
     {
       const result = getDataByLanguage('reusables.otherness', 'ja')
       expect(result).toBe('**Also** Markdown')
@@ -152,7 +144,6 @@ describe('get-data', () => {
       const result = getDataByLanguage('reusables.neverheardof', 'en')
       expect(result).toBeUndefined()
     }
-    // Test translations will try English but fail if the fallback fails too
     {
       const result = getDataByLanguage('reusables.neverheardof', 'ja')
       expect(result).toBeUndefined()
@@ -165,12 +156,10 @@ describe('get-data', () => {
       expect(result.key).toBe('Value')
       expect((result.deep as Record).er).toBe('Depth')
     }
-    // In a specific language
     {
       const result = getUIDataMerged('ja')
       expect(result.key).toBe('価値')
       expect((result.deep as Record).er).toBe('深さ')
-      // Note how it falls back to English on that key
       expect((result.deep as Record).est).toBe('Deepest')
     }
   })
@@ -181,7 +170,6 @@ describe('get-data', () => {
       expect((result.stuff as Record).foo).toBe('Foo')
       expect((result.stuff as Record).bar).toBe('Bar')
     }
-    // All reusables
     {
       const result = getDeepDataByLanguage('reusables', 'en')
       expect(result['coolness.md']).toBe('This is *Markdown*')
@@ -213,7 +201,7 @@ front: >'matter
 describe('get-data on corrupt translations', () => {
   let dd: DataDirectory
   const enDirBefore = languages.en.dir
-  // Only `en` is available in vitest tests, so pretend we also have Japanese
+  // Only en is available in tests, so copy English metadata for Japanese fixtures.
   languages.ja = Object.assign({}, languages.en, {})
 
   beforeAll(() => {
@@ -263,12 +251,10 @@ describe('get-data on corrupt translations', () => {
   })
 
   test('getDataByLanguage on a corrupt .yml file', () => {
-    // First make sure it works in English
     {
       const result = getDataByLanguage('variables.everything.is', 'en')
       expect(result).toBe('Awesome')
     }
-    // Japanese translations would fall back due to a corrupt Yaml file
     {
       const result = getDataByLanguage('variables.everything.is', 'ja')
       expect(result).toBe('Awesome')
@@ -276,12 +262,10 @@ describe('get-data on corrupt translations', () => {
   })
 
   test('getDataByLanguage on a corrupt .md file', () => {
-    // First make sure it works in English
     {
       const result = getDataByLanguage('reusables.cool', 'en')
       expect(result).toBe('*English* /Markdown/')
     }
-    // Japanese translations would fall back due to a corrupt Yaml file
     {
       const result = getDataByLanguage('reusables.cool', 'ja')
       expect(result).toBe('*English* /Markdown/')
@@ -317,11 +301,11 @@ describe('get-data applies corrections to translated variables', () => {
         data: {
           variables: {
             myproduct: {
-              // Corrupted: `data` translated to Japanese `データ`
+              // Translation corrupts the data keyword to データ.
               name: '{% データ variables.myproduct.name %}',
             },
             phases: {
-              // Not corrupted, so it should pass through unchanged
+              // Valid ifversion stays unchanged.
               preview: '{% ifversion ghes < 3.16 %}ベータ{% else %}パブリックプレビュー{% endif %}',
             },
           },
@@ -337,12 +321,10 @@ describe('get-data applies corrections to translated variables', () => {
   })
 
   test('corrects corrupted Liquid keywords in translated variables', () => {
-    // English variable is returned as-is
     {
       const result = getDataByLanguage('variables.myproduct.name', 'en')
       expect(result).toBe('GitHub')
     }
-    // Japanese translation with corrupted `データ` → `data` gets corrected
     {
       const result = getDataByLanguage('variables.myproduct.name', 'ja')
       expect(result).toBe('{% data variables.myproduct.name %}')
@@ -350,7 +332,6 @@ describe('get-data applies corrections to translated variables', () => {
   })
 
   test('leaves valid translated variables unchanged', () => {
-    // Valid ifversion in translated variable should pass through
     {
       const result = getDataByLanguage('variables.phases.preview', 'ja')
       expect(result).toBe(
diff --git a/src/data-directory/tests/index.ts b/src/data-directory/tests/index.ts
index cdef3164a438..03e0de1a292b 100644
--- a/src/data-directory/tests/index.ts
+++ b/src/data-directory/tests/index.ts
@@ -31,16 +31,14 @@ describe('data-directory', () => {
     const extensions = ['.yml', 'markdown']
     const data = dataDirectory(fixturesDir, { extensions })
     expect('bar' in data).toBe(true)
-    expect('foo' in data).toBe(false) // JSON file should be ignored
+    expect('foo' in data).toBe(false)
   })
 
   test('option: ignorePatterns', async () => {
     const ignorePatterns: RegExp[] = []
 
-    // README is ignored by default
     expect('README' in dataDirectory(fixturesDir)).toBe(false)
 
-    // README can be included by setting empty ignorePatterns array
     expect('README' in dataDirectory(fixturesDir, { ignorePatterns })).toBe(true)
   })
 })
diff --git a/src/data-directory/tests/orphaned-features.ts b/src/data-directory/tests/orphaned-features.ts
index ea931b6b4f21..e63c7c1f8db7 100644
--- a/src/data-directory/tests/orphaned-features.ts
+++ b/src/data-directory/tests/orphaned-features.ts
@@ -49,7 +49,6 @@ describe('orphaned features detection', () => {
   })
 
   test('helper functions handle nested directories', () => {
-    // Create a temporary nested structure to test
     const tempDir = path.join(__dirname, 'temp-nested-test')
     const nestedVariablesDir = path.join(tempDir, 'variables', 'nested')
     const nestedReusablesDir = path.join(tempDir, 'reusables', 'nested')
@@ -78,7 +77,6 @@ describe('orphaned features detection', () => {
   })
 
   test('helper functions ignore non-target files', () => {
-    // Create a temporary directory with mixed file types
     const tempDir = path.join(__dirname, 'temp-mixed-files')
     fs.mkdirSync(tempDir, { recursive: true })
 
@@ -90,12 +88,10 @@ describe('orphaned features detection', () => {
     fs.writeFileSync(path.join(tempDir, 'README.md'), '# README')
 
     try {
-      // getVariableFiles should only find .yml files (excluding README.yml)
       const variableFiles = getVariableFiles(tempDir)
       expect(variableFiles).toHaveLength(1)
       expect(variableFiles[0]).toMatch(/test\.yml$/)
 
-      // getReusableFiles should only find .md files (excluding README.md)
       const reusableFiles = getReusableFiles(tempDir)
       expect(reusableFiles).toHaveLength(1)
       expect(reusableFiles[0]).toMatch(/test\.md$/)
@@ -105,13 +101,9 @@ describe('orphaned features detection', () => {
   })
 
   test('verify fix addresses the original issue scenario', () => {
-    // This test simulates the original issue where features were used only in variables
-    // but not detected by the orphaned features script
-
     const variablesDir = path.join(fixturesDir, 'data', 'variables')
     const featuresDir = path.join(fixturesDir, 'data', 'features')
 
-    // Verify our test setup has the scenario described in the issue
     expect(fs.existsSync(path.join(featuresDir, 'used-in-variables.yml'))).toBe(true)
     expect(fs.existsSync(path.join(featuresDir, 'truly-orphaned.yml'))).toBe(true)
 
@@ -121,8 +113,6 @@ describe('orphaned features detection', () => {
     const variableFiles = getVariableFiles(variablesDir)
     expect(variableFiles.length).toBeGreaterThan(0)
 
-    // This proves that the fix would catch features used in variables files
-    // because the orphaned features script now scans these files
     const foundFeatureUsage = variableFiles.some((filePath) => {
       const content = fs.readFileSync(filePath, 'utf-8')
       return content.includes('used-in-variables')
@@ -132,8 +122,6 @@ describe('orphaned features detection', () => {
   })
 
   test('functions correctly identify different file types in same directory', () => {
-    // Create a directory with both .yml and .md files to ensure each function
-    // only picks up its target file types
     const tempDir = path.join(__dirname, 'temp-mixed-target-files')
     fs.mkdirSync(tempDir, { recursive: true })
 
@@ -148,7 +136,6 @@ describe('orphaned features detection', () => {
     fs.writeFileSync(path.join(tempDir, 'other.txt'), 'other content')
 
     try {
-      // Each function should only find its target file type
       const variableFiles = getVariableFiles(tempDir)
       const reusableFiles = getReusableFiles(tempDir)
 
diff --git a/src/data-directory/tests/ui-yml-structure.ts b/src/data-directory/tests/ui-yml-structure.ts
index edc8cf7e789a..91803b1c281e 100644
--- a/src/data-directory/tests/ui-yml-structure.ts
+++ b/src/data-directory/tests/ui-yml-structure.ts
@@ -13,7 +13,7 @@ describe('data/ui.yml structure', () => {
     const violations: string[] = []
 
     for (let i = 0; i < lines.length; i++) {
-      // A top-level key starts at column 0 with a word followed by ':'
+      // Top-level keys start at column 0 with a word followed by colon.
       if (/^[a-z_]+:/.test(lines[i]) && i > 0) {
         if (lines[i - 1].trim() !== '') {
           violations.push(`Line ${i + 1}: "${lines[i]}" is not preceded by a blank line`)
diff --git a/src/deployments/production/build-scripts/clone-or-use-cached-repo.sh b/src/deployments/production/build-scripts/clone-or-use-cached-repo.sh
index a908885ab652..0dad817f3ff9 100644
--- a/src/deployments/production/build-scripts/clone-or-use-cached-repo.sh
+++ b/src/deployments/production/build-scripts/clone-or-use-cached-repo.sh
@@ -1,11 +1,7 @@
 set -e
 
-# Reuses the repo cached by a previous Dockerfile build, or clones it fresh
-# and checks out the given branch/SHA.
-# Arguments:
-#   $1 - Repository name (for directory naming)
-#   $2 - Repository URL
-#   $3 - Branch to clone
+# Reuses a cached repo or clones it, then checks out the requested branch.
+# Arguments: $1 cache directory, $2 GitHub repo name under github, $3 branch.
 clone_or_use_cached_repo() {
   repo_name="$1"
   repo_url="$2"
diff --git a/src/deployments/production/build-scripts/fetch-repos.sh b/src/deployments/production/build-scripts/fetch-repos.sh
index 3f2239fc448e..9f24050f0013 100644
--- a/src/deployments/production/build-scripts/fetch-repos.sh
+++ b/src/deployments/production/build-scripts/fetch-repos.sh
@@ -1,7 +1,7 @@
 #!/usr/bin/env sh
 
-# Called from the production Dockerfile. The Dockerfile only COPYs what it
-# needs, but these scripts still run as if from the docs-internal root.
+# The production Dockerfile copies only required files, but these scripts still run from the
+# docs-internal root.
 
 echo "Fetching and resolving early-access, and translations repos"
 
@@ -9,7 +9,7 @@ set -e
 
 . ./build-scripts/clone-or-use-cached-repo.sh
 
-# From the --secret mounted by the Docker build.
+# Docker build mounts DOCS_BOT_PAT_BASE at /run/secrets/DOCS_BOT_PAT_BASE.
 GITHUB_TOKEN=$(cat /run/secrets/DOCS_BOT_PAT_BASE)
 
 echo "Fetching early access..."
@@ -17,11 +17,11 @@ clone_or_use_cached_repo "docs-early-access" "docs-early-access" "main"
 echo "Merging early access..."
 . ./build-scripts/merge-early-access.sh
 
-# Clone into `translations/` inside the Dockerfile's WORKDIR, the docs-internal root.
+# Clone translations under the Dockerfile WORKDIR, the docs-internal root.
 mkdir -p translations
 cd translations
 
-# Temporarily turn off exit-on-error so we can collect all PIDs
+# Disable exit-on-error so the script can collect every background clone failure.
 set +e
 
 pids=""
@@ -35,7 +35,6 @@ for pid in $pids; do
   wait "$pid" || failures=$((failures+1))
 done
 
-# Restore strict mode
 set -e
 
 if [ "$failures" -gt 0 ]; then
@@ -45,8 +44,8 @@ else
   echo "✅  All translations fetched."
 fi
 
-# Go back to the root of the docs-internal repo
+# Return to the docs-internal root after cloning translations.
 cd ..
 
-# Don't leave the token in the environment.
+# Remove the token from the shell environment.
 unset GITHUB_TOKEN
diff --git a/src/deployments/production/build-scripts/merge-early-access.sh b/src/deployments/production/build-scripts/merge-early-access.sh
index 317f81a9dc87..00a3c3bfef7a 100755
--- a/src/deployments/production/build-scripts/merge-early-access.sh
+++ b/src/deployments/production/build-scripts/merge-early-access.sh
@@ -1,7 +1,6 @@
 #!/usr/bin/env sh
 
-# Merges docs-early-access files into docs-internal. Runs from the
-# docs-internal root.
+# Merges docs-early-access files into docs-internal from the docs-internal root.
 
 mv docs-early-access/assets/images assets/images/early-access
 mv docs-early-access/content content/early-access
diff --git a/src/dev-toc/generate.ts b/src/dev-toc/generate.ts
index f883ccbbad96..3d7c16ffc1ec 100644
--- a/src/dev-toc/generate.ts
+++ b/src/dev-toc/generate.ts
@@ -1,14 +1,10 @@
-/**
- * @purpose Writer tool
- * @description Generate a local table of contents for the GitHub Docs website
- *
- * This script creates static HTML files for each documentation version, renders page titles
- * using Liquid templating, and opens the generated TOC in your browser for easy navigation
- * during development. Supports command-line options to specify which sections should be
- * open by default.
- *
- * Usage: tsx src/dev-toc/generate.ts [-o product-ids...]
- */
+// @purpose Writer tool
+// @description Generate a local table of contents for the GitHub Docs website
+//
+// Creates static HTML for each documentation version, renders Liquid page titles, and opens the
+// generated table of contents in your browser. Use -o product-ids... to open sections by default.
+//
+// Run with: tsx src/dev-toc/generate.ts [-o product-ids...]
 
 import fs from 'fs'
 import path from 'path'
diff --git a/src/dev-toc/layout.html b/src/dev-toc/layout.html
index 24d8fab8fccd..78c08dde5811 100644
--- a/src/dev-toc/layout.html
+++ b/src/dev-toc/layout.html
@@ -38,7 +38,6 @@ 

TOC for {{ allVersions[currentVersion].versio
  • {{ productPage.renderedFullTitle }} - {% comment %} Unified nested rendering with depth control {% endcomment %} {% if productPage.childPages and productPage.childPages.size > 0 %}
      {% for l1 in productPage.childPages %} diff --git a/src/early-access/middleware/early-access-links.ts b/src/early-access/middleware/early-access-links.ts index a09dfdc939f5..b9ac7d0f7cbd 100644 --- a/src/early-access/middleware/early-access-links.ts +++ b/src/early-access/middleware/early-access-links.ts @@ -8,13 +8,11 @@ export default function earlyAccessContext( res: Response, next: NextFunction, ) { - // Use req.pagePath instead of req.path because req.path is the path - // normalized after "converting" that `/_next/data/...` path to the - // equivalent path if it had *not* been a client-side routing fetch. + // handleNextDataPath sets converted routes in req.pagePath; req.path keeps the /_next/data URL. const url = req.pagePath!.split('/').slice(2) if ( !( - // Is it `/early-access` or `/enterprise-cloud@latest/early-access`? + // Match /early-access and versioned /early-access routes. ( (url.length === 2 && url[1] === 'early-access') || (url.length === 1 && url[0] === 'early-access') @@ -45,7 +43,7 @@ export default function earlyAccessContext( .sort() .map((permalink) => `- [${permalink.title}](${permalink.href})`) - // Only read by the separate EA repo, in local development. + // Only the separate early access repo reads this, in local development. req.context.earlyAccessPageLinks = earlyAccessPageLinks.length ? earlyAccessPageLinks.join('\n') : '_None for this version!_' diff --git a/src/early-access/scripts/clone-locally b/src/early-access/scripts/clone-locally index ab816c586dba..4564e5f83a04 100755 --- a/src/early-access/scripts/clone-locally +++ b/src/early-access/scripts/clone-locally @@ -5,7 +5,6 @@ set -e -# Go up a directory pushd .. > /dev/null if [ -d "docs-early-access" ]; then @@ -14,13 +13,10 @@ if [ -d "docs-early-access" ]; then exit 0 fi -# Clone the repo git clone https://github.com/github/docs-early-access.git -# Go back to the previous working directory popd > /dev/null -# Symlink the local docs-early-access repo into this repo npm run symlink-from-local-repo -- -p ../docs-early-access echo -e '\nDone!' diff --git a/src/early-access/scripts/create-branch b/src/early-access/scripts/create-branch index c5f6fb6fad46..456e98db95ae 100755 --- a/src/early-access/scripts/create-branch +++ b/src/early-access/scripts/create-branch @@ -5,7 +5,6 @@ set -e -# Get current branch name currentBranch=$(git rev-parse --abbrev-ref HEAD) if [ $currentBranch == "main" ]; then @@ -13,7 +12,6 @@ if [ $currentBranch == "main" ]; then exit 0 fi -# Go up a directory pushd .. > /dev/null if [ ! -d "docs-early-access" ]; then @@ -22,17 +20,13 @@ if [ ! -d "docs-early-access" ]; then exit 0 fi -# Navigate to docs-early-access cd docs-early-access -# Check out main and update git checkout main git pull origin main -# Create a branch with the current docs-internal branch name git checkout -b $currentBranch -# Go back to the previous working directory popd > /dev/null echo -e "\nDone! Created a branch called ${currentBranch}. Remember to commit your work in ../docs-early-access when you're ready." diff --git a/src/early-access/scripts/merge-early-access.sh b/src/early-access/scripts/merge-early-access.sh index 8c70e549dc4c..00df810ca60d 100755 --- a/src/early-access/scripts/merge-early-access.sh +++ b/src/early-access/scripts/merge-early-access.sh @@ -1,10 +1,6 @@ #!/usr/bin/env bash -# [start-readme] -# -# This script takes docs-early-access files and merges them into docs-internal -# -# [end-readme] +# Merges docs-early-access files into docs-internal. mv docs-early-access/assets/images assets/images/early-access mv docs-early-access/content content/early-access diff --git a/src/early-access/scripts/migrate-early-access-product.ts b/src/early-access/scripts/migrate-early-access-product.ts index ef7e6cf2ae6f..0dbedc8430ea 100644 --- a/src/early-access/scripts/migrate-early-access-product.ts +++ b/src/early-access/scripts/migrate-early-access-product.ts @@ -1,8 +1,4 @@ -// [start-readme] -// -// Move the files from an early-access product level docs set into an existing product. -// -// [end-readme] +// Moves a product-level early access docs set into an existing product. import fs from 'fs' import path from 'path' @@ -54,7 +50,7 @@ if (!filesToMigrate.length) { const migratePath: string = path.posix.join(contentDir, newPathId) -// Update the image and data refs in the to-be-migrated early access files BEFORE moving them. +// Rewrite early access image and data refs before moving files. try { execFileSync('tsx', [ 'src/early-access/scripts/update-data-and-image-paths.ts', @@ -71,7 +67,7 @@ const variablesToMove: string[] = [] const reusablesToMove: string[] = [] const imagesToMove: string[] = [] -// Add redirects to and update frontmatter in the to-be-migrated early access files BEFORE moving them. +// Apply redirects and frontmatter changes before moving files. for (const filepath of filesToMigrate) { const { content, data } = frontmatter(fs.readFileSync(filepath, 'utf8')) const redirectString: string = filepath @@ -86,7 +82,6 @@ for (const filepath of filesToMigrate) { fs.writeFileSync(filepath, frontmatter.stringify(content || '', data)) } - // Find the data files and images referenced in the early access files so we can move them over. const dataRefs: string[] = content ? content.match(patterns.dataReference) || [] : [] const variables: string[] = dataRefs.filter((ref) => ref.includes('variables')) const reusables: string[] = dataRefs.filter((ref) => ref.includes('reusables')) @@ -97,7 +92,6 @@ for (const filepath of filesToMigrate) { imagesToMove.push(...images) } -// Move the data files and images. for (const varRef of Array.from(new Set(variablesToMove))) { moveVariable(varRef) } @@ -108,10 +102,8 @@ for (const imageRef of Array.from(new Set(imagesToMove))) { moveImage(imageRef) } -// Move the content files. execFileSync('mv', [oldPath, migratePath]) -// Update the parent product TOC with the new child path. const parentProductTocPath: string = path.posix.join(path.dirname(newPath), 'index.md') const parentProductToc = frontmatter(fs.readFileSync(parentProductTocPath, 'utf-8')) if (parentProductToc.data && Array.isArray(parentProductToc.data.children)) { @@ -123,7 +115,6 @@ fs.writeFileSync( frontmatter.stringify(parentProductToc.content || '', parentProductToc.data || {}), ) -// Optionally, update the new product TOC with the new title. if (program.opts().newTitle) { const productTocPath: string = path.posix.join(newPath, 'index.md') const productToc = frontmatter(fs.readFileSync(productTocPath, 'utf-8')) @@ -137,7 +128,6 @@ if (program.opts().newTitle) { ) } -// Update internal links now that the files have been moved. console.log('\nRunning script to update internal links...') execFileSync('tsx', ['src/links/scripts/update-internal-links.ts']) @@ -153,18 +143,15 @@ Please review all the changes in docs-internal and docs-early-access, especially `) function moveVariable(dataRef: string): void { - // Get the data filepath from the data reference, - // where the data reference looks like: {% data variables.foo.bar %} - // and the data filepath looks like: data/variables/foo.yml with key of 'bar'. + // Variable refs like {% data variables.foo.bar %} map to data/variables/foo.yml plus key bar. const variablePathArray: string[] = dataRef .match(/{% (?:data|indented_data_reference) (.*?) %}/)?.[1] .split('.') - // If early access is part of the path, remove it (since the path below already includes it) + // Remove early-access because the path already joins under data/early-access. .filter((n) => n !== 'early-access') || [] - // In `variables.foo.bar` the last segment is the variable key. - // Pop it off, leaving the filepath `variables/foo.yml`. + // The last segment is the variable key; the remaining segments form variables/foo.yml. const variableKey: string = last(variablePathArray) as string variablePathArray.pop() @@ -218,14 +205,12 @@ function moveVariable(dataRef: string): void { } function moveReusable(dataRef: string): void { - // Get the data filepath from the data reference, - // where the data reference looks like: {% data reusables.foo.bar %} - // and the data filepath looks like: data/reusables/foo/bar.md. + // Reusable refs like {% data reusables.foo.bar %} map to data/reusables/foo/bar.md. const reusablePath: string = dataRef .match(/{% (?:data|indented_data_reference) (\S*?) .*%}/)?.[1] .split('.') - // If early access is part of the path, remove it (since the path below already includes it) + // Remove early-access because the path already joins under data/early-access. .filter((n) => n !== 'early-access') .join('/') || '' @@ -254,7 +239,7 @@ function moveReusable(dataRef: string): void { function moveImage(imageRef: string): void { const imagePath: string = imageRef .replace('/assets/images/', '') - // If early access is part of the path, remove it (since the path below already includes it) + // Remove early-access because the path already joins under assets/images/early-access. .replace('early-access', '') const oldImagePath: string = path.posix.join( diff --git a/src/early-access/scripts/symlink-from-local-repo.ts b/src/early-access/scripts/symlink-from-local-repo.ts index 43ef6423ec9c..59040f0a295b 100644 --- a/src/early-access/scripts/symlink-from-local-repo.ts +++ b/src/early-access/scripts/symlink-from-local-repo.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Create or destroy symlinks to your local docs-early-access checkout - */ +// @purpose Writer tool +// @description Create or destroy symlinks to your local docs-early-access checkout import fs from 'fs' import path from 'path' @@ -64,7 +62,6 @@ const destinationDirsMap: Record = destinationDirNames.reduce( {} as Record, ) -// Remove all existing early access directories from this repo for (const dirName of destinationDirNames) { const destDir = destinationDirsMap[dirName] fs.rmSync(destDir, { recursive: true, force: true }) @@ -75,7 +72,6 @@ if (unlink) { process.exit(0) } -// Symlink the latest early access source directories into this repo for (const dirName of destinationDirNames) { if (!earlyAccessLocalRepoDir) continue diff --git a/src/early-access/scripts/update-data-and-image-paths.ts b/src/early-access/scripts/update-data-and-image-paths.ts index f1823f9e1187..c7220d4f0803 100644 --- a/src/early-access/scripts/update-data-and-image-paths.ts +++ b/src/early-access/scripts/update-data-and-image-paths.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Add or remove "early-access" from data and image paths - */ +// @purpose Writer tool +// @description Add or remove "early-access" from data and image paths import fs from 'fs' import path from 'path' @@ -45,7 +43,7 @@ let selectedFiles: string[] = allEarlyAccessFiles if (earlyAccessPath) { const contentFiles = allEarlyAccessFiles.filter((file) => file.includes(earlyAccessPath)) - // We also need to include any reusable files that are referenced in the selected content files. + // Include reusable files referenced by selected content files. const referencedDataFiles: string[] = [] for (const file of contentFiles) { const contents = fs.readFileSync(file, 'utf8') diff --git a/src/early-access/scripts/what-docs-early-access-branch.ts b/src/early-access/scripts/what-docs-early-access-branch.ts index fd69e85f1c1c..41e89a0ff5f9 100644 --- a/src/early-access/scripts/what-docs-early-access-branch.ts +++ b/src/early-access/scripts/what-docs-early-access-branch.ts @@ -16,9 +16,7 @@ async function main(): Promise { const OUTPUT_KEY = 'branch' - // If being run from a PR, this becomes 'my-cool-branch'. - // If run on main, with the `workflow_dispatch` action for - // example, the value becomes 'main'. + // Use the matching docs-early-access branch when it exists; 404 falls back to main. const github = getOctokit(GITHUB_TOKEN) for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { @@ -39,7 +37,7 @@ async function main(): Promise { setOutput(OUTPUT_KEY, 'main') return } - // Retry on network/server errors (5xx, timeouts, etc.) + // Retry any non-404 failure until MAX_RETRIES is reached. if (attempt < MAX_RETRIES) { console.warn( `Attempt ${attempt}/${MAX_RETRIES} failed with error: ${err instanceof Error ? err.message : String(err)}. Retrying in ${RETRY_DELAY_SECONDS}s...`, diff --git a/src/early-access/tests/early-access-unit.ts b/src/early-access/tests/early-access-unit.ts index d133f1479a12..a5b267b1ca34 100644 --- a/src/early-access/tests/early-access-unit.ts +++ b/src/early-access/tests/early-access-unit.ts @@ -28,7 +28,7 @@ describeIfDocsEarlyAccess('early access rendering', () => { test('404 if any other language than English', async () => { for (const code of Object.keys(languages)) { if (code === 'en') { - // This is tested elsewhere + // English early access rendering has separate tests above. continue } const res = await get(`/${code}${VALID_EARLY_ACCESS_URI}`) diff --git a/src/eslint-rules/no-dangerously-set-inner-html/no-dangerously-set-inner-html.js b/src/eslint-rules/no-dangerously-set-inner-html/no-dangerously-set-inner-html.js index 75b90dbc8b14..174e8a8856bf 100644 --- a/src/eslint-rules/no-dangerously-set-inner-html/no-dangerously-set-inner-html.js +++ b/src/eslint-rules/no-dangerously-set-inner-html/no-dangerously-set-inner-html.js @@ -15,17 +15,12 @@ module.exports = { }, create(context) { return { - // Flag the JSX attribute form:
      JSXAttribute(node) { if (node.name && node.name.name === "dangerouslySetInnerHTML") { context.report({ node, messageId: "noDanger" }); } }, - // Flag the object-property form used when spreading props, e.g. - // { dangerouslySetInnerHTML: { __html: html } }. Only object *expressions* - // (constructing props) are unsafe; skip object *patterns* (destructuring - // like `const { dangerouslySetInnerHTML, ...rest } = props`), which strip - // the prop and are safe. + // Object expressions can build JSX-spread dangerouslySetInnerHTML; patterns only read props. Property(node) { if (!node.parent || node.parent.type !== "ObjectExpression") return; const key = node.key; @@ -38,9 +33,7 @@ module.exports = { context.report({ node, messageId: "noDanger" }); } }, - // Flag the assignment form, including the computed string-key bypass: - // props.dangerouslySetInnerHTML = { __html: html } - // props['dangerouslySetInnerHTML'] = { __html: html } + // Direct and computed assignments bypass JSX-attribute checks, so flag both forms. AssignmentExpression(node) { const left = node.left; if (!left || left.type !== "MemberExpression") return; diff --git a/src/eslint-rules/no-dangerously-set-inner-html/tests/no-dangerously-set-inner-html.ts b/src/eslint-rules/no-dangerously-set-inner-html/tests/no-dangerously-set-inner-html.ts index 399ef0225838..c5a80d0189aa 100644 --- a/src/eslint-rules/no-dangerously-set-inner-html/tests/no-dangerously-set-inner-html.ts +++ b/src/eslint-rules/no-dangerously-set-inner-html/tests/no-dangerously-set-inner-html.ts @@ -21,7 +21,7 @@ describe('no-dangerously-set-inner-html', () => { { code: `const el = ` }, { code: `const el = ` }, { code: `const props = { className: 'x', children: nodes }` }, - // Destructuring that strips the prop is safe and must not be flagged. + // Destructuring strips the prop, so the rule leaves it alone. { code: `const { dangerouslySetInnerHTML, ...safeProps } = props` }, ], invalid: [], @@ -64,7 +64,7 @@ describe('no-dangerously-set-inner-html', () => { code: `props.dangerouslySetInnerHTML = { __html: html }`, errors: [{ messageId: 'noDanger' }], }, - // Computed string-key assignment is a trivial bypass and must be flagged. + // Computed string-key assignment bypasses JSX-attribute checks, so the rule flags it. { code: `props['dangerouslySetInnerHTML'] = { __html: html }`, errors: [{ messageId: 'noDanger' }], diff --git a/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts b/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts index 37aeb287c2d4..9fd4dff0d791 100644 --- a/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts +++ b/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts @@ -442,7 +442,7 @@ const logger = createLogger(import.meta.url); }) it('should handle logger variable with destructuring pattern', () => { - // A destructured logger already exists, so the fix must not redeclare it. + // A destructured logger already exists, so the fixer must not redeclare it. ruleTester.run('use-custom-logger', rule, { valid: [], invalid: [ @@ -456,7 +456,7 @@ const logger = createLogger(import.meta.url); message: 'Please use our internal logger.info instead of console.log', }, ], - // The auto-fix will add the import but not the declaration since logger exists via destructuring + // The fixer adds the import but skips the declaration because destructuring provides it. output: `import { createLogger } from '@/observability/logger'; const { logger } = something; diff --git a/src/eslint-rules/use-custom-logger/use-custom-logger.js b/src/eslint-rules/use-custom-logger/use-custom-logger.js index 05d273fb7a5f..982b9482daef 100644 --- a/src/eslint-rules/use-custom-logger/use-custom-logger.js +++ b/src/eslint-rules/use-custom-logger/use-custom-logger.js @@ -13,7 +13,6 @@ module.exports = { const sourceCode = context.getSourceCode(); let setupInserted = false; - // Check if the logger import is already present. function needsLoggerImport() { return !sourceCode.ast.body.some( (node) => @@ -22,18 +21,13 @@ module.exports = { ); } - // Check if a logger variable is already declared. - // This checks for both direct declarations (const logger = ...) and - // destructured patterns (const { logger } = ...). function needsLoggerDeclaration() { return !sourceCode.ast.body.some((node) => { if (node.type === "VariableDeclaration") { return node.declarations.some((decl) => { - // Check for direct identifier: const logger = ... if (decl.id.type === "Identifier" && decl.id.name === "logger") { return true; } - // Check for destructured pattern: const { logger } = ... if (decl.id.type === "ObjectPattern") { return decl.id.properties.some( (prop) => @@ -49,7 +43,6 @@ module.exports = { }); } - // Retrieve the last import statement. function getLastImportNode() { const imports = sourceCode.ast.body.filter( (node) => node.type === "ImportDeclaration", @@ -69,7 +62,6 @@ module.exports = { ["log", "error", "debug", "warn"].includes(callee.property.name) ) { const method = callee.property.name; - // Determine the replacement method: "log" should become "info". const newMethod = method === "log" ? "info" : method; context.report({ node: callee, @@ -78,14 +70,10 @@ module.exports = { const fixes = []; const args = node.arguments; - // Replace 'console' with 'logger' fixes.push(fixer.replaceText(callee.object, "logger")); - // Replace the property; if it's "log", change to "info" fixes.push(fixer.replaceText(callee.property, newMethod)); - // Check if we need to transform arguments for error-level methods - // If the first argument appears to be an error variable (common pattern: err, error, e) - // and there's only one argument, we should add a descriptive message + // Add a message when error or warn receives one error variable; keep it as metadata. if ( (newMethod === "error" || newMethod === "warn") && args.length === 1 && @@ -94,8 +82,6 @@ module.exports = { args[0].name, ) ) { - // Transform console.error(err) to logger.error('Error occurred', { err }) - // This makes the log message more useful and follows structured logging pattern const errorVarName = sourceCode.getText(args[0]); fixes.push( fixer.replaceText( @@ -105,7 +91,7 @@ module.exports = { ); } - // Insert our logger setup (import + declaration) only once per file. + // Insert logger setup once per file. if (!setupInserted) { setupInserted = true; @@ -114,7 +100,6 @@ module.exports = { const lastImport = getLastImportNode(); if (needsImport && needsDeclaration) { - // Insert both import and declaration together if (lastImport) { fixes.push( fixer.insertTextAfter( @@ -123,7 +108,6 @@ module.exports = { ), ); } else { - // No imports – insert at the top fixes.push( fixer.insertTextBeforeRange( [0, 0], @@ -132,7 +116,6 @@ module.exports = { ); } } else if (needsImport) { - // Only insert the import if (lastImport) { fixes.push( fixer.insertTextAfter( @@ -149,7 +132,6 @@ module.exports = { ); } } else if (needsDeclaration) { - // Only insert the logger declaration if (lastImport) { fixes.push( fixer.insertTextAfter( diff --git a/src/fixtures/helpers/color-contrast.ts b/src/fixtures/helpers/color-contrast.ts index 4d2e6fc8fe77..1f6463defa36 100644 --- a/src/fixtures/helpers/color-contrast.ts +++ b/src/fixtures/helpers/color-contrast.ts @@ -1,5 +1,5 @@ -// WCAG contrast for computed `rgb()`/`rgba()` colours. Keywords, hex and -// translucent values throw rather than being coerced — `rgba(0, 0, 0, 0)` would +// Computes WCAG contrast only for opaque computed rgb()/rgba() colours. +// Reject keywords, hex, and translucent values, because rgba(0, 0, 0, 0) would // otherwise read as opaque black and yield a confident, wrong ratio. function parseComputedColor(color: string) { diff --git a/src/fixtures/helpers/turn-off-experiments.ts b/src/fixtures/helpers/turn-off-experiments.ts index cfb9547b4e2e..b26fb37bee9e 100644 --- a/src/fixtures/helpers/turn-off-experiments.ts +++ b/src/fixtures/helpers/turn-off-experiments.ts @@ -18,7 +18,7 @@ async function alterExperimentsInPage( variation: typeof TREATMENT_VARIATION | typeof CONTROL_VARIATION, ) { const experiments = getActiveExperiments('all') - // Include a page.evaluate call to simulate the same # of events as if an experiment were active + // When no experiments run, page.evaluate keeps the Playwright event count matching active runs. if (!experiments.length) { await page.evaluate(() => { console.log('No experiments to turn off, skipping') @@ -28,7 +28,7 @@ async function alterExperimentsInPage( for (const experiment of getActiveExperiments('all')) { await page.evaluate( ({ experimentKey, variationType }) => { - // @ts-expect-error overrideControlGroup is a custom function added to the window object + // @ts-expect-error -- overrideControlGroup is a custom window helper for experiment tests. window.overrideControlGroup(experimentKey, variationType) }, { experimentKey: experiment.key, variationType: variation }, @@ -36,8 +36,7 @@ async function alterExperimentsInPage( } } -// Place Playwright tests in control group for every active experiment -// To write a test for an experiment, explicitly turn that experiment on in the test +// Playwright fixtures start in the control group; tests opt into treatments explicitly. export function turnOffExperimentsBeforeEach(test: typeof Test) { test.beforeEach(async ({ page }) => { await page.goto('/') diff --git a/src/fixtures/playwright.config.ts b/src/fixtures/playwright.config.ts index 7d4e382171aa..b0d0bbda814a 100644 --- a/src/fixtures/playwright.config.ts +++ b/src/fixtures/playwright.config.ts @@ -5,14 +5,8 @@ const CI = Boolean(JSON.parse(process.env.CI || 'false')) const PLAYWRIGHT_START_SERVER_COMMAND = process.env.PLAYWRIGHT_START_SERVER_COMMAND || 'npm run start-for-playwright' -// All of these "patience" related settings follow a simple pattern; -// If the env var are explicitly set, use that value, otherwise, if -// we're in CI, be very patient, otherwise, be much less patient. -// The reasoning is that most engineer laptops are faster than CI -// and most importantly, if a test gets stuck it's probably not because -// of a slow CPU, but because the test is plainly wrong. The engineer -// working on it doesn't want to have to wait half a minute to find out -// they have a bug in a test action or an assertion. +// Environment variables override the retry and timeout defaults. CI gets longer waits +// than local runs, so broken local tests fail quickly instead of waiting on CI-sized timeouts. const RETRIES = process.env.PLAYWRIGHT_RETRIES ? Number(process.env.PLAYWRIGHT_RETRIES) : CI ? 2 : 0 const TIMEOUT = process.env.PLAYWRIGHT_TIMEOUT ? Number(process.env.PLAYWRIGHT_TIMEOUT) @@ -25,17 +19,12 @@ const EXPECT_TIMEOUT = process.env.PLAYWRIGHT_EXPECT_TIMEOUT ? 5 * 1000 : 2 * 1000 -/** - * See https://playwright.dev/docs/test-configuration. - */ +// See https://playwright.dev/docs/test-configuration. export default defineConfig({ testDir: './tests', timeout: TIMEOUT, expect: { - /** - * Maximum time expect() should wait for the condition to be met. - * For example in `await expect(locator).toHaveText();` - */ + // EXPECT_TIMEOUT controls waits such as await expect(locator).toHaveText(). timeout: EXPECT_TIMEOUT, }, fullyParallel: true, @@ -46,61 +35,21 @@ export default defineConfig({ : CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - // reporter: 'html', - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + // See https://playwright.dev/docs/api/class-testoptions for shared project options. use: { - /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ actionTimeout: 0, baseURL: 'http://localhost:4000', - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + // See https://playwright.dev/docs/trace-viewer for trace collection behavior. trace: 'on-first-retry', }, projects: [ - // { - // name: 'chromium', - // use: { - // ...devices['Desktop Chrome'], - // // need this wider width because of our slightly wider than normal xl - // // breakpoint that helps prevent overlapping main content with the minitoc - // viewport: { - // width: 1400, - // height: 720, - // }, - // }, - // }, - - // { - // name: 'firefox', - // use: { ...devices['Desktop Firefox'] }, - // }, - - // { - // name: 'webkit', - // use: { ...devices['Desktop Safari'] }, - // }, - - /* Test against mobile viewports. */ - // { - // name: 'Mobile Chrome', - // use: { ...devices['Pixel 5'] }, - // }, - // { - // name: 'Mobile Safari', - // use: { ...devices['iPhone 12'] }, - // }, - - /* Test against branded browsers. */ - // { - // name: 'Microsoft Edge', - // use: { channel: 'msedge' }, - // }, { name: 'Google Chrome', use: { channel: 'chromium', + // The 1400px width avoids overlap between main content and the mini table of contents. viewport: { width: 1400, height: 720, @@ -109,9 +58,6 @@ export default defineConfig({ }, ], - /* Folder for test artifacts such as screenshots, videos, traces, etc. */ - // outputDir: 'test-results/', - webServer: { command: PLAYWRIGHT_START_SERVER_COMMAND, port: 4000, diff --git a/src/fixtures/tests/annotations.ts b/src/fixtures/tests/annotations.ts index 87f35190137e..0f9b7122146e 100644 --- a/src/fixtures/tests/annotations.ts +++ b/src/fixtures/tests/annotations.ts @@ -8,13 +8,9 @@ describe('annotations', () => { const $: CheerioAPI = await getDOM('/get-started/foo/code-snippet-with-hashbang') const annotations = $('#article-contents .annotate') - // Check http://localhost:4000/en/get-started/foo/code-snippet-with-hashbang - // to understand the confidence in the assertions. - - // This fixture page has 2 bash annotations and 1 yaml + // The fixture page intentionally has 2 Bash annotations and 1 YAML annotation. expect(annotations.length).toBe(2 + 1) - // First code snippet block { const annotation = annotations.eq(0) expect(annotation.find('.annotate-header').length).toBe(1) @@ -25,7 +21,6 @@ describe('annotations', () => { const noteTexts = notes.map((_, el) => $(el).text()).get() expect(noteTexts).toEqual(["Let's get started", 'This is just a sample', 'End of the script']) } - // Second code snippet block { const annotation = annotations.eq(1) expect(annotation.find('.annotate-header').length).toBe(1) @@ -36,7 +31,7 @@ describe('annotations', () => { const noteTexts = notes.map((_, el) => $(el).text()).get() expect(noteTexts).toEqual(['Has to start with a comment.', 'This is the if statement']) } - // Yaml code snippet that starts with an empty comment + // The YAML snippet starts with an empty comment. { const annotation = annotations.eq(2) expect(annotation.find('.annotate-header').length).toBe(1) diff --git a/src/fixtures/tests/api-article-body.ts b/src/fixtures/tests/api-article-body.ts index fb4d3de6d23a..d74f787a25ad 100644 --- a/src/fixtures/tests/api-article-body.ts +++ b/src/fixtures/tests/api-article-body.ts @@ -6,15 +6,13 @@ const makeURL = (pathname: string) => `/api/article/body?${new URLSearchParams({ describe('article body api', () => { beforeAll(() => { - // If you didn't set the `ROOT` variable, the tests will fail rather - // cryptically. So as a warning for engineers running these tests, - // alert in case it was accidentally forgotten. + // Missing ROOT makes local fixture failures hard to trace. if (!process.env.ROOT) { console.warn( 'WARNING: The articlebody tests require the ROOT environment variable to be set to the fixture root', ) } - // Ditto for fixture-based translations to work + // Missing TRANSLATIONS_FIXTURE_ROOT breaks fixture-based translations. if (!process.env.TRANSLATIONS_FIXTURE_ROOT) { console.warn( 'WARNING: The articlebody tests require the TRANSLATIONS_FIXTURE_ROOT environment variable to be set', @@ -28,7 +26,7 @@ describe('article body api', () => { expect(res.headers['content-type']).toContain('text/markdown') expect(res.body).toContain('## About GitHub') expect(res.body).toContain('## About Git') - expect(res.body).toMatch(/^#+\s+\w+/m) // Check for any markdown heading pattern + expect(res.body).toMatch(/^#+\s+\w+/m) expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') @@ -123,7 +121,7 @@ describe('article body api', () => { }) test('codespaces content included in production markdown API', async () => { - // Test a real production page that has codespaces content + // This production URL exercises real Codespaces tool content when fixtures can reach it. const res = await get( makeURL( '/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/reviewing-proposed-changes-in-a-pull-request', @@ -144,8 +142,7 @@ describe('article body api', () => { }) test('verifies original issue #5400 is resolved', async () => { - // This test specifically addresses the original issue where tool picker - // content was missing from the Markdown API response + // This production URL verifies the Markdown API includes Codespaces tool content. const res = await get( makeURL( '/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/reviewing-proposed-changes-in-a-pull-request', @@ -162,7 +159,6 @@ describe('article body api', () => { expect(res.statusCode).toBe(200) expect(res.headers['content-type']).toContain('text/markdown') - // The original issue was that only webui content was returned, missing codespaces expect(res.body).toContain('
      ') expect(res.body).toContain('
      ') diff --git a/src/fixtures/tests/breadcrumbs.ts b/src/fixtures/tests/breadcrumbs.ts index 9559fcd43eef..d53b2fc4bd00 100644 --- a/src/fixtures/tests/breadcrumbs.ts +++ b/src/fixtures/tests/breadcrumbs.ts @@ -6,13 +6,11 @@ describe('breadcrumbs', () => { test('links always prefixed with language', async () => { const $ = await getDOM('/get-started/start-your-journey/hello-world') const links = $('[data-testid=breadcrumbs-bar] a') - // Home and the two ancestors are links; the current article is static text. + // The current article is static text, so only Home and two ancestors are links. expect(links.length).toBe(3) links.each((i, element) => { const href = $(element).attr('href')! - // The Home crumb points at the locale root (`/en` on the default version, - // no trailing slash); every other crumb is under `/en/…`. Both are - // language-prefixed, which is what this test guards. + // Home uses /en; every other crumb starts with /en/. expect(href === '/en' || href.startsWith('/en/')).toBe(true) }) }) @@ -45,7 +43,7 @@ describe('breadcrumbs', () => { expect(current.text()).toBe('Hello World') expect(current.is('a')).toBe(false) expect(current.attr('href')).toBeUndefined() - // The secondary-bar variant shows the full trail (no hidden last crumb). + // The secondary bar shows the full trail, including the last crumb. expect(current.hasClass('d-none')).toBe(false) }) diff --git a/src/fixtures/tests/categories-and-subcategory.ts b/src/fixtures/tests/categories-and-subcategory.ts index 24aa47af6056..36abceb149d5 100644 --- a/src/fixtures/tests/categories-and-subcategory.ts +++ b/src/fixtures/tests/categories-and-subcategory.ts @@ -12,12 +12,10 @@ describe('subcategories', () => { const links = $('[data-testid=table-of-contents] a[href]') expect(links.length).toBeGreaterThan(0) - // They all have the same prefix const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get() expect( hrefs.every((href: string) => href.startsWith('/en/get-started/start-your-journey/')), ).toBeTruthy() - // They all resolve to a 200 OK without redirects const responses = await Promise.all(hrefs.map((href: string) => head(href))) expect(responses.every((r: { statusCode: number }) => r.statusCode === 200)).toBeTruthy() }) @@ -35,7 +33,7 @@ describe('subcategories', () => { expect(firstArticleH2.text()).toMatch('Article title') const firstArticleIntro = $('[data-testid=table-of-contents] p').first() - // Its HTML in the intro is escaped and Markdown converted + // The intro escapes title HTML and converts Markdown. expect(firstArticleIntro.html()).toMatch( 'This page uses < and > in the title and shortTitle', ) @@ -50,10 +48,8 @@ describe('categories', () => { const links = $('[data-testid=table-of-contents] a[href]') expect(links.length).toBeGreaterThan(0) - // They all have the same prefix const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get() expect(hrefs.every((href: string) => href.startsWith('/en/actions/category/'))).toBeTruthy() - // They all resolve to a 200 OK without redirects const responses = await Promise.all(hrefs.map((href: string) => head(href))) expect(responses.every((r: { statusCode: number }) => r.statusCode === 200)).toBeTruthy() }) diff --git a/src/fixtures/tests/footer.ts b/src/fixtures/tests/footer.ts index 2df4b449b796..ac302a742c55 100644 --- a/src/fixtures/tests/footer.ts +++ b/src/fixtures/tests/footer.ts @@ -14,7 +14,7 @@ describe('footer', () => { }) test('renders minimal 404 page', async () => { - // 404 pages now render a minimal HTML response without the full layout + // Minimal 404 responses omit the full layout. const $ = await getDOM('/en/delicious-snacks/donuts.php', { allow404: true }) expect($('p').text()).toContain('Page not found.') }) diff --git a/src/fixtures/tests/glossary.ts b/src/fixtures/tests/glossary.ts index e214b7927aa4..dab192b44eef 100644 --- a/src/fixtures/tests/glossary.ts +++ b/src/fixtures/tests/glossary.ts @@ -17,7 +17,7 @@ describe('glossary', () => { const $: CheerioAPI = await getDOM('/get-started/learning-about-github/github-glossary') const internalLink = $('#article-contents a[href="/en/get-started/foo"]') expect(internalLink.length).toBe(1) - // That link used AUTOTITLE so it should be "expanded" + // AUTOTITLE expands this fixture link to the page title. expect(internalLink.text()).toBe('Fooing Around') }) @@ -29,7 +29,6 @@ describe('glossary', () => { }) test('liquid in one of the description depends on version', async () => { - // fpt { const $: CheerioAPI = await getDOM('/get-started/learning-about-github/github-glossary') const paragraphs = $('#article-contents p') @@ -40,7 +39,6 @@ describe('glossary', () => { expect(paragraphTexts).toContain('status check on HubGit.') } - // ghes { const $: CheerioAPI = await getDOM( '/enterprise-server@latest/get-started/learning-about-github/github-glossary', diff --git a/src/fixtures/tests/head.ts b/src/fixtures/tests/head.ts index 253f43190423..6eb6ab0fd8d0 100644 --- a/src/fixtures/tests/head.ts +++ b/src/fixtures/tests/head.ts @@ -6,10 +6,10 @@ import { getDOM } from '@/tests/helpers/e2etest' describe('', () => { test('includes page intro in `description` meta tag', async () => { const $: CheerioAPI = await getDOM('/get-started/markdown/intro') - // The intro has Markdown syntax which becomes HTML encoded in the lead element. + // The lead renders Markdown syntax as HTML. const lead = $('[data-testid="lead"] p') expect(lead.html()).toMatch('syntax') - // As a meta description its content is stripped of all HTML + // Meta descriptions strip all HTML from Markdown-rendered intros. const description = $('head meta[name="description"]') expect(description.attr('content')).toBe('This intro has Markdown syntax for HubGit') }) diff --git a/src/fixtures/tests/homepage.ts b/src/fixtures/tests/homepage.ts index 8ba9d7ece5e0..db1536eac79a 100644 --- a/src/fixtures/tests/homepage.ts +++ b/src/fixtures/tests/homepage.ts @@ -21,7 +21,8 @@ describe('home page', () => { for (const href of hrefs) { if (!href.attr('href')?.startsWith('https://')) { const res = await get(href.attr('href')!) - expect(res.statusCode).toBe(200) // Not needing to redirect + // Product group links resolve without redirects. + expect(res.statusCode).toBe(200) expect(href.text().includes('{%')).toBe(false) } else { externalLinks++ diff --git a/src/fixtures/tests/images.ts b/src/fixtures/tests/images.ts index a1eaaccfec5d..b9aee2601786 100644 --- a/src/fixtures/tests/images.ts +++ b/src/fixtures/tests/images.ts @@ -6,15 +6,16 @@ import type { Element } from 'domhandler' import { get, head, getDOM } from '@/tests/helpers/e2etest' import { MAX_WIDTH } from '@/content-render/unified/rewrite-asset-img-tags' -// `getDOM` parses with `xmlMode: true`, which is case-sensitive on attribute -// names. The legacy string render path emits a lowercase `srcset`, but the -// React render path (hast -> JSX) emits React 19's camelCase `srcSet`. Both are -// valid HTML (attribute names are case-insensitive in browsers), so read either. +// getDOM parses in xmlMode, so attribute names are case-sensitive. +// The string render path emits srcset, and the React render path emits srcSet. +// Browsers treat both as valid HTML, so read either spelling. function srcsetOf(el: Cheerio): string | undefined { return el.attr('srcset') ?? el.attr('srcSet') } describe('render Markdown image tags', () => { + // _fixtures/screenshot.png is 2000x1494 and wider than MAX_WIDTH, so picture + // sources include mw-XXXXX resizing and preserve aspect ratio at 1076px tall. test('page with a single image', async () => { const $: CheerioAPI = await getDOM('/get-started/images/single-image') @@ -41,16 +42,9 @@ describe('render Markdown image tags', () => { expect(res.statusCode).toBe(200) expect(res.headers['content-type']).toBe('image/webp') - // The fixture image `_fixtures/screenshot.png` is known to be very - // large. Larger than MAX_WIDTH pixels wide. - // When transformed as a source in a `` tag, it's automatically - // injected with the `mw-XXXXX` virtual indicator in the URL that - // resizes it on-the-fly. const image = sharp(Buffer.from(res.body as ArrayBuffer)) const { width, height } = await image.metadata() expect(width).toBe(MAX_WIDTH) - // The `_fixtures/screenshot.png` is 2000x1494. - // So if 2000/1494==MAX_WIDTH/x, then x becomes 1494*MAX_WIDTH/2000=1076 expect(height).toBe(Math.round((1494 * MAX_WIDTH) / 2000)) }) @@ -63,9 +57,9 @@ describe('render Markdown image tags', () => { const sources = $('source', pictures) expect(sources.length).toBe(3) - expect(srcsetOf(sources.eq(0))).toContain('1x') // 0 - expect(srcsetOf(sources.eq(1))).toContain('2x') // 1 - expect(srcsetOf(sources.eq(2))).toContain('2x') // 2 + expect(srcsetOf(sources.eq(0))).toContain('1x') + expect(srcsetOf(sources.eq(1))).toContain('2x') + expect(srcsetOf(sources.eq(2))).toContain('2x') }) test('image inside a list keeps its span', async () => { @@ -77,10 +71,10 @@ describe('render Markdown image tags', () => { test("links directly to images aren't rewritten", async () => { const $: CheerioAPI = await getDOM('/get-started/images/link-to-image') - // There is only 1 link inside that page - const links = $('#article-contents a[href^="/"]') // exclude header link + // The fixture has one article link; header links are out of scope. + const links = $('#article-contents a[href^="/"]') expect(links.length).toBe(1) - // This proves that the link didn't get rewritten to `/en/...` + // Asset links must stay under /assets instead of gaining a language prefix. expect(links.attr('href'), '/assets/images/_fixtures/screenshot.png') const res = await head(links.attr('href')!) expect(res.statusCode).toBe(200) diff --git a/src/fixtures/tests/internal-links.ts b/src/fixtures/tests/internal-links.ts index 353dc286168c..e659aebf97dc 100644 --- a/src/fixtures/tests/internal-links.ts +++ b/src/fixtures/tests/internal-links.ts @@ -15,13 +15,12 @@ describe('autotitle', () => { expect($(element).text()).toBe('Hello World') } }) - // There are 4 links on the `autotitling.md` content. + // autotitling.md has 4 AUTOTITLE links. expect.assertions(4) }) test('typos lead to error when NODE_ENV !== production', async () => { - // The fixture typo-autotitling.md contains two different typos - // of the word "AUTOTITLE", separated by `{% if version ghes %}` + // typo-autotitling.md contains two AUTOTITLE typos split by {% if version ghes %}. { const res = await get('/get-started/foo/typo-autotitling', { followRedirects: true }) expect(res.statusCode).toBe(500) @@ -48,14 +47,14 @@ describe('cross-version-links', () => { const $: CheerioAPI = await getDOM(URL) const links = $('#article-contents a[href]') - // Tests that the hardcoded prefix is always removed + // Cross-version links drop hardcoded free-pro-team prefixes. const firstLink = links.filter( (i: number, element: Element) => $(element).text() === 'Hello world always in free-pro-team', ) expect(firstLink.attr('href')).toBe('/en/get-started/start-your-journey/hello-world') - // Tests that the second link always goes to enterprise-server@X.Y + // Cross-version links keep explicit enterprise-server targets. const secondLink = links.filter( (i: number, element: Element) => $(element).text() === 'Autotitling page always in enterprise-server latest', @@ -79,7 +78,7 @@ describe('link-rewriting', () => { expect(link.attr('href')).toMatch('/en/get-started/') } - // Some links are left untouched + // External, asset, public, and enterprise links keep their original prefixes. { const link = links.filter((i: number, element: Element) => @@ -120,7 +119,7 @@ describe('link-rewriting', () => { }) test('/en and current version number is injected', async () => { - // enterprise-server, unlike enterprise-cloud, use numbers + // enterprise-server URLs use numbered releases, unlike enterprise-cloud. const $: CheerioAPI = await getDOM( '/enterprise-server@latest/get-started/start-your-journey/link-rewriting', ) diff --git a/src/fixtures/tests/liquid.ts b/src/fixtures/tests/liquid.ts index af380472590f..78da5ef5b547 100644 --- a/src/fixtures/tests/liquid.ts +++ b/src/fixtures/tests/liquid.ts @@ -56,70 +56,44 @@ describe('post', () => { expect(html).toMatch('
    • HubGit
    • ') expect(html).toMatch('CramFPTped') - // Test what happens to `Cram{% ifversion fpt %}FPT{% endif %}ped.` - // when it's not free-pro-team. + // Cram{% ifversion fpt %}FPT{% endif %}ped renders as Cramped outside free-pro-team. { const $inner: CheerioAPI = await getDOM( '/enterprise-server@latest/get-started/liquid/whitespace', ) const innerHtml = $inner('#article-contents').html() - // Assures that there's not whitespace left when the `{% ifversion %}` - // yields an empty string. + // Empty ifversion output must not leave extra whitespace. expect(innerHtml).toMatch('Cramped') } }) }) describe('rowheaders', () => { + // The first fixture table rewrites the first cell in each of two tbody rows to th, + // leaving three td cells per row. + // The second fixture table has three tbody rows with three td cells each. + // Axe's scope-attr-valid rule requires col scope on thead th and row scope on tbody th. + // https://dequeuniversity.com/rules/axe/4.1/scope-attr-valid?application=RuleDescription test('rowheaders', async () => { const $: CheerioAPI = await getDOM('/get-started/liquid/table-row-headers') const tables = $('#article-contents table') expect(tables.length).toBe(2) - // The first table should have this structure: - // - // table - // tbody - // tr - // th - // td - // td - // td - // - // (and there are 2 of these rows) - // - // That's because a Liquid + Markdown solution rewrites the - // *first* `tbody td` to become a `th` instead. const firstTable = tables.filter((i: number) => i === 0) expect($('tbody tr th', firstTable).length).toBe(2) expect($('tbody tr td', firstTable).length).toBe(2 * 3) - // The second table should have this structure: - // - // table - // tbody - // tr - // td - // td - // td - // - // (and there are 3 of these rows) const secondTable = tables.filter((i: number) => i === 1) expect($('tbody tr th', secondTable).length).toBe(0) expect($('tbody tr td', secondTable).length).toBe(3 * 3) - // More specifically, the tags should have the appropriate - // `scope` attribute. - // See "Scope attribute should be used correctly on tables" - // https://dequeuniversity.com/rules/axe/4.1/scope-attr-valid?application=RuleDescription $('thead th', firstTable).each((i, element) => { expect($(element).attr('scope')).toBe('col') }) $('tbody th', firstTable).each((i, element) => { expect($(element).attr('scope')).toBe('row') }) - // The 5 here is the other `expect(...)` that happens before these - // two, just above, `expect(...)` inside the `.each(...)` loops. + // Start with the five fixed assertions before counting each loop assertion. let totalAssertions = 5 totalAssertions += $('thead th', firstTable).length totalAssertions += $('tbody th', firstTable).length @@ -128,9 +102,7 @@ describe('rowheaders', () => { }) describe('ifversion', () => { - // the matchesPerVersion object contains a list of conditions that - // should match per version tested, but we also operate against it - // to find out versions that shouldn't match + // matchesPerVersion lists expected conditions and also defines the inverse set per version. const ghesLast = `enterprise-server@${supported[supported.length - 1]}` const ghesPenultimate = `enterprise-server@${supported[supported.length - 2]}` const matchesPerVersion: Record = { @@ -174,12 +146,10 @@ describe('ifversion', () => { const allConditions = Object.values(matchesPerVersion).flat() - // this is all conditions that should match for this rendered version const wantedConditions = allConditions.filter((condition: string) => { return matchesPerVersion[version].includes(condition) }) - // this is the inverse of the above, conditions that shouldn't match for this rendered version const unwantedConditions = allConditions.filter((condition: string) => { return !matchesPerVersion[version].includes(condition) }) @@ -197,7 +167,6 @@ describe('ifversion', () => { describe('misc Liquid', () => { test('links with liquid from data', async () => { const $: CheerioAPI = await getDOM('/get-started/liquid/links-with-liquid') - // The URL comes from variables.product.pricing_url const url = getDataByLanguage('variables.product.pricing_url', 'en') if (!url) throw new Error('variable could not be found') const links = $(`#article-contents a[href="${url}"]`) @@ -212,10 +181,7 @@ describe('misc Liquid', () => { }) test('page with tool Liquid tag followed by Markdown', async () => { - // This test tests Markdown being correctly rendered when the - // Markdown directly follows a tool tag like `{% linux %}...{% endlinux %}`. - // The next line immediately after the `{% endlinux %}` should not - // leave the Markdown unrendered + // Markdown must render when it immediately follows a {% linux %}...{% endlinux %} tag. const $: CheerioAPI = await getDOM('/get-started/liquid/tool-platform-switcher') const innerHTML = $('#article-contents').html() expect(innerHTML).not.toMatch('On *this* line is `Markdown` too.') @@ -227,62 +193,33 @@ describe('data tag', () => { test('injects data reusables with the right whitespace', async () => { const $: CheerioAPI = await getDOM('/get-started/liquid/data') - // This proves that the two injected reusables tables work. - // CommonMark is finicky if the indentation isn't perfect, so - // if you don't get exactly 2 tables, something is wrong, and if it's - // wrong it's most likely because of the leading whitespaces. + // Incorrect reusable indentation can break CommonMark parsing, so expect exactly two tables. expect($('#article-contents table').length).toBe(2) - // To truly understand this test, you have to see - // http://localhost:4000/en/get-started/liquid/data to understand it. - // The page uses `{% data ... %}` within the bodies of bullet points. - // If the whitespace isn't correct and working, the bullet points - // would get confused and think the bullet point "body" is a new - // bullet point on its own. + // Data tags inside ordered-list items must not split item bodies into new list items. expect($('#article-contents ol').length).toBe(3) expect($('#article-contents ol li').length).toBe(2 + 1 + 2) - // In the very first bullet point we inject something that multiple - // linebreaks in it. The source looks like this: - // - // 1. Bullet point - // - // {% data reusables.injectables.multiple_numbers %} - // - // (The code comment itself here has 3 spaces of manual indentation) - // What's important is that all the expected lines of that reusables - // stick inside this `ul li` block. + // The indented {% data reusables.injectables.multiple_numbers %} call keeps every line in the first list item. const liText = $('#article-contents ol li').first().text() expect(liText).toMatch(/Bullet point\nOne\nTwo\nThree\nFour/) - // The code block uses `{% data ... %}` and it should be indented - // so that it aligns perfectly with the code block itself. - // One of the injected data reusables contains multiple lines. - // It's important that each line from that starts at the far - // left. No more or less whitespace. + // Multi-line code-block reusables start at the far left, with no extra indentation. const codeBlock = $('#article-contents li pre').text() expect(codeBlock).toMatch(/^One\n/) expect(codeBlock).toMatch(/^One\nTwo\n/) expect(codeBlock).toMatch(/^One\nTwo\nThree\n/) - // The code block also a reusables that is just one line. + // The code block also receives one single-line reusable. expect(codeBlock).toMatch(/One Two Three Four\n/) - // On its own, if you look at - // src/fixtures/fixtures/data/reusables/injectables/paragraphs.md, you'll - // see each line is NOT prefixed with whitespace indentation. - // But because `{% data reusables.injectables.paragraphs %}` is - // inserted with some indentation, that's replicated on every line. + // src/fixtures/fixtures/data/reusables/injectables/paragraphs.md inherits indentation from its data call. const li = $('#article-contents li') .filter((_, element) => { return $(element).text().trim().startsWith('Point 1') }) .eq(0) - // You can't really test the exact whitespace with cheerio, - // of the original HTML, but it doesn't actually matter. What - // matters is that within the bullet point, that starts with "Point 1", - // it *contains* all the paragraphs - // from src/fixtures/fixtures/data/reusables/injectables/paragraphs.md. + // Cheerio cannot test original HTML whitespace, so the bullet text checks every paragraph. expect(li.text()).toMatch(/Paragraph one/) expect(li.text()).toMatch(/Paragraph two/) expect(li.text()).toMatch(/Paragraph three/) diff --git a/src/fixtures/tests/markdown.ts b/src/fixtures/tests/markdown.ts index b7f15b614a3a..cd2a8280532c 100644 --- a/src/fixtures/tests/markdown.ts +++ b/src/fixtures/tests/markdown.ts @@ -17,8 +17,7 @@ describe('alerts', () => { test('basic rendering', async () => { const $: CheerioAPI = await getDOM('/get-started/markdown/alerts') const alerts = $('#article-contents .ghd-alert') - // See src/fixtures/fixtures/content/get-started/markdown/alerts.md - // to be this confident in the assertions. + // src/fixtures/fixtures/content/get-started/markdown/alerts.md defines five alert types. expect(alerts.length).toBe(5) const svgs = $('svg', alerts) expect(svgs.length).toBe(5) diff --git a/src/fixtures/tests/permissions-callout.ts b/src/fixtures/tests/permissions-callout.ts index 93cc31cd9d87..e23b9af0f615 100644 --- a/src/fixtures/tests/permissions-callout.ts +++ b/src/fixtures/tests/permissions-callout.ts @@ -11,11 +11,7 @@ describe('permission statements', () => { }) test('callout disappears depend on Liquid inside it', async () => { - // This page has `product:` property which is a piece of Liquid - // which makes it so that the rendered output of that becomes - // an empty string. - // This test tests that alert is not rendered if its output - // "exits" but is empty. + // Liquid in the product: frontmatter property renders empty, so the product statement disappears. const $: CheerioAPI = await getDOM( '/enterprise-server@latest/get-started/foo/page-with-callout', ) @@ -32,16 +28,13 @@ describe('permission statements', () => { test('page with permission frontmatter', async () => { const $: CheerioAPI = await getDOM('/get-started/markdown/permissions') const html = $('[data-testid=permissions-statement] div').html() - // Markdown expect(html).toMatch('admin') - // Liquid expect(html).toMatch('HubGit Pages site') }) test('page with permission frontmatter and product statement', async () => { const $: CheerioAPI = await getDOM('/get-started/foo/page-with-permissions-and-product-callout') const html = $('[data-testid=permissions-callout] div').html() - // part of the UI expect(html).toMatch('Who can use this feature') const permission = $('[data-testid=permissions-statement] div') diff --git a/src/fixtures/tests/playwright-a11y.spec.ts b/src/fixtures/tests/playwright-a11y.spec.ts index 1c2017dce6ce..8cb28892669d 100644 --- a/src/fixtures/tests/playwright-a11y.spec.ts +++ b/src/fixtures/tests/playwright-a11y.spec.ts @@ -7,12 +7,9 @@ const SEARCH_TESTS = !!process.env.ELASTICSEARCH_URL const pages: { [key: string]: string } = { category: '/actions/category', codeAnnotations: '/get-started/markdown/code-annotations', - // The only fixture page that renders a CTA button. A `.btn-primary` anchor is the - // one shape the brand article-link override can drive under 4.5:1 — its label sits - // on a coloured fill rather than the page background — which is exactly what it did - // before `:not(.btn)` was added to - // src/frame/stylesheets/article-link-overrides.scss. Without this entry that - // exclusion has no test at all. + // This CTA fixture is the only page that covers the .btn-primary article-link override. + // Its filled label can fall below 4.5:1 without the :not(.btn) exclusion in + // src/frame/stylesheets/article-link-overrides.scss. ctaButton: '/get-started/foo/page-with-permissions-and-product-callout', homepage: '/', learningPath: @@ -28,7 +25,6 @@ const pages: { [key: string]: string } = { tableWithHeaders: '/get-started/liquid/table-row-headers', } -// create a test for each page, will eventually be separated into finer grain tests for (const pageName of Object.keys(pages)) { test.describe(`${pageName}`, () => { test('full page axe scan without experiments', async ({ page }) => { @@ -55,14 +51,12 @@ for (const pageName of Object.keys(pages)) { }) } -// The search facet filters collapse behind a "Show filters" disclosure below -// Primer Brand's `medium` breakpoint. The scans above run at the default desktop -// viewport, where that disclosure is display:none, so the expanded panel would +// The search facet filters collapse behind a Show filters disclosure below +// Primer Brand's medium breakpoint. The scans above run at the default desktop +// viewport, where that disclosure has display: none, so the expanded panel would // otherwise never be scanned. test.describe('search filters (narrow viewport)', () => { - // Without a local Elasticsearch the middleware proxies to production, so there are no - // aggregations, the disclosure never renders, and this would time out rather than - // skip. Matches the guard every search test in playwright-rendering.spec.ts uses. + // Without local Elasticsearch, the production proxy returns no aggregations, so the disclosure never renders. test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') test('expanded filter disclosure passes axe', async ({ page }) => { @@ -76,8 +70,7 @@ test.describe('search filters (narrow viewport)', () => { await toggle.click() await expect(toggle).toHaveAttribute('aria-expanded', 'true') - // Scoped to the disclosure's own panel: a bare `fieldset` locator would hit strict - // mode the moment anything else on the page renders one. + // Scope to the panel, because other fieldsets would trigger Playwright strict mode. const panelId = await toggle.getAttribute('aria-controls') await expect(page.locator(`#${panelId} fieldset`)).toBeVisible() diff --git a/src/fixtures/tests/playwright-header.spec.ts b/src/fixtures/tests/playwright-header.spec.ts index 36261f1d7ffc..8fdeb5661b79 100644 --- a/src/fixtures/tests/playwright-header.spec.ts +++ b/src/fixtures/tests/playwright-header.spec.ts @@ -9,35 +9,29 @@ import { } from '../../frame/lib/constants' const ARTICLE = '/en/get-started/foo/bar' -// `find-page.ts` narrows `context.languages` to English alone for early-access -// pages, which makes this the production route through the single-language -// branch of the header's language slot. +// find-page.ts narrows context.languages to English for early-access pages, so +// this route exercises the single-language branch of the header's language slot. const ENGLISH_ONLY_ARTICLE = '/en/early-access/secrets/deeper/mariana-trench' const SEARCH_LABEL = 'Search or ask Copilot' const LANGUAGE_LABEL = 'Select language: current language is English' const PLAN_LABEL = 'Select your plan:' const VERSION_LABEL = 'Select your version:' -// The pill's line-height is the Docs design's own decision, set in -// HeaderPicker.module.scss -- Brand's --brand-text-lineHeight-100 is 1.5 -- so -// unlike the sizes below it is not resolved from a token. +// The pill's line-height comes from the Docs design, not Brand's +// --brand-text-lineHeight-100 value of 1.5. const PILL_LINE_HEIGHT = 1.2 const PLAN_TRIGGER_TESTID = 'version-picker-button' const LANGUAGE_TRIGGER_TESTID = 'language-picker-button' -// Brand renders the trailing slot on `trailingComponent != null`, so the wrapper -// survives a child that renders nothing. Its class name is CSS-module hashed, so -// only the stable fragment can be matched -- and an absence assertion on a name -// Brand might rename would pass vacuously, which is why the test below always -// pairs it with a page where the same selector must still match. +// Brand renders the trailing slot when trailingComponent != null, so the wrapper +// survives a child that renders nothing. +// The CSS module hash leaves only this stable fragment to match; the paired +// presence test prevents a vacuous absence check after a Brand rename. const BRAND_TRAILING_SLOT = '[class*="SubdomainNavBar-trailing-component"]' -/** - * Resolve Brand custom properties in whatever theme the page is currently in, - * instead of hardcoding light-mode RGB values. The probe is appended inside - * `locator` on purpose: the plan menu renders inside its own nested Brand - * ThemeProvider, so tokens have to be read from within that subtree to reflect - * the color mode the menu actually paints with. The hidden probe only - * normalizes CSS color syntax into rgb(); it never styles the UI. - */ +// Resolve Brand custom properties in the page's current theme instead of +// hardcoding light-mode RGB values. +// Append the probe inside locator because the plan menu has its own nested Brand +// ThemeProvider, so tokens must come from that subtree. +// The hidden probe normalizes CSS color syntax into rgb() without styling the UI. async function resolveThemeTokens(locator: Locator, tokens: string[]) { return locator.evaluate((element, tokenNames: string[]) => { const probe = document.createElement('span') @@ -59,12 +53,10 @@ async function resolveThemeTokens(locator: Locator, tokens: string[]) { }, tokens) } -/** - * Resolve Brand length tokens to pixels, so the pill's geometry can be checked - * against the tokens it is built from instead of the numbers those tokens happen - * to produce today. The probe is laid out (absolute + hidden rather than - * `hidden`) so `width` resolves through calc()/max() to a used pixel value. - */ +// Resolve Brand length tokens to pixels so the pill geometry stays tied to +// tokens, not their current numeric values. +// The absolute hidden probe stays laid out so width resolves through calc() and +// max() to a used pixel value. async function resolveTokenPixels(locator: Locator, tokens: string[]) { return locator.evaluate((element, tokenNames: string[]) => { const probe = document.createElement('div') @@ -92,7 +84,7 @@ async function resolveTokenPixels(locator: Locator, tokens: string[]) { }, tokens) } -/** Read raw custom-property values (font weights resolve to plain numbers). */ +// Font weights resolve to plain numbers, so this reads raw custom-property values. async function resolveTokenValues(locator: Locator, tokens: string[]) { return locator.evaluate((element, tokenNames: string[]) => { const resolved: Record = {} @@ -122,10 +114,7 @@ async function expectHeaderPlanPicker(page: Page) { expect(valueId).toBeTruthy() await expect(button).toHaveAttribute('aria-labelledby', `${labelId} ${valueId}`) - // Every size below is arithmetic over Brand tokens, so resolve the tokens and - // derive the expectations rather than hardcoding today's pixels: a - // @primer/react-brand bump that moves --base-size-* then updates both sides at - // once, instead of failing CI with no user-visible regression. + // Resolve Brand tokens so expected sizes move with --base-size-* changes instead of failing. const sizes = await resolveTokenPixels(picker, [ '--brand-text-size-100', '--base-size-2', @@ -197,8 +186,7 @@ async function expectHeaderPlanPicker(page: Page) { expect(buttonBox.x - (labelBox.x + labelBox.width)).toBeCloseTo(labelGap, 0) expect(labelBox.y + labelBox.height / 2).toBeCloseTo(buttonBox.y + buttonBox.height / 2, 0) expect(buttonBox.height).toBeCloseTo(pillHeight, 0) - // The normal plan name must fit even with Signup visible at 1012px. Keep - // ellipsis available for unusually long labels, not this default English one. + // Default English plan name must fit with Signup at 1012px; ellipsis is for longer labels. await expect .poll(() => value.evaluate((element) => element.scrollWidth - element.clientWidth)) .toBeLessThanOrEqual(0) @@ -217,16 +205,12 @@ async function expectHeaderPlanPicker(page: Page) { await expectFilledTriangleCaret(button, colors.text) } -/** - * Both header triggers end in the same caret, so both are checked the same way. - * The design's caret is a filled triangle. Brand's ActionMenu.Button hardcodes a - * ChevronDownIcon and only loses to a caller-supplied trailingVisual because it - * spreads rest props after that default -- a single shared cast (ActionMenuTrigger) - * relies on that. A Brand upgrade that destructures trailingVisual would silently - * restore the chevron on both controls at once, so assert the chevron is gone and - * that the glyph really has the triangle's geometry: the triangle's path is - * ~7.15 x 3.82 user units, where chevron-down's is ~9.56 x 5.31. - */ +// Both header triggers use the same filled triangle caret, checked through one helper. +// Brand's ActionMenu.Button defaults to ChevronDownIcon; the ActionMenuTrigger +// cast relies on a caller-supplied trailingVisual overriding it. +// A Brand change that destructures trailingVisual would restore chevrons on both controls. +// Assert the chevron is gone and the triangle path is about 7.15 by 3.82 user +// units, not chevron-down's 9.56 by 5.31. async function expectFilledTriangleCaret(trigger: Locator, color: string) { const caret = trigger.locator('svg.octicon-triangle-down') await expect(caret).toBeVisible() @@ -244,13 +228,10 @@ async function expectFilledTriangleCaret(trigger: Locator, color: string) { expect(glyph.height).toBeLessThan(4.6) } -/** - * The language trigger deliberately does *not* match the plan pill: Figma draws - * it as a flat control -- a 16px globe, the language in muted 14px regular, then - * the same filled caret. Only the dropdown below it is shared, so this asserts - * the trigger keeps its own treatment and never drifts into the pill (which is - * exactly what reusing the shared pill class would do). - */ +// The language trigger deliberately does not match the plan pill. +// Figma specifies a flat control: 16px globe, muted 14px regular language text, +// then the same filled caret. +// Only the dropdown is shared, so this catches accidental reuse of the shared pill class. async function expectHeaderLanguageTrigger(page: Page) { const picker = page.getByTestId('desktop-header').getByTestId('language-picker') const trigger = picker.getByTestId(LANGUAGE_TRIGGER_TESTID) @@ -272,10 +253,7 @@ async function expectHeaderLanguageTrigger(page: Page) { ) expect(valueFontSize).toBeCloseTo(sizes['--brand-text-size-100'], 1) - // Flat, not a pill: no fill at rest, no border, and a small corner rather than - // the pill's full radius. The canvas-subtle comparison keeps this honest -- it - // is the fill the pill carries and the fill this control only takes on hover - // and while open. + // Flat trigger: no rest fill or border, a 6px corner, and canvas-subtle on hover or open. await expect(trigger).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') expect(tokens['--brand-color-canvas-subtle']).not.toBe('rgba(0, 0, 0, 0)') for (const side of ['top', 'right', 'bottom', 'left']) { @@ -285,9 +263,7 @@ async function expectHeaderLanguageTrigger(page: Page) { await expect(trigger).toHaveCSS(`border-${corner}-radius`, '6px') } const triggerBox = (await trigger.boundingBox())! - // Brand's ActionMenu remaps --brand-borderRadius-medium to the full radius on - // its own trigger, so a 6px corner is the difference between this control and - // a pill rather than a cosmetic detail. + // Brand's ActionMenu remaps --brand-borderRadius-medium to full radius; 6px prevents a pill. expect(triggerBox.height / 2).toBeGreaterThan(6) const globe = trigger.locator('svg.octicon-globe') @@ -301,29 +277,25 @@ async function expectHeaderLanguageTrigger(page: Page) { await expectFilledTriangleCaret(trigger, tokens['--brand-color-text-muted']) } -/** - * The two header dropdowns are the same control with different content: both are - * Brand ActionMenus whose surface and rows come entirely from the shared - * HeaderPicker.module.scss. Every design assertion below therefore runs against - * both -- that is what proves they are identical rather than merely similar -- - * so only the content is parameterized here. - */ +// The two header dropdowns use the same Brand ActionMenu surface and row styles +// from HeaderPicker.module.scss. +// Running each design assertion against both menus proves shared styling, not similar styling. type HeaderDropdown = { name: string pickerTestId: string triggerTestId: string - /** The span each row wraps its label in. */ + // The span each row wraps its label in. itemTestId: string expectTrigger: (page: Page) => Promise - /** The row that opens already chosen: tinted, with the trailing green dot. */ + // The row that opens already chosen: tinted, with the trailing green dot. selectedRow: string - /** Another selectable row: no tint, no dot. */ + // Another selectable row: no tint, no dot. unselectedRow: string - /** Rows that navigate instead of selecting, so they stay plain menuitems. */ + // Rows that navigate instead of selecting, so they stay plain menuitems. navigationRowCount: number - /** The plan menu keeps one rule between its versions and its navigation rows. */ + // The plan menu keeps one rule between its versions and its navigation rows. separatorCount: number - /** The final row -- whatever a clipped menu loses first. */ + // A clipped menu loses this final row first. lastRowRole: 'menuitem' | 'menuitemradio' lastRowName: RegExp } @@ -358,11 +330,8 @@ const LANGUAGE_DROPDOWN: HeaderDropdown = { lastRowName: /日本語/, } -/** - * A Docs 2026 header dropdown, rebuilt on Brand's ActionMenu. Opens the menu, - * checks the surface, rows, selection indicator and the absence of Brand's own - * leading check slot, then closes it and confirms focus returns to the trigger. - */ +// Docs 2026 rebuilds header dropdowns on Brand ActionMenu, so this helper checks +// the shared menu contract end to end. async function expectHeaderDropdownDesign( page: Page, colorScheme: 'light' | 'dark', @@ -374,7 +343,7 @@ async function expectHeaderDropdownDesign( await trigger.click() await expect(trigger).toHaveAttribute('aria-expanded', 'true') - // Brand's menu is not portalled -- it renders inside the picker wrapper. + // Brand's menu renders inside the picker wrapper, not a portal. const menu = picker.getByRole('menu') await expect(menu).toBeVisible() @@ -385,8 +354,7 @@ async function expectHeaderDropdownDesign( '--brand-color-text-default', '--brand-color-success-fg', ]) - // Proves the emulated scheme reached Brand's tokens: a dark run that silently - // stayed light would satisfy every assertion above on its own. + // A dark run that stays light would pass above, so verify Brand tokens changed. const luminance = relativeLuminance(tokens['--brand-color-canvas-default']) if (colorScheme === 'dark') { expect(luminance).toBeLessThan(0.2) @@ -394,8 +362,7 @@ async function expectHeaderDropdownDesign( expect(luminance).toBeGreaterThan(0.8) } - // Menu surface: canvas-default fill, 1px subtle border, 6px radius, 8px pad. - // Brand's own defaults are a border-muted border and a 16px radius. + // Overrides Brand's border-muted border and 16px radius; assertions also pin fill and 8px pad. await expect(menu).toHaveCSS('background-color', tokens['--brand-color-canvas-default']) for (const side of ['top', 'right', 'bottom', 'left']) { await expect(menu).toHaveCSS(`border-${side}-width`, '1px') @@ -406,13 +373,10 @@ async function expectHeaderDropdownDesign( for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { await expect(menu).toHaveCSS(`border-${corner}-radius`, '6px') } - // The design's menu is 256px wide; a long row may grow it, never shrink it. + // The design sets a 256px minimum menu width; long rows can grow it, never shrink it. const menuBox = (await menu.boundingBox())! expect(menuBox.width).toBeGreaterThanOrEqual(256) - // Brand anchors with `allowOutOfBounds`, so nothing clamps a menu that would - // overhang -- which matters most for the language menu, the one control sitting - // at the header's right edge. `menuAlignment` is what keeps it on screen, so - // assert the result instead of trusting the prop. + // Brand allowOutOfBounds can overhang the right-edge menu; menuAlignment keeps it on screen. const viewportWidth = page.viewportSize()!.width expect(menuBox.x).toBeGreaterThanOrEqual(-1) expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(viewportWidth + 1) @@ -420,16 +384,10 @@ async function expectHeaderDropdownDesign( const selectableRows = menu.getByRole('menuitemradio') const navigationRows = menu.getByRole('menuitem') expect(await selectableRows.count()).toBeGreaterThanOrEqual(2) - // In the plan menu "All Enterprise Server releases" and "About versions" - // navigate rather than select, so they stay plain menuitems. The language menu - // has no such rows. + // In the plan menu, All Enterprise Server releases and About versions stay navigation menuitems. await expect(navigationRows).toHaveCount(dropdown.navigationRowCount) - // A single rule divides the versions from those two navigation rows. Brand has - // no divider child, so the picker renders the separator itself; it must not be - // focusable, and must be neither the first nor the last row, because Brand - // focuses the first
    • and wires its arrow-key wrap-around to the first and - // the last. The language menu divides nothing, so it carries no separator. + // Brand lacks a divider child; keep the separator unfocusable and outside arrow-key wrap ends. const separator = menu.locator('[role="separator"]') await expect(separator).toHaveCount(dropdown.separatorCount) if (dropdown.separatorCount > 0) { @@ -462,8 +420,7 @@ async function expectHeaderDropdownDesign( expect(rule.previousRole).toBe('menuitemradio') expect(rule.nextRole).toBe('menuitem') expect(rule.nextText).toMatch(/All Enterprise Server releases/) - // A plain
    • is a block box, so the rule spans the menu's inner width - // rather than sitting inside a row's own 12px insets. + // A block li spans the menu's inner width instead of a row's 12px insets. expect(rule.width).toBeCloseTo(rule.innerWidth, 0) expect(rule.marginTop).toBeCloseTo(8, 0) expect(rule.marginBottom).toBeCloseTo(8, 0) @@ -476,8 +433,7 @@ async function expectHeaderDropdownDesign( const row = rows.nth(index) expect((await row.boundingBox())!.height).toBeCloseTo(32, 0) await expect(row).toHaveCSS('padding-left', '12px') - // The reserved indicator column replaces Brand's 48px single-selection - // gutter: a 12px inset, the 16px dot, then a 12px gap before the label. + // The indicator column reserves 12px, a 16px dot and a 12px gap, replacing Brand's 48px gutter. await expect(row).toHaveCSS('padding-right', '40px') for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { await expect(row).toHaveCSS(`border-${corner}-radius`, '6px') @@ -509,10 +465,7 @@ async function expectHeaderDropdownDesign( expect(selectedBox.x + selectedBox.width - (dotBox.x + dotBox.width)).toBeCloseTo(12, 0) expect(dotBox.y + dotBox.height / 2).toBeCloseTo(selectedBox.y + selectedBox.height / 2, 0) - // Brand renders a leading check slot on every row of a single-selection menu; - // the design marks the current row with the trailing dot instead. Assert the - // rendered result rather than Brand's hashed class names: the selected row's - // only visible glyph is the dot. + // The selected row's only visible glyph must be the trailing dot, not Brand's leading check slot. await expect(selectedRow.locator('svg.octicon-check')).not.toBeVisible() const visibleGlyphs = await selectedRow .locator('svg') @@ -521,8 +474,7 @@ async function expectHeaderDropdownDesign( ) expect(visibleGlyphs).toHaveLength(1) expect(visibleGlyphs[0]).toContain('octicon-dot-fill') - // When Brand renders that slot it must be hidden outright. Written so a future - // Brand release that stops rendering it altogether does not fail the suite. + // Accept a missing leading slot so Brand can remove it without failing this suite. const leadingSlotDisplay = await selectedRow.evaluate((row) => { const first = row.firstElementChild return first && row.children.length > 1 ? getComputedStyle(first).display : null @@ -543,8 +495,7 @@ async function expectHeaderDropdownDesign( for (let index = 0; index < dropdown.navigationRowCount; index++) { const extra = navigationRows.nth(index) - // axe rejects aria-checked on role=menuitem, so the extras must opt out of - // the selection semantics ActionMenu.Overlay injects into its children. + // axe rejects aria-checked on menuitem, so navigation rows opt out of selection semantics. await expect(extra).not.toHaveAttribute('aria-checked') await expect(extra.locator('svg.octicon-dot-fill')).toHaveCount(0) } @@ -555,9 +506,8 @@ async function expectHeaderDropdownDesign( await expect(trigger).toBeFocused() } -// The properties a shared stylesheet is supposed to fix identically for both -// dropdowns. Content-dependent geometry (the menu's used width, a row's text) is -// deliberately absent: only the styling has to match. +// The shared stylesheet must fix these properties identically for both dropdowns. +// Content-dependent geometry is absent; only styling has to match. const SURFACE_PROPERTIES = [ 'background-color', 'min-width', @@ -593,14 +543,11 @@ const LABEL_PROPERTIES = [ ] const DOT_PROPERTIES = ['position', 'right', 'width', 'height', 'fill'] -/** - * A style fingerprint of an open header dropdown: the surface, the selected row, - * its label and its trailing dot. Two dropdowns whose styling really does come - * from one shared module produce equal fingerprints -- which is a stronger claim - * than each one separately matching the design, and it is the claim the user - * actually made ("the language dropdown needs to look like the version - * dropdown"). - */ +// An open header dropdown fingerprint covers the surface, selected row, label +// and trailing dot. +// Equal fingerprints prove the two menus share styling, not merely that each matches the design. +// This tests the user-visible request: the language dropdown needs to look like +// the version dropdown. async function dropdownStyleFingerprint(menu: Locator, dropdown: HeaderDropdown) { const selectedRow = menu.getByRole('menuitemradio', { name: dropdown.selectedRow, exact: true }) const read = (locator: Locator, properties: string[]) => @@ -614,8 +561,7 @@ async function dropdownStyleFingerprint(menu: Locator, dropdown: HeaderDropdown) row: await read(selectedRow, ROW_PROPERTIES), label: await read(selectedRow.getByTestId(dropdown.itemTestId), LABEL_PROPERTIES), dot: await read(selectedRow.locator('svg.octicon-dot-fill'), DOT_PROPERTIES), - // Brand's leading check slot is hidden structurally, so it has to be hidden - // in both menus or one of them grows a check icon the other does not have. + // Structural hiding must match so one menu cannot grow a Brand check icon the other lacks. leadingSlotDisplay: await selectedRow.evaluate((row) => { const first = row.firstElementChild return first && row.children.length > 1 ? getComputedStyle(first).display : null @@ -644,8 +590,7 @@ async function expectDesktopHeaderSections(page: Page, signupVisible: boolean) { const search = element.querySelector('[data-testid="toggle-search"]')! const language = element.querySelector('[data-testid="language-picker"]')! const signup = element.querySelector('[data-testid="header-signup"]') - // Find the native section wrappers from stable Docs control anchors, not - // Brand's private CSS class names or a hardcoded number of parent hops. + // Find section wrappers from stable Docs anchors, not Brand CSS hashes or parent-hop counts. let sectionRow = search.parentElement! while (!sectionRow.contains(language)) sectionRow = sectionRow.parentElement! const sectionFor = (control: HTMLElement) => { @@ -720,8 +665,7 @@ async function expectDesktopHeaderSections(page: Page, signupVisible: boolean) { expect(section.rect.top).toBeCloseTo(layout.header.top, 0) expect(section.rect.bottom).toBeCloseTo(layout.contentBottom, 0) } - // Search owns the full-height divider before Language. Language must not - // double that border; Signup owns its own separate full-height left divider. + // Search owns the divider before Language; Signup owns its own left divider. expect(layout.search.borderEnd).toBe('1px') expect(layout.search.borderEndStyle).toBe('solid') expect(layout.search.borderEndColor).not.toBe('rgba(0, 0, 0, 0)') @@ -744,23 +688,21 @@ async function expectDocsSearchOpen(page: Page) { await searchInput.click() await expect(searchInput).toBeFocused() await expect(page.getByRole('dialog')).toHaveCount(1) - // Brand mounts its native dialog even while closed. Only the existing Docs - // dialog may become modal; opening both would leave competing focus traps. + // Only Docs search may become modal; opening Brand's closed native dialog would add a focus trap. const brandDialog = page.getByTestId('desktop-header').locator('dialog') await expect(brandDialog).toHaveCount(1) await expect(brandDialog).toHaveJSProperty('open', false) await expect(page).toHaveURL((url) => url.searchParams.get('search-overlay-open') === 'true') } +// expectBackgroundIsolated includes Brand's skip link because it sits outside +// the inert wrapper as a sibling before header, yet still targets #main-content +// while the menu is open. +// CSS avoids getByText strict-mode matches from the wrapped label and getByRole +// misses after aria-hidden. async function expectBackgroundIsolated(page: Page, isolated: boolean) { for (const locator of [ page.getByText('Skip to main content', { exact: true }), - // Brand's own skip link sits outside the inert wrapper (it renders as a - // sibling before
      ) yet still targets #main-content, which is inert - // while the menu is open. Matched by CSS rather than text or role: Brand - // wraps the label in a span, so getByText resolves to both the and that - // span -- a strict mode violation -- and aria-hidden removes it from the - // accessibility tree that getByRole searches once isolated. page.locator('[data-container="header"] a[href="#main-content"]'), page.locator('#main-content'), page.getByTestId('sidebar-mobile-toggle'), @@ -777,8 +719,7 @@ async function expectBackgroundIsolated(page: Page, isolated: boolean) { test.describe('Brand header', () => { test.beforeEach(async ({ page }) => { - // These regressions cover header coordination, not remote search quality. - // Return empty suggestions so they also run without Elasticsearch or Copilot. + // Empty suggestions keep header coordination tests independent of Elasticsearch and Copilot. await page.route('**/api/search/combined-search/v1?**', (route) => route.fulfill({ json: { @@ -810,32 +751,18 @@ test.describe('Brand header', () => { await page.reload() } - // Wait for account detection/desktop slots before measuring the pill: - // Signup mounting must not shrink a name that only fit before hydration. + // Wait for account detection; Signup can mount after hydration and shrink the plan name. await expectDesktopHeaderSections(page, !hasAccount) await expectHeaderPlanPicker(page) - // 1012px is where the two triggers compete for room with Signup, so it is - // also where the flat language control is most likely to be "fixed" by - // giving it the pill's class. + // At 1012px, Signup pressure exposes accidental pill styling on the language trigger. await expectHeaderLanguageTrigger(page) }) } } - /** - * Brand renders its trailing slot whenever `trailingComponent` is not null, - * so a `LanguagePicker` that returned `null` from inside the slot would still - * leave the wrapper behind: an empty divided cell at the header's right edge - * on desktop, and a full-width 16px-padded block in the narrow menu. Header.tsx - * therefore withholds the prop itself rather than letting the picker opt out, - * and that decision is invisible to every other test here -- they all run on - * multi-language pages, where the slot is supposed to be present. - * - * Each absence is paired with the same assertion on a multi-language page. - * Brand's class name is hashed, so `BRAND_TRAILING_SLOT` on its own would keep - * passing the day Brand renames it; proving the selector still matches - * something is what stops this from becoming a test of nothing. - */ + // Header.tsx omits trailingComponent because Brand keeps wrapper if LanguagePicker returns null. + + // The multi-language assertion keeps BRAND_TRAILING_SLOT from passing after a Brand class rename. test('the language slot is omitted, not left empty, when only English is available', async ({ page, }) => { @@ -849,15 +776,12 @@ test.describe('Brand header', () => { await page.goto(ENGLISH_ONLY_ARTICLE) await turnOffExperimentsInPage(page) const header = page.getByTestId('desktop-header') - // The plan picker still renders here, so an empty header would fail this - // rather than passing as a trivially absent language control. + // Assert the plan picker first so an empty header cannot pass the absence checks below. await expect(header.getByRole('button', { name: PLAN_LABEL, exact: false })).toBeVisible() await expect(page.getByTestId('language-picker')).toHaveCount(0) await expect(header.locator(BRAND_TRAILING_SLOT)).toHaveCount(0) - // Independently of Brand's class names: every divided cell in the header's - // section row still holds a control. An empty slot is exactly a cell that - // does not, and it would carry its own gridline and margin. + // Every divided header cell must hold a control; an empty slot would add a gridline and margin. await page.evaluate(() => document.fonts.ready) await expect(async () => { const sections = await header.evaluate((element) => { @@ -882,8 +806,7 @@ test.describe('Brand header', () => { expect(sections.lastReachesEdge).toBe(true) }).toPass() - // The narrow menu is where the leftover wrapper would be most visible: a - // full-width padded block above Sign up rather than a thin cell. + // The narrow menu exposes a leftover wrapper as a full-width padded block above Sign up. await page.setViewportSize({ width: 390, height: 800 }) await page.getByRole('button', { name: 'Menu', exact: true }).click() await expect(page.getByTestId('header-signup')).toBeVisible() @@ -897,38 +820,21 @@ test.describe('Brand header', () => { page, }) => { await page.setViewportSize({ width: 1440, height: 800 }) - // No color_mode cookie, so colorModeScript resolves `auto` from this - // emulation. Set before navigating so the first paint already uses it. + // Emulate color before navigation so colorModeScript resolves auto without a cookie. await page.emulateMedia({ colorScheme }) await page.goto(ARTICLE) await turnOffExperimentsInPage(page) - // Each trigger resolves every color through tokens, so both are worth - // re-checking in dark mode rather than only in the light-mode loop above. - // The two triggers are intentionally different -- a filled pill for the - // plan, a flat control for the language -- which is why only the dropdown - // below them is shared. + // Recheck both token-based triggers in dark mode; only the dropdown below them is shared. await dropdown.expectTrigger(page) await expectHeaderDropdownDesign(page, colorScheme, dropdown) }) } } - /** - * The sticky ladder: header > Docs 2026 secondary bar > sticky table headers. - * - * Brand's ActionMenu is not portalled, so the plan and language dropdowns - * render inside the header's stacking context and hang well below it, across - * the secondary bar. The bar is sticky at every width and sits above sticky - * table headers, so if the header does not outrank the bar, the bar paints a - * band straight through the open menu and eats the clicks behind it -- which - * is invisible to every other test here, because the menu still has the right - * geometry, styling and roles while being covered. - * - * Asserted by hit-testing rather than by comparing z-index values: equal - * z-index is resolved by DOM order, so the numbers alone do not say which - * element a reader actually reaches. - */ + // The unportalled ActionMenu overlaps the sticky secondary bar, so the header must outrank it. + + // Hit test overlap because DOM-order z-index ties and blocked clicks do not change geometry. test('an open dropdown stays clickable where the secondary bar crosses it', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 800 }) await page.emulateMedia({ colorScheme: 'light' }) @@ -946,7 +852,6 @@ test.describe('Brand header', () => { const b = bar.getBoundingClientRect() const m = menuEl.getBoundingClientRect() const crosses = m.bottom > b.top && m.top < b.bottom - // Sample the full height of the band the two share. const x = m.left + m.width / 2 const top = Math.max(m.top, b.top) + 2 const bottom = Math.min(m.bottom, b.bottom) - 2 @@ -955,7 +860,7 @@ test.describe('Brand header', () => { const el = document.elementFromPoint(x, y) if (!el || !el.closest('[role="menu"]')) covered.push(Math.round(y)) } - // A row the bar crosses must receive its own clicks, not just paint above. + // A crossed row must receive clicks, not merely paint above the bar. const row = [ ...document.querySelectorAll('[data-testid="version-picker"] [role="menuitemradio"]'), ].find((candidate) => { @@ -979,8 +884,7 @@ test.describe('Brand header', () => { } }) - // If the menu stopped overlapping the bar, this test would pass while - // asserting nothing, so require the overlap it exists to check. + // Require actual overlap so this cannot pass after the menu stops crossing the bar. expect(overlap.barFound).toBe(true) expect(overlap.crosses).toBe(true) expect(overlap.covered).toEqual([]) @@ -994,8 +898,7 @@ test.describe('Brand header', () => { await page.goto(ARTICLE) await turnOffExperimentsInPage(page) - // Opened one at a time: Brand closes a menu as soon as the other trigger is - // clicked, and both menus read their tokens from the same page and theme. + // Open one menu at a time because Brand closes the first; both read the same page theme. const fingerprints: Record = {} for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) { const picker = page.getByTestId('desktop-header').getByTestId(dropdown.pickerTestId) @@ -1009,13 +912,11 @@ test.describe('Brand header', () => { expect(fingerprints[LANGUAGE_DROPDOWN.name]).toEqual(fingerprints[PLAN_DROPDOWN.name]) }) - // Below 1012px both pickers move inside SubdomainNavBar's narrow menu, which is a - // scrolling panel. Brand's ActionMenu is absolutely positioned and — unlike the - // @primer/react menu it replaced — is not portalled, so it regresses easily into - // rendering outside that panel: cut off mid-list, or running past the viewport's - // right edge. Both of those still satisfy toBeVisible(), so assert geometry. The - // inline-flow rule that fixes it now lives in the shared module, so a change to it - // moves both dropdowns at once and both are covered here. + // Below 1012px, SubdomainNavBar's scrolling narrow menu contains both pickers. + + // Geometry catches an unportalled ActionMenu outside the panel while toBeVisible still passes. + + // The shared module owns the inline-flow rule, so both dropdowns must prove the geometry. for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) { for (const width of [390, 1000]) { test(`the ${dropdown.name} dropdown stays inside the narrow menu at ${width}px`, async ({ @@ -1033,7 +934,7 @@ test.describe('Brand header', () => { await expect(page.getByRole('menu')).toBeVisible() const layout = await page.getByRole('menu').evaluate((element) => { - // The panel is found by its scrolling, not by Brand's hashed class name. + // Find the panel by scrolling behavior, not Brand's hashed class name. let panel = element.parentElement while (panel) { const { overflowX, overflowY } = getComputedStyle(panel) @@ -1053,11 +954,11 @@ test.describe('Brand header', () => { }) expect(layout.panel).not.toBeNull() - // Inside the panel, so no row is cut off... + // The menu stays inside the panel so no row gets cut off. expect(layout.menu.bottom).toBeLessThanOrEqual(layout.panel.bottom + 1) expect(layout.menu.right).toBeLessThanOrEqual(layout.panel.right + 1) expect(layout.lastRowBottom).toBeLessThanOrEqual(layout.panel.bottom + 1) - // ...and inside the viewport, so no row is sliced by the screen edge. + // The menu stays inside the viewport so no row is sliced by the screen edge. expect(layout.menu.left).toBeGreaterThanOrEqual(-1) expect(layout.menu.right).toBeLessThanOrEqual(layout.viewportWidth + 1) expect(layout.scrollsHorizontally).toBe(false) @@ -1075,13 +976,9 @@ test.describe('Brand header', () => { } } - // Brand staggers the narrow menu's items in at 80ms per slot and hardcodes the - // signup CTA's wrapper to slot 10 -- the moment ten `SubdomainNavBar.Link` - // children would have finished cascading in. Docs passes zero links, so the - // shipped 800ms is a dead second: the pickers ride the panel's fade and - // "Sign up" trails them. Header.module.scss cuts it to a single slot, so assert - // the computed delay rather than a wall clock, and assert that only the delay - // moved -- duration and fill mode still have to be Brand's. + // Brand assigns signup to stagger slot 10, but Docs passes zero SubdomainNavBar.Link children. + + // Header.module.scss cuts the 800ms delay to one 80ms slot; duration and fill mode stay Brand's. test('signup follows the narrow menu pickers by one stagger step, not ten', async ({ page }) => { await page.setViewportSize({ width: 390, height: 800 }) await page.goto(ARTICLE) @@ -1090,9 +987,7 @@ test.describe('Brand header', () => { const signup = page.getByTestId('header-signup') await expect(signup).toBeVisible() const animation = await signup.evaluate((element) => { - // Brand hashes this class and exposes no test id for it, so match the - // stable part of the name -- the same anchor the override in - // Header.module.scss uses. + // Brand hashes class names, so match the stable SubdomainNavBar-button-area--visible part. const area = element.closest('[class*="SubdomainNavBar-button-area--visible"]') if (!area) throw new Error('Signup is not inside the narrow-menu button area') const { animationDelay, animationDuration, animationFillMode } = getComputedStyle(area) @@ -1103,7 +998,7 @@ test.describe('Brand header', () => { } }) - // Brand's untouched default is calc(10 * 80ms). + // Brand's untouched default delay equals 10 * 80ms. expect(animation.delay).not.toBeCloseTo(0.8, 3) // Still staggered after the pickers, but by one 80ms slot rather than ten. expect(animation.delay).toBeGreaterThan(0) @@ -1186,8 +1081,7 @@ test.describe('Brand header', () => { 'open', false, ) - // PRC restores focus during mousedown capture; the browser then transfers it - // to the clicked backdrop. Persistent return focus is an Escape contract only. + // PRC restores focus on mousedown, but backdrop click moves it; Escape owns return focus. await expect(searchTrigger).toBeVisible() await expect(searchTrigger).toBeEnabled() }) @@ -1197,7 +1091,7 @@ test.describe('Brand header', () => { }) => { await page.goto(ARTICLE) await expect(page.getByTestId('toggle-search')).toBeVisible() - // Use real DOM fields without depending on survey or search results data. + // Real DOM fields avoid survey or search-results data dependencies. await page.locator('#main-content').evaluate((main) => { const fields = document.createElement('div') fields.innerHTML = ` @@ -1471,8 +1365,7 @@ test.describe('Brand header', () => { const picker = page.getByTestId('desktop-header').getByTestId('version-picker') const button = picker.getByRole('button') const value = (await button.getByTestId('field').textContent())! - // versionTitle is `${planTitle} ${release}` for a numbered release, so the - // plan label would announce "Select your plan: Enterprise Server 3.19". + // A numbered release uses the version label instead of the plan label. expect(value).toMatch(/^Enterprise Server [\d.]+$/) await expect(picker.getByText(VERSION_LABEL, { exact: true })).toBeVisible() await expect(button).toHaveAccessibleName(`${VERSION_LABEL} ${value}`) diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index c005042e32df..e27c4cd06cc2 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -8,13 +8,7 @@ import { COLOR_MODE_COOKIE_NAME, } from '../../frame/lib/constants' -// This exists for the benefit of local testing. -// In GitHub Actions, we rely on setting the environment variable directly -// but for convenience, for local development, engineers might have a -// .env file that can set environment variable. E.g. ELASTICSEARCH_URL. -// The `src/frame/start-server.ts` script uses dotenv too, but since Playwright -// tests only interface with the server via HTTP, we too need to find -// this out. +// Local Playwright loads .env so tests read ELASTICSEARCH_URL independently of start-server.ts. dotenv.config({ quiet: true }) const SEARCH_TESTS = !!process.env.ELASTICSEARCH_URL @@ -28,10 +22,9 @@ test.describe('Brand document canvas', () => { test('follows system color scheme changes in auto mode without a cookie', async ({ page }) => { await page.emulateMedia({ colorScheme: 'dark' }) await page.goto('/get-started/foo/bar') - // `auto` is resolved before first paint, so the raw preference gets its own attribute. + // Preserve auto because data-color-mode resolves to light or dark before first paint. await expect(page.locator('html')).toHaveAttribute('data-color-mode-preference', 'auto') - // Check both the initial dark paint and live preference changes without reloading. for (const colorScheme of ['dark', 'light', 'dark'] as const) { await page.emulateMedia({ colorScheme }) const backgroundColor = colorScheme === 'dark' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)' @@ -71,14 +64,12 @@ test.describe('Brand document canvas', () => { }) } - // A concrete [data-color-mode] below re-declares brand's whole palette - // for that subtree. + // A data-color-mode below html re-declares Brand's whole palette for that subtree. const MISMATCHES = [ { name: 'OS dark, explicit light mode', colorScheme: 'dark', cookie: { color_mode: 'light' } }, { name: 'OS light, explicit dark mode', colorScheme: 'light', cookie: { color_mode: 'dark' } }, { - // Day and night themes are picked independently on github.com, so `light` - // mode can itself resolve to a dark theme. + // GitHub.com picks day and night themes separately, so light can resolve to a dark theme. name: 'light mode whose day theme is itself dark', colorScheme: 'light', cookie: { @@ -95,8 +86,7 @@ test.describe('Brand document canvas', () => { context, baseURL, }) => { - // A settled assertion cannot catch a wrapper that self-corrects within a - // macrotask, so record every data-color-mode below from first paint on. + // Record modes from first paint to catch wrappers that self-correct within a macrotask. await page.addInitScript(() => { const seen: string[] = [] ;(window as unknown as { __modes: string[] }).__modes = seen @@ -135,13 +125,11 @@ test.describe('Brand document canvas', () => { const rootMode = await page.locator('html').getAttribute('data-color-mode') expect(rootMode).toMatch(/^(light|dark)$/) - // Brand's ActionMenu.Overlay wraps an open menu in its own ThemeProvider, - // which emits a data-color-mode from brand's context, and only while open. + // ActionMenu.Overlay emits data-color-mode from its own ThemeProvider only while open. await page.getByTestId('version-picker-button').first().click() await expect(page.getByRole('menu').first()).toBeVisible() - // `auto` is exempt: brand has no `auto` block, so such a wrapper declares - // nothing and inherits. + // Brand has no auto color block, so auto wrappers declare nothing and inherit. await expect(async () => { const offenders = await page .locator('body [data-color-mode]') @@ -160,9 +148,7 @@ test.describe('Brand document canvas', () => { ) expect(everSeen.filter((value) => value !== 'auto' && value !== rootMode)).toEqual([]) - // heading-links.ts wraps every heading's text in an `` - // held at heading color, so a bare `a[href]` here picks a heading. The - // exclusions mirror article-link-overrides.scss. + // Exclude a.heading-link and .btn; they match article-link-overrides.scss. const link = page .locator('#article-contents .markdown-body a[href]:not(.heading-link):not(.btn)') .first() @@ -181,8 +167,7 @@ test.describe('Brand document canvas', () => { probe.remove() } }) - // Equality alone passes if is wrong; contrast alone passes if the - // selector drifts off brand links. + // Equality alone can pass with wrong html vars; contrast alone can pass with selector drift. expect(linkColor).toBe(expectedLinkColor) expect(contrastRatio(linkColor, canvas)).toBeGreaterThanOrEqual(4.5) }) @@ -192,7 +177,6 @@ test.describe('Brand document canvas', () => { test('logo link keeps current version', async ({ page }) => { await page.goto('/enterprise-cloud@latest') await turnOffExperimentsInPage(page) - // Basically clicking into any page that isn't the home page for this version. await page.getByTestId('product').getByRole('link', { name: 'Get started' }).click() await expect(page).toHaveURL(/\/en\/enterprise-cloud@latest\/get-started/) await page @@ -206,7 +190,6 @@ test('view the for-playwright article', async ({ page }) => { await page.goto('/get-started/foo/for-playwright') await expect(page).toHaveTitle(/For Playwright - GitHub Docs/) - // This is the right-hand sidebar mini-toc link await page .getByTestId('minitoc') .getByRole('link', { name: 'Second heading', exact: true }) @@ -237,11 +220,7 @@ test('use sidebar to go to Hello World page', async ({ page }) => { test('sidebar highlights the clicked item optimistically while navigation is pending', async ({ page, }) => { - // Article pages are getServerSideProps routes, so router.asPath (and thus the real - // aria-current) only updates after the destination loads. The sidebar marks the - // clicked link with a visual-only `data-pending` accent so the click is acknowledged - // immediately. Throttle the client-side data fetch so the navigation stays pending - // long enough to observe that intermediate state. + // getServerSideProps delays router.asPath and aria-current; throttle _next/data for data-pending. await page.goto('/get-started') await page.getByTestId('product-sidebar').getByText('Start your journey').click() @@ -249,7 +228,6 @@ test('sidebar highlights the clicked item optimistically while navigation is pen const helloWorld = sidebar.getByRole('link', { name: 'Hello World' }) const linkRewriting = sidebar.getByRole('link', { name: 'Link rewriting' }) - // Hold the next data request open until we release it, so navigation stays pending. let releaseNavigation = () => {} const navigationHeld = new Promise((resolve) => { releaseNavigation = resolve @@ -261,24 +239,16 @@ test('sidebar highlights the clicked item optimistically while navigation is pen await helloWorld.click() - // While pending: the clicked link carries the optimistic visual marker, but the URL - // and the semantic aria-current still reflect the (still-loaded) get-started page. await expect(helloWorld).toHaveAttribute('data-pending', '') await expect(helloWorld).not.toHaveAttribute('aria-current', 'page') await expect(page).not.toHaveURL(/hello-world/) - // Let the navigation finish: the marker gives way to a real aria-current. releaseNavigation() await expect(page).toHaveURL(/\/en\/get-started\/start-your-journey\/hello-world/) await expect(helloWorld).toHaveAttribute('aria-current', 'page') await expect(helloWorld).not.toHaveAttribute('data-pending', '') - // A modifier-click (open in new tab) must NOT move the optimistic selection. - // handleNavClick bails on modifier clicks, so pendingHref is never set: the current - // page keeps its URL, its aria-current, and the clicked link gets no data-pending. - // Use ControlOrMeta so the real "open in new tab" modifier is sent per-platform - // (Ctrl on Linux/Windows CI, Meta on macOS). The click opens a background tab we - // don't need to assert on; catch any popup so it doesn't leak. + // handleNavClick skips modifier clicks; ControlOrMeta must not set pendingHref. Close the popup. page.on('popup', (popup) => popup.close()) await linkRewriting.click({ modifiers: ['ControlOrMeta'] }) await expect(linkRewriting).not.toHaveAttribute('data-pending', '') @@ -290,18 +260,15 @@ test('press "/" to open the search overlay', async ({ page }) => { await page.goto('/') await turnOffExperimentsInPage(page) - // Wait for the header search button to render, so the keydown listener is attached. + // The keydown listener attaches when the header search button renders. await page.getByTestId('toggle-search').waitFor() const searchInput = page.getByTestId('overlay-search-input') - // The overlay (and its input) is not in the DOM until it's opened. await expect(searchInput).toHaveCount(0) - // Pressing "/" anywhere on the page opens the overlay and focuses the input. await page.keyboard.press('/') await expect(searchInput).toBeFocused() - // Escape closes it again and returns focus to the same responsive trigger. await page.keyboard.press('Escape') await expect(searchInput).toHaveCount(0) await expect(page.getByTestId('toggle-search')).toBeFocused() @@ -317,7 +284,7 @@ test('"/" typed inside the search input is a literal slash', async ({ page }) => const searchInput = page.getByTestId('overlay-search-input') await expect(searchInput).toBeFocused() - // The "/" shortcut must not fire while typing in a field, so it is not swallowed. + // The slash shortcut must not fire while typing in a field. await page.keyboard.type('a/b') await expect(searchInput).toHaveValue('a/b') }) @@ -352,11 +319,8 @@ test('open search, and perform a general search', async ({ page }) => { await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('serve playwright') - // Wait for the results to load - // NOTE: In the UI we wait for results to load before allowing "enter", because we don't want - // to allow an unnecessary request when there are no search results. Easier to wait 1 second + // Wait 1 second for results to load, because the UI blocks submitting the query until then. await page.waitForTimeout(1000) - // Scroll down to "View all results" then press enter await page.getByText('View more results').click() await expect(page).toHaveURL( @@ -364,7 +328,6 @@ test('open search, and perform a general search', async ({ page }) => { ) await expect(page).toHaveTitle(/\d Search results for "serve playwright"/) - // The first result should be "For Playwright" await page.getByRole('link', { name: 'For Playwright' }).click() await expect(page).toHaveURL(/\/get-started\/foo\/for-playwright$/) @@ -379,13 +342,11 @@ test('open search, and select a general search article', async ({ page }) => { await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('serve playwright') - // Let new suggestions load const searchOverlay = page.getByTestId('general-autocomplete-suggestions') await expect(searchOverlay.getByText('For Playwright')).toBeVisible() await page.keyboard.press('ArrowDown') await page.keyboard.press('Enter') - // We should now be on the page for "For Playwright" await expect(page).toHaveURL(/\/get-started\/foo\/for-playwright$/) await expect(page).toHaveTitle(/For Playwright/) }) @@ -403,7 +364,7 @@ test('open search, and get auto-complete results', async ({ page }) => { let listItems = listGroup.locator('li') await expect(listItems).toHaveCount(4) - // Top queries from queries.json fixture's 'topQueries' + // The first list mirrors queries.json fixture topQueries. let expectedTexts = [ 'What is GitHub and how do I get started?', 'What is GitHub Copilot and how do I get started?', @@ -422,7 +383,6 @@ test('open search, and get auto-complete results', async ({ page }) => { await searchInput.fill('rest') await page.waitForTimeout(1000) - // Ask AI suggestions listGroup = page.getByTestId('ai-autocomplete-suggestions') listItems = listGroup.locator('li') await expect(listItems).toHaveCount(3) @@ -448,20 +408,14 @@ test('search from enterprise-cloud and filter by top-level Fooing', async ({ pag await page.waitForTimeout(1000) await page.getByText('View more results').click() - // Now we're on the search results page, apply the filter await page.getByText('Fooing (1)').click() await page.getByRole('link', { name: 'Clear' }).click() - - // At the moment this test isn't great because it's not proving that - // certain things cease to be visible, that was visible before. Room - // for improvement! }) test('404 page renders correctly', async ({ page }) => { const response = await page.goto('/this-definitely-does-not-exist') expect(response?.status()).toBe(404) - // 404 pages now render a minimal HTML response await expect(page.getByText('Page not found.')).toBeVisible() }) @@ -507,7 +461,6 @@ test.describe('platform picker', () => { await turnOffExperimentsInPage(page) await page.getByTestId('platform-picker').getByRole('link', { name: 'Windows' }).click() - // Return and now the cookie should start us off on Windows again await page.goto('/get-started/liquid/platform-specific') await expect(page.getByRole('heading', { name: /Windows 95/ })).toBeVisible() await expect(page.getByRole('heading', { name: /Macintosh/ })).not.toBeVisible() @@ -534,7 +487,7 @@ test.describe('tool picker', () => { test('prefer default tool', async ({ page }) => { await page.goto('/get-started/liquid/tool-specific') - // defaultTool is set in the fixture frontmatter to webui + // The fixture frontmatter defaults defaultTool to webui. await expect(page.getByText('This is webui content')).toBeVisible() await expect(page.getByText('This is desktop content')).not.toBeVisible() await expect(page.getByText('This is cli content')).not.toBeVisible() @@ -545,7 +498,6 @@ test.describe('tool picker', () => { await turnOffExperimentsInPage(page) await page.getByTestId('tool-picker').getByRole('link', { name: 'Web browser' }).click() - // Return and now the cookie should start us off with Web UI content again await page.goto('/get-started/liquid/tool-specific') await expect(page.getByText('This is cli content')).not.toBeVisible() await expect(page.getByText('This is desktop content')).not.toBeVisible() @@ -553,10 +505,9 @@ test.describe('tool picker', () => { }) test('minitoc matches picker', async ({ page }) => { - // See the note on the platform-specific version of this test: don't sit on - // the drawer's exact reveal breakpoint. + // Avoid the drawer's exact reveal breakpoint. await page.setViewportSize({ width: 1440, height: 900 }) - // default tool set to webui in fixture frontmatter + // The fixture frontmatter defaults defaultTool to webui. await page.goto('/get-started/liquid/tool-specific') await turnOffExperimentsInPage(page) await expect( @@ -616,9 +567,7 @@ test.describe('code tabs', () => { test('navigate with side bar into article inside a subcategory inside a category', async ({ page, }) => { - // Our TreeView sidebar only shows "2 levels". If you click and expand - // the category, you'll be able to see the subcategory and the article - // within. + // The TreeView sidebar shows two levels until the category expands. await page.goto('/actions') await page.getByTestId('sidebar').getByText('Category', { exact: true }).click() await page.getByTestId('sidebar').getByText('Subcategory').click() @@ -645,7 +594,6 @@ test.describe('hover cards', () => { await page.goto('/pages/quickstart') await turnOffExperimentsInPage(page) - // hover over a link and check for intro content from hovercard await page .locator('#article-contents') .getByRole('link', { name: 'Start your journey' }) @@ -656,8 +604,6 @@ test.describe('hover cards', () => { ), ).toBeVisible() - // now move the mouse away from hovering over the link, the hovercard should - // no longer be visible await page.mouse.move(0, 0) await expect( page.getByText( @@ -665,38 +611,31 @@ test.describe('hover cards', () => { ), ).not.toBeVisible() - // external links don't have a hovercard await page.getByRole('link', { name: 'github.com/github/docs' }).hover() await expect(page.getByTestId('popover')).not.toBeVisible() - // links in the main navigation sidebar don't have a hovercard await page.getByTestId('sidebar').getByRole('link', { name: 'Quickstart' }).hover() await expect(page.getByTestId('popover')).not.toBeVisible() - // links in the secondary minitoc sidebar don't have a hovercard await page .getByTestId('minitoc') .getByRole('link', { name: 'Regular internal link', exact: true }) .hover() await expect(page.getByTestId('popover')).not.toBeVisible() - // links in the article intro have a hovercard await page.locator('#article-intro').getByRole('link', { name: 'article intro link' }).hover() await expect(page.getByText('You can use HubGit Pages to showcase')).toBeVisible() - // this page's intro has two links; one in-page and one internal await page.locator('#article-intro').getByRole('link', { name: 'another link' }).hover() await expect( page.getByText('Follow this Hello World exercise to get started with HubGit.'), ).toBeVisible() - // same page anchor links have a hovercard await page .locator('#article-contents') .getByRole('link', { name: 'introduction', exact: true }) .hover() await expect(page.getByText('You can use HubGit Pages to showcase')).toBeVisible() - // links with formatted text need to work too await page.locator('#article-contents').getByRole('link', { name: 'Bold is strong' }).hover() await expect(page.getByText('The most basic of fixture data for HubGit')).toBeVisible() await page.locator('#article-contents').getByRole('link', { name: 'bar' }).hover() @@ -707,7 +646,6 @@ test.describe('hover cards', () => { await page.goto('/pages/quickstart') await turnOffExperimentsInPage(page) - // Simply putting focus on the link should not open the hovercard await page .locator('#article-contents') .getByRole('link', { name: 'Start your journey' }) @@ -718,7 +656,6 @@ test.describe('hover cards', () => { ), ).not.toBeVisible() - // Once a link has got focus, you can use Alt+ArrowUp to open the hovercard await page.keyboard.press('Alt+ArrowUp') await expect( page.getByText( @@ -738,7 +675,6 @@ test.describe('hover cards', () => { await page.goto('/pages/quickstart') await turnOffExperimentsInPage(page) - // hover over a link and check for intro content from hovercard await page .locator('#article-contents') .getByRole('link', { name: 'Start your journey' }) @@ -766,9 +702,7 @@ test.describe('test nav at different viewports', () => { }) await page.goto('/get-started/foo/bar') - // The Docs 2026 secondary bar leads with a Home crumb, then the full trail - // 'Get started / Foo / Bar' (no hidden last crumb). The current page is - // static text rather than a link, so only the three ancestors are links. + // Breadcrumbs include Home and the full trail; only the three ancestors are links. expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(3) await expect(page.getByTestId('breadcrumbs-bar').locator('[aria-current="page"]')).toHaveText( 'Bar', @@ -776,64 +710,51 @@ test.describe('test nav at different viewports', () => { await expect(page.getByTestId('breadcrumbs-bar').getByText('Foo')).toBeVisible() await expect(page.getByTestId('breadcrumbs-bar').getByText('Bar')).toBeVisible() - // breadcrumbs show up in rest reference pages await page.goto('/rest/actions/artifacts') await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible() - // breadcrumbs show up in one of the pages that use the AutomatedPage - // component (e.g. graphql, audit log). This one uses the webhooks - // reference page here + // Webhooks renders through an AutomatedPage reference page, which shows breadcrumbs. await page.goto('/webhooks/webhook-events-and-payloads') await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible() }) test('mobile nav opens even when the desktop rail was collapsed', async ({ page }) => { - // Collapse the desktop rail with both drawers out (xxl) so the persisted - // `collapsed` state is set via the secondary-bar collapse toggle. + // At xxl with both drawers out, the secondary-bar collapse toggle persists collapsed. page.setViewportSize({ width: 1400, height: 700, }) await page.goto('/get-started/foo/bar') await page.getByTestId('sidebar-collapse-toggle').click() - // With the rail collapsed the sidebar is not rendered on desktop. await expect(page.getByTestId('sidebar')).toHaveCount(0) - // Drop below lg (1012) where the inline mobile nav toggle lives (Docs 2026: - // the lg–xxl range keeps the desktop collapse toggle instead). `collapsed` - // persists across the resize. + // Below lg 1012px, the inline mobile nav toggle replaces the desktop collapse toggle. page.setViewportSize({ width: 1000, height: 700, }) - // Opening the mobile nav must still render the doc-tree drawer. Before the - // fix, `collapsed` short-circuited the sidebar to null while the open state - // hid the content column, leaving a blank area with no drawer. + // Mobile nav must render the doc-tree drawer even with persisted collapsed state. await page.getByTestId('sidebar-mobile-toggle').click() await expect(page.getByTestId('sidebar')).toBeVisible() - // Closing it restores the content column (main content visible again). await page.getByTestId('sidebar-mobile-toggle').click() await expect(page.locator('#main-content')).toBeVisible() }) test('resizing from mobile to desktop closes the inline nav', async ({ page }) => { - // Start below the lg (1012px) breakpoint where the inline mobile nav lives. + // Below lg 1012px, the inline mobile nav lives in the secondary bar. await page.setViewportSize({ width: 1000, height: 700, }) await page.goto('/get-started/foo/bar') - // Open the inline doc-tree nav from the secondary bar. await page.getByTestId('sidebar-mobile-toggle').click() const nav = page.locator('[data-container="nav"]') await expect(nav).toHaveAttribute('data-mobile-open', 'true') - // Resize up to the desktop breakpoint. The inline nav should close and the - // fixed desktop rail (326px) should take over rather than the full-width - // mobile markup persisting over the page. + // At 1400px, the desktop rail is 326px and replaces full-width mobile markup. await page.setViewportSize({ width: 1400, height: 700, @@ -849,7 +770,6 @@ test.describe('test nav at different viewports', () => { }) await page.goto('/get-started/foo/bar') - // Both complete pickers are visible directly in the wide header. await expect( page.getByTestId('version-picker').getByText('Select your plan:', { exact: true }), ).toBeVisible() @@ -863,7 +783,6 @@ test.describe('test nav at different viewports', () => { await page.keyboard.press('Escape') await expect(planMenu).not.toBeVisible() - // The language picker is the same kind of nested dropdown as the plan one. const languageButton = page.getByRole('button', { name: 'Select language: current language is English', }) @@ -887,15 +806,10 @@ test.describe('test nav at different viewports', () => { }) await page.goto('/get-started/foo/bar') - // breadcrumbs show up in the secondary bar; for this page we should have - // a Home crumb plus 'Get started / Foo / Bar' — the last of which is the - // current page, rendered as static text rather than a link. await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible() expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(3) - // At lg+ (Docs 2026) the doc-tree rail is shown by default with the desktop - // collapse toggle; the mobile inline-nav toggle is hidden. Clicking the - // collapse toggle hides the rail. + // At lg+, the desktop rail shows and the inline-nav toggle hides. await expect(page.getByTestId('sidebar')).toBeVisible() await expect(page.getByTestId('sidebar-collapse-toggle')).toBeVisible() await expect(page.getByTestId('sidebar-mobile-toggle')).toBeHidden() @@ -945,8 +859,7 @@ test.describe('test nav at different viewports', () => { await expect(languageMenu).not.toBeVisible() await expect(page.getByTestId('header-signup')).toBeVisible() - // The independent secondary-bar navigation is intentionally inert until the - // modal header menu closes, then still expands the doc tree inline. + // The secondary-bar nav stays inert until the modal header menu closes. await page.getByRole('button', { name: 'Close menu', exact: true }).click() await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible() await page.getByTestId('sidebar-mobile-toggle').click() @@ -998,13 +911,9 @@ test.describe('test nav at different viewports', () => { }) test.describe('secondary-bar breadcrumb scroller', () => { - // The secondary bar (and its breadcrumb scroller) only renders at wide - // viewports, and the fixture trail is short enough to fit there, so we cap the - // scroller width to force a deterministic overflow independent of title - // lengths, then exercise the chevrons. + // Cap breadcrumb scroller width to force deterministic overflow. test('chevrons scroll one crumb at a time instead of jumping to the ends', async ({ page }) => { - // Smooth-scroll settle waits across several chevron clicks add up past the - // default 5s cap. + // Several smooth-scroll waits can exceed the default 5s test cap. test.setTimeout(20000) page.setViewportSize({ width: 1300, height: 700 }) await page.goto('/get-started/foo/bar') @@ -1015,11 +924,7 @@ test.describe('secondary-bar breadcrumb scroller', () => { const scrollArea = page.locator('[data-search="breadcrumbs"]') await expect(scrollArea).toBeVisible() - // Force a deterministic overflow independent of title lengths: cap the - // scroll region, drop the nav's min-width:100% (which otherwise stretches the - // short fixture trail to fill the container so it never overflows), and pad - // the crumbs so several are hidden at once — enough that a per-crumb nudge is - // distinguishable from a jump to the end. + // Cap scroll width, remove nav min-width:100%, and pad crumbs to detect nudges. await page.addStyleTag({ content: ` [data-search="breadcrumbs"] { max-width: 360px; } @@ -1032,37 +937,28 @@ test.describe('secondary-bar breadcrumb scroller', () => { const maxScrollOf = () => scrollArea.evaluate((el) => el.scrollWidth - el.clientWidth) await expect.poll(maxScrollOf).toBeGreaterThan(0) - // Anchor to the right end explicitly so we start from a known state: fully - // scrolled right (current page visible), only the left chevron active. + // Start fully scrolled right so only the left chevron is active. await scrollArea.evaluate((el) => el.scrollTo({ left: el.scrollWidth, behavior: 'instant' })) const maxScroll = await maxScrollOf() await expect.poll(scrollLeftOf).toBe(maxScroll) const leftChevron = page.getByRole('button', { name: 'Scroll breadcrumbs left' }) const rightChevron = page.getByRole('button', { name: 'Scroll breadcrumbs right' }) - // At the right extreme the left chevron is active and the right one is hidden. await expect(leftChevron).toBeVisible() await expect(rightChevron).toBeHidden() - // One left click nudges toward the start by a single crumb — it must move, - // but must NOT jump all the way to 0 (the old behavior) while more than one - // crumb is still hidden to the left. + // One left click must move without jumping to 0 while crumbs stay hidden left. await leftChevron.click() await expect.poll(scrollLeftOf).toBeLessThan(maxScroll) const afterOneLeft = await scrollLeftOf() expect(afterOneLeft).toBeGreaterThan(0) - // The right chevron appears once we're no longer at the right extreme. await expect(rightChevron).toBeVisible() - // A right click walks back toward the current page by one crumb, not a full - // jump back to the right extreme. + // Right click returns by one crumb, not a jump to the right extreme. await rightChevron.click() await expect.poll(scrollLeftOf).toBeGreaterThan(afterOneLeft) - // Repeated left clicks eventually reach the start, which hides the left - // chevron (canScrollLeft flips false). Drive off the chevron's own visibility - // rather than an exact scrollLeft, since smooth scrolling can leave a - // sub-pixel remainder. + // Drive off chevron visibility because smooth scrolling can leave sub-pixel scrollLeft. for (let i = 0; i < 6 && (await leftChevron.isVisible()); i++) { await leftChevron.click() await page.waitForTimeout(200) @@ -1073,16 +969,10 @@ test.describe('secondary-bar breadcrumb scroller', () => { }) test.describe('anchor link scrolling', () => { - // The doc-tree rail only renders at the xxl breakpoint (1400px) and up. Its - // "centre the active item" effect used to call scrollIntoView, which scrolls - // every scrollable ancestor including the document, so it undid the browser's - // scroll to the #anchor and dumped the reader at the top of the article. - // These tests only mean anything with the rail on screen. + // At xxl, centering the active doc-tree item can undo browser anchor scrolling. const WIDE = { width: 1400, height: 720 } - // The heading is offset from the top of the viewport by `scroll-margin-top` - // (109px at xxl, see src/frame/stylesheets/scroll-top.scss). Allow slack for - // rounding and sticky-header tweaks, but stay well clear of "not scrolled". + // scroll-margin-top is 109px at xxl; allow rounding and sticky-header slack, but reject an unscrolled page. const expectScrolledToTarget = async (page: import('@playwright/test').Page) => { const heading = page.locator('#target-heading') await expect(heading).toBeVisible() @@ -1096,10 +986,7 @@ test.describe('anchor link scrolling', () => { await expect(page.getByTestId('sidebar')).toBeVisible() await expectScrolledToTarget(page) - // Guard the setup: the regression only shows when the rail has actually - // scrolled its own container to centre the active item. If a fixture change - // ever makes the rail short enough that it doesn't need to scroll, these - // tests would keep passing while covering nothing — fail loudly instead. + // Fail if the fixture rail stops scrolling, because the test would cover nothing. const railScrollTop = await page .getByTestId('sidebar') .evaluate((el) => el.closest('[role="region"]')!.scrollTop) @@ -1135,8 +1022,7 @@ test.describe('survey', () => { const surveyComment = 'This is a comment' - // Important to set this up *before* interacting with the page - // in case of possible race conditions. + // Install the route before interacting with the page to avoid event races. await page.route('**/api/events', (route, request) => { const postData = request.postData() if (postData) { @@ -1160,10 +1046,7 @@ test.describe('survey', () => { } } } - // At the time of writing you can't get the posted payload - // when you use `navigator.sendBeacon(url, data)`. - // So we can't make assertions about the payload. - // See https://github.com/microsoft/playwright/issues/12231 + // Chromium hides sendBeacon payloads from Playwright: https://github.com/microsoft/playwright/issues/12231 }) await page.addInitScript(() => { @@ -1172,7 +1055,7 @@ test.describe('survey', () => { await page.goto('/get-started/foo/for-playwright') - // The label is visually an SVG. Finding it by its `for` value feels easier. + // The label renders as an SVG, so locate it by for=survey-yes. await page.locator('[for=survey-yes]').click() await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible() await expect(page.getByRole('button', { name: 'Send' })).toBeVisible() @@ -1181,7 +1064,6 @@ test.describe('survey', () => { await page.locator('[name=survey-email]').click() await page.locator('[name=survey-email]').fill('test@example.com') await page.getByRole('button', { name: 'Send' }).click() - // simulate sending an exit event to trigger sending all queued events await page.evaluate(() => { Object.defineProperty(document, 'visibilityState', { configurable: true, @@ -1193,11 +1075,7 @@ test.describe('survey', () => { return new Promise((resolve) => setTimeout(resolve, 100)) }) - // Events: - // 1. page view event when navigating to the page - // 2. Survey thumbs up event - // 3. Survey submit event - // 4. Exit event + // fulfilled counts page view, survey thumbs up, survey submit, and exit events. expect(fulfilled).toBe(1 + 1 + 1 + 1) expect(hasSurveyPressedEvent).toBe(true) expect(hasSurveySubmittedEvent).toBe(true) @@ -1208,8 +1086,7 @@ test.describe('survey', () => { let fulfilled = 0 let hasSurveyEvent = false - // Important to set this up *before* interacting with the page - // in case of possible race conditions. + // Install the route before interacting with the page to avoid event races. await page.route('**/api/events', (route, request) => { const postData = request.postData() if (postData) { @@ -1223,10 +1100,7 @@ test.describe('survey', () => { } } } - // At the time of writing you can't get the posted payload - // when you use `navigator.sendBeacon(url, data)`. - // So we can't make assertions about the payload. - // See https://github.com/microsoft/playwright/issues/12231 + // Chromium hides sendBeacon payloads from Playwright: https://github.com/microsoft/playwright/issues/12231 }) await page.addInitScript(() => { @@ -1236,7 +1110,6 @@ test.describe('survey', () => { await page.goto('/get-started/foo/for-playwright') await page.locator('[for=survey-yes]').click() - // simulate sending an exit event to trigger sending all queued events await page.evaluate(() => { Object.defineProperty(document, 'visibilityState', { configurable: true, @@ -1247,10 +1120,7 @@ test.describe('survey', () => { document.dispatchEvent(new Event('visibilitychange')) return new Promise((resolve) => setTimeout(resolve, 100)) }) - // Events: - // 1. page view event when navigating to the page - // 2. the thumbs up click - // 3. the exit event + // fulfilled counts page view, thumbs up, and exit events. expect(fulfilled).toBe(1 + 1 + 1) expect(hasSurveyEvent).toBe(true) @@ -1259,8 +1129,7 @@ test.describe('survey', () => { }) test('vote on one page, then go to another and it should reset', async ({ page }) => { - // Important to set this up *before* interacting with the page - // in case of possible race conditions. + // Install the route before interacting with the page to avoid event races. await page.route('**/api/events', (route) => { route.fulfill({}) }) @@ -1284,12 +1153,10 @@ test.describe('survey', () => { test.describe('rest API reference pages', () => { test('REST actions', async ({ page }) => { await page.goto('/rest') - // Before using the sidebar, make sure the page has redirected to a - // URL that has that `?apiVersion=` query parameter. + // Redirect must add the apiVersion query before sidebar navigation. await expect(page).toHaveURL(/\/en\/rest\?apiVersion=/) await page.getByTestId('sidebar').getByText('Actions').click() - // Brand NavList renders leaf articles as links (not the label-associated - // controls Primer used), so locate them by link role rather than getByLabel. + // Brand NavList renders leaf articles as links, not Primer's label-associated controls. await page.getByTestId('sidebar').getByRole('link', { name: 'Artifacts' }).click() await page .getByTestId('sidebar') @@ -1313,7 +1180,6 @@ test.describe('translations', () => { await expect(page).toHaveURL('/ja') await expect(page.getByRole('heading', { name: '日本 GitHub Docs' })).toBeVisible() - // Having done this once, should now use a cookie to redirect back to Japanese await page.goto('/') await expect(page).toHaveURL('/ja') }) @@ -1326,16 +1192,11 @@ test.describe('translations', () => { await expect(page).toHaveURL('/ja/get-started/start-your-journey/hello-world') await expect(page.getByRole('heading', { name: 'こんにちは World' })).toBeVisible() - // Having done this once, should now use a cookie to redirect - // back to Japanese. - // Playwright will cache this redirect, so we need to add something - // to "cache bust" the URL + // Bust the URL because Playwright caches the redirect back to Japanese. const cb = `?cb=${Math.random()}` await page.goto(`/get-started/start-your-journey/hello-world${cb}`) await expect(page).toHaveURL(`/ja/get-started/start-your-journey/hello-world${cb}`) - // If you go, with the Japanese cookie, to the English page directly, - // it will offer a link to the Japanese URL in a banner. await page.goto('/en/get-started/start-your-journey/hello-world') await expect(page).toHaveURL('/ja/get-started/start-your-journey/hello-world') }) @@ -1344,9 +1205,7 @@ test.describe('translations', () => { test('open search, and ask Copilot (Ask AI) a question', async ({ page }) => { test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') - // Mock the CSE Copilot endpoint await page.route('**/api/ai-search/v1', async (route) => { - // Simulate the streaming response from CSE Copilot const mockResponse = `{"chunkType":"SOURCES","sources":[{"title":"Creating a new repository","index":"/en/get-started","url":"http://localhost:4000/en/get-started"}]} {"chunkType":"MESSAGE_CHUNK","text":"Creating "} @@ -1380,31 +1239,23 @@ test('open search, and ask Copilot (Ask AI) a question', async ({ page }) => { await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('How do I create a Repository?') - // Pressing enter should ask AI the question await page.keyboard.press('Enter') - // Wait for the AI response to appear await expect(page.getByText('Creating a repository on GitHub')).toBeVisible() - // Verify that sources are displayed await expect(page.getByText('Creating a new repository')).toBeVisible() - // Verify the full response appears await expect(page.getByText('something you should already know how to do')).toBeVisible() - // Open the "Creating new repository" source link list item - // Find the references section first const aiReferencesSection = page.getByTestId('ai-references') await expect(aiReferencesSection).toBeVisible() - // Wait for the reference list to be populated await expect(page.getByText('Creating a new repository')).toBeVisible() }) test('open search, Ask AI returns 400 error and shows general search results', async ({ page }) => { test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') - // Mock the CSE Copilot endpoint to return a 400 error await page.route('**/api/ai-search/v1', async (route) => { await route.fulfill({ status: 400, @@ -1422,21 +1273,16 @@ test('open search, Ask AI returns 400 error and shows general search results', a await page.getByTestId('toggle-search').click() await page.getByTestId('overlay-search-input').fill('foo') - // Pressing enter should trigger Ask AI, get 400 error, and show general search results await page.keyboard.press('Enter') - // Wait for the general search results to appear inside the overlay's suggestions - // group. These render as ActionList items (buttons), so scope the lookup to the - // group rather than matching page-level links of the same name. + // Search suggestions render as ActionList buttons, so scope Foo and Bar to the suggestion group. const generalSuggestions = page.getByTestId('general-autocomplete-suggestions') await expect(generalSuggestions.getByRole('button', { name: 'Foo' })).toBeVisible() await expect(generalSuggestions.getByRole('button', { name: 'Bar' })).toBeVisible() - // Wait for the AI error message to appear - // This is a canned response for the 400 error - await page.waitForTimeout(1000) // Wait for the AI error message to appear + // Wait for the canned 400 response before checking the paragraph. + await page.waitForTimeout(1000) - // Verify the AI error message appears (canned response for 400 error) await expect( page .getByRole('paragraph') @@ -1445,7 +1291,6 @@ test('open search, Ask AI returns 400 error and shows general search results', a ), ).toBeVisible() - // Verify general search results appear above the AI section const searchResults = page.getByTestId('general-autocomplete-suggestions') const aiSection = page.locator('#ask-ai-result-container') @@ -1460,13 +1305,11 @@ test.describe('LandingCarousel component', () => { const carousel = page.locator('[data-testid="landing-carousel"]') await expect(carousel).toBeVisible() - // Check that article cards are present. Brand Card renders each card's title - // as an

      (Card.Heading) wrapping a stretched , so target the heading. + // Brand Card renders each card title as h3 Card.Heading around a stretched link. const items = page.locator('[data-testid="carousel-items"]') const cardHeadings = items.locator('h3') await expect(cardHeadings.first()).toBeVisible() - // Verify cards have real titles (not "Unknown Article" when article not found) await expect(cardHeadings.first()).not.toHaveText('Unknown Article') }) @@ -1477,15 +1320,13 @@ test.describe('LandingCarousel component', () => { const carousel = page.locator('[data-testid="landing-carousel"]') await expect(carousel).toBeVisible() - // Should show 3 cards on desktop const cards = carousel.locator('a') await expect(cards).toHaveCount(3) - // Check for navigation buttons if there are more than 3 articles const nextButton = carousel.getByRole('button', { name: 'Next articles' }) if (await nextButton.isVisible()) { const prevButton = carousel.getByRole('button', { name: 'Previous articles' }) - await expect(prevButton).toBeDisabled() // Should be disabled on first page + await expect(prevButton).toBeDisabled() await expect(nextButton).toBeEnabled() } }) @@ -1497,7 +1338,6 @@ test.describe('LandingCarousel component', () => { const carousel = page.locator('[data-testid="landing-carousel"]') await expect(carousel).toBeVisible() - // Should show 1 card on mobile const cards = carousel.locator('a') await expect(cards).toHaveCount(1) }) @@ -1507,36 +1347,32 @@ test.describe('Multi-carousel support', () => { test('displays multiple carousels from carousels frontmatter', async ({ page }) => { await page.goto('/get-started/multi-carousel') - // Should have multiple carousels rendered const carousels = page.locator('[data-testid="landing-carousel"]') const carouselCount = await carousels.count() - // We defined exactly 2 carousels in the frontmatter + // Frontmatter defines exactly two carousels. expect(carouselCount).toBe(2) }) test('carousel with matching ui.yml key displays translated title', async ({ page }) => { await page.goto('/get-started/multi-carousel') - // The "recommended" carousel should show "Recommended" title from ui.yml + // The recommended carousel title comes from ui.yml. const carouselHeadings = page.locator('[data-testid="landing-carousel"] h2') const headingTexts = await carouselHeadings.allTextContents() - // Check that at least one heading has "Recommended" expect(headingTexts.some((text) => text.includes('Recommended'))).toBe(true) }) test('carousel without matching ui.yml key renders without title', async ({ page }) => { await page.goto('/get-started/multi-carousel') - // The "titleTwoNoMatchingUiYml" carousel should not have a visible heading - // or the heading element should be empty/not exist for that carousel + // A carousel without a matching ui.yml key has no heading element. const carouselHeadings = page.locator('[data-testid="landing-carousel"] h2') const headingTexts = await carouselHeadings.allTextContents() - // The raw key "titleTwoNoMatchingUiYml" should NOT appear as a heading - // (the component should not show the key as fallback) + // titleTwoNoMatchingUiYml must not render as a fallback heading. expect(headingTexts.some((text) => text === 'titleTwoNoMatchingUiYml')).toBe(false) }) @@ -1546,10 +1382,8 @@ test.describe('Multi-carousel support', () => { const carousels = page.locator('[data-testid="landing-carousel"]') const count = await carousels.count() - // We have 2 carousels: "recommended" and "titleTwoNoMatchingUiYml" expect(count).toBe(2) - // Count carousels that have h2 elements let carouselsWithHeadings = 0 for (let i = 0; i < count; i++) { const carousel = carousels.nth(i) @@ -1559,11 +1393,9 @@ test.describe('Multi-carousel support', () => { } } - // Only 1 carousel should have a heading (recommended has ui.yml entry) - // titleTwoNoMatchingUiYml should NOT have an h2 element at all + // Only recommended has a ui.yml entry, so titleTwoNoMatchingUiYml must not render an h2. expect(carouselsWithHeadings).toBe(1) - // Verify the specific titles that should be visible const visibleHeadings = await carousels.locator('h2').allTextContents() expect(visibleHeadings).toContain('Recommended') expect(visibleHeadings).not.toContain('titleTwoNoMatchingUiYml') @@ -1575,7 +1407,6 @@ test.describe('Multi-carousel support', () => { const carousels = page.locator('[data-testid="landing-carousel"]') const count = await carousels.count() - // Each carousel should have at least one article for (let i = 0; i < count; i++) { const carousel = carousels.nth(i) const articles = carousel.locator('[data-testid="carousel-items"] a') @@ -1592,14 +1423,12 @@ test.describe('Journey Tracks', () => { const journeyTracks = page.locator('[data-testid="journey-tracks"]') await expect(journeyTracks).toBeVisible() - // Check that at least one track is displayed const tracks = page.locator('[data-testid="journey-track"]') await expect(tracks.first()).toBeVisible() - // Verify track has proper structure const firstTrack = tracks.first() - await expect(firstTrack.locator('h2')).toBeVisible() // Track title - await expect(firstTrack.locator('p')).toBeVisible() // Track description + await expect(firstTrack.locator('h2')).toBeVisible() + await expect(firstTrack.locator('p')).toBeVisible() }) test('track expansion and collapse functionality', async ({ page }) => { @@ -1608,7 +1437,6 @@ test.describe('Journey Tracks', () => { const firstTrack = page.locator('[data-testid="journey-track"]').first() const expandButton = firstTrack.locator('summary') - // Initially collapsed const articlesList = firstTrack.locator('[data-testid="journey-articles"]') await expect(articlesList).not.toBeVisible() @@ -1630,7 +1458,6 @@ test.describe('Journey Tracks', () => { await expandButton.click() - // Click on first article const firstArticle = firstTrack.locator('[data-testid="journey-articles"] li a').first() await expect(firstArticle).toBeVisible() @@ -1646,7 +1473,7 @@ test.describe('Journey Tracks', () => { const expandButton = firstTrack.locator('summary') await expandButton.click() - // article links should preserve the language and version + // Article links preserve language and version. const firstArticle = firstTrack.locator('[data-testid="journey-articles"] li a').first() const href = await firstArticle.getAttribute('href') @@ -1659,7 +1486,6 @@ test.describe('Journey Tracks', () => { const tracks = page.locator('[data-testid="journey-track"]') - // Check that liquid templates are rendered (no raw template syntax visible) const trackContent = await tracks.first().textContent() expect(trackContent).not.toContain('{{') expect(trackContent).not.toContain('}}') @@ -1670,21 +1496,18 @@ test.describe('Journey Tracks', () => { test('renders the single-track journey landing path', async ({ page }) => { await page.goto('/get-started/test-journey-single') - // single-track pages use the simplified heading + guide list, not the numbered cards + // Single-track pages render the simplified heading and guide list instead of numbered cards. const singleTrack = page.locator('[data-testid="journey-single-track"]') await expect(singleTrack).toBeVisible() await expect(page.locator('[data-testid="journey-tracks"]')).toHaveCount(0) - // heading is present await expect(singleTrack.locator('h2')).toBeVisible() - // guide list renders its article links const guides = singleTrack.locator('[data-testid="journey-articles"] li a') await expect(guides.first()).toBeVisible() expect(await guides.count()).toBeGreaterThan(0) - // without a surrounding card, the list must sit flush with the heading - // rather than picking up the card's inset + // Without a card, the guide list sits flush with the heading instead of inheriting inset. const listPaddingLeft = await singleTrack .locator('[data-testid="journey-articles"]') .evaluate((el) => getComputedStyle(el).paddingLeft) @@ -1692,21 +1515,14 @@ test.describe('Journey Tracks', () => { }) test('journey navigation components show on article pages', async ({ page }) => { - // go to an article that's part of a journey track await page.goto('/get-started/start-your-journey/hello-world') - // The journey footer "Up next" nav should be visible. (The Docs 2026 redesign - // removed the sidebar journey card; next-step info now lives in the bottom - // pager + the in-panel "Up next" section.) + // Journey next-step info shows in the bottom pager and the right-rail Up next section. const journeyNav = page.locator('[data-testid="journey-track-nav"]') await expect(journeyNav).toBeVisible() }) - // Restores the coverage the Docs 2026 migration dropped along with the sidebar - // journey card: `alternativeNextStep` and its AUTOTITLE resolution now render - // in the right-rail "Up next" section instead. That section rides the drawer's - // reveal breakpoint, so it needs a viewport inside the drawer range and a - // fixture long enough to keep the bottom pager outside the viewport. + // A drawer-range viewport shows alternativeNextStep and AUTOTITLE in Up next; the long fixture hides the pager. test('up next displays branching text when present', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }) await page.goto('/get-started/foo/journey-test-article') @@ -1716,7 +1532,7 @@ test.describe('Journey Tracks', () => { const upNext = page.getByTestId('up-next') await expect(upNext).toBeVisible() - // Branching text should be rendered with its markdown link resolved + // Branching text renders after resolving its markdown link. await expect(upNext).toContainText('Want to skip ahead?') await expect(upNext).not.toContainText('AUTOTITLE') @@ -1758,7 +1574,6 @@ test.describe('Journey Tracks', () => { const journeyNav = page.locator('[data-testid="journey-track-nav"]') await expect(journeyNav).toBeVisible() - // Link should display the next track's title and go to its first article const nextTrackLink = journeyNav.locator('a').filter({ hasText: 'Advanced topics' }) await expect(nextTrackLink).toBeVisible() @@ -1768,9 +1583,7 @@ test.describe('Journey Tracks', () => { }) test.describe('Docs 2026 in-article navigation', () => { - // Below the drawer's reveal breakpoint the right-rail "In this article" panel - // is hidden and the collapsed control in the secondary bar is the ONLY - // mini-TOC — the common case for most readers — so it needs its own coverage. + // Below the drawer breakpoint, the secondary-bar mini-TOC is the only in-page control. test('the collapsed "In this article" menu navigates below the drawer breakpoint', async ({ page, }) => { @@ -1780,7 +1593,6 @@ test.describe('Docs 2026 in-article navigation', () => { const subBar = page.getByTestId('overview-subbar') await expect(subBar).toBeVisible() - // The full drawer must not also be showing at this width. await expect(page.getByTestId('minitoc')).toBeHidden() await subBar.getByRole('button').click() @@ -1794,11 +1606,7 @@ test.describe('Docs 2026 in-article navigation', () => { expect(page.url()).toContain(href) }) - // Regression guard. Platform/tool-gated headings stay in the DOM with the - // `hidden` attribute, so they measure as an all-zero rect. Before - // useActiveSection filtered by the selection, such a heading always satisfied - // the "scrolled past" threshold, so the collapsed control could end up - // labelled with a section belonging to a platform the reader had not chosen. + // useActiveSection filters hidden platform/tool headings because they have zero rects. test('the collapsed menu is never labelled with a hidden platform section', async ({ page }) => { await page.setViewportSize({ width: 1100, height: 900 }) await page.goto('/get-started/liquid/platform-specific?platform=windows') @@ -1808,7 +1616,6 @@ test.describe('Docs 2026 in-article navigation', () => { await expect(trigger).toBeVisible() await expect(trigger).not.toContainText('Macintosh') - // Scroll past the first heading so an active section is actually resolved. await page.mouse.wheel(0, 2000) await expect(trigger).not.toContainText('Macintosh') }) @@ -1818,8 +1625,6 @@ test.describe('LandingArticleGridWithFilter component', () => { test('displays article grid with filter controls', async ({ page }) => { await page.goto('/get-started/article-grid-discovery') - // Check that the main components are visible, title, categories drop - // down, search input. const articleGrid = page.getByTestId('article-grid') await expect(articleGrid).toBeVisible() @@ -1842,14 +1647,11 @@ test.describe('LandingArticleGridWithFilter component', () => { const articleGrid = page.getByTestId('article-grid') await expect(articleGrid).toBeVisible() - // Check that article cards are present and they have expected structure - // by checking the first card. const articleCards = articleGrid.getByTestId('article-card') await expect(articleCards.first()).toBeVisible() const firstCard = articleCards.first() - // Brand Card renders the title as an

      (Card.Heading) wrapping a - // stretched , and the intro as a Card.Description

      . + // Brand Card renders titles as h3 Card.Heading links and intros as Card.Description paragraphs. const titleLink = firstCard.locator('h3 a') await expect(titleLink).toBeVisible() @@ -1858,8 +1660,6 @@ test.describe('LandingArticleGridWithFilter component', () => { const introText = await intro.textContent() expect(introText).toBeTruthy() - // Card should have categories, title, and intro, just check the card has - // some text const cardText = await firstCard.textContent() expect(cardText).toBeTruthy() expect(cardText!.length).toBeGreaterThan(0) @@ -1868,27 +1668,23 @@ test.describe('LandingArticleGridWithFilter component', () => { test('category filtering works correctly', async ({ page }) => { await page.goto('/get-started/article-grid-discovery') - // Check that category dropdown button exists and is clickable const categoryDropdown = page.getByRole('button').filter({ hasText: 'All categories' }) await expect(categoryDropdown).toBeVisible() - // Initially should show all articles (4 total in our fixtures) + // The fixture starts with four articles. const articleGrid = page.getByTestId('article-grid') await expect(articleGrid).toBeVisible() const allArticleCards = articleGrid.getByTestId('article-card') await expect(allArticleCards).toHaveCount(4) - // Click the dropdown and the 'Testing' category await categoryDropdown.click() const testingOption = page.getByText('Testing', { exact: true }).last() await expect(testingOption).toBeVisible() await testingOption.click() - // After filtering by Testing category, should show only 1 article based - // on our fixtures. + // Filtering by Testing leaves one fixture article. await expect(allArticleCards).toHaveCount(1) - // Verify the filtered article contains "Testing" somewhere in its markup const remainingCard = allArticleCards.first() await expect(remainingCard).toContainText('Testing') }) @@ -1899,18 +1695,17 @@ test.describe('LandingArticleGridWithFilter component', () => { const searchInput = page.getByPlaceholder('Search articles') await expect(searchInput).toBeVisible() - // Initially should show all articles (4 total in our fixtures) + // The fixture starts with four articles. const articleGrid = page.getByTestId('article-grid') await expect(articleGrid).toBeVisible() const articleCards = articleGrid.getByTestId('article-card') await expect(articleCards).toHaveCount(4) - // Search for "Grid" - based on our fixtures, multiple articles should have "Grid" in their names + // Multiple fixture article names contain Grid. await searchInput.fill('Grid') await expect(articleCards.first()).toBeVisible() - // Verify that the remaining articles contain "Grid" in their content const remainingCount = await articleCards.count() expect(remainingCount).toBeGreaterThan(0) for (let i = 0; i < remainingCount; i++) { @@ -1925,44 +1720,33 @@ test.describe('LandingArticleGridWithFilter component', () => { const searchInput = page.getByPlaceholder('Search articles') await expect(searchInput).toBeVisible() - // Search for a term that definitely won't match any articles, should show - // no article cards await searchInput.fill('noSuchArticles') const articleGrid = page.getByTestId('article-grid') await expect(articleGrid).toBeVisible() const articleCards = articleGrid.getByTestId('article-card') await expect(articleCards).toHaveCount(0) - // Should show "no articles found" message as well const noResultsMessage = page.getByTestId('no-articles-message') await expect(noResultsMessage).toBeVisible() await expect(noResultsMessage).toHaveText('No articles found matching your criteria.') }) test('responsive behavior on different screen sizes', async ({ page }) => { - // Super basic test, just make sure the article grid is visible on - // different viewports sizes - - // Test desktop view (3 columns) await page.setViewportSize({ width: 1200, height: 800 }) await page.goto('/get-started/article-grid-discovery') const articleGrid = page.getByTestId('article-grid') await expect(articleGrid).toBeVisible() - // Test tablet view (2 columns) await page.setViewportSize({ width: 768, height: 1024 }) - await page.waitForTimeout(100) // Brief wait for responsive changes + await page.waitForTimeout(100) await expect(articleGrid).toBeVisible() - // Test mobile view (1 column) await page.setViewportSize({ width: 375, height: 667 }) - await page.waitForTimeout(100) // Brief wait for responsive changes + await page.waitForTimeout(100) await expect(articleGrid).toBeVisible() }) test('works with bespoke landing page', async ({ page }) => { - // Other grid tests use the discovery landing page, bespoke pages are - // similar so just do a quick check. await page.goto('/get-started/article-grid-bespoke') const articleGrid = page.getByTestId('article-grid') @@ -1970,10 +1754,7 @@ test.describe('LandingArticleGridWithFilter component', () => { }) test('card is keyboard-navigable via Enter (client-side)', async ({ page }) => { - // The brand Card renders a native stretched anchor; a synthetic click from - // pressing Enter on that anchor must bubble to the card's onClick handler so - // keyboard users get the same client-side SPA navigation as mouse users. - // Guards against a regression if the click-intercept logic is refactored. + // Brand Card's stretched anchor must bubble keyboard clicks for client-side navigation. await page.goto('/get-started/article-grid-discovery') const articleGrid = page.getByTestId('article-grid') @@ -1983,8 +1764,7 @@ test.describe('LandingArticleGridWithFilter component', () => { const href = await firstCardLink.getAttribute('href') expect(href).toBeTruthy() - // Mark the current document so we can prove navigation was client-side - // (no full page reload): a hard navigation would wipe this window property. + // A hard navigation would clear this window marker; client-side navigation preserves it. await page.evaluate(() => { ;(window as unknown as { __spaMarker?: boolean }).__spaMarker = true }) @@ -2000,20 +1780,16 @@ test.describe('LandingArticleGridWithFilter component', () => { }) test('bespoke landing page does not show duplicate articles', async ({ page }) => { - // The bespoke fixture lists individual articles AND their parent group - // as children, which would cause duplicates without deduplication. + // Bespoke fixtures list articles and their parent group, so deduplication prevents duplicates. await page.goto('/get-started/article-grid-bespoke') const articleGrid = page.getByTestId('article-grid') await expect(articleGrid).toBeVisible() const articleCards = articleGrid.getByTestId('article-card') - // There are 4 unique articles across grid-category-one (2) and grid-category-two (2). - // Even though grid-article-one and grid-article-two are listed both individually - // and as children of grid-category-one, they should appear only once each. + // Four unique articles remain after deduplicating grid-article-one and grid-article-two. await expect(articleCards).toHaveCount(4) - // Verify no duplicate titles by collecting all card titles const titles: string[] = [] const count = await articleCards.count() for (let i = 0; i < count; i++) { @@ -2027,19 +1803,16 @@ test.describe('LandingArticleGridWithFilter component', () => { test.describe('Non-child page resolution', () => { test('category page with local children renders properly', async ({ page }) => { - // The local-category has local children (local-article-one, local-article-two) - // and an external article reference via children frontmatter + // local-category mixes local-article-one, local-article-two, and an external frontmatter child. await page.goto('/get-started/non-child-resolution/local-category') - // Should have a title await expect(page).toHaveTitle(/Local category test/) - // The page should load without errors and have main content await expect(page.locator('main')).toBeVisible() }) test('cross-product children page loads correctly', async ({ page }) => { - // The articles-only fixture now uses /content/ prefix in children for cross-product paths + // The articles-only fixture prefixes cross-product children with /content/. await page.goto('/get-started/non-child-resolution/articles-only') await expect(page).toHaveTitle(/Cross-product children test/) @@ -2047,7 +1820,7 @@ test.describe('Non-child page resolution', () => { }) test('children-only page with /content/ path loads correctly', async ({ page }) => { - // The children-only fixture uses /content/ prefix for cross-product paths + // The children-only fixture prefixes cross-product children with /content/. await page.goto('/get-started/non-child-resolution/children-only') await expect(page).toHaveTitle(/Children only test/) @@ -2062,20 +1835,19 @@ test.describe('Non-child page resolution', () => { }) test('versioned cross-product children - fpt shows only fpt article', async ({ page }) => { - // In fpt version, only the only-fpt article should be available + // In fpt, only only-fpt is available. await page.goto('/get-started/non-child-resolution/versioned-cross-product') await expect(page).toHaveTitle(/Versioned cross-product test/) await expect(page.locator('main')).toBeVisible() - // Check TOC has the fpt-only article const tocLinks = page.locator('[data-testid="table-of-contents"] a') await expect(tocLinks).toHaveCount(1) await expect(tocLinks.first()).toHaveAttribute('href', /only-fpt/) }) test('versioned cross-product children - ghec shows ghec articles', async ({ page }) => { - // In ghec version, only-ghec and only-ghec-and-ghes should be available + // In ghec, only-ghec and only-ghec-and-ghes are available. await page.goto( '/enterprise-cloud@latest/get-started/non-child-resolution/versioned-cross-product', ) @@ -2083,58 +1855,46 @@ test.describe('Non-child page resolution', () => { await expect(page).toHaveTitle(/Versioned cross-product test/) await expect(page.locator('main')).toBeVisible() - // Check TOC has ghec articles (only-ghec and only-ghec-and-ghes) const tocLinks = page.locator('[data-testid="table-of-contents"] a') await expect(tocLinks).toHaveCount(2) }) test('cross-product children excluded from sidebar in Japanese translation', async ({ page }) => { - // The Japanese translation should work with cross-product children + // Japanese translations work with cross-product children. await page.goto('/ja/get-started/non-child-resolution') - // Verify page loads correctly with Japanese site context - // Note: The title may not be fully translated in test fixtures, but the page should render + // Fixture titles can be partly untranslated, but Japanese site context must render. await expect(page).toHaveTitle(/GitHub Docs/) await expect(page.locator('main')).toBeVisible() - - // Verify page loads correctly - the cross-product children don't prevent the page from working - // The detailed sidebar filtering is tested by the survey test which verifies no duplicate entries }) }) test.describe('copy as markdown button', () => { - // The article-body fetch backing this button is served for this fixture page - // (see src/fixtures/tests/api-article-body.ts), so the copy path succeeds. + // api-article-body.ts serves this fixture's article-body fetch, so the copy path succeeds. const articlePath = '/en/get-started/start-your-journey/api-article-body-test-page' test('shows a checkmark after a successful copy', async ({ page, context }) => { - // The click handler writes the article markdown to the clipboard. await context.grantPermissions(['clipboard-read', 'clipboard-write']) await page.goto(articlePath) await turnOffExperimentsInPage(page) - // `exact` matters: accessible-name matching is substring-based, so a bare - // 'Copy markdown' also matches the code-block copy buttons that articles - // with a ```markdown fence render ('Copy Markdown code to clipboard'). + // Accessible-name matching treats names as substrings, so exact avoids code-block copy buttons. const copyButton = page.getByRole('button', { name: 'Copy markdown', exact: true }) await expect(copyButton).toHaveCount(1) await expect(copyButton).toBeVisible() - // At rest the button is text-only — no icon at all. The checkmark below is - // purely the success state. + // At rest the button is text-only; the checkmark is only the success state. await expect(copyButton.locator('svg')).toHaveCount(0) await copyButton.click() - // After a successful copy, a checkmark appears... await expect(copyButton.locator('.octicon-check')).toBeVisible() - // ...and the article markdown lands on the clipboard. const clipboardText = await page.evaluate(() => navigator.clipboard.readText()) expect(clipboardText).toContain('About GitHub') - // The checkmark is temporary and clears again (2s timeout). + // The success checkmark clears after the 2s timeout. await expect(copyButton.locator('.octicon-check')).toHaveCount(0, { timeout: 5000 }) }) }) diff --git a/src/fixtures/tests/playwright-secret-scanning.spec.ts b/src/fixtures/tests/playwright-secret-scanning.spec.ts index a7a190190f32..60df4fa6096c 100644 --- a/src/fixtures/tests/playwright-secret-scanning.spec.ts +++ b/src/fixtures/tests/playwright-secret-scanning.spec.ts @@ -9,11 +9,9 @@ test.describe('Secret scanning DataTable accessibility', () => { const table = page.getByRole('table') await expect(table).toBeVisible() - // The table should be labelled by the Table.Title heading const labelledBy = await table.getAttribute('aria-labelledby') expect(labelledBy).toBeTruthy() - // The referenced element should exist and contain text const titleEl = page.locator(`#${labelledBy}`) await expect(titleEl).toBeVisible() await expect(titleEl).not.toBeEmpty() @@ -22,7 +20,7 @@ test.describe('Secret scanning DataTable accessibility', () => { test('heading hierarchy does not skip levels within main content', async ({ page }) => { await page.goto(PAGE_PATH) - // Scope to main content area — nav/sidebar/footer may have their own heading structure + // Scope to main content because nav, sidebar, and footer have their own heading structure. const main = page.locator('main, article, [role="main"]').first() const headings = await main.locator('h1, h2, h3, h4, h5, h6').all() expect(headings.length).toBeGreaterThan(0) @@ -31,8 +29,7 @@ test.describe('Secret scanning DataTable accessibility', () => { for (const heading of headings) { const tagName = await heading.evaluate((el) => el.tagName.toLowerCase()) const level = parseInt(tagName.replace('h', ''), 10) - // Level can go up (same or smaller number) freely, but going deeper - // should never skip more than one level + // Heading levels may go up freely, but going deeper must not skip a level. if (level > previousLevel) { expect(level - previousLevel).toBeLessThanOrEqual(1) } @@ -43,7 +40,7 @@ test.describe('Secret scanning DataTable accessibility', () => { test('all interactive controls have accessible names', async ({ page }) => { await page.goto(PAGE_PATH) - // Search input — Primer TextInput renders as input[type="text"] with role "textbox" + // Primer TextInput renders the search input as input[type="text"] with role=textbox. const searchInput = page.locator('[role="search"] input') await expect(searchInput).toBeVisible() const searchLabel = @@ -51,7 +48,6 @@ test.describe('Secret scanning DataTable accessibility', () => { (await searchInput.getAttribute('placeholder')) expect(searchLabel).toBeTruthy() - // Filter buttons (ActionMenu triggers) const buttons = page.locator('[role="search"] button') const buttonCount = await buttons.count() expect(buttonCount).toBeGreaterThan(0) @@ -61,7 +57,6 @@ test.describe('Secret scanning DataTable accessibility', () => { expect(name.length).toBeGreaterThan(0) } - // Pagination (if present) const pagination = page.getByRole('navigation', { name: /pagination/i }) if ((await pagination.count()) > 0) { await expect(pagination).toHaveAttribute('aria-label', /.+/) @@ -71,8 +66,7 @@ test.describe('Secret scanning DataTable accessibility', () => { test('provider column cells are row headers', async ({ page }) => { await page.goto(PAGE_PATH) - // Primer DataTable uses CSS grid layout — row headers are rendered as - // elements with role="rowheader" (via scope="row" on the cell) + // Primer DataTable uses CSS grid, and scope=row cells render as role=rowheader. const rowHeaders = page.locator('[role="rowheader"]') const count = await rowHeaders.count() expect(count).toBeGreaterThan(0) @@ -87,16 +81,13 @@ test.describe('Secret scanning DataTable accessibility', () => { const table = page.getByRole('table') await expect(table).toBeVisible() - // At narrow viewports, the table should not be hidden or clipped. - // Content must remain reachable even if it overflows horizontally. - // Verify the table itself is not display:none or visibility:hidden + // At narrow viewports, the table must stay visible even when it overflows horizontally. await expect(table).toBeVisible() - // Verify data cells are present and accessible const cells = page.locator('[role="rowheader"], [role="cell"]') expect(await cells.count()).toBeGreaterThan(0) - // The table's container should allow horizontal scrolling (overflow not hidden) + // The overflow wrapper must allow horizontal scrolling. const overflowX = await table.evaluate((el) => { const wrapper = el.closest('[class*="OverflowWrapper"]') || el.parentElement return wrapper ? getComputedStyle(wrapper).overflowX : 'visible' @@ -106,8 +97,7 @@ test.describe('Secret scanning DataTable accessibility', () => { }) test('color contrast meets 4.5:1 minimum', async ({ page }) => { - // This is primarily covered by the axe scan in playwright-a11y.spec.ts, - // but we include a targeted check here for the table specifically + // Axe covers this broadly; this test isolates table color contrast. const { default: AxeBuilder } = await import('@axe-core/playwright') await page.goto(PAGE_PATH) diff --git a/src/fixtures/tests/sidebar.ts b/src/fixtures/tests/sidebar.ts index 8607f0ed57ee..5d5eadccc5b5 100644 --- a/src/fixtures/tests/sidebar.ts +++ b/src/fixtures/tests/sidebar.ts @@ -6,11 +6,10 @@ import { getDOMCached as getDOM } from '@/tests/helpers/e2etest' describe('sidebar', () => { test('top level product mentioned at top of sidebar', async () => { const $: CheerioAPI = await getDOM('/get-started') - // Desktop const sidebarProduct = $('[data-testid="sidebar-product-xl"]') expect(sidebarProduct.text()).toBe('Get started') expect(sidebarProduct.attr('href')).toBe('/en/get-started') - // Docs 2026 secondary bar (breadcrumbs + nav toggle) replaces the old subnav + // Docs 2026 uses the secondary bar for breadcrumbs and the nav toggle. expect($('[data-testid="docs-secondary-bar"]').length).toBe(1) expect($('[data-testid="sidebar-mobile-toggle"]').length).toBe(1) }) @@ -31,8 +30,7 @@ describe('sidebar', () => { test('sidebar should always use the shortTitle', async () => { const $: CheerioAPI = await getDOM('/get-started/foo/bar') - // The page /get-started/foo/bar has a short title that is different - // from its regular title. + // /get-started/foo/bar has a short title that differs from its regular title. expect( $( '[data-testid=sidebar] [data-testid=product-sidebar] a[href*="/get-started/foo/bar"] span span', @@ -49,19 +47,16 @@ describe('sidebar', () => { }) test('Liquid is rendered in short title used at top of sidebar', async () => { - // Free, pro, team { const $: CheerioAPI = await getDOM('/pages') const link = $('#allproducts-menu a') expect(link.text()).toBe('Pages (HubGit)') } - // Enterprise Server { const $: CheerioAPI = await getDOM('/enterprise-server@latest/pages') const link = $('#allproducts-menu a') expect(link.text()).toBe('Pages (HubGit Enterprise Server)') } - // Enterprise Cloud { const $: CheerioAPI = await getDOM('/enterprise-cloud@latest/pages') const link = $('#allproducts-menu a') @@ -71,45 +66,35 @@ describe('sidebar', () => { test('no docset link for early-access', async () => { const $: CheerioAPI = await getDOM('/early-access/secrets/deeper/mariana-trench') - // Deskop expect($('[data-testid="sidebar-product-xl"]').length).toBe(0) - // The secondary bar renders, but early-access has no nav toggle + // Early access renders the secondary bar without a nav toggle. expect($('[data-testid="docs-secondary-bar"]').length).toBe(1) expect($('[data-testid="sidebar-mobile-toggle"]').length).toBe(0) }) test('category-landing pages show title entry in sidebar', async () => { const $ = await getDOM('/get-started') - // Check that page loads and has proper sidebar structure - // This tests the core functionality using a guaranteed stable page const sidebarLinks = $('[data-testid="sidebar"] a') expect(sidebarLinks.length).toBeGreaterThan(0) - // Verify sidebar has proper structure indicating layout changes are in place const sidebar = $('[data-testid="sidebar"]') expect(sidebar.length).toBe(1) }) test('non-category-landing pages do not show specific copilot entries', async () => { - // Test a page from a different product that should have different sidebar content const $ = await getDOM('/rest') const sidebarLinks = $('[data-testid="sidebar"] a') expect(sidebarLinks.length).toBeGreaterThan(0) - // Verify this page has REST-specific sidebar structure expect($('[data-testid=rest-sidebar-reference]').length).toBe(1) }) test('layout property implementation exists in codebase', async () => { - // This test verifies the layout property changes are in place - // by testing a stable page and checking sidebar structure const $ = await getDOM('/pages') - // Verify basic sidebar functionality works const sidebar = $('[data-testid="sidebar"]') expect(sidebar.length).toBe(1) - // Check that sidebar has proper structure for testing the layout changes const sidebarLinks = $('[data-testid="sidebar"] a') expect(sidebarLinks.length).toBeGreaterThan(0) }) diff --git a/src/fixtures/tests/spotlight-processing.ts b/src/fixtures/tests/spotlight-processing.ts index 1716a402eb84..32b0b160088b 100644 --- a/src/fixtures/tests/spotlight-processing.ts +++ b/src/fixtures/tests/spotlight-processing.ts @@ -19,7 +19,6 @@ interface ProcessedSpotlightItem { image: string } -// Mock data to simulate tocItems and spotlight configurations const mockTocItems: TocItem[] = [ { title: 'Test Debug Article', @@ -38,7 +37,6 @@ const mockTocItems: TocItem[] = [ }, ] -// Helper function to simulate the spotlight processing logic from CategoryLanding function processSpotlight( spotlight: SpotlightItem[] | undefined, tocItems: TocItem[], diff --git a/src/fixtures/tests/translations.ts b/src/fixtures/tests/translations.ts index a35fa3e346c5..2f7a8316c3d0 100644 --- a/src/fixtures/tests/translations.ts +++ b/src/fixtures/tests/translations.ts @@ -15,7 +15,7 @@ describe('translations', () => { test('home page', async () => { const $: CheerioAPI = await getDOM('/ja') const h1 = $('h1').text() - // You gotta know your src/fixtures/fixtures/translations/ja-jp/data/ui.yml + // src/fixtures/fixtures/translations/ja-jp/data/ui.yml localizes the home-page h1. expect(h1).toBe('日本 GitHub Docs') const links = $('[data-testid=product] a[href]') @@ -61,12 +61,11 @@ describe('translations', () => { expect($(element).text()).toBe('こんにちは World') } }) - // There are 4 links on the `autotitling.md` content. + // autotitling.md has 4 AUTOTITLE links. expect.assertions(4) }) test('correction of linebreaks in translations', async () => { - // free-pro-team { const $: CheerioAPI = await getDOM('/ja/get-started/foo/table-with-ifversions') @@ -79,7 +78,6 @@ describe('translations', () => { expect(tds.length).toBe(2) expect(tds[1]).toBe('Not') } - // enterprise-server { const $: CheerioAPI = await getDOM( '/ja/enterprise-server@latest/get-started/foo/table-with-ifversions', @@ -96,37 +94,21 @@ describe('translations', () => { } }) + // Japanese translation fixtures include malformed AUTOTITLE links in content and reusables. + // Input: ["AUTOTITLE](/get-started/start-your-journey/hello-world)." + // Bad output: "AUTOTITLE + // Runtime correction must remove AUTOTITLE because translation CI does not catch this Markdown. test('automatic correction of bad AUTOTITLE in reusables', async () => { const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/hello-world') const links = $('#article-contents a[href]') const texts = links.map((i: number, element: Element) => $(element).text()).get() - // That Japanese page uses AUTOTITLE links. Both in the main `.md` file - // but also inside a reusable. - // E.g. `["AUTOTITLE](/get-started/start-your-journey/hello-world)."` - // If we didn't do the necessary string corrections on translations' - // content and reusables what *would* remain is a HTML link that - // would look like this: - // - // "AUTOTITLE - // - // This test makes sure no such string is left in any of the article - // content links. - // Note that, in English, it's not acceptable to have such a piece of - // Markdown. It would not be let into `main` by our CI checks. But - // by their nature, translations are not checked by CI in the same way. - // Its "flaws" have to be corrected at runtime. const stillAutotitle = texts.filter((text: string) => /autotitle/i.test(text)) expect(stillAutotitle.length).toBe(0) }) + // Translators wrote [[Bar](バー)](/get-started/foo/bar), which must render as + // [Bar](バー). test('markdown link looking constructs inside links', async () => { - // On this page, the translators had written: - // - // [[Bar](バー)](/get-started/foo/bar) - // - // which needs to become: - // - // [Bar](バー) const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/hello-world') const links = $('#article-contents a[href]') const texts = links @@ -136,7 +118,6 @@ describe('translations', () => { }) .map((i: number, element: Element) => $(element).text()) .get() - // Check that the text contains the essential parts rather than exact spacing const foundBarLink = texts.find( (text: string) => text.includes('[Bar]') && text.includes('(バー)'), ) @@ -146,18 +127,16 @@ describe('translations', () => { describe('localized category versioning', () => { test('category page works in all children versions', async () => { { - // for translated content, we expect this to be OK const res = await head('/ja/get-started') expect(res.statusCode).toBe(200) } { - // The actual versioning for get-started/empty-categories - // does not specify ghes, so it should 404. + // The category allows ghes, but its only child is ghec-only, so enterprise-server 404s. const res = await head('/ja/enterprise-server@latest/get-started/empty-categories') expect(res.statusCode).toBe(404) } { - // Yet this nested page shoudl work. + // The ghec-only child renders under enterprise-cloud. const res = await head('/ja/enterprise-cloud@latest/get-started/empty-categories/only-ghec') expect(res.statusCode).toBe(200) } diff --git a/src/fixtures/tests/versioning.ts b/src/fixtures/tests/versioning.ts index 5fe70db14127..33f7549f4d83 100644 --- a/src/fixtures/tests/versioning.ts +++ b/src/fixtures/tests/versioning.ts @@ -8,7 +8,7 @@ describe('article versioning', () => { test('only links to articles for fpt', async () => { const $: CheerioAPI = await getDOM('/get-started/versioning') const links = $('[data-testid="table-of-contents"] a') - // Only 1 link because there's only 1 article available in fpt + // /get-started/versioning has one free-pro-team article. expect(links.length).toBe(1) expect(links.attr('href')).toBe('/en/get-started/versioning/only-fpt') }) @@ -23,7 +23,7 @@ describe('article versioning', () => { expect(second.attr('href')).toBe( '/en/enterprise-cloud@latest/get-started/versioning/only-ghec-and-ghes', ) - // Both links should 200 if you go to them + // Both linked enterprise-cloud articles must resolve without redirects. expect((await head(first.attr('href')!)).statusCode).toBe(200) expect((await head(second.attr('href')!)).statusCode).toBe(200) }) @@ -38,7 +38,7 @@ describe('article versioning', () => { expect(res.statusCode).toBe(404) }) test('going to non-fpt article with fpt prefix will redirect', async () => { - // Viewing a ghec only article without ghec prefix + // Without the ghec prefix, a ghec-only article redirects to enterprise-cloud. const res = await head('/get-started/versioning/only-ghec', { followRedirects: false, }) @@ -52,15 +52,12 @@ describe('article versioning', () => { describe('category versioning', () => { test('category page work in all children versions', async () => { { - // Note that in the `versions:` of get-started/versioning/index.md - // it *lacks* fpt. It's a deliberate pretend omission/mistake. - // But clearly the page works. + // get-started/versioning/index.md deliberately omits fpt, but the category resolves. const res = await head('/en/get-started/versioning') expect(res.statusCode).toBe(200) } { - // The actual version number of get-started/versioning/index.md - // does not specify this version of ghes, it still works. + // get-started/versioning/index.md omits latest ghes, but it redirects to a number. const res = await head('/en/enterprise-server@latest/get-started/versioning') expect(res.statusCode).toBe(302) expect(res.headers.location).toMatch( @@ -68,8 +65,7 @@ describe('category versioning', () => { ) } { - // The actual version number of get-started/versioning/index.md - // does not specify this version of ghec, it still works. + // get-started/versioning/index.md omits latest ghec, but enterprise-cloud resolves. const res = await head('/en/enterprise-cloud@latest/get-started/versioning') expect(res.statusCode).toBe(200) } @@ -78,8 +74,7 @@ describe('category versioning', () => { describe('home page versioning', () => { test('invalid language and valid version', async () => { - // Don't use 'latest' here because that will trigger a redirect - // first to the latest actual number. + // Use a numbered release so the invalid language returns 404 before any version redirect. const res = await head(`/ennnnn/enterprise-server@${supported[0]}`) expect(res.statusCode).toBe(404) }) diff --git a/src/frame/middleware/README.md b/src/frame/middleware/README.md index e3f8c2b279f3..63e5e757e193 100644 --- a/src/frame/middleware/README.md +++ b/src/frame/middleware/README.md @@ -3,3 +3,15 @@ Each file in this directory exports an Express Middleware function. For more info, see https://expressjs.com/en/guide/using-middleware.html + +## Mock Virtual Assistant portal + +`mock-va-portal.ts` lets you test the Virtual Assistant integration without access to a staging portal. The production portal rejects `localhost:4000` because it is hardened to `https://docs.github.com`. + +To test locally: + +1. Add `SUPPORT_PORTAL_URL=http://localhost:4000` to your `.env` file. +2. Run `npm run dev`. +3. Navigate to a page listed in the `PagePathToVaFlowMapping` object in `ArticleContext`. + +This mock is not secure. Use it only for local development. diff --git a/src/frame/middleware/abort.ts b/src/frame/middleware/abort.ts index e9f9496578ed..0e98af463e1a 100644 --- a/src/frame/middleware/abort.ts +++ b/src/frame/middleware/abort.ts @@ -14,18 +14,14 @@ class AbortError extends Error { } export default function abort(req: ExtendedRequest, res: Response, next: NextFunction) { - // If the client aborts the connection, send an error req.once('aborted', () => { - // ignore aborts from next, usually has to do with webpack-hmr + // Ignore _next aborts, which usually come from webpack HMR. if (req.path.startsWith('/_next')) { return } - // NOTE: Node.js will also automatically set `req.aborted = true` const incrementTags = [] - // Be careful with depending on attributes set on the `req` because - // under certain conditions the contextualizers might not yet have - // had a chance to run. + // Request contextualizers might not run before an abort, so guard optional request fields. if (req.pagePath) { incrementTags.push(`path:${req.pagePath}`) } diff --git a/src/frame/middleware/api.ts b/src/frame/middleware/api.ts index a7c3f1acb7ac..4eef5c90a193 100644 --- a/src/frame/middleware/api.ts +++ b/src/frame/middleware/api.ts @@ -23,12 +23,8 @@ router.use('/anchor-redirect', anchorRedirect) router.use('/pagelist', pageList) router.use('/article', article) -// The purpose of this is for convenience to everyone who runs this code -// base locally but don't have an Elasticsearch server locally. -// In production, this env var is always set but perhaps in a writer's -// local laptop, they don't have an Elasticsearch. Neither a running local -// server or the known credentials to a remote Elasticsearch. Whenever -// that's the case, they can just HTTP proxy to the production server. +// Local development proxies AI Search to docs.github.com when CSE_COPILOT_ENDPOINT +// is unset, so writers do not need a local AI Search service. if (process.env.CSE_COPILOT_ENDPOINT || process.env.NODE_ENV === 'test') { router.use('/ai-search', aiSearch) } else { @@ -52,9 +48,8 @@ if (process.env.ELASTICSEARCH_URL) { ) } -// We need access to specific httpOnly cookies set on github.com from the client -// The only way to access these on the client is to fetch them from the server -// Limit this endpoint to 1req/min because a client should only call this route once +// Browser JavaScript cannot read github.com httpOnly cookies. +// The server endpoint returns the staff flag that client code needs. router.get('/cookies', (req, res) => { noCacheControl(res) const cookies = { diff --git a/src/frame/middleware/block-robots.ts b/src/frame/middleware/block-robots.ts index 298575ec9f0c..c49e8f602f98 100644 --- a/src/frame/middleware/block-robots.ts +++ b/src/frame/middleware/block-robots.ts @@ -4,7 +4,7 @@ import { productMap } from '@/products/lib/all-products' import { deprecated } from '@/versions/lib/enterprise-server-releases' const pathRegExps: RegExp[] = [ - // Disallow indexing of WIP products + // WIP and hidden products stay out of search indexes. ...Object.values(productMap) .filter((product) => product.wip || product.hidden) .map((product) => [ @@ -12,7 +12,7 @@ const pathRegExps: RegExp[] = [ ...product.versions!.map((version) => new RegExp(`^/.*?${version}/${product.id}`, 'i')), ]), - // Disallow indexing of deprecated enterprise versions + // Deprecated enterprise versions stay out of search indexes. ...deprecated.map((version) => [ new RegExp(`^/.*?/enterprise-server@${version}/.*?`, 'i'), new RegExp(`^/.*?/enterprise/${version}/.*?`, 'i'), diff --git a/src/frame/middleware/cache-control.ts b/src/frame/middleware/cache-control.ts index b53e5ca98364..c529f64f7918 100644 --- a/src/frame/middleware/cache-control.ts +++ b/src/frame/middleware/cache-control.ts @@ -19,9 +19,8 @@ const ONE_DAY = 24 * ONE_HOUR const ONE_WEEK = 7 * ONE_DAY const ONE_YEAR = 365 * ONE_DAY -// Return a function you can pass a Response object to and it will set the `Cache-Control` header. -// Max age is in seconds. -// Max age should not be greater than 31536000, per . +// maxAge is seconds. Keep it at or below 31536000. +// https://www.ietf.org/rfc/rfc2616.txt function cacheControlFactory( maxAge: number = 0, { @@ -53,16 +52,14 @@ function cacheControlFactory( } } -// The rest of this file is roughly in order from shortest max age to longest. - -// If you do not want caching. export const noCacheControl = cacheControlFactory(0) -// Short cache for 4xx errors. +// 4xx errors get a short cache. export const errorCacheControl = cacheControlFactory(ONE_MINUTE) -// For default cache control, up to one week in cache but much shorter in browser. -// Most responses are under the default cache control policy. +// Default responses cache for 1 minute in browsers and 10 minutes in the CDN. +// The CDN can serve stale responses for 1 week while revalidating or on errors. +// Most responses use this policy. const browserCacheControl = cacheControlFactory(ONE_MINUTE) const defaultCDNCacheControl = cacheControlFactory(TEN_MINUTES, { key: 'surrogate-control', @@ -75,30 +72,29 @@ export function defaultCacheControl(res: Response): void { } export const searchCacheControl = defaultCacheControl -// For requests where the response can vary between a HTML and Markdown response -// using the accept header. +// The Accept header can switch content responses between HTML and Markdown. export function contentTypeCacheControl(res: Response): void { defaultCacheControl(res) res.append('vary', 'accept') } -// Vary on language when needed. -// `x-user-language` is a custom request header derived from `req.cookie:user_language`. -// `accept-language` is truncated to one of our available languages. +// Vary by accept-language and x-user-language. +// x-user-language comes from req.cookie:user_language. +// Upstream code truncates accept-language to available languages. // https://bit.ly/3u5UeRN export function languageCacheControl(res: Response): void { defaultCacheControl(res) res.append('vary', 'accept-language, x-user-language') } -// Vary on both language and version for homepage redirects. -// `x-user-version` is a custom request header derived from `req.cookie:user_version`. +// Homepage redirects also vary by x-user-version. +// x-user-version comes from req.cookie:user_version. export function languageAndVersionCacheControl(res: Response): void { defaultCacheControl(res) res.append('vary', 'accept-language, x-user-language, x-user-version') } -// Long cache control for versioned assets: such as images, CSS, prebuilt JS. +// Versioned images, CSS, and prebuilt JS use long browser and CDN caches. const assetBrowserCacheControl = cacheControlFactory(TEN_MINUTES) const assetCDNCacheControl = cacheControlFactory(ONE_WEEK, { key: 'surrogate-control', @@ -111,7 +107,7 @@ export function assetCacheControl(res: Response): void { assetCDNCacheControl(res) } -// Long caching for archived pages and assets. +// Archived pages and assets use long browser and CDN caches. const archivedBrowserCacheControl = cacheControlFactory(TEN_MINUTES) const archivedCDNCacheControl = cacheControlFactory(ONE_YEAR, { key: 'surrogate-control', diff --git a/src/frame/middleware/categories-for-support.ts b/src/frame/middleware/categories-for-support.ts index ad8bae698a57..bf1bac8f945f 100644 --- a/src/frame/middleware/categories-for-support.ts +++ b/src/frame/middleware/categories-for-support.ts @@ -14,8 +14,7 @@ type Category = { published_articles: Article[] } -// This middleware exposes a list of all categories and child articles at /categories.json. -// GitHub Support uses this for internal ZenDesk search functionality. +// /categories.json gives GitHub Support categories and child articles for Zendesk search. export default async function categoriesForSupport(req: ExtendedRequest, res: Response) { const englishSiteTree = req.context!.siteTree!.en const allCategories: Category[] = [] @@ -26,9 +25,7 @@ export default async function categoriesForSupport(req: ExtendedRequest, res: Re if (!productPage.childPages || !productPage.childPages.length) continue await Promise.all( productPage.childPages.map(async (categoryPage) => { - // We can't get the rendered titles from middleware/render-tree-titles - // here because that middleware only runs on the current version, and this - // middleware processes all versions. + // Site-tree titles are raw, so render any that contain Liquid. if (!req.context) return const name = categoryPage.page.title.includes('{') ? await categoryPage.page.renderProp('title', req.context, renderOpts) @@ -42,8 +39,7 @@ export default async function categoriesForSupport(req: ExtendedRequest, res: Re ) } - // Cache somewhat aggressively but note that it will be soft-purged - // in every prod deployment. + // Use the default browser and CDN cache policy. defaultCacheControl(res) return res.json(allCategories) @@ -67,7 +63,6 @@ async function findArticlesPerCategory( if (!currentPage.childPages) return articlesArray - // Run recursively to find any articles deeper in the tree. await Promise.all( currentPage.childPages.map(async (childPage) => { await findArticlesPerCategory(childPage, articlesArray, context) diff --git a/src/frame/middleware/context/breadcrumbs.ts b/src/frame/middleware/context/breadcrumbs.ts index 9f5beba950dd..b6cbca12c224 100644 --- a/src/frame/middleware/context/breadcrumbs.ts +++ b/src/frame/middleware/context/breadcrumbs.ts @@ -10,7 +10,6 @@ export default function breadcrumbs(req: ExtendedRequest, res: Response, next: N req.context.breadcrumbs = [] - // Return an empty array on the landing page. if (req.context.page.documentType === 'homepage') { return next() } @@ -22,21 +21,15 @@ export default function breadcrumbs(req: ExtendedRequest, res: Response, next: N const earlyAccessExceptions = ['insights', 'enterprise-importer'] +// For Early Access pages, getBreadcrumbs omits /early-access and the product segment. +// For example, /en/early-access/github/migrating starts at /migrating. function getBreadcrumbs(req: ExtendedRequest, isEarlyAccess: boolean) { if (!req.context || !req.context.currentPath || !req.context.currentProductTreeTitles) throw new Error('request is not contextualized') let cutoff = 0 - // When in Early access docs consider the "root" be much higher. - // E.g. /en/early-access/github/migrating/understanding/about - // we only want it start at /migrating/understanding/about - // Essentially, we're skipping "/early-access" and its first - // top-level like "/github" if (isEarlyAccess) { const split = req.context.currentPath!.split('/') - // There are a few exceptions to this rule for the - // /{version}/early-access//... URLs because they're a - // bit different. - // If there are more known exceptions, add them to the array above. + // insights and enterprise-importer Early Access URLs keep their product segment. if (earlyAccessExceptions.some((product) => split.includes(product))) { cutoff = 1 } else { @@ -55,22 +48,7 @@ function getBreadcrumbs(req: ExtendedRequest, isEarlyAccess: boolean) { return breadcrumbsResult } -// Return an array as if you'd traverse down a tree. Imagine a tree like -// -// (root /) -// / \ -// (/foo) (/bar) -// / \ -// (/foo/bar) (/foo/buzz) -// -// If the "currentPath" is `/foo/buzz` what you want to return is: -// -// [ -// {href: /, title: TITLE}, -// {href: /foo, title: TITLE} -// {href: /foo/buzz, title: TITLE} -// ] -// +// Example: /en/actions/learn-github-actions returns each matching ancestor and that page. function traverseTreeTitles(currentPath: string | string[], tree: TitlesTree) { const { href, title, shortTitle } = tree const crumbs = [ @@ -85,17 +63,13 @@ function traverseTreeTitles(currentPath: string | string[], tree: TitlesTree) { for (const child of tree.childPages) { if (isParentOrEqualArray(child.href.split('/'), currentPathSplit)) { crumbs.push(...traverseTreeTitles(currentPathSplit, child)) - // Only ever going down 1 of the children break } } return crumbs } -// Return true if an array is part of another array or equal. -// Like `/foo/bar` is part of `/foo/bar/buzz`. -// But also include `/foo/bar/buzz`. -// Don't include `/foo/ba` if the final path is `/foo/baring`. +// Compare split paths so /foo/ba does not match /foo/baring. function isParentOrEqualArray(base: string[], final: string[]) { return base.every((part, i) => part === final[i]) } diff --git a/src/frame/middleware/context/context.ts b/src/frame/middleware/context/context.ts index 7d0e0390b1da..b3dd70c75c50 100644 --- a/src/frame/middleware/context/context.ts +++ b/src/frame/middleware/context/context.ts @@ -19,19 +19,20 @@ import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-v import { getDataByLanguage, getUIDataMerged } from '@/data-directory/lib/get-data' import { updateLoggerContext } from '@/observability/logger/lib/logger-context' -// This doesn't change just because the request changes, so compute it once. +// Enterprise Server version keys do not depend on each request, so compute them once. const enterpriseServerVersions = Object.keys(allVersions).filter((version) => version.startsWith('enterprise-server@'), ) -// Supply all route handlers with a baseline `req.context` object -// Note that additional middleware in middleware/index.ts adds to this context object +// middleware/index.ts depends on contextualize setting baseline req.context. +// For non-English pages, contextualize adds getEnglishPage for renderContentWithFallback. +// It handles fallback-eligible Liquid, autotitle, and empty-title errors. export default async function contextualize( req: ExtendedRequest, res: Response, next: NextFunction, ) { - // Ensure that we load some data only once on first request + // warmServer caches this data after the first request. const { redirects, siteTree, pages: pageMap } = await warmServer([]) const context: Context = {} @@ -40,17 +41,14 @@ export default async function contextualize( req.context.process = { env: {} } if (req.pagePath && req.pagePath.endsWith('.md')) { - // req.pagePath is used later in the rendering pipeline to - // locate the file in the tree so it cannot have .md + // The rendering pipeline resolves req.pagePath in the tree without the .md suffix. req.pagePath = req.pagePath.replace(/\/index\.md$/, '').replace(/\.md$/, '') req.context.markdownRequested = true - // Track that markdown was requested via URL suffix, not Accept header. - // This avoids adding a misleading Vary: accept cache header. + // markdownViaUrl avoids a misleading Vary: accept header for URL suffix requests. req.context.markdownViaUrl = true } - // define each context property explicitly for code-search friendliness - // e.g. searches for "req.context.page" will include results from this file + // Explicit req.context property assignments keep code search results discoverable. req.context.currentLanguage = req.language req.context.userLanguage = req.userLanguage req.context.currentVersion = getVersionStringFromPath(req.pagePath) as string @@ -62,8 +60,7 @@ export default async function contextualize( req.context.allVersions = allVersions req.context.currentPathWithoutLanguage = getPathWithoutLanguage(req.pagePath) - // define property for writers to link to the current page in a different version - // includes any type of rendered page not just "articles" + // currentArticle lets writers link any rendered page, not only articles, in another version. req.context.currentArticle = getPathWithoutVersion(req.context.currentPathWithoutLanguage) req.context.currentPath = req.pagePath req.context.query = req.query @@ -83,20 +80,15 @@ export default async function contextualize( req.context.nonEnterpriseDefaultVersion = nonEnterpriseDefaultVersion req.context.initialRestVersioningReleaseDate = allVersions[nonEnterpriseDefaultVersion].apiVersions[0] - // The default REST API version that requests use when no X-GitHub-Api-Version header is specified - // This is the oldest supported version (last in the sorted descending array) + // apiVersions sorts newest first, so the last item is the default without X-GitHub-Api-Version. const apiVersions = allVersions[nonEnterpriseDefaultVersion].apiVersions req.context.defaultRestApiVersion = apiVersions[apiVersions.length - 1] const restDate = new Date(req.context.initialRestVersioningReleaseDate) req.context.initialRestVersioningReleaseDateLong = restDate.toUTCString().split(' 00:')[0] - // Non-English pages need this so that `Page.render`, when it calls - // `renderContentWithFallback`, can fall back to the English content when the - // translation hits a fallback-eligible error (Liquid, autotitle, empty title). if (req.language !== 'en') { - // This is a function so the lookup only happens when a translated page - // actually needs to fall back. Most requests never need it. + // getEnglishPage delays the lookup until a translated page needs fallback content. req.context.getEnglishPage = (ctx) => { if (!ctx.enPage) { const { page } = ctx diff --git a/src/frame/middleware/context/current-product-tree.ts b/src/frame/middleware/context/current-product-tree.ts index 1a34c3015c83..c99b741c3b23 100644 --- a/src/frame/middleware/context/current-product-tree.ts +++ b/src/frame/middleware/context/current-product-tree.ts @@ -8,7 +8,6 @@ import findPageInSiteTree from '@/frame/lib/find-page-in-site-tree' import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path' import { executeWithFallback } from '@/languages/lib/render-with-fallback' -// This module adds currentProductTree to the context object for use in layouts. export default async function currentProductTree( req: ExtendedRequest, res: Response, @@ -18,7 +17,7 @@ export default async function currentProductTree( if (!req.context.page) return next() if (req.context.page.documentType === 'homepage') return next() - // We need this so we can fall back to English if localized pages are out of sync. + // Keep the English tree available because localized pages can lag behind it. if (!req.context.siteTree) throw new Error('siteTree is required') if (!req.context.currentVersion) throw new Error('currentVersion is required') req.context.currentEnglishTree = req.context.siteTree.en[req.context.currentVersion] @@ -41,22 +40,17 @@ export default async function currentProductTree( currentProductPath, ) - // First make a slim tree of just the 'href', 'title', 'shortTitle' - // 'documentType' and 'childPages' (which is recursive). - // This gets used for subcategory and category pages. + // currentProductTreeTitles keeps href, title, shortTitle, documentType, and childPages. req.context.currentProductTreeTitles = await getCurrentProductTreeTitles( req.context.currentProductTree, req.context, ) - // Now make an even slimmer version that excludes all hidden pages. - // This is used for sidebars. + // Sidebar data excludes hidden pages. req.context.currentProductTreeTitlesExcludeHidden = excludeHidden( req.context.currentProductTreeTitles, ) - // Some pages, like hidden pages, don't have a tree. For example, - // the search page. That one uses the same items as the homepage - // for its sidebar. + // Hidden pages leave sidebarTree unset because excludeHidden returns null for the root. if (req.context.currentProductTreeTitlesExcludeHidden) { req.context.sidebarTree = sidebarTree(req.context.currentProductTreeTitlesExcludeHidden) } @@ -64,35 +58,18 @@ export default async function currentProductTree( return next() } -// Return a nested object that contains the bits and pieces we need -// for the tree which is used for sidebars and listing async function getCurrentProductTreeTitles(input: Tree, context: Context): Promise { const { page, href } = input const childPages = await Promise.all( (input.childPages || []).map((child) => getCurrentProductTreeTitles(child, context)), ) - // If the current page is a translation we're going to need the English - // equivalent for multiple things later in this function. + // Translated pages need their English page for fallback rendering and short-title comparison. const enPage = page.languageCode !== 'en' ? context.pages![href.replace(`/${page.languageCode}`, '/en')] : null - let rawShortTitle = page.rawShortTitle // might change our minds about this - // A lot of translations have a short title that is identical to the - // English equivalent. E.g. - // - // content/foo.md: - // - // title: Something Something Bla - // shortTitle: Something - // - // translations/docs-internal.se-sv/content/foo.md: - // - // title: Nånting Nånting Blä - // shortTitle: Something - // - // I.e. the translations `shortTitle` hasn't been translated. - // If this is the case, use the long title instead. + let rawShortTitle = page.rawShortTitle + // Swaps in rawTitle when shortTitle matches English, but the render below reads page.rawShortTitle. if (page.languageCode !== 'en' && page.rawShortTitle) { if (page.rawShortTitle === enPage!.shortTitle) { rawShortTitle = page.rawTitle @@ -112,8 +89,7 @@ async function getCurrentProductTreeTitles(input: Tree, context: Context): Promi ) } - // If the short title was present but "useless" (same as the title), - // force it to be an empty string to not waste space. + // Empty duplicate short titles to avoid wasting sidebar space. const shortTitle = renderedShortTitle && (renderedShortTitle || '') !== renderedFullTitle ? renderedShortTitle : '' @@ -148,12 +124,10 @@ function excludeHidden(tree: TitlesTree) { function sidebarTree(tree: TitlesTree) { const { href, title, shortTitle, childPages, sidebarLink } = tree - // Filter out cross-product children from the sidebar + // Sidebars show only children from the current product. const filteredChildPages = childPages.filter((child) => !child.crossProductChild) - // Filter out children that are descendants of another sibling. - // When a page lists both a subdirectory and individual articles from it, - // the articles should only appear nested under the subdirectory in the sidebar. + // If siblings include a subdirectory and its articles, nest the articles under the subdirectory. const siblingHrefs = filteredChildPages.map((c) => c.href) const dedupedChildPages = filteredChildPages.filter( (child) => !siblingHrefs.some((sh) => sh !== child.href && child.href.startsWith(`${sh}/`)), diff --git a/src/frame/middleware/context/generic-toc.ts b/src/frame/middleware/context/generic-toc.ts index 93a57bf99f91..67012fe6c363 100644 --- a/src/frame/middleware/context/generic-toc.ts +++ b/src/frame/middleware/context/generic-toc.ts @@ -12,9 +12,7 @@ function isNewLandingPage(currentLayoutName: string): boolean { ) } -// This module adds either flatTocItems or nestedTocItems to the context object for -// product, category, and subcategory TOCs that don't have other layouts specified. -// They are rendered by includes/generic-toc-flat.html or includes/generic-toc-nested.html. +// genericToc assigns genericTocFlat or genericTocNested for React landing contexts. export default async function genericToc(req: ExtendedRequest, res: Response, next: NextFunction) { if (!req.context) throw new Error('request not contextualized') if (!req.context.page) return next() @@ -23,7 +21,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne !isNewLandingPage(req.context.currentLayoutName || '') ) return next() - // This middleware can only run on product, category, and subcategories. + // TOC layouts skip homepages, articles, and search. if ( req.context.page.documentType === 'homepage' || req.context.page.documentType === 'article' || @@ -39,8 +37,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne subcategory: 'flat', } - // Frontmatter can optionally be set on an Early Access product to show hidden child items. - // If so, this is a special case where we want to override the flat tocType and use a nested type. + // earlyAccessToc frontmatter exposes hidden child items by switching products to nested TOCs. const earlyAccessToc = req.context.page.earlyAccessToc if (!req.context.currentProductTree) throw new Error('currentProductTree not in context') @@ -53,10 +50,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne req.pagePath, ) - // The intent is that a category whose children have no children of their own - // should render like a subcategory. It doesn't work: `child.children` is `[]` - // for leaf pages and `[]` is truthy, so `hasGrandchildren` is true for any - // category with children at all. This probably wants `child.children?.length`. + // fauxSubcategory is meant to flatten categories without grandchildren, but [] is truthy. let fauxSubcategory = false if (req.context.page.documentType === 'category' && req.context.page.autogenerated !== 'rest') { const hasGrandchildren = (treePage.childPages || []).some((child) => child.children) @@ -69,10 +63,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne ? 'flat' : tocTypes[req.context.page.documentType] - // By default, only include hidden child items on a TOC page if it's an Early Access category or - // subcategory page, not a product or 'articles' fake category page (e.g., /early-access/github/articles). - // This is because we don't want entire EA product TOCs to be publicly browseable, but anything at the category - // or below level is fair game because that content is scoped to specific features. + // By default, Early Access category and subcategory TOCs expose hidden children except /articles. const isCategoryOrSubcategory = req.context.page.documentType === 'category' || req.context.page.documentType === 'subcategory' if (!req.context.currentPath) throw new Error('currentPath not in context') @@ -85,7 +76,6 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne let isRecursive let renderIntros - // Get an array of child links with intros and add it to the context object. if (currentTocType === 'flat' && !isOneOffProductToc) { isRecursive = false renderIntros = true @@ -97,7 +87,6 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne }) } - // Get an array of child subcategories and their child articles and add it to the context object. if (currentTocType === 'nested' || isOneOffProductToc) { isRecursive = !isOneOffProductToc renderIntros = false @@ -119,8 +108,9 @@ type Options = { textOnly: boolean } +// rawIntro can contain Markdown without Liquid, so renderProp still needs to process it. +// Generic TOC components keep intro HTML unless a landing-page layout needs text only. async function getTocItems(node: Tree, context: Context, opts: Options): Promise { - // Cleaner than trying to be too terse inside the `.filter()` inline callback. function filterHidden(child: Tree): boolean { return opts.includeHidden || !child.page.hidden } @@ -138,11 +128,6 @@ async function getTocItems(node: Tree, context: Context, opts: Options): Promise if (opts.renderIntros) { intro = '' if (page.rawIntro) { - // The intro can contain Markdown even though it might not - // contain any Liquid. - // Use textOnly for new landing pages to strip HTML tags. - // For other pages, we intend to display the intro in a table of contents - // component with the HTML (dangerouslySetInnerHTML). intro = await page.renderProp( 'rawIntro', context, diff --git a/src/frame/middleware/context/glossaries.ts b/src/frame/middleware/context/glossaries.ts index 8d49f6a815e2..8b7c81cef50c 100644 --- a/src/frame/middleware/context/glossaries.ts +++ b/src/frame/middleware/context/glossaries.ts @@ -12,18 +12,11 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne if (!req.context) throw new Error('request is not contextualized') - // If the current version (which is found as part of the URL), does not - // correspond to a supported version, the Liquid rendering will fail - // (if there's uses of `ifversion` in any the Liquid). - // So we'll skip this contextualizer and let the 404 error take over later. + // Skip unsupported versions so ifversion Liquid errors do not replace the later 404. if (!req.context.currentVersionObj) return next() - // When the current language is *not* English, we'll need to get the English - // glossary based on the term. We'll use this to render the translated - // glossaries. For example, if the Korean translation has a corruption - // in its description we need to know the English equivalent. + // Translated glossaries need English descriptions to repair corrupted Liquid before rendering. const enGlossaryMap = new Map() - // But we don't need to bother if the current language is English. if (req.context.currentLanguage !== 'en') { const enGlossariesRaw: Glossary[] = getDataByLanguage('glossaries.external', 'en') as Glossary[] @@ -32,11 +25,7 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne } } - // The glossaries Yaml file contains descriptions that might contain - // Liquid. They need to be rendered out. - // The github-glossary.md file uses Liquid to generate the Markdown. - // It uses Liquid to say `{{ glossary.description }}` but once that's - // injected there it needs to have its own possible Liquid rendered out. + // github-glossary.md injects glossary descriptions before their Liquid renders. const glossariesRaw: Glossary[] = getDataByLanguage( 'glossaries.external', req.context.currentLanguage!, @@ -48,13 +37,7 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne if (req.context!.currentLanguage !== 'en') { description = correctTranslatedContentStrings( description, - // The function needs the English equivalent of the translated - // Markdown. It's to make possible corrections to the - // translation's Liquid which might have lost important - // linebreaks. - // But because the terms themselves are often translated, - // in this mapping we often don't have an English equivalent. - // So that's why we fall back on the empty string. + // English Markdown repairs Liquid line breaks; some translated terms lack matches. enGlossaryMap.get(glossary.term) || '', { code: req.context!.currentLanguage }, ) @@ -64,17 +47,13 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne () => liquid.parseAndRender(description, req.context), (enContext: Context) => { const { term } = glossary - // It *could* be that the translation is referring to a term - // that no longer exists in the English glossary. In that case, - // simply skip this term. + // Skip translated terms missing from the English glossary. if (!enGlossaryMap.has(term)) return const enDescription = enGlossaryMap.get(term) return liquid.parseAndRender(enDescription, enContext) }, ) - // It's important to use `Object.assign` here to avoid mutating the - // original object because from `getDataByLanguage`, reads from an - // in-memory cache so if we mutated it, it would be mutated for all. + // Object.assign preserves the getDataByLanguage cache object shared across requests. return Object.assign({}, glossary, { description }) }), ) diff --git a/src/frame/middleware/context/layout.ts b/src/frame/middleware/context/layout.ts index 447955cc8917..45f3a5342c84 100644 --- a/src/frame/middleware/context/layout.ts +++ b/src/frame/middleware/context/layout.ts @@ -9,7 +9,7 @@ export default function layoutContext(req: ExtendedRequest, res: Response, next: let layoutName = 'default' if (req.context.page.layout) { if (typeof req.context.page.layout === 'boolean') { - // A `layout: false` value means use no layout. + // Only layout: true reaches here and clears the layout name. layout: false gets the default. layoutName = '' } else if (typeof req.context.page.layout === 'string') { layoutName = req.context.page.layout diff --git a/src/frame/middleware/context/product-groups.ts b/src/frame/middleware/context/product-groups.ts index 6a9427277394..b875c711d5b4 100644 --- a/src/frame/middleware/context/product-groups.ts +++ b/src/frame/middleware/context/product-groups.ts @@ -8,18 +8,20 @@ import { allVersionKeys } from '@/versions/lib/all-versions' const isHomepage = (path: string) => { const split = path.split('/') - // E.g. `/foo` but not `foo/bar` or `foo/` + // Matches /en but not en/foo or /en/. if (split.length === 2 && split[1] && !split[0]) { return languageKeys.includes(split[1]) } - // E.g. `/foo/possiblyproductname` but not `foo/possiblyproductname` or - // `/foo/something/` + // Matches /en/free-pro-team@latest but not en/free-pro-team@latest or /en/actions/. if (split.length === 3 && !split[0] && split[2]) { return allVersionKeys.includes(split[2]) } return false } +// handleNextDataPath maps Next data URLs, such as /_next/data/development/en/actions.json, +// to normal page paths, so productGroups reads req.pagePath. +// It requires a valid currentVersionObj because ifversion Liquid in getProductGroups throws otherwise. export default async function productGroups( req: ExtendedRequest, res: Response, @@ -28,18 +30,6 @@ export default async function productGroups( if (!req.context) throw new Error('request is not contextualized') if (!req.pagePath) throw new Error('pagePath is not set on request') if (!req.language) throw new Error('language is not set on request') - // It's important to use `req.pagePath` instead of `req.path` because - // the request could be the client-side routing from Next where the URL - // might be something like `/_next/data/foo/bar.json` which is translated, - // in another middleware, to what it would equate to if it wasn't - // client-side routing. - // Before executing getProductGroups, which might need to do some - // Liquid parsing & executing, we want to make sure the request - // does have a valid version. - // The `currentVersion` is taken from the `req.path` but - // `currentVersionObj` is looking up `currentVersion` with all - // known versions. Because if it's not valid, any possible - // use of `{% ifversion ... %}` in Liquid, will throw an error. if (isHomepage(req.pagePath) && req.context.currentVersionObj) { const { pages } = await warmServer([]) req.context.productGroups = await getProductGroups(pages, req.language, req.context) diff --git a/src/frame/middleware/context/render-product-name.ts b/src/frame/middleware/context/render-product-name.ts index 151afab5cc68..2ca7b2f85c50 100644 --- a/src/frame/middleware/context/render-product-name.ts +++ b/src/frame/middleware/context/render-product-name.ts @@ -12,13 +12,12 @@ export default async function renderProductName( const { productMap, currentProduct } = req.context if (!productMap) throw new Error('request is not contextualized') - // `currentProduct` might be an empty string, which is a valid value. + // Empty currentProduct is valid. if (currentProduct === undefined) throw new Error('currentProduct is not contextualized') const productObject = productMap[currentProduct] if (!productObject) { - // If the "currentProduct" isn't recognized, there's no point trying - // to render its name. Skip this middleware. + // Skip unrecognized currentProduct values because renderContent needs a product object. return next() } req.context.currentProductName = await renderContent(productObject.name, req.context, { diff --git a/src/frame/middleware/cookie-parser.ts b/src/frame/middleware/cookie-parser.ts index ffe82e348ba0..f9bc1e2e43f2 100644 --- a/src/frame/middleware/cookie-parser.ts +++ b/src/frame/middleware/cookie-parser.ts @@ -5,8 +5,7 @@ import { cookieSettings } from '@/frame/lib/cookie-settings' export default cookieParser( process.env.COOKIE_SECRET, - // `cookie-settings.ts` declares these as `CookieSerializeOptions` because - // that is the right type for the places that set cookies. cookie-parser - // wants `CookieParseOptions`, so bridge the two here. + // cookie-settings.ts exports CookieSerializeOptions for cookie writers. + // cookie-parser expects CookieParseOptions, so bridge the two here. cookieSettings as CookieParseOptions, ) diff --git a/src/frame/middleware/fast-head.ts b/src/frame/middleware/fast-head.ts index 2930aeda248f..cd6475d0df32 100644 --- a/src/frame/middleware/fast-head.ts +++ b/src/frame/middleware/fast-head.ts @@ -8,8 +8,7 @@ export default function fastHead(req: ExtendedRequest, res: Response, next: Next const { context } = req const { page } = context if (page) { - // Since the *presence* is not affected by the request, we can cache - // this and allow the CDN to hold on to it. + // Cache by URL because request headers do not change this empty HEAD response. defaultCacheControl(res) res.status(200).send('') diff --git a/src/frame/middleware/fastly-cache-test.ts b/src/frame/middleware/fastly-cache-test.ts index 4970fc9fa9be..b1908a3dbf82 100644 --- a/src/frame/middleware/fastly-cache-test.ts +++ b/src/frame/middleware/fastly-cache-test.ts @@ -1,19 +1,13 @@ -// -// This middleware function is intended to be used for testing caching behavior with Fastly. -// It will intercept ALL URLs that are routed to it and respond with a simple HTML body -// containing a timestamp. -// The logic will detect certain values in the path and set the HTTP status and/or the -// Surrogate-Control header value. -// -// NOTE: This middleware is intended to be removed once testing is complete! -// +// Tests Fastly caching by returning timestamped HTML for any routed URL. +// Path tokens set the HTTP status and cache directives. +// X-CacheTest-CCMode chooses Surrogate-Control, Cache-Control, or both. import express from 'express' import crypto from 'crypto' const router = express.Router() router.get('/*path', function (req, res) { - // If X-CacheTest-Error is set, simulate the site being down (regardless of URL) + // X-CacheTest-Error simulates a site outage for any URL. if (req.get('X-CacheTest-Error')) { res.status(parseInt(req.get('X-CacheTest-Error') as string)).end() return diff --git a/src/frame/middleware/favicons.ts b/src/frame/middleware/favicons.ts index 44993db15454..de496bdb6245 100644 --- a/src/frame/middleware/favicons.ts +++ b/src/frame/middleware/favicons.ts @@ -1,8 +1,5 @@ -// We actually don't rely and use /favicon.ico but it's nevertheless a -// very common request. Same with /apple-touch-icon.png. -// Because we store our images, including those not for the Markdown text, -// in the `assets/images/site` directory, we will use a custom -// solution to serve this directly. +// Browsers request /favicon.ico and Apple touch icons even though pages do not link them. +// Serve these root icon URLs directly from assets/images/site. import fs from 'fs' import type { Response, NextFunction } from 'express' @@ -36,10 +33,7 @@ const MAP: { }, } -// It's the same image but it's fine. By default, when Safari tries to -// to figure out which apple touch icons are available it will -// try to load this by default. For example, if you in desktop Safari -// click share icon, it will load this to serve as a preview icon. +// Safari probes precomposed Apple touch icon names for desktop share previews. MAP['/apple-touch-icon-precomposed.png'] = MAP['/apple-touch-icon.png'] MAP['/apple-touch-icon-120x120-precomposed.png'] = MAP['/apple-touch-icon-120x120.png'] MAP['/apple-touch-icon-152x152-precomposed.png'] = MAP['/apple-touch-icon-152x152.png'] @@ -51,9 +45,7 @@ function getBuffer(filePath: string) { } return () => { if (!buffer) { - // Yes, sync and a bit slow, but the headers we send will - // make sure these requests are rare because the payload - // will be sticky in the CDN and stickly in the browser too. + // Sync reads are rare because assetCacheControl keeps icons in the CDN and browser cache. buffer = fs.readFileSync(filePath) } return buffer @@ -63,11 +55,10 @@ function getBuffer(filePath: string) { export default function favicons(req: ExtendedRequest, res: Response, next: NextFunction) { if (!MAP[req.path]) return next() - // This makes sure the CDN caching survives each production deployment. + // The manual surrogate key keeps CDN caching through production deploys. setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL) - // Manually settings a Cache-Control because no other middleware - // will get a chance to do this later since we terminate here. + // Set asset caching here because this middleware sends the response. assetCacheControl(res) const { contentType, buffer } = MAP[req.path] diff --git a/src/frame/middleware/find-page.ts b/src/frame/middleware/find-page.ts index 7f606fc4abb4..12ea98233c80 100644 --- a/src/frame/middleware/find-page.ts +++ b/src/frame/middleware/find-page.ts @@ -15,18 +15,19 @@ interface FindPageOptions { const englishPrefixRegex = /^\/en(\/|$)/ const CONTENT_ROOT = path.join(ROOT, 'content') +// Development rereads of index pages keep startup versions and permalinks. +// Tree construction mutates category pages from child versions, but rereads use only file data. export default async function findPage( req: ExtendedRequest, res: Response, next: NextFunction, - // Express won't execute these but it makes it easier to unit test - // the middleware. + // Express ignores these options, but tests can pass them directly. { isDev = process.env.NODE_ENV === 'development', contentRoot = CONTENT_ROOT, }: FindPageOptions = {}, ): Promise { - // Filter out things like `/will/redirect` or `/_next/data/...` + // Only language-prefixed content paths can map to pages; /will/redirect continues. if (!req.pagePath || !languagePrefixPathRegex.test(req.pagePath)) { return next() } @@ -37,11 +38,6 @@ export default async function findPage( let page = req.context.pages[req.pagePath] as Page | undefined if (page && isDev && englishPrefixRegex.test(req.pagePath)) { - // The .applicableVersions and .permalinks properties are computed - // when the page is read in from disk. But when the initial tree - // was created at startup, the pages in the tree were mutated - // based on their context. For example, a category page's versions - // is based on looping through all its children's versions. const reuseOldVersions = page.relativePath.endsWith('index.md') const oldApplicableVersions = page.applicableVersions const oldPermalinks = page.permalinks @@ -59,9 +55,7 @@ export default async function findPage( page.permalinks = oldPermalinks } - // This can happen if the page we just re-read has changed which - // versions it's available in (the `versions` frontmatter) meaning - // it might no longer be available on the current URL. + // A reread page can drop the requested version from applicableVersions. if ( req.context?.currentVersion && !page.applicableVersions.includes(req.context.currentVersion) @@ -80,9 +74,7 @@ export default async function findPage( req.context.page = page ;(req.context.page as Page & { version: string }).version = req.context.currentVersion || '' - // We can't depend on `page.hidden` because the dedicated search - // results page is a hidden page but it needs to offer all possible - // languages. + // page.hidden also hides search, which needs every language; restrict only early-access pages. if (page.relativePath.startsWith('early-access') && req.context?.languages?.en) { req.context.languages = { en: req.context.languages.en, @@ -93,6 +85,7 @@ export default async function findPage( return next() } +// rereadByPath handles only English content because translations load at build time. async function rereadByPath( uri: string, contentRoot: string, @@ -103,18 +96,12 @@ async function rereadByPath( const languageCode = match[1] const withoutLanguage = uri.replace(languagePrefixPathRegex, '/') const withoutVersion = withoutLanguage.replace(`/${currentVersion}`, '') - // Note: We don't support loading translations at runtime. All translations - // are loaded at build time. This function only handles English content reloading - // during development. const possible = path.join(contentRoot, withoutVersion) const filePath = existsSync(possible) ? path.join(possible, 'index.md') : `${possible}.md` const relativePath = path.relative(contentRoot, filePath) const basePath = contentRoot - // Remember, the Page.init() can return a Promise that resolves to falsy - // if it can't read the file in from disk. E.g. a request for /en/non/existent. - // In other words, it's fine if it can't be read from disk. It'll get - // handled and turned into a nice 404 message. + // When a reread fails, the caller keeps the already-found page. const page = await Page.init({ basePath, relativePath, diff --git a/src/frame/middleware/handle-next-data-path.ts b/src/frame/middleware/handle-next-data-path.ts index 882720748ce0..78eb3e6748b9 100644 --- a/src/frame/middleware/handle-next-data-path.ts +++ b/src/frame/middleware/handle-next-data-path.ts @@ -5,16 +5,15 @@ import type { ExtendedRequest } from '@/types' const STATSD_KEY = 'middleware.handle_next_data_path' +// Client route transitions request _next/data JSON paths; map them back to page paths. +// Example: /_next/data/development/en/actions/foo.json becomes +// /en/actions/foo. export default function handleNextDataPath( req: ExtendedRequest, res: Response, next: NextFunction, ) { if (req.path.startsWith('/_next/data/') && req.path.endsWith('.json')) { - // translate a nextjs data request to a page path that the server can use on context - // this is triggered via client-side route transitions - // example path: - // /_next/data/development/en/free-pro-team%40latest/github/setting-up-and-managing-your-github-user-account.json let decodedPath = '' try { decodedPath = decodeURIComponent(req.path) @@ -26,7 +25,7 @@ export default function handleNextDataPath( } const parts = decodedPath.split('/').slice(4) - // free-pro-team@latest should not be included in the page path + // Drop free-pro-team@latest because page paths omit that default version. if (parts[1] === 'free-pro-team@latest') { parts.splice(1, 1) } diff --git a/src/frame/middleware/healthcheck.ts b/src/frame/middleware/healthcheck.ts index 68e3089b17d5..514e1688ee53 100644 --- a/src/frame/middleware/healthcheck.ts +++ b/src/frame/middleware/healthcheck.ts @@ -4,11 +4,8 @@ import statsd from '@/observability/lib/statsd' const router = express.Router() -// Returns the healthiness of the service. -// Moda may use this to decide whether this instance stays in the pool. -// Today it checks nothing and always returns 200. If we ever needed to drain -// an instance, for example on a failing dependency, this is where a 500 -// would go. +// Moda may use this endpoint to decide whether an instance stays in the pool. +// It always returns 200 and sends memory gauges to StatsD, without testing service health. router.get('/', function healthcheck(req, res) { noCacheControl(res) diff --git a/src/frame/middleware/helmet.ts b/src/frame/middleware/helmet.ts index ddbd4b90e8a1..7814bcde5975 100644 --- a/src/frame/middleware/helmet.ts +++ b/src/frame/middleware/helmet.ts @@ -9,11 +9,8 @@ import { colorModeScript } from '@/color-schemes/lib/color-mode-script' const isDev = process.env.NODE_ENV === 'development' -// The pre-paint theme script in `_document.tsx` is inlined, so it needs an -// explicit CSP `script-src` allowance. We hash the exact script string rather -// than using a nonce, because a nonce would have to vary per response and would -// break the shared CDN cache. The script is identical for every request, so its -// hash is stable and the HTML stays cacheable. +// The pre-paint theme script from _document.tsx is inline, so CSP needs a script-src hash. +// A nonce would vary per response and break shared CDN caching. const colorModeScriptHash = `'sha256-${createHash('sha256').update(colorModeScript).digest('base64')}'` const GITHUB_DOMAINS = [ "'self'", @@ -29,22 +26,19 @@ const DEFAULT_OPTIONS = { referrerPolicy: { policy: 'no-referrer-when-downgrade' as const, }, - // This module defines a Content Security Policy (CSP) to disallow - // inline scripts and content from untrusted sources. + // The default CSP blocks untrusted origins and limits inline scripts to approved hashes. contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], prefetchSrc: ["'self'"], - // When doing local dev, especially in Safari, you need to add `ws:` - // which NextJS uses for the hot module reloading. + // Safari local development needs ws: for Next.js hot module reloading. connectSrc: ["'self'", 'https://collector.githubapp.com', isDev && 'ws:'].filter( Boolean, ) as string[], fontSrc: ["'self'", 'data:'], imgSrc: [...GITHUB_DOMAINS, 'data:', 'placehold.it'], objectSrc: ["'self'"], - // For use during development only! - // `unsafe-eval` allows us to use a performant webpack devtool setting (eval) + // Development webpack eval devtool needs unsafe-eval. // https://webpack.js.org/configuration/devtool/#devtool scriptSrc: [ ...GITHUB_DOMAINS, @@ -57,16 +51,17 @@ const DEFAULT_OPTIONS = { frameSrc: [ ...GITHUB_DOMAINS, isDev && 'http://localhost:3000', - // ArticleContext.tsx sets this URL too. We don't import a shared - // constant because the env var may not be set yet at import time. + // src/frame/components/context/ArticleContext.tsx sets this URL too. + // A shared constant could capture SUPPORT_PORTAL_URL before it is set. process.env.NODE_ENV === 'production' ? 'https://support.github.com' - : // Assume that a developer is not testing the VA iframe locally if this env var is not set + : // Missing SUPPORT_PORTAL_URL means local development is not testing the VA iframe. process.env.SUPPORT_PORTAL_URL || '', ].filter(Boolean) as string[], frameAncestors: isDev ? ['*'] : [...GITHUB_DOMAINS], styleSrc: [...GITHUB_DOMAINS, "'self'", "'unsafe-inline'", 'data:'], - childSrc: ["'self'"], // exception for search in deprecated GHE versions + // Deprecated GitHub Enterprise search still needs child-src. + childSrc: ["'self'"], manifestSrc: ["'self'"], upgradeInsecureRequests: isDev ? null : [], }, @@ -100,23 +95,21 @@ const staticDeprecatedHelmet = helmet(STATIC_DEPRECATED_OPTIONS) const developerDeprecatedHelmet = helmet(DEVELOPER_DEPRECATED_OPTIONS) export default function helmetMiddleware(req: Request, res: Response, next: NextFunction) { - // Enable CORS if (['GET', 'OPTIONS'].includes(req.method)) { res.set('access-control-allow-origin', '*') } - // Determine version for exceptions const { requestedVersion } = isArchivedVersion(req) - // Check if this is a legacy developer.github.com path const isDeveloper = req.path .replace(languagePrefixPathRegex, '/') .startsWith(`/enterprise/${requestedVersion}/developer`) if (versionSatisfiesRange(requestedVersion, '<=2.18') && isDeveloper) { + // Deprecated developer.github.com paths need Google font and inline script exceptions. return developerDeprecatedHelmet(req, res, next) } - // Exception for deprecated Enterprise docs (Node.js era) + // Node.js-era deprecated Enterprise docs need relaxed CSP directives. if ( versionSatisfiesRange(requestedVersion, '<=2.19') && versionSatisfiesRange(requestedVersion, '>2.12') @@ -124,7 +117,7 @@ export default function helmetMiddleware(req: Request, res: Response, next: Next return nodeDeprecatedHelmet(req, res, next) } - // Exception for search in deprecated Enterprise docs <=2.12 (static site era) + // Static-site-era Enterprise search needs inline scripts. if (versionSatisfiesRange(requestedVersion, '<=2.12')) { return staticDeprecatedHelmet(req, res, next) } diff --git a/src/frame/middleware/index.ts b/src/frame/middleware/index.ts index d4070063dd82..41aa501ce6a8 100644 --- a/src/frame/middleware/index.ts +++ b/src/frame/middleware/index.ts @@ -69,7 +69,7 @@ import urlDecode from './url-decode' const ENABLE_FASTLY_TESTING = JSON.parse(process.env.ENABLE_FASTLY_TESTING || 'false') -// Catch unhandled promise rejections and passing them to Express's error handler +// asyncMiddleware passes unhandled promise rejections to Express's error handler. // https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016 const asyncMiddleware = ( @@ -83,63 +83,38 @@ const asyncMiddleware = } } +// trust proxy makes req.ip read the left-most X-Forwarded-For value for rate limits and logs. +// https://expressjs.com/en/guide/behind-proxies.html export default function index(app: Express) { app.use(abort) - // Don't use the proxy's IP, use the requester's for rate limiting or - // logging. - // See https://expressjs.com/en/guide/behind-proxies.html - // Essentially, setting this means it believe that the IP is the - // first of the `X-Forwarded-For` header values. - // If it was 0 (or false), the value would be that - // of `req.socket.remoteAddress`. - // Now, the `req.ip` becomes the first entry from x-forwarded-for - // and falls back on `req.socket.remoteAddress` in all other cases. - // Their documentation says: - // - // If true, the client's IP address is understood as the - // left-most entry in the X-Forwarded-For header. - // app.set('trust proxy', true) - // *** Logging *** - app.use(initLoggerContext) // Context for both inline logs (e.g. logger.info) and automatic logs - app.use(getAutomaticRequestLogger()) // Automatic logging for all requests e.g. "GET /path 200" - app.use(expressMetrics) // StatsD metrics for response time and status codes + app.use(initLoggerContext) + app.use(getAutomaticRequestLogger()) + app.use(expressMetrics) - // Put this early to make it as fast as possible because it's used - // to check the health of each cluster. + // Keep healthcheck early so cluster probes skip slower middleware. app.use('/healthcheck', healthcheck) - // Must appear before static assets and all other requests - // otherwise we won't be able to benefit from that functionality - // for static assets as well. + // Default surrogate keys must run before static assets, so static responses can inherit them. app.use(setDefaultFastlySurrogateKey) - // Attaches res.safeRedirect() to every response. Must appear before - // any middleware that redirects. + // safeRedirect must run before middleware that redirects. app.use(safeRedirect) - // archivedEnterpriseVersionsAssets must come before static/assets + // archivedEnterpriseVersionsAssets must run before static asset middleware. app.use(asyncMiddleware(archivedEnterpriseVersionsAssets)) app.use(favicons) - // Any static URL that contains some sort of checksum that makes it - // unique gets the "manual" surrogate key. If it's checksummed, - // it's bound to change when it needs to change. Otherwise, - // we want to make sure it doesn't need to be purged just because - // there's a production deploy. - // Note, for `/assets/cb-*...` requests, - // this needs to come before `assetPreprocessing` because - // the `assetPreprocessing` middleware will rewrite `req.url` if - // it applies. + // Checksummed assets keep manual keys; assetPreprocessing later rewrites /assets/cb-* URLs. app.use(setStaticAssetCaching) - // Must come before any other middleware for assets + // archivedAssetRedirects must run before other asset middleware. app.use(archivedAssetRedirects) - // This must come before the express.static('assets') middleware. + // assetPreprocessing must run before express.static assets. app.use(assetPreprocessing) app.use( @@ -147,11 +122,10 @@ export default function index(app: Express) { express.static('assets', { index: false, etag: false, - // Can be aggressive because images inside the content get unique - // URLs with a cache busting prefix. + // Content image URLs have cache-busting prefixes, so assets can cache aggressively. maxAge: '7 days', immutable: process.env.NODE_ENV !== 'development', - // The next middleware will try its luck and send the 404 if must. + // Let later middleware send the asset 404. fallthrough: true, }), ) @@ -161,15 +135,13 @@ export default function index(app: Express) { express.static('src/graphql/data', { index: false, etag: false, - maxAge: '7 days', // A bit longer since releases are more sparse - // See note about the use of 'fallthrough' + maxAge: '7 days', // Sparse releases tolerate longer caching. + // Missing release assets 404 here. fallthrough: false, }), ) - // In development, let NextJS on-the-fly serve the static assets. - // But in production, don't let NextJS handle any static assets - // because they are costly to generate (the 404 HTML page). + // In production, skip Next static handling because generated 404 HTML is expensive. if (process.env.NODE_ENV !== 'development') { const assetDir = path.join('.next', 'static') if (!fs.existsSync(assetDir)) @@ -182,60 +154,52 @@ export default function index(app: Express) { etag: false, maxAge: '365 days', immutable: true, - // See note about the use of 'fallthrough' + // Missing Next assets 404 here. fallthrough: false, }), ) } - // *** Early exits *** app.use(shielding) app.use(handleNextDataPath) - // *** Security *** app.use(helmet) app.use(cookieParser) app.use(express.json()) if (process.env.NODE_ENV === 'development') { - app.use(mockVaPortal) // FOR TESTING. + app.use(mockVaPortal) } - // *** Headers *** - app.set('etag', false) // We will manage our own ETags if desired + app.set('etag', false) // Disable Express ETags so middleware can set them explicitly when needed. - // *** Config and context for redirects *** - app.use(urlDecode) // Must come before detectLanguage to decode @ symbols in version segments - app.use(detectLanguage) // Must come before context, breadcrumbs, find-page, handle-errors, homepages - app.use(detectVersion) // Must come before handle-redirects for version cookie support - app.use(asyncMiddleware(reloadTree)) // Must come before context - app.use(asyncMiddleware(context)) // Must come before early-access-*, handle-redirects - app.use(shortVersions) // Support version shorthands - app.use(asyncMiddleware(renderProductName)) // Must come after shortVersions + app.use(urlDecode) // Must run before detectLanguage to decode @ symbols in version segments. + // Must run before context, breadcrumbs, findPage, handleErrors, and homepages. + app.use(detectLanguage) + app.use(detectVersion) // Must run before handleRedirects for version cookie support. + app.use(asyncMiddleware(reloadTree)) // Must run before context. + app.use(asyncMiddleware(context)) // Must run before earlyAccessLinks and handleRedirects. + app.use(shortVersions) + app.use(asyncMiddleware(renderProductName)) // Must run after shortVersions. - // Must come before handleRedirects. - // This middleware might either redirect or serve something. + // archivedEnterpriseVersions must run before handleRedirects because it can redirect or serve. app.use(asyncMiddleware(archivedEnterpriseVersions)) - // *** Redirects, 3xx responses *** - // I ordered these by use frequency app.use(trailingSlashes) - app.use(languageCodeRedirects) // Must come before contextualizers - app.use(handleRedirects) // Must come before contextualizers + app.use(languageCodeRedirects) // Must run before contextualizers. + app.use(handleRedirects) // Must run before contextualizers. - // *** Config and context for rendering *** - app.use(asyncMiddleware(findPage)) // Must come before archived-enterprise-versions, breadcrumbs, featured-links, products, render-page + // Must run before breadcrumbs, featuredLinks, productGroups, and renderPage. + app.use(asyncMiddleware(findPage)) app.use(blockRobots) - // *** Rendering, 2xx responses *** app.use('/api', api) app.use('/llms.txt', llmsTxt) app.get('/_build', buildInfo) app.get('/_req-headers', reqHeaders) app.use(asyncMiddleware(manifestJson)) - // Things like `/api` sets their own Fastly surrogate keys. - // Now that the `req.language` is known, set it for the remaining endpoints + // After req.language exists, remaining endpoints get language keys; /api keeps its own. app.use(setLanguageFastlySurrogateKey) app.use(robots) @@ -243,16 +207,14 @@ export default function index(app: Express) { app.use('/categories.json', asyncMiddleware(categoriesForSupport)) app.get('/_500', asyncMiddleware(triggerError)) - // Specifically deal with HEAD requests before doing the slower - // full page rendering. + // HEAD requests skip slower full page rendering. app.head('/*path', fastHead) - // *** Preparation for render-page: contextualizers *** app.use(asyncMiddleware(dataTables)) app.use(asyncMiddleware(secretScanning)) app.use(asyncMiddleware(ghesReleaseNotes)) app.use(layout) - app.use(features) // needs to come before product tree + app.use(features) // Must run before currentProductTree. app.use(asyncMiddleware(currentProductTree)) app.use(asyncMiddleware(genericToc)) app.use(breadcrumbs) @@ -264,18 +226,15 @@ export default function index(app: Express) { app.use(asyncMiddleware(journeyTrack)) if (ENABLE_FASTLY_TESTING) { - // The fastlyCacheTest middleware is intended to be used with Fastly to test caching behavior. - // This middleware will intercept ALL requests routed to it, so be careful if you need to - // make any changes to the following line: + // fastlyCacheTest intercepts all routed requests, so keep the route narrow. app.use('/fastly-cache-test', fastlyCacheTest) } - // handle serving NextJS bundled code (/_next/*) app.use(next) - // *** Rendering, must go almost last *** + // renderPage must run after specialized routes. app.get('/*path', asyncMiddleware(renderPage)) - // *** Error handling, must go last *** + // handleErrors must run last to catch middleware errors. app.use(handleErrors) } diff --git a/src/frame/middleware/manifest-json.ts b/src/frame/middleware/manifest-json.ts index 851ca6ce5f11..1e40440f39b1 100644 --- a/src/frame/middleware/manifest-json.ts +++ b/src/frame/middleware/manifest-json.ts @@ -30,21 +30,16 @@ export default async function manifestJson(req: Request, res: Response, next: Ne } if (req.url !== '/manifest.json') { - // E.g. `/manifest.json/anything` or `/manifest.json?foo=bar` + // Examples: /manifest.json/anything and /manifest.json?foo=bar. defaultCacheControl(res) return res.safeRedirect(302, '/manifest.json') } const icons: Icon[] = [] - // This is modelled after https://github.com/manifest.json + // The manifest mirrors https://github.com/manifest.json. const manifest = { - // In the future we might want to have a different manifest for each - // language. Particularly, the `name` property. - // But as of May 2023, this is overkill because all translations's - // home page refer to the name of the site as "GitHub Docs". - // For example, on https://docs.github.com/ja the `` - // is "GitHub Docs". + // Localized home pages title the site GitHub Docs, so one manifest covers every language. name: 'GitHub Docs', short_name: 'GitHub Docs', start_url: '/', diff --git a/src/frame/middleware/mock-va-portal.ts b/src/frame/middleware/mock-va-portal.ts index cafc8781dc4d..be2647716ba9 100644 --- a/src/frame/middleware/mock-va-portal.ts +++ b/src/frame/middleware/mock-va-portal.ts @@ -1,15 +1,4 @@ -// Mocks the VA portal so you can test the VA integration without access to a -// staging VA portal. You can't point at the production one either, because it -// is hardened to https://docs.github.com and will reject your localhost:4000. -// -// To test locally: -// -// 1. Add `SUPPORT_PORTAL_URL=http://localhost:4000` to your `.env` file -// 2. `npm run dev` -// 3. Navigate to a page listed in the `PagePathToVaFlowMapping` object in -// the `ArticleContext`. -// -// This mocking is not secure. It is only for local development. +// Local-only Virtual Assistant portal mock; the production portal rejects localhost:4000. import type { Response, NextFunction } from 'express' diff --git a/src/frame/middleware/next.ts b/src/frame/middleware/next.ts index bc50942ffeae..a467ddffc588 100644 --- a/src/frame/middleware/next.ts +++ b/src/frame/middleware/next.ts @@ -12,8 +12,7 @@ export const nextHandleRequest = nextApp.getRequestHandler() await nextApp.prepare() function renderPageWithNext(req: ExtendedRequest, res: Response, nextFn: NextFunction) { - // This catches URLs like `/_next/webpack-hmr` and - // `/_next/static/webpack/64e44ef62e261d3a.webpack.hot-update.json`. + // _next asset and HMR requests, like /_next/webpack-hmr, bypass docs routing. if (req.path.startsWith('/_next') && !req.path.startsWith('/_next/data')) { return nextHandleRequest(req, res) } diff --git a/src/frame/middleware/reload-tree.ts b/src/frame/middleware/reload-tree.ts index 3007311e64c4..4432194a54dc 100644 --- a/src/frame/middleware/reload-tree.ts +++ b/src/frame/middleware/reload-tree.ts @@ -1,13 +1,7 @@ -// This exists for local reviewing only. -// -// We load the entire tree on startup and use it for sidebars, breadcrumbs, -// landing pages, and ToC pages. In development, an individual English page is -// reread from disk on each request in case it changed, but doing that for all -// 1k+ pages is not feasible. -// -// So this middleware calls `createTree()` with the previous tree, letting -// `createTree` reuse the pages that haven't changed on disk. That way things -// like sidebars refresh without restarting the server. +// The app loads the full tree at startup for sidebars, breadcrumbs, landing pages, and ToC pages. +// This development-only middleware rereads individual English pages per request. +// Rereading all 1k+ pages per request would be too slow. +// createTree receives the previous tree, so navigation refreshes without restarting the server. import path from 'path' @@ -27,15 +21,14 @@ const isDev = process.env.NODE_ENV === 'development' export default async function reloadTree(req: ExtendedRequest, res: Response, next: NextFunction) { if (!isDev) return next() - // Filter out things like `/will/redirect` or `/_next/data/...` + // Only language-prefixed content paths can refresh the tree; /will/redirect continues. if (!req.pagePath || !languagePrefixRegex.test(req.pagePath)) return next() - // We only bother if the loaded URL is something `/en/...` + // Only English content can refresh the development tree. if (!englishPrefixRegex.test(req.pagePath)) return next() const warmed = await warmServer([]) - // For all the real English content, this usually takes about 30-60ms on - // an Intel MacBook Pro. + // createTree below usually takes 30-60ms for real English content on an Intel MacBook Pro. const before = getMtimes(warmed.unversionedTree.en) warmed.unversionedTree.en = (await createTree( path.join(languages.en.dir, 'content'), @@ -43,11 +36,7 @@ export default async function reloadTree(req: ExtendedRequest, res: Response, ne warmed.unversionedTree.en, )) as UnversionedTree const after = getMtimes(warmed.unversionedTree.en) - // The next couple of operations are much slower (in total) than - // refreshing the tree. So we want to know if the tree changed before - // bothering. - // If refreshing of the `.en` part of the `unversionedTree` takes 40ms - // then the following operations takes about 140ms. + // Dependent maps take about 140ms after a 40ms tree refresh, so skip them when mtimes match. if (before !== after) { warmed.siteTree = (await loadSiteTree(warmed.unversionedTree)) as SiteTree warmed.pageList = await loadPages(warmed.unversionedTree) @@ -58,10 +47,7 @@ export default async function reloadTree(req: ExtendedRequest, res: Response, ne return next() } -// Given a tree, return a number that represents the mtimes for all pages -// in the tree. -// You can use this to compute it before and after the tree is (maybe) -// mutated and if the numbers *change* you can know the tree changed. +// Summing mtimes lets reloadTree detect page changes before rebuilding slower maps. function getMtimes(tree: UnversionedTree) { let mtimes = tree.page.mtime for (const child of tree.childPages || []) { diff --git a/src/frame/middleware/render-page.ts b/src/frame/middleware/render-page.ts index 0aed59fe722e..1d3e0b4b7d2a 100644 --- a/src/frame/middleware/render-page.ts +++ b/src/frame/middleware/render-page.ts @@ -27,8 +27,7 @@ async function buildRenderedPage(req: ExtendedRequest): Promise<string> { if (!page) throw new Error('page not set in context') const path = req.pagePath || req.path - // Set up collection array for the collect-mini-toc rehype plugin only when - // the page actually needs a mini-TOC, avoiding unnecessary work. + // Collect mini-TOC headings only for pages that show one, so other renders avoid plugin work. if (page.showMiniToc) { const collectMiniToc: CollectedHeading[] = [] context.collectMiniToc = collectMiniToc @@ -41,16 +40,10 @@ async function buildRenderedPage(req: ExtendedRequest): Promise<string> { return (await pageRenderTimed(context)) as string } -// Spike for #6619: produce the article body as a serializable hast (HTML AST) -// tree alongside the legacy HTML string. -// -// Must run AFTER buildRenderedPage, which calls page.render and populates the -// context fields the pipeline reads (englishHeadings, alertTitles). We render -// the same raw `page.markdown`, but with a context clone that omits -// `collectMiniToc` so the mini-TOC isn't collected a second time. -// -// Wrapped so a hast failure can never break the page. The React layer falls -// back to the string path when this is undefined. +// buildRenderedPageHast returns a serializable HTML AST alongside renderedPage. +// It runs after buildRenderedPage because page.render populates englishHeadings and alertTitles. +// It disables collectMiniToc so mini-TOC collection does not repeat. +// Failures return undefined, and the React layer falls back to renderedPage. async function buildRenderedPageHast(req: ExtendedRequest) { const { context } = req if (!context) throw new Error('request not contextualized') @@ -76,12 +69,11 @@ function buildMiniTocItems(req: ExtendedRequest) { if (!context) throw new Error('request not contextualized') const { page } = context - // get mini TOC items on articles if (!page || !page.showMiniToc) { return } - // Use headings collected during rendering via the collect-mini-toc rehype plugin. + // Collected headings avoid rendering article content a second time. const collected = context.collectMiniToc as CollectedHeading[] | undefined if (collected) { return buildMiniTocFromCollected(collected, 2) @@ -91,15 +83,13 @@ function buildMiniTocItems(req: ExtendedRequest) { export default async function renderPage(req: ExtendedRequest, res: Response) { const { context } = req - // `Error.getInitialProps`, which NextJS runs on errors, reads this off the - // request so it can send the error to Failbot. + // Next.js Error.getInitialProps reads req.FailBot so it can report errors to Failbot. req.FailBot = FailBot as Failbot if (!context) throw new Error('request not contextualized') const { page } = context const path = req.pagePath || req.path - // render a 404 page if (!page) { if (process.env.NODE_ENV !== 'test' && context.redirectNotFound) { logger.error('Tried to redirect to a page that was not found', { @@ -107,27 +97,23 @@ export default async function renderPage(req: ExtendedRequest, res: Response) { }) } - // send minimal 404 at this point since we ran into hydration issues trying to pass - // these along to AppRouter 404 handling + // Passing this context to App Router 404 handling causes hydration failures. defaultCacheControl(res) return res.status(404).type('html').send(minimumNotFoundHtml) } - // Just finish fast without all the details like Content-Length + // HEAD skips page rendering but still lets Express send Content-Length: 0. if (req.method === 'HEAD') { return res.status(200).send('') } - // Updating the Last-Modified header for substantive changes on a page for engineering - // Docs Engineering Issue #945 + // effectiveDate marks substantive page changes for clients that watch Last-Modified. if (page.effectiveDate) { - // The frontmatter schema only checks that this is a string. An unparseable - // date gets caught later, in ArticleContext, and ends up as a 500. + // ArticleContext turns unparseable effectiveDate strings into a 500. res.setHeader('Last-Modified', new Date(page.effectiveDate).toUTCString()) } - // Content negotiation: serve markdown when the client prefers it over HTML. - // Agents like Claude Code send Accept headers that omit text/html. + // Serve markdown when the client prefers it over HTML; agents can omit text/html. if (req.accepts(['text/html', 'text/markdown']) === 'text/markdown') { context.markdownRequested = true } @@ -137,10 +123,7 @@ export default async function renderPage(req: ExtendedRequest, res: Response) { if (context.markdownRequested) { const transformer = transformerRegistry.findTransformer(page) if (!transformer) throw new Error(`No transformer found for page: ${req.pagePath}`) - // Pass context without markdownRequested, because transformers set it - // themselves when rendering templates. Having it set during prepareTemplateData() - // causes renderTitle/renderProp to output markdown instead of HTML, - // which breaks the cheerio-based unwrap logic. + // Clear markdownRequested so renderTitle and renderProp output HTML for stripOuterTag. const transformerContext = { ...context, markdownRequested: false } req.context.renderedPage = normalizeRenderedMarkdown( await transformer.transform(page, path, transformerContext), @@ -153,7 +136,6 @@ export default async function renderPage(req: ExtendedRequest, res: Response) { page.fullTitle = page.title - // add localized ` - GitHub Docs` suffix to <title> tag (except for the homepage) if (!patterns.homepagePath.test(path)) { if ( req.context.currentVersion === 'free-pro-team@latest' || @@ -163,9 +145,7 @@ export default async function renderPage(req: ExtendedRequest, res: Response) { } else { const { versionTitle } = allVersions[req.context.currentVersion!] page.fullTitle += ' - ' - // Some plans don't have the word "GitHub" in them. - // E.g. "Enterprise Server 3.5" - // In those cases manually prefix the word "GitHub" before it. + // Prefix version titles that omit GitHub. if (!versionTitle.includes('GitHub')) { page.fullTitle += 'GitHub ' } @@ -178,15 +158,15 @@ export default async function renderPage(req: ExtendedRequest, res: Response) { if (isRequestingJsonForDebugging) { const json = req.query.json if (Array.isArray(json)) { - // e.g. ?json=page.permalinks&json=currentPath + // Example: ?json=page.permalinks&json=currentPath. throw new Error("'json' query string can only be 1") } if (json) { - // deep reference: ?json=page.permalinks + // Example deep reference: ?json=page.permalinks. return res.json(get(context, req.query.json as string)) } else { - // dump all the keys: ?json + // Example full key dump: ?json. return res.json({ message: 'The full context object is too big to display! Try one of the individual keys below, e.g. ?json=page. You can also access nested props like ?json=site.data.reusables', diff --git a/src/frame/middleware/resolve-carousels.ts b/src/frame/middleware/resolve-carousels.ts index 2aa68876923b..a69c60253eff 100644 --- a/src/frame/middleware/resolve-carousels.ts +++ b/src/frame/middleware/resolve-carousels.ts @@ -6,7 +6,7 @@ import Permalink from '@/frame/lib/permalink' import { createLogger } from '@/observability/logger/index' -// The Page class has rawCarousels and carousels properties that aren't on the Page type +// Page adds rawCarousels and carousels at runtime, but the Page type omits them. interface PageCarouselProps { rawCarousels?: Record<string, string[]> carousels?: Record<string, ResolvedArticle[]> @@ -20,6 +20,7 @@ function buildArticlePath(currentLanguage: string, articlePath: string, basePath return `${pathPrefix}${separator}${articlePath}` } +// Resolve carousel paths as content-relative, then page-relative, then retry both with .md. function tryResolveArticlePath( rawPath: string, pageRelativePath: string | undefined, @@ -32,7 +33,6 @@ function tryResolveArticlePath( return undefined } - // Strategy 1: Try content-relative path (add language prefix to raw path) const contentRelativePath = buildArticlePath(currentLanguage, rawPath) let foundPage = findPage(contentRelativePath, pages, redirects) @@ -40,7 +40,6 @@ function tryResolveArticlePath( return foundPage } - // Strategy 2: Try page-relative path if page context is available if (pageRelativePath) { const pageDirPath = pageRelativePath.split('/').slice(0, -1).join('/') const pageRelativeFullPath = buildArticlePath(currentLanguage, rawPath, pageDirPath) @@ -51,11 +50,9 @@ function tryResolveArticlePath( } } - // Strategy 3: Try with .md extension if not already present if (!rawPath.endsWith('.md')) { const pathWithExtension = `${rawPath}.md` - // Try Strategy 1 with .md extension const contentRelativePathWithExt = buildArticlePath(currentLanguage, pathWithExtension) foundPage = findPage(contentRelativePathWithExt, pages, redirects) @@ -63,7 +60,6 @@ function tryResolveArticlePath( return foundPage } - // Try Strategy 2 with .md extension if (pageRelativePath) { const pageDirPath = pageRelativePath.split('/').slice(0, -1).join('/') const pageRelativeFullPathWithExt = buildArticlePath( @@ -82,7 +78,6 @@ function tryResolveArticlePath( return foundPage } -// Returns a page's path without the language or version prefix. function getPageHref(page: Page): string { if (page.relativePath) { return Permalink.relativePathToSuffix(page.relativePath) @@ -137,7 +132,7 @@ async function resolveCarousels( } if (resolved.length > 0) { - // Prevent prototype pollution by rejecting __proto__ keys + // Reject unsafe object keys to prevent prototype pollution. if ( carouselKey !== '__proto__' && carouselKey !== 'constructor' && diff --git a/src/frame/middleware/robots.ts b/src/frame/middleware/robots.ts index 11d17680d6f3..137b9ef7d8c9 100644 --- a/src/frame/middleware/robots.ts +++ b/src/frame/middleware/robots.ts @@ -17,7 +17,7 @@ export default function robots(req: ExtendedRequest, res: Response, next: NextFu const host = req.get('x-host') || req.get('x-forwarded-host') || req.get('host') - // only include robots.txt when it's our production domain and adding localhost for robots-txt.ts test + // Allow indexing only on docs.github.com and 127.0.0.1 for tests. if ( host === 'docs.github.com' || req.hostname === 'docs.github.com' || diff --git a/src/frame/middleware/safe-redirect.ts b/src/frame/middleware/safe-redirect.ts index 0c875bfe9ed7..128b0418ec47 100644 --- a/src/frame/middleware/safe-redirect.ts +++ b/src/frame/middleware/safe-redirect.ts @@ -2,20 +2,19 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest } from '@/types' -// Normalizes a redirect URL to prevent open redirects via protocol-relative -// URLs (e.g. "//evil.com" which browsers interpret as "https://evil.com"). +// Strip protocol-relative prefixes so browsers cannot turn redirects into external URLs. +// Example: //evil.com becomes /evil.com. export function safeRedirectUrl(url: string): string { return url.replace(/^\/\/+/, '/') } -// Matches the overloaded signature of Express's res.redirect(). +// SafeRedirect matches the overloaded signature of Express res.redirect. export type SafeRedirect = { (url: string): void (status: number, url: string): void } -// Attaches res.safeRedirect() to the response for all downstream middleware. -// Same signature as res.redirect() but normalizes the URL first. +// Downstream middleware calls res.safeRedirect with the Express redirect signature. export default function safeRedirect(req: ExtendedRequest, res: Response, next: NextFunction) { res.safeRedirect = function (statusOrUrl: number | string, url?: string) { if (typeof statusOrUrl === 'number' && url !== undefined) { diff --git a/src/frame/middleware/set-fastly-surrogate-key.ts b/src/frame/middleware/set-fastly-surrogate-key.ts index a455d661a4f3..23394048441e 100644 --- a/src/frame/middleware/set-fastly-surrogate-key.ts +++ b/src/frame/middleware/set-fastly-surrogate-key.ts @@ -3,15 +3,12 @@ import type { Request, Response, NextFunction } from 'express' import { ExtendedRequest } from '@/types' import type { Page, Version } from '@/types' -// Fastly provides a Soft Purge feature that allows you to mark content as outdated (stale) instead of permanently -// purging and thereby deleting it from Fastly's caches. Objects invalidated with Soft Purge will be treated as -// outdated (stale) while Fastly fetches a new version from origin. -// -// Use of a surrogate key is required for soft purging +// Fastly soft purges mark cached objects stale while origin fetches a fresh copy. +// Soft purges require surrogate keys. // https://docs.fastly.com/en/guides/soft-purges // https://docs.fastly.com/en/guides/getting-started-with-surrogate-keys -// What the header needs to be called for Fastly to recognize it. +// Fastly reads surrogate keys from this response header. const KEY = 'surrogate-key' export const SURROGATE_ENUMS = { @@ -59,16 +56,14 @@ export function makeLanguageSurrogateKey(langCode?: string) { return `language:${langCode}` } -// Build the fine-grained surrogate keys for a content response. -// A content page is exactly one of each axis, so ~5 keys per page, well under -// Fastly's 16 KB Surrogate-Key header limit: -// -// language:<code> (also emitted for non-content responses) -// product:<top-level dir> e.g. product:actions (~36) -// version:<short release slug> e.g. version:ghes-3.14 (~7-8) -// product:<x>,language:<y> compound, for targeted translation purges -// language:<code>,path:<path> compound, one key per source page, all versions -// +// Content responses get about five keys, one per purge axis, +// below Fastly's 16 KB header limit. +// Shapes include language:<code>, product:<top-level-dir>, version:<short-release-slug>, +// product:<product>,language:<code>, and language:<code>,path:<source-path>. +// language:<code> also appears on non-content responses. +// product:<product>,language:<code> targets translation purges. +// language:<code>,path:<source-path> covers one source page across all versions. +// Each response emits at most one product key and at most one version key. export function makeContentSurrogateKeys({ langCode, productId, @@ -97,20 +92,19 @@ export function makeContentSurrogateKeys({ return keys } -// One surrogate key per source page, e.g. `language:en,path:actions/foo.md`, -// covering every version-URL of the page. A pure function of language and -// relativePath so the purge job can rebuild the same key from a changed file's -// path. Language-scoped so an English deploy doesn't evict translations. Returns -// undefined for non-content responses. +// Page surrogate keys cover every version URL for one source page. +// Example: language:en,path:actions/foo.md. +// The purge job rebuilds them from changed file paths. +// Language scoping avoids evicting translations on English deploys. +// Missing language or path returns undefined for non-content responses. export function makePageSurrogateKey(langCode?: string, relativePath?: string): string | undefined { if (!langCode || !relativePath) return undefined return `language:${langCode},path:${relativePath}` } -// Derive the product id for the `product:` surrogate key from a content page's -// path. The top-level content directory is the product id (mirrors -// Page.parentProductId), e.g. `actions`. Returns undefined for non-content -// responses and the top-level homepage (`content/index.md`). +// Product surrogate keys use the top-level content directory, mirroring Page.parentProductId. +// Example: actions. +// Non-content responses and the top-level homepage return undefined. function productSurrogateId(page?: Page): string | undefined { const relativePath = page?.relativePath if (!relativePath) return undefined @@ -119,10 +113,9 @@ function productSurrogateId(page?: Page): string | undefined { return id } -// Derive the short release slug for the `version:` surrogate key, e.g. `fpt`, -// `ghec`, `ghes-3.14`. Numbered releases (GHES) get the release appended so a -// version-scoped purge can target a single release; unnumbered plans use the -// plain short name. +// Version surrogate keys use the short name. +// Numbered GitHub Enterprise Server releases append currentRelease for single-release purges. +// Unnumbered plans use the short name alone. function versionSurrogateKey(versionObj?: Version): string | undefined { if (!versionObj) return undefined return versionObj.hasNumberedReleases diff --git a/src/frame/middleware/trailing-slashes.ts b/src/frame/middleware/trailing-slashes.ts index 53e34ac1856d..cd80572031b9 100644 --- a/src/frame/middleware/trailing-slashes.ts +++ b/src/frame/middleware/trailing-slashes.ts @@ -15,7 +15,8 @@ export default function trailingSlashes(req: ExtendedRequest, res: Response, nex if (split.length) { url += `?${split.join('?')}` } - url = url.replace(/\/+/g, '/') // Prevent multiple slashes + // 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/middleware/url-decode.ts b/src/frame/middleware/url-decode.ts index 7e49a5e3f86f..ae76a8774b6d 100644 --- a/src/frame/middleware/url-decode.ts +++ b/src/frame/middleware/url-decode.ts @@ -1,7 +1,7 @@ import type { NextFunction, Response } from 'express' import type { ExtendedRequest } from '@/types' -// Decodes URL-encoded @ symbols anywhere in the URL. +// Decode URL-encoded @ symbols anywhere in the URL. // SharePoint and other systems encode @ as %40, which breaks our versioned // URLs like /en/enterprise-cloud@latest. export default function urlDecode(req: ExtendedRequest, res: Response, next: NextFunction) { @@ -16,7 +16,6 @@ export default function urlDecode(req: ExtendedRequest, res: Response, next: Nex req.url = decodedUrl return next() } catch { - // If decoding fails for any reason, continue with original URL return next() } } diff --git a/src/frame/pages/app.tsx b/src/frame/pages/app.tsx index 386c5c496705..4ee1f221fcfe 100644 --- a/src/frame/pages/app.tsx +++ b/src/frame/pages/app.tsx @@ -45,11 +45,9 @@ const stagingNames = new Set([ 'yew', ]) +// Cache-busting prefixes need any cb-number so Fastly assigns the manual surrogate key. +// Change the cb number when the image changes, so browsers and the CDN miss the old URL. function getFaviconHref(stagingName?: string) { - // The number in these "/cb-xxxxx" prefixes does not matter, it just has to be - // present. It marks the URL as checksummed, which gets it a manual Fastly - // surrogate key so a production deploy does not purge it. - // If you edit these images on disk, change the numbers. if (stagingName) { return `/assets/cb-346/images/site/evergreens/${stagingName}.png` } @@ -110,12 +108,7 @@ const MyApp = ({ Component, pageProps, languagesContext, stagingName }: MyAppPro dayScheme={theme.component.dayScheme} nightScheme={theme.component.nightScheme} > - {/* - Primer Brand ThemeProvider, nested so migrated @primer/react-brand - components receive brand theme context during the Docs 2026 migration - (github/docs-engineering#5879). Runs alongside the @primer/react - ThemeProvider above while the component-by-component swap is in progress. - */} + {/* @primer/react-brand context lets Brand components coexist with @primer/react. */} <BrandThemeProvider> <LanguagesContext.Provider value={languagesContext}> <SharedUIContextProvider> @@ -131,30 +124,22 @@ const MyApp = ({ Component, pageProps, languagesContext, stagingName }: MyAppPro MyApp.getInitialProps = async (appContext: AppContext) => { const { ctx } = appContext - // calls page's `getInitialProps` and fills `appProps.pageProps` const appProps = await App.getInitialProps(appContext) const req = ctx.req as unknown as ExtendedRequest - // Have to define the type manually here because `req.context.languages` - // comes from Node JS and is not type-aware. const languagesContext: LanguagesContextT = { languages: {}, } - // If we're rendering certain 404 error pages, the middleware might not - // yet have contextualized the `context.languages`. So omit this - // context mutation and live without it. - // Note, `req` will be undefined if this is the client-side rendering - // of a 500 page ("Ooops! It looks like something went wrong.") + // Some 404 renders lack req.context.languages. if (req?.context?.languages) { const languageEntries = Object.entries(req.context.languages as Record<string, LanguageItem>) for (const [langCode, langObj] of languageEntries) { - // Only pick out the keys we actually need languagesContext.languages[langCode] = { name: langObj.name, code: langObj.code, } - // The `hreflang` is used for the `<link rel="alternate">` tags. + // hreflang drives alternate-language link tags. if (langObj.hreflang && langObj.hreflang !== langObj.code) { languagesContext.languages[langCode].hreflang = langObj.hreflang } diff --git a/src/frame/start-server.ts b/src/frame/start-server.ts index aa7021dd454a..ec5f6620269e 100644 --- a/src/frame/start-server.ts +++ b/src/frame/start-server.ts @@ -1,6 +1,5 @@ -// IMPORTANT: OTel tracing MUST be the first import. It patches Node.js -// built-ins (http, etc.) at load time via auto-instrumentation. -// Moving this after any framework import will silently break tracing. +// Import tracing before framework code so auto-instrumentation can patch Node.js built-ins. +// Moving it later silently breaks OTel tracing. import '@/observability/lib/tracing' import http from 'http' @@ -44,17 +43,13 @@ async function checkPortAvailability() { } } +// startServer warms the idempotent server cache before listen, so development restarts do not +// block the first page refresh. +// The SIGTERM timer forces exit after 25s because the preStop hook sleeps 5s and Kubernetes +// SIGKILLs at 60s, while the deploy controller can time out on old terminating pods. async function startServer() { const app = createApp() - // Warm up as soon as possible. - // The `warmServer()` function is idempotent and it will soon be used - // by some middleware, but there's no point in having a started server - // without this warmed up. Besides, by starting this slow thing now, - // it can start immediately instead of waiting for the first request - // to trigger it to warm up. That way, when in development and triggering - // a `nodemon` restart, there's a good chance the warm up has come some - // way before you manage to reach for your browser to do a page refresh. await warmServer([]) // Workaround for https://github.com/expressjs/express/issues/1101 @@ -65,8 +60,7 @@ async function startServer() { process.once('SIGTERM', () => { logger.info('Received SIGTERM, beginning graceful shutdown', { pid: process.pid, port }) - // Force-close idle keep-alive sockets so server.close() doesn't hang - // waiting for them to disconnect naturally. + // Force-close idle keep-alive sockets so server.close() does not wait for natural disconnects. try { server.closeIdleConnections() } catch (err) { @@ -77,11 +71,6 @@ async function startServer() { logger.info('HTTP server closed') }) - // If in-flight requests haven't drained within 25s, force exit. - // Kubernetes sends SIGKILL at terminationGracePeriodSeconds (60s), - // but the deploy controller may time out before that if an old pod - // stays in "Terminating" state too long. The preStop hook sleeps 5s, - // so 25s here keeps total shutdown well under the 60s grace period. setTimeout(() => { logger.warn('Graceful shutdown timed out, forcing exit') try { diff --git a/src/frame/stylesheets/article-link-overrides.scss b/src/frame/stylesheets/article-link-overrides.scss index 47ea20966506..d3519c2ee5dc 100644 --- a/src/frame/stylesheets/article-link-overrides.scss +++ b/src/frame/stylesheets/article-link-overrides.scss @@ -1,49 +1,29 @@ -// Docs 2026 article-body link colour, overriding @primer/css's base `a` rule. +// Docs 2026 article-body links need Brand blue instead of @primer/css accent blue. // -// @primer/css/base/base.scss sets -// a { color: var(--fgColor-accent, var(--color-accent-fg)); } -// `--fgColor-accent` is defined nowhere in this app except inside a -// `forced-colors` block, so every link falls through to `--color-accent-fg` and -// paints Primer's accent blue — #0969da light / #58a6ff dark. That is still the -// OLD design system's accent; brand ships its own link blue, and the article -// body is the most visible place the difference shows. +// @primer/css/base/base.scss sets a to Primer accent blue through --fgColor-accent: +// #0969da light and #4493f8 dark. Article body links need Brand link blue instead. // -// Scoped to `#article-contents[data-article-body] .markdown-body`, matching the -// scope src/content-render/stylesheets/article-section-framing.scss uses for the -// article body. All three parts are load-bearing: this stylesheet is global, so -// a bare `a` rule would repaint the header, sidebar, landings, search and footer -// as well; `.markdown-body` on its own would still catch the REST / GraphQL / -// webhook pages that reuse the class; and the id on its own is not enough -// either, because AutomatedPage renders the same `#article-contents` wrapper for -// the GraphQL, webhook, audit-log and github-apps pages. The data attribute is -// set only by ArticlePage and TocLanding. +// The selector must match src/content-render/stylesheets/article-section-framing.scss: +// #article-contents[data-article-body] .markdown-body. A bare a rule would repaint the header, +// sidebar, landings, search, and footer. .markdown-body would also catch REST, GraphQL, and +// webhook pages. #article-contents would also catch AutomatedPage GraphQL, webhook, audit-log, +// and github-apps pages. ArticlePage and TocLanding set data-article-body. #article-contents[data-article-body] .markdown-body { - // `--brand-color-text-link-*` are the semantic tokens that brand's own - // InlineLink component aliases into `--brand-InlineLink-color-*`. Using the - // semantic pair keeps this independent of any ancestor that re-maps the - // component token — breadcrumbs-overrides.scss does exactly that. + // Use --brand-color-text-link-* semantic tokens because breadcrumbs-overrides.scss remaps + // --brand-InlineLink-color-* for breadcrumbs. // - // Three exclusions, all because this selector outweighs the rules that - // currently keep those anchors uncoloured: - // - `[href]` — @primer/css holds `.markdown-body a:not([href])` at - // `color: inherit`, for the bare named anchors markdown emits. - // - `:not(.heading-link)` — heading anchors wrap the entire heading text, - // and headings.scss holds them at `color: unset`. - // - `:not(.btn)` — CTA buttons in content are plain anchors carrying - // @primer/css's button classes (`<a class="btn btn-primary">`), so they - // match this rule too. `.btn-primary`'s own `color` is only (0,1,0) - // against this selector's (1,4,1), so without the exclusion the label - // painted brand link-blue on the green button — #005dd5 on #1f883d is - // 1.31:1, well under the 4.5:1 WCAG AA floor - // (github/technical-content#7679). Excluding `.btn` - // hands every button variant back to its own Primer colour tokens. + // Exclude anchors that this selector would otherwise override: + // a[href] preserves @primer/css .markdown-body a:not([href]) named anchors at color: inherit. + // :not(.heading-link) preserves headings.scss, which keeps heading text at color: unset. + // :not(.btn) preserves CTA button labels. A .btn-primary label would be #005dd5 on #1f883d, + // 1.31:1 contrast, below the 4.5:1 WCAG AA floor. a[href]:not(.heading-link):not(.btn) { - // Fallback literals are brand's LIGHT values, not Primer's. + // Fallback literals apply only when Brand tokens are undefined. color: var(--brand-color-text-link-rest, #005dd5); - // Pressed is brand's only other link colour state. It defines no `:visited` - // colour, and InlineLink's hover thickens the underline rather than changing - // colour, so @primer/css's hover underline is left alone. + // Pressed is Brand's only other link color state. + // Brand has no visited color, and InlineLink only thickens hover underlines. + // Leave @primer/css hover behavior unchanged. &:active { color: var(--brand-color-text-link-pressed, #002f7a); } diff --git a/src/frame/stylesheets/breadcrumbs-overrides.scss b/src/frame/stylesheets/breadcrumbs-overrides.scss index 9f04809032d7..743bce5fdb65 100644 --- a/src/frame/stylesheets/breadcrumbs-overrides.scss +++ b/src/frame/stylesheets/breadcrumbs-overrides.scss @@ -1,31 +1,21 @@ -// Docs 2026 breadcrumb treatment, overriding @primer/react-brand's Breadcrumbs. +// Docs 2026 breadcrumbs invert @primer/react-brand default emphasis. // -// Brand's `default` variant renders the ancestor crumbs at full strength and -// MUTES the current page. The design wants the inverse: the pages you are not -// viewing — and the "/" separators between them — recede, while the page you're -// on reads at full strength. +// Brand's default variant gives ancestor crumbs full strength and mutes the current page. The +// design needs ancestor pages and "/" separators to recede while the current page stays full +// strength. // -// Keyed off the `data-container` attribute rather than a class because brand's -// Breadcrumbs <nav> discards any className passed to it, so there is no class -// hook on that element. The element selectors (`nav`, `li`) are here to out- -// weigh brand's own rules, which are applied through the component's CSS module -// and can load after this stylesheet — specificity, rather than source order, -// is what keeps these overrides winning. +// Use data-container because Brand Breadcrumbs nav drops className, so no class hook reaches that +// element. The nav and li selectors raise specificity above Brand CSS modules that can load later. nav[data-container="breadcrumbs"] { - // Match the type of the SELECTED doc-tree nav item exactly. Brand already - // sizes crumbs at 14px/400 (text-size-100) but leaves letter-spacing unset, - // where the NavList item carries the 100-scale value. + // Match the selected doc-tree nav item type: 14px/400 plus 100-scale letter spacing. letter-spacing: var(--brand-text-letterSpacing-100); - // Ancestor crumbs (still links) and the separator rules recede. The separator - // is drawn as a rotated border on `li::after`, so it reads this token rather - // than `color`. + // Ancestor links and separators recede. li::after draws separators as a rotated border. --brand-InlineLink-color-rest: var(--brand-color-text-muted); --brand-InlineLink-color-pressed: var(--brand-color-text-muted); --brand-breadcrumbs-separator-borderColor: var(--brand-color-text-muted); - // The current page reads at full strength. Brand's default variant explicitly - // mutes this one, so this needs to outweigh a two-class selector. + // The current page needs more specificity than Brand's two-class selector. li [aria-current="page"] { color: var(--brand-color-text-default); } diff --git a/src/frame/stylesheets/dialog-overrides.scss b/src/frame/stylesheets/dialog-overrides.scss index 4401dcd0e78d..1f32f8305af5 100644 --- a/src/frame/stylesheets/dialog-overrides.scss +++ b/src/frame/stylesheets/dialog-overrides.scss @@ -1,7 +1,7 @@ @import "breakpoint-xxl.scss"; #__primerPortalRoot__ [class*="prc-Dialog-Backdrop"] { - /* Make sure the dialog backdrop is hidden on large screens */ + /* The XXL breakpoint below hides the dialog backdrop on large screens. */ display: flex; visibility: visible; @@ -11,7 +11,7 @@ } } -// Fix z-index for ActionMenu overlays (version picker, etc.) to appear above site header +// Keep ActionMenu overlays, including the version picker, above the site header. #__primerPortalRoot__ * { z-index: 3 !important; } diff --git a/src/frame/stylesheets/index.scss b/src/frame/stylesheets/index.scss index f2e1ea2ed82e..50bc17542340 100644 --- a/src/frame/stylesheets/index.scss +++ b/src/frame/stylesheets/index.scss @@ -1,38 +1,32 @@ -@import "@primer/css/color-modes/index.scss"; // Must come first -@import "@primer/css/core/index.scss"; // Must come second +@import "@primer/css/color-modes/index.scss"; // Color modes must load before core. +@import "@primer/css/core/index.scss"; // Core must load before component styles. -// Primer Primitives functional themes. +// Primer functional theme tokens bridge @primer/css and @primer/react. // -// @primer/css@21 only defines the legacy `--color-*` names (it pins its own -// nested @primer/primitives@7). @primer/react@38's component CSS, and a lot of -// our own SCSS, reference the newer `--fgColor-*` / `--bgColor-*` / -// `--borderColor-*` / `--button-*` names instead — and nothing in this app -// defined them. An undefined custom property makes the whole declaration -// invalid at computed-value time, so those rules either dropped out entirely -// (`border: 1px solid var(--borderColor-default)` painted no border at all) or -// fell through to a hardcoded light-mode hex that then won in BOTH modes. -// That is why PRC components rendered light-grey icons in dark mode. +// @primer/css defines legacy --color-* names, while @primer/react and this SCSS reference newer +// --fgColor-*, --bgColor-*, --borderColor-*, and --button-* names. Missing custom properties +// invalidate whole declarations at computed-value time, which drops rules or falls through to +// hardcoded light-mode hex values in both modes. Primer React components then render light-grey +// icons in dark mode. // -// Scoped by [data-color-mode]/[data-*-theme] exactly like the @primer/css -// themes above, so only the active theme's block applies. Limited to the four -// themes `SupportedTheme` actually allows (see src/color-schemes). +// Scope these imports by [data-color-mode] and [data-*-theme] like @primer/css themes, so only +// the active theme block applies. Limit them to the four themes SupportedTheme allows in +// src/color-schemes. @import "@primer/primitives/dist/css/functional/themes/light.css"; @import "@primer/primitives/dist/css/functional/themes/dark.css"; @import "@primer/primitives/dist/css/functional/themes/dark-dimmed.css"; @import "@primer/primitives/dist/css/functional/themes/dark-high-contrast.css"; -// Primer components @import "@primer/css/markdown/index.scss"; @import "@primer/css/labels/index.scss"; @import "@primer/css/alerts/index.scss"; @import "@primer/css/popover/index.scss"; -// Primer Brand design system (Docs 2026 migration — github/docs-engineering#5879). +// Primer Brand design system for the Docs 2026 migration. // Loaded alongside @primer/css during the incremental component-by-component swap. @import "@primer/react-brand/lib/css/main.css"; -// Registers the Mona Sans / Hubot Sans @font-face rules (+ metric-adjusted -// fallbacks) that back the brand `--brand-*-fontFamily` tokens. Without this, -// those tokens silently fall back to the system font stack. +// Registers the Mona Sans and Hubot Sans font-face rules plus metric-adjusted fallbacks that back +// the Brand fontFamily tokens. Without this, those tokens silently fall back to the system stack. @import "@primer/react-brand/fonts/fonts.css"; @import "headings.scss"; @@ -45,58 +39,39 @@ @import "src/content-render/stylesheets/index.scss"; @import "src/links/stylesheets/hover-card.scss"; -// For primer Spinners and other animated components @import "@primer/primitives/dist/css/base/motion/motion.css"; -// Docs 2026: Primer Brand owns the page canvas. +// Primer Brand owns the page canvas. // -// `@primer/css/base/native-colors.scss` paints both `body` and *every* -// `[data-color-mode]` element from the Primer Component palette: +// @primer/css/base/native-colors.scss paints body and every [data-color-mode] element from the +// Primer Component palette. @primer/react ThemeProvider renders a wrapper div with +// data-color-mode, while src/color-schemes/components/BrandThemeProvider.tsx does not, so that +// wrapper would paint Primer's canvas. In dark mode that canvas is #0d1117, while migrated Brand +// surfaces sit on #000000 or #0f1511. #0f1511 on #0d1117 is 1.02:1. // -// [data-color-mode] { -// color: var(--fgColor-default, var(--color-fg-default)); -// background-color: var(--bgColor-default, var(--color-canvas-default)); -// } +// Repoint the root canvas at Brand so migrated surfaces read together. Primer-palette subtle fills +// #161b22 and borders #30363d remain visible above true black. // -// @primer/react's `ThemeProvider` renders a real wrapping `<div data-color-mode>` -// (brand's no longer does; see src/color-schemes/components/BrandThemeProvider.tsx), -// so it matched that selector and painted itself Primer's canvas. In dark mode -// that is #0d1117 — a -// blue-tinted charcoal — while every surface migrated to Brand tokens sits on -// Brand's true black (#000000) or its green-tinted near-black (#0f1511). The -// result was up to three different "blacks" stacked on one page, and fills -// meant to read as a lift above the page instead read as an invisible dip below -// it (#0f1511 on #0d1117 is 1.02:1). -// -// Repointing the canvas at Brand is what makes the migrated surfaces cohere. -// Primer-palette components layered on top still read correctly, because their -// subtle fills (#161b22) and borders (#30363d) become lifts above true black -// rather than clashing steps beside it. -// -// The canvas is decided at the ROOT, and only at the root. `<html>` is the one -// element whose `data-color-mode` is correct before first paint: colorModeScript -// (src/color-schemes/lib/color-mode-script.ts) stamps it from the cookie -// synchronously in <head>. The SSR markup cannot carry the user's real mode, -// because that HTML is shared-cacheable in the CDN and must be identical for -// every request. +// Only the root decides the canvas. html carries the correct data-color-mode before first paint +// because src/color-schemes/lib/color-mode-script.ts stamps it from the cookie in head. SSR cannot +// carry the user's mode because shared CDN HTML must stay identical for every request. html[data-color-mode], html[data-color-mode] body { background-color: var(--brand-color-canvas-default); color: var(--brand-color-text-default); } -// ...and every NESTED [data-color-mode] wrapper stays transparent so the root -// canvas shows through — @primer/react's ThemeProvider still renders one. Never -// a `--brand-*` token here: a nested wrapper re-declares brand's palette and -// would paint its own mode, not the page's. `:not(html)` adds html's (0,0,1) to -// beat @primer/css's `[data-color-mode]` (0,1,0). +// Nested [data-color-mode] wrappers stay transparent so the root canvas shows through. +// @primer/react ThemeProvider still renders one. Never use a --brand-* token here: a nested +// wrapper re-declares Brand's palette and would paint its own mode instead of the page mode. +// :not(html) adds html specificity to beat @primer/css [data-color-mode] at 0,1,0. [data-color-mode]:not(html) { background-color: transparent; color: inherit; } -// No-JS fallback, not dead code: without the pre-paint script <html> keeps the -// SSR `auto` default, which @primer/primitives honours and brand does not. +// No-JS fallback: without the pre-paint script, html keeps the SSR auto default, which +// @primer/primitives honors and Brand does not. @media (prefers-color-scheme: dark) { html[data-color-mode="auto"][data-dark-theme*="dark"] { --brand-color-canvas-default: var(--base-color-scale-black-0); @@ -118,24 +93,19 @@ html[data-color-mode] body { } } -// Docs 2026: links use Brand's link colour. +// Site-wide links use Brand link blue. // -// `@primer/css/base/base.scss` sets `a { color: var(--fgColor-accent, …) }` — -// Primer's blue (#0969da light / #4493f8 dark). Brand's link blue is a -// different value (#0055d5 light / #a2daff dark) and is what the migrated -// surfaces are designed around. `.markdown-body` does not set its own anchor -// colour, so prose links inherit this too. +// @primer/css/base/base.scss sets a color to var(--fgColor-accent, ...) values: #0969da light and +// #4493f8 dark. Brand link blue is #0055d5 light and #a2daff dark. Migrated surfaces use Brand +// colors, and .markdown-body prose links inherit this rule. // -// Specificity is deliberately left at (0,0,1) to exactly match the rule this -// replaces. Anything more specific — `a[href]`, say — would start winning -// against component classes like `.prc-Button-*` (0,1,0) and repaint PRC -// buttons that happen to render as anchors. This is a value swap, not a -// cascade change; primer's own `a:not([href])` carve-outs still apply. +// Keep specificity at 0,0,1 to match the rule this replaces. More specificity, such as a[href], +// would win against component classes like .prc-Button-* at 0,1,0 and repaint PRC buttons that +// render as anchors. This swaps the value without changing the cascade, so @primer/css +// a:not([href]) carve-outs still apply. // -// article-link-overrides.scss layers a much more specific rule over the article -// body for the same token. That is not redundant: it adds the `:active` pressed -// state and deliberately exempts `.heading-link` and bare named anchors. This -// rule is the site-wide base; that one is the article body's specialisation. +// article-link-overrides.scss layers a more specific rule over the article body for the same token. +// It adds the :active pressed state and exempts .heading-link, .btn, and bare named anchors. a { color: var(--brand-color-text-link-rest); } diff --git a/src/frame/stylesheets/scroll-top.scss b/src/frame/stylesheets/scroll-top.scss index 4645b5ff64eb..f7c3f93d1a1e 100644 --- a/src/frame/stylesheets/scroll-top.scss +++ b/src/frame/stylesheets/scroll-top.scss @@ -1,10 +1,9 @@ @import "src/frame/stylesheets/breakpoint-xxl.scss"; -// Mirrors Brand's SubdomainNavBar height so every sticky offset tracks the real -// header. Brand defines that height in rem -- `--base-size-64` (4rem), reduced by -// `--base-size-8` (0.5rem) below its `medium` breakpoint of 47.99rem -- so these -// must be rem too. A px value with a px breakpoint silently drifts from the -// actual header for any reader whose browser default font size is not 16px. +// Brand SubdomainNavBar height uses rem, so the header portion of sticky offsets must use rem too. +// Brand sets --base-size-64 at 4rem and subtracts --base-size-8, 0.5rem, below its medium +// breakpoint of 47.99rem. A px value or px breakpoint drifts when readers change the browser +// default font size. :root { --docs-header-height: 3.5rem; @@ -21,6 +20,6 @@ @include breakpoint-xxl { scroll-margin-top: calc( var(--docs-header-height) + 44px - ) !important; // Header + secondary bar + ) !important; // Include the secondary bar below the header. } } diff --git a/src/frame/stylesheets/utilities.scss b/src/frame/stylesheets/utilities.scss index 38d550bc3949..5bf4343df673 100644 --- a/src/frame/stylesheets/utilities.scss +++ b/src/frame/stylesheets/utilities.scss @@ -37,7 +37,7 @@ width: 1px; } -// For the screenreader +// Screen readers and keyboard users need a visible skip link on focus. .skip-button { width: auto; height: auto; @@ -56,7 +56,7 @@ } } -// used to help prevent overlapping main content and the minitoc sidebar +// Avoid overlapping main content and the mini TOC sidebar at XXL widths. .d-xxl-block { @include breakpoint-xxl { display: block !important; diff --git a/src/frame/tests/content.ts b/src/frame/tests/content.ts index 0e145e55e82e..31e3efc9cc9f 100644 --- a/src/frame/tests/content.ts +++ b/src/frame/tests/content.ts @@ -36,7 +36,7 @@ describe('content files', () => { const orphanedFiles = contentFiles.filter((file) => !relativeFiles.includes(file)) - // Filter out intentional test fixture files that are meant to be orphaned + // These fixture files intentionally stay outside the tree. const allowedOrphanedFiles = [ path.join(contentDir, 'article-one.md'), path.join(contentDir, 'article-two.md'), diff --git a/src/frame/tests/fetch-utils.test.ts b/src/frame/tests/fetch-utils.test.ts index c98addc7aa7a..2bdfb82ef29f 100644 --- a/src/frame/tests/fetch-utils.test.ts +++ b/src/frame/tests/fetch-utils.test.ts @@ -85,8 +85,7 @@ describe('fetchWithRetry ttfb timeout mode', () => { await fetchWithRetry('https://example.test/big', {}, { timeout: 10, timeoutMode: 'ttfb' }) - // Wait well past the TTFB deadline; the timer must have been cleared so the - // signal stays unaborted and a subsequent body read wouldn't be cut off. + // Wait past the TTFB deadline to prove response resolution cleared the timer. await new Promise((resolve) => setTimeout(resolve, 40)) expect(capturedSignal?.aborted).toBe(false) expect(statsdIncrement).not.toHaveBeenCalled() @@ -197,9 +196,7 @@ describe('fetchStream timeout mode', () => { await fetchStream('https://example.test/stream', {}, { timeout: 10, throwHttpErrors: false }) - // A streaming caller reads the body well past the connect deadline. With the - // ttfb default the timer is cleared on response-resolve, so the signal stays - // unaborted and a long-running reader.read() loop won't be cut off. + // Wait past the TTFB deadline to prove response resolution cleared the default timer. await new Promise((resolve) => setTimeout(resolve, 40)) expect(capturedSignal?.aborted).toBe(false) expect(statsdIncrement).not.toHaveBeenCalled() diff --git a/src/frame/tests/find-page-middleware.ts b/src/frame/tests/find-page-middleware.ts index ab42f6d23883..9aa21f4047a5 100644 --- a/src/frame/tests/find-page-middleware.ts +++ b/src/frame/tests/find-page-middleware.ts @@ -127,8 +127,7 @@ describe('find page middleware', () => { }) test("will 404 if the request version doesn't match the page", async () => { - // The 'versions:' frontmatter on 'page-with-redirects.md' does - // not include ghes. So this'll eventually 404. + // page-with-redirects.md excludes GHES, so enterprise-server@latest eventually 404s. const [req, res] = makeRequestResponse('/en/page-with-redirects', 'enterprise-server@latest') const page = await Page.init({ relativePath: 'page-with-redirects.md', diff --git a/src/frame/tests/get-remote-json.ts b/src/frame/tests/get-remote-json.ts index 5f1270ef0b09..87e0e639ca0e 100644 --- a/src/frame/tests/get-remote-json.ts +++ b/src/frame/tests/get-remote-json.ts @@ -7,8 +7,7 @@ import nock from 'nock' import getRemoteJSON, { cache } from '@/frame/lib/get-remote-json' -// Covers the in-memory cache, and the fallback to the disk cache when memory -// misses. +// Covers in-memory caching and disk-cache fallback after a memory miss. describe('getRemoteJSON', () => { const envVarValueBefore = process.env.GET_REMOTE_JSON_DISK_CACHE_ROOT @@ -34,8 +33,7 @@ describe('getRemoteJSON', () => { const data = await getRemoteJSON(url, {}) expect((data as Record<string, unknown>).foo).toBe('bar') expect(cache.get(url)).toBeTruthy() - // Second time, despite not setting up a second nock(), will work - // because it can use memory now. + // A second network request would fail unless getRemoteJSON uses the memory cache. const data2 = await getRemoteJSON(url, {}) expect((data2 as Record<string, unknown>).foo).toBe('bar') expect(cache.get(url)).toBeTruthy() @@ -50,9 +48,7 @@ describe('getRemoteJSON', () => { expect(cache.get(url)).toBeTruthy() cache.delete(url) - // This time, the nock won't fail despite not using `.persist()`. - // That means it didn't need the network because it was able to - // use the disk cache. + // A second network request would fail unless getRemoteJSON uses the disk cache. const data2 = await getRemoteJSON(url, {}) expect((data2 as Record<string, unknown>).cool).toBe(true) }) @@ -70,8 +66,7 @@ describe('getRemoteJSON', () => { } cache.delete(url) - // If we don't do this, nock will fail because a second network - // request became necessary. + // A second nock response lets getRemoteJSON recover after the corrupted disk cache misses. nock(origin).get(pathname).reply(200, { cool: true }) const data = await getRemoteJSON(url, {}) @@ -92,8 +87,7 @@ describe('getRemoteJSON', () => { } cache.delete(url) - // If we don't do this, nock will fail because a second network - // request became necessary. + // A second nock response lets getRemoteJSON recover after the corrupted disk cache misses. nock(origin).get(pathname).reply(200, { cool: true }) const data = await getRemoteJSON(url, {}) diff --git a/src/frame/tests/manifest.ts b/src/frame/tests/manifest.ts index 4582d1087d3f..88d5d1211dbd 100644 --- a/src/frame/tests/manifest.ts +++ b/src/frame/tests/manifest.ts @@ -26,7 +26,7 @@ describe('manifest', () => { const res = await get(url) expect(res.statusCode).toBe(200) - // Check that it can be cached at the CDN + // CDN caching must not set cookies and must include the no-language surrogate key. expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -45,7 +45,7 @@ describe('manifest', () => { const iconRes = await get(icon.src, { responseType: 'buffer' }) expect(iconRes.statusCode).toBe(200) expect(iconRes.headers['content-type']).toBe(icon.type) - // The `sizes` should match the payload + // sizes must match the image payload. const image = sharp(iconRes.body) const [width, height] = icon.sizes.split('x').map((s) => parseInt(s)) const dimensions = await image.metadata() diff --git a/src/frame/tests/mini-toc-items.ts b/src/frame/tests/mini-toc-items.ts index 3b43d011f62f..a8ea0eead14b 100644 --- a/src/frame/tests/mini-toc-items.ts +++ b/src/frame/tests/mini-toc-items.ts @@ -28,18 +28,7 @@ describe('buildMiniTocFromCollected', () => { expect(tocItems[0].items?.length).toBe(3) }) - /** - * Mock scenario from: /en/rest/reference/apps - * The TOC starts out with lower importance headers that aren't nested in - * higher importance headers - * - * 3 - * 3 - * 2 - * 3 - * 2 - * 3 - */ + // /en/rest/reference/apps begins with unnested levels 3, 3, then continues 2, 3, 2, 3. test('creates toc that starts with lower importance headers', () => { const collected = [ heading('section-1-A', 3), @@ -54,8 +43,7 @@ describe('buildMiniTocFromCollected', () => { expect(tocItems[3].items?.length).toBe(1) }) - // Mock scenario from: - // /en/organizations/managing-membership-in-your-organization/inviting-users-to-join-your-organization + // Scenario from: /en/organizations/managing-membership-in-your-organization/inviting-users-to-join-your-organization test('creates empty toc', () => { const tocItems = buildMiniTocFromCollected([], 3) expect(tocItems.length).toBe(0) diff --git a/src/frame/tests/non-child-pages-resolution.test.ts b/src/frame/tests/non-child-pages-resolution.test.ts index 47fe3c6fd6e9..ec1bb1fc841b 100644 --- a/src/frame/tests/non-child-pages-resolution.test.ts +++ b/src/frame/tests/non-child-pages-resolution.test.ts @@ -4,13 +4,11 @@ import fs from 'fs' const ROOT = path.resolve(__dirname, '../../..') -// Tests for non-child page resolution: a `/content/` prefix in children -// frontmatter resolves to an absolute content path, which is what lets a page -// pull in a directory or article from another product. +// Tests for non-child page resolution: /content/ in children frontmatter resolves to an absolute +// content path, so a page can pull in a directory or article from another product. // -// Note this file imports no production code. The behaviour tests reimplement -// logic from create-tree.ts and current-product-tree.ts, and the rest only check -// that fixture files exist, so none of it exercises the real resolution path. +// This file imports no production code. Behavior tests reimplement logic from create-tree.ts and +// current-product-tree.ts, and the rest only check fixtures, so none exercises the real path. describe('Non-child page resolution', () => { describe('/content/ prefix in children frontmatter', () => { @@ -34,7 +32,6 @@ describe('Non-child page resolution', () => { const basePath = '/Users/test/docs-internal/content' const child = '/content/actions/workflows' - // Simulate the logic from create-tree.ts let childPath: string if (child.startsWith('/content/')) { const absoluteChildPath = child.slice('/content/'.length) @@ -50,7 +47,6 @@ describe('Non-child page resolution', () => { const basePath = '/Users/test/docs-internal/content' const child = '/content/get-started/foo/bar' - // Simulate the logic from create-tree.ts let childPath: string if (child.startsWith('/content/')) { const absoluteChildPath = child.slice('/content/'.length) @@ -66,7 +62,6 @@ describe('Non-child page resolution', () => { const originalPath = '/Users/test/docs-internal/content/get-started' const child = '/local-child' - // Simulate the logic from create-tree.ts let childPath: string if (child.startsWith('/content/')) { const absoluteChildPath = child.slice('/content/'.length) @@ -143,8 +138,7 @@ describe('Non-child page resolution', () => { describe('translation behavior', () => { test('cross-product children paths are language-agnostic', () => { - // The /content/ prefix paths should work regardless of the current language - // The actual translation is handled by the page loading system + // Translation happens after /content/ cross-product path resolution. const child = '/content/actions/using-workflows/storing-workflow-data-as-artifacts' expect(child.startsWith('/content/')).toBe(true) @@ -152,8 +146,7 @@ describe('Non-child page resolution', () => { }) test('resolved paths use content directory, not translations', () => { - // Cross-product children are resolved from the main content directory - // Translations are handled separately by the page rendering system + // Cross-product children resolve from the main content directory before translations apply. const basePath = '/Users/test/docs-internal/content' const child = '/content/actions/workflows' @@ -165,7 +158,6 @@ describe('Non-child page resolution', () => { describe('crossProductChild flag', () => { test('flag is set for /content/ prefix paths', () => { - // Simulate the logic from create-tree.ts const child = '/content/actions/workflows' const isCrossProduct = child.startsWith('/content/') expect(isCrossProduct).toBe(true) @@ -178,7 +170,6 @@ describe('Non-child page resolution', () => { }) test('crossProductChild flag excludes items from sidebar', () => { - // Simulate the sidebarTree filtering logic const childPages = [ { href: '/en/get-started/foo', title: 'Foo', crossProductChild: false }, { href: '/en/actions/workflows', title: 'Workflows', crossProductChild: true }, @@ -192,11 +183,8 @@ describe('Non-child page resolution', () => { }) describe('descendant-of-sibling filtering in sidebar', () => { + // When a bespoke landing page lists a parent group and its articles, the sidebar drops those articles. test('filters out children that are descendants of another sibling', () => { - // Simulate the sidebarTree descendant filtering logic. - // When a bespoke landing page lists both individual articles and their - // parent group as children, the individual articles should be filtered out - // from the sidebar (they appear nested under their parent group instead). const childPages = [ { href: '/en/get-started/copilot/add-custom-instructions', @@ -212,13 +200,11 @@ describe('Non-child page resolution', () => { (child) => !siblingHrefs.some((sh) => sh !== child.href && child.href.startsWith(`${sh}/`)), ) - // The two individual articles under /copilot/ should be removed expect(dedupedChildPages).toHaveLength(2) expect(dedupedChildPages.map((c) => c.title)).toEqual(['Copilot', 'Actions']) }) test('does not filter children that are not descendants of any sibling', () => { - // Normal case: no overlapping paths, nothing should be filtered const childPages = [ { href: '/en/get-started/copilot', title: 'Copilot' }, { href: '/en/get-started/actions', title: 'Actions' }, @@ -234,7 +220,6 @@ describe('Non-child page resolution', () => { }) test('handles multiple overlapping groups correctly', () => { - // Multiple groups each with their own individual articles listed const childPages = [ { href: '/en/get-started/copilot/article-a', title: 'Article A' }, { href: '/en/get-started/copilot', title: 'Copilot' }, diff --git a/src/frame/tests/page.ts b/src/frame/tests/page.ts index bebcd92e1960..7e14641ed98d 100644 --- a/src/frame/tests/page.ts +++ b/src/frame/tests/page.ts @@ -23,7 +23,6 @@ const enterpriseServerVersions = Object.keys(allVersions).filter((v) => v.startsWith('enterprise-server@'), ) -// get the `free-pro-team` segment of `free-pro-team@latest` const nonEnterpriseDefaultPlan = nonEnterpriseDefaultVersion.split('@')[0] const opts = { @@ -80,9 +79,7 @@ describe('Page class', () => { }) describe('page.render(context)', () => { - // Most of our Liquid versioning tests are in https://github.com/docs/render-content, - // But they don't have access to our currently supported versions, which we're testing here. - // This test ensures that this works as expected: {% if enterpriseServerVersions contains currentVersion %} + // Test {% if enterpriseServerVersions contains currentVersion %} here; docs/render-content lacks version data. test('renders the expected Enterprise Server versioned content', async () => { const page = await Page.init({ relativePath: 'page-versioned-for-all-enterprise-releases.md', @@ -104,8 +101,7 @@ describe('Page class', () => { 'This text should only render on non-Enterprise', ) - // change version to the oldest enterprise version, re-render, and test again; - // the results should be the same + // Re-render with the oldest Enterprise Server version; the text must stay the same. context.currentVersion = `enterprise-server@${enterpriseServerReleases.oldestSupported}` context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page!.relativePath}` rendered = await page!.render(context) @@ -117,8 +113,7 @@ describe('Page class', () => { 'This text should only render on non-Enterprise', ) - // change version to non-enterprise, re-render, and test again; - // the results should be the opposite + // Re-render with the non-enterprise version; the text must invert. context.currentVersion = nonEnterpriseDefaultVersion context.currentPath = `/${context.currentLanguage}/${context.currentVersion}/${page!.relativePath}` rendered = await page!.render(context) @@ -325,37 +320,17 @@ describe('Page class', () => { expect(page!.versions.ghes).toBe('*') }) + // Feature versions must combine with frontmatter versions so latest GHES remains applicable. test('feature versions frontmatter', async () => { - // This fixture file has the frontmatter: - // - // versions: - // fpt: '*' - // ghes: '*' - // feature: 'placeholder' - // - // and placeholder.yml has: - // - // versions: - // ghes: '<3.0' - // - // So we expect to get the versioning from both. const page = await Page.init({ relativePath: 'feature-versions-frontmatter.md', basePath: path.join(__dirname, '../../../src/fixtures/fixtures'), languageCode: 'en', }) - // Test the raw page data. expect(page!.versions.fpt).toBe('*') expect(page!.versions.ghes).toBe('>2.21') - // Test the resolved versioning, where GHES releases specified in frontmatter and in - // feature versions are combined (i.e., one doesn't overwrite the other). - // We can't test that GHES 2.21 is _not_ included here (which it shouldn't be), - // because lib/get-applicable-versions only returns currently supported versions, - // so as soon as 2.21 is deprecated, a test for that _not_ to exist will not be meaningful. - // But by testing that the _latest_ GHES version is returned, we can ensure that the - // the frontmatter GHES `*` is not being overwritten by the placeholder's GHES `<3.0`. expect(page!.applicableVersions.includes('free-pro-team@latest')).toBe(true) expect(page!.applicableVersions.includes(`enterprise-server@${latest}`)).toBe(true) expect(page!.applicableVersions.includes('feature')).toBe(false) @@ -469,7 +444,6 @@ describe('catches errors thrown in Page class', () => { expect(page!.product).toBe('') expect(page!.permissions).toBe('') - // Change to FPT context.page.version = nonEnterpriseDefaultVersion context.version = nonEnterpriseDefaultVersion context.currentPath = '/en/optional/attributes' diff --git a/src/frame/tests/pages.ts b/src/frame/tests/pages.ts index 913c49cd5364..7827708918a3 100644 --- a/src/frame/tests/pages.ts +++ b/src/frame/tests/pages.ts @@ -57,7 +57,7 @@ describe('pages module', () => { const redirectToFiles = new Map<string, Set<string>>() const versionedRedirects: Array<{ path: string; file: string }> = [] - // Page objects have dynamic properties from chain/lodash that aren't fully typed + // lodash pick loses the concrete Page fields that the redirect loop needs. for (const page of englishPages) { const pageObj = page as Record<string, unknown> for (const redirect of pageObj.redirect_from as string[]) { @@ -72,7 +72,7 @@ describe('pages module', () => { } } - // Only consider as duplicate if more than one unique file defines the same redirect + // A redirect duplicates only when more than one file defines it. const duplicates = Array.from(redirectToFiles.entries()) .filter(([, files]) => files.size > 1) .map(([redirectPath]) => redirectPath) @@ -93,15 +93,15 @@ describe('pages module', () => { .filter((page) => { slugger.reset() return ( - page.languageCode === 'en' && // only check English - !page.relativePath.includes('index.md') && // ignore TOCs - // Page class has dynamic frontmatter properties like 'allowTitleToDifferFromFilename' not in type definition - !(page as Record<string, unknown>).allowTitleToDifferFromFilename && // ignore docs with override + page.languageCode === 'en' && // Only English pages enforce filename-title matching. + !page.relativePath.includes('index.md') && // TOCs do not use slugified filenames. + // Page has dynamic frontmatter properties that its type omits. + !(page as Record<string, unknown>).allowTitleToDifferFromFilename && // Allows override. slugger.slug(decode(page.title)) !== path.basename(page.relativePath, '.md') && slugger.slug(decode(page.shortTitle || '')) !== path.basename(page.relativePath, '.md') ) }) - // make the output easier to read + // Format failures for review. .map((page) => { return JSON.stringify( { @@ -126,7 +126,7 @@ describe('pages module', () => { test('every page has valid frontmatter', async () => { const frontmatterErrors = chain(pages) - // Page class has dynamic error properties like 'frontmatterErrors' not in type definition + // Loaded pages cannot expose frontmatterErrors because Page throws before construction. .map((page) => (page as Record<string, unknown>).frontmatterErrors) .filter(Boolean) .flatten() @@ -146,7 +146,7 @@ describe('pages module', () => { const liquidErrors: Array<{ filename: string; error: string }> = [] for (const page of pages) { - // Page class has dynamic properties like 'raw' markdown not in type definition + // raw is not a Page property here, so this loop does not parse page markdown. const markdown = (page as Record<string, unknown>).raw as string if (!patterns.hasLiquid.test(markdown)) continue try { diff --git a/src/frame/tests/path-utils.ts b/src/frame/tests/path-utils.ts index 1df7e18f0bba..302b6ec4165e 100644 --- a/src/frame/tests/path-utils.ts +++ b/src/frame/tests/path-utils.ts @@ -31,7 +31,7 @@ describe('getProductStringFromPath', () => { }) test('extracts product from versioned paths', () => { - // Note: These tests use free-pro-team which is a supported version + // free-pro-team@latest is a supported version for these paths. expect(getProductStringFromPath('/en/free-pro-team@latest/admin/installation')).toBe('admin') expect(getProductStringFromPath('/free-pro-team@latest/actions/quickstart')).toBe('actions') expect(getProductStringFromPath('/en/free-pro-team@latest/github/getting-started')).toBe( @@ -40,7 +40,7 @@ describe('getProductStringFromPath', () => { }) test('handles enterprise landing pages (version without product)', () => { - // When a version is present but no product segment follows, return the version string + // A version without a product segment resolves to the version string. expect(getProductStringFromPath('/en/free-pro-team@latest')).toBe('free-pro-team@latest') expect(getProductStringFromPath('/enterprise-server@latest')).toBe('enterprise-server@latest') }) diff --git a/src/frame/tests/permalink.ts b/src/frame/tests/permalink.ts index a1b9e99426de..0f61f784590f 100644 --- a/src/frame/tests/permalink.ts +++ b/src/frame/tests/permalink.ts @@ -5,8 +5,7 @@ import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases' import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' import getApplicableVersions from '@/versions/lib/get-applicable-versions' -// Permalink constructor requires: languageCode, pageVersion, relativePath, title -// Permalink.derive requires: languageCode, relativePath, title, versions (<- FM prop) +// Permalink.derive receives applicableVersions after Page merges feature-derived versions. describe('Permalink class', () => { test('derives info for unversioned homepage', () => { diff --git a/src/frame/tests/resolve-carousels.test.ts b/src/frame/tests/resolve-carousels.test.ts index d185c24c10b6..a7a49d80f1c8 100644 --- a/src/frame/tests/resolve-carousels.test.ts +++ b/src/frame/tests/resolve-carousels.test.ts @@ -10,7 +10,7 @@ vi.mock('@/frame/lib/find-page', () => ({ vi.mock('@/content-render/index', () => ({ renderContent: vi.fn((content, _context, options) => { - // When textOnly is true, return plain text (no HTML wrapper) + // textOnly returns plain text so carousel intros stay text-only. if (options?.textOnly) { return content } @@ -199,7 +199,6 @@ describe('resolveCarousels middleware', () => { req.context!.pages, req.context!.redirects, ) - // Carousel should not be added if all articles are not found expect( (req.context!.page as Page & { carousels?: Record<string, ResolvedArticle[]> }).carousels, ).toBeUndefined() @@ -217,7 +216,6 @@ describe('resolveCarousels middleware', () => { await resolveCarousels(req, mockRes, mockNext) - // Should still call next even on error expect(mockNext).toHaveBeenCalled() }) @@ -260,7 +258,7 @@ describe('resolveCarousels middleware', () => { applicableVersions: ['free-pro-team@latest'], } - // Mock findPage to fail on first call (content-relative) and succeed on second (page-relative) + // First lookup is content-relative; second lookup is page-relative. mockFindPage.mockReturnValueOnce(undefined).mockReturnValueOnce(testPage as unknown as Page) const req = createMockRequest({ @@ -318,7 +316,6 @@ describe('resolveCarousels middleware', () => { req.context!.redirects, ) - // Verify that the href is a clean path without language/version expect( (req.context!.page as Page & { carousels?: Record<string, ResolvedArticle[]> }).carousels, ).toEqual({ @@ -354,7 +351,6 @@ describe('resolveCarousels middleware', () => { await resolveCarousels(req, mockRes, mockNext) - // The carousels should not be added since the article isn't available in enterprise-cloud expect( (req.context!.page as Page & { carousels?: Record<string, ResolvedArticle[]> }).carousels, ).toBeUndefined() @@ -377,7 +373,6 @@ describe('resolveCarousels middleware', () => { await resolveCarousels(req, mockRes, mockNext) - // Should only have one article, not three duplicates expect( (req.context!.page as Page & { carousels?: Record<string, ResolvedArticle[]> }).carousels, ).toEqual({ diff --git a/src/frame/tests/secure-files.ts b/src/frame/tests/secure-files.ts index 9c760ecd3bc6..bf795fab65f2 100644 --- a/src/frame/tests/secure-files.ts +++ b/src/frame/tests/secure-files.ts @@ -2,13 +2,7 @@ import fs from 'fs/promises' import { describe, expect, test } from 'vitest' -/* - * Verify that a list of file paths are present and optionally have a CODEOWNERS entry - * - * name: Readable description of file(s) - * path: Path to secure files (must match entry in CODEOWNERS if code owner required) - * requiredCodeOwner: (optional) Name of code owner if a code owner is required - */ +// path must match the CODEOWNERS entry when requiredCodeOwner is set. type SecureFile = { name: string path: string diff --git a/src/frame/tests/server.ts b/src/frame/tests/server.ts index f97554d740ca..011911e5c17a 100644 --- a/src/frame/tests/server.ts +++ b/src/frame/tests/server.ts @@ -15,10 +15,8 @@ interface Category { published_articles: string[] } -// Parses a Content-Security-Policy header into its directives. Mirrors the -// behavior of the unmaintained `csp-parse` package it replaces: the policy is -// lowercased, split on `;`, and each directive's values are returned as a -// space-joined string, or an empty string when the directive is absent. +// Match unmaintained csp-parse: lowercase the policy, split directives on semicolons, and join +// each directive's values with spaces. get() returns '' when a directive is absent. function parseCsp(policy: string) { const directives = new Map<string, string>() for (const part of (policy || '').toLowerCase().split(';')) { @@ -33,10 +31,8 @@ function parseCsp(policy: string) { describe('server', () => { vi.setConfig({ testTimeout: 60 * 1000 }) + // Warm /en first so a slow first page load fails here instead of in the first test. beforeAll(async () => { - // The first page load takes a long time so let's get it out of the way in - // advance to call out that problem specifically rather than misleadingly - // attributing it to the first test const res = await get('/en') expect(res.statusCode).toBe(200) }) @@ -46,9 +42,7 @@ describe('server', () => { expect(res.statusCode).toBe(200) expect(res.headers['content-length']).toBe('0') expect(res.body).toBe('') - // Because the HEAD requests can't be different no matter what's - // in the request headers (Accept-Language or Cookies) - // it's safe to let it cache. The only key is the URL. + // HEAD responses ignore Accept-Language and Cookies, so URL alone can key the public cache. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=\d+/) }) @@ -115,8 +109,7 @@ describe('server', () => { expect(keys).toContain('product:get-started') expect(keys).toContain('product:get-started,language:en') expect(keys.some((key: string) => /^version:.+/.test(key))).toBe(true) - // Exact key, not just a pattern, to lock the render side byte-for-byte to what - // the purge job rebuilds from a changed file path (content/get-started/index.md). + // The render key must match the purge key derived from content/get-started/index.md. expect(keys).toContain('language:en,path:get-started/index.md') expect(keys).toContain(makePageSurrogateKey('en', 'get-started/index.md')) expect(keys.length).toBeLessThanOrEqual(6) @@ -129,8 +122,7 @@ describe('server', () => { }) test('renders a 404 page', async () => { - // Important to use the prefix /en/ on the failing URL or else - // it will render a very basic plain text 404 response. + // The /en/ prefix reaches the full 404 page instead of the plain text fallback. const $ = await getDOM('/en/not-a-real-page', { allow404: true }) expect(($ as unknown as { text(): string }).text()).toContain('Page not found.') expect($.res.statusCode).toBe(404) @@ -141,10 +133,7 @@ describe('server', () => { expect(res.statusCode).toBe(404) }) - // When using `got()` to send full end-to-end URLs, you can't use - // URLs like in this test because got will - // throw `RequestError: URI malformed`. - // So for now, this test is skipped. + // The skip predates the native-fetch helper, which sends this malformed path unchanged. test.skip('renders a 400 for invalid paths', async () => { const $ = await getDOM('/en/%7B%') expect($.res.statusCode).toBe(400) @@ -153,7 +142,7 @@ describe('server', () => { test('renders a 500 page when errors are thrown', async () => { const $ = await getDOM('/_500', { allow500s: true }) expect($('h1').first().text()).toBe('Ooops!') - // Using type assertion because cheerio v1 types don't include text() on root + // Cheerio v1 root types omit text(). expect( ($ as unknown as { text(): string }).text().includes('It looks like something went wrong.'), ).toBe(true) @@ -177,7 +166,6 @@ describe('server', () => { expect(res.statusCode).toBe(400) }) - // see issue 9678 test('does not use cached intros in subcategories', async () => { let $ = await getDOM( '/en/get-started/importing-your-projects-to-github/importing-source-code-to-github/importing-a-git-repository-using-the-command-line', @@ -196,7 +184,7 @@ describe('server', () => { expect(res.headers['access-control-allow-origin']).toBe('*') - // Check that it can be cached at the CDN + // CDN caching must not set cookies. expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -213,11 +201,7 @@ describe('server', () => { describeViaActionsOnly('Early Access articles', () => { test('have noindex meta tags', async () => { const allPages = await loadPages() - // This is what the earlyAccessContext middleware does to get a - // list of early-access pages for that TOC it displays when - // viewing /en/early-access in development. - // Here we're using it to get a least 1 page we can end-to-end - // test to look at it's meta tags. + // Match earlyAccessContext's development TOC input: English hidden early-access articles. const hiddenPages = allPages.filter( (page) => page.languageCode === 'en' && @@ -237,7 +221,7 @@ describe('server', () => { const res = await get('/articles/deleting-a-team', { followRedirects: false }) expect(res.statusCode).toBe(302) expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects must vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') @@ -249,18 +233,14 @@ describe('server', () => { expect(res.statusCode).toBe(302) expect(res.headers.location).toBe('/en') expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects must vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') expect(res.headers.vary).toContain('x-user-language') }) - // This test exists because in a previous life, our NextJS used to - // 500 if the 'Accept-Language' header was malformed. - // We *used* have a custom middleware to cope with this and force a - // fallback redirect. - // See internal issue 19909 + // Invalid Accept-Language once triggered a downstream Next.js 500; this route must redirect. test('redirects /en if Accept-Language header is malformed', async () => { const res = await get('/', { headers: { @@ -272,7 +252,7 @@ describe('server', () => { expect(res.statusCode).toBe(302) expect(res.headers.location).toBe('/en') expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects must vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') @@ -290,7 +270,7 @@ describe('server', () => { expect(res.statusCode).toBe(302) expect(res.headers.location).toBe('/en') expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects must vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') @@ -302,7 +282,7 @@ describe('server', () => { expect(res.statusCode).toBe(302) expect(res.headers.location.startsWith('/en/')).toBe(true) expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects must vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') @@ -359,7 +339,6 @@ describe('server', () => { expect(res.statusCode).toBe(200) expect(res.headers['content-type']).toContain('text/markdown') expect(res.body).toMatch(/^# .+/) - // Verify the landing page has content beyond just the title expect(res.body).toMatch(/\n\n/) expect(res.body.split('\n').length).toBeGreaterThan(3) }) @@ -398,10 +377,9 @@ describe('static routes', () => { expect(res.statusCode).toBe(200) expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=\d+/) - // Because static assets shouldn't be setting a cookie. + // Static assets must not set cookies. expect(res.headers['set-cookie']).toBeUndefined() - // The "Surrogate-Key" header is set so we can do smart invalidation - // in the Fastly CDN. This needs to be available for static assets too. + // Unhashed asset URLs use the generic language surrogate key, not the manual key. expect(res.headers['surrogate-key']).toBeTruthy() expect(res.headers.etag).toBeUndefined() expect(res.headers['last-modified']).toBeTruthy() @@ -433,7 +411,7 @@ describe('static routes', () => { expect(res.statusCode).toBe(200) expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=\d+/) - // Because static assets shouldn't be setting a cookie. + // Static assets must not set cookies. expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers.etag).toBeUndefined() expect(res.headers['last-modified']).toBeTruthy() @@ -459,8 +437,7 @@ describe('static routes', () => { '/server.js', '/.git', '/.env', - // Also add paths that aren't at the root. But it doesn't matter - // which page this is done for so much. + // Nested dotfile paths prove product routing cannot expose repo contents. '/en/billing/.env', '/en/billing/.env.local', '/en/pages/.env_sample', diff --git a/src/frame/tests/site-tree.ts b/src/frame/tests/site-tree.ts index a62099e603be..1be82f9da431 100644 --- a/src/frame/tests/site-tree.ts +++ b/src/frame/tests/site-tree.ts @@ -37,14 +37,12 @@ describe('siteTree', () => { const ghesLatest = `enterprise-server@${latestEnterpriseRelease}` const ghesSiteTree = siteTree.en[ghesLatest] - // Find a page in the tree that we know contains Liquid const pageWithDynamicTitle = findPageInSiteTree( ghesSiteTree, siteTree.en[nonEnterpriseDefaultVersion], `/en/${ghesLatest}/admin/installing-your-enterprise-server`, ) - // Confirm the raw title contains Liquid expect(pageWithDynamicTitle.page.title).toEqual( 'Installing {% data variables.product.prodname_enterprise %}', ) @@ -62,7 +60,7 @@ describe('siteTree', () => { function validate(currentPage: Tree): void { const childPages: Tree[] = currentPage.childPages || [] for (const childPage of childPages) { - // Store page reference before validation to avoid type narrowing + // Store page reference before validation to avoid type narrowing. const pageRef: Tree = childPage const isValid = siteTreeValidate(childPage) let errors: string | undefined diff --git a/src/frame/tests/url-encoding.ts b/src/frame/tests/url-encoding.ts index 2c4d0e8d32c9..6927f46ff21c 100644 --- a/src/frame/tests/url-encoding.ts +++ b/src/frame/tests/url-encoding.ts @@ -2,15 +2,12 @@ import { describe, expect, test } from 'vitest' import { get } from '@/tests/helpers/e2etest' describe('URL encoding for version paths', () => { + // SharePoint encodes @ as %40: /en/enterprise-cloud@latest becomes /en/enterprise-cloud%40latest. test('handles URL-encoded @ symbol in enterprise-cloud version', async () => { - // SharePoint encodes @ as %40, so /en/enterprise-cloud@latest becomes /en/enterprise-cloud%40latest const encodedUrl = '/en/enterprise-cloud%40latest/copilot/concepts/chat' const res = await get(encodedUrl) - // Should either: - // 1. Work directly (200) - the encoded URL should decode and work - // 2. Redirect (301/302) to the proper decoded URL - // Should NOT return 404 + // Encoded @ may render directly or redirect to the decoded URL, but it must not 404. expect([200, 301, 302]).toContain(res.statusCode) if (res.statusCode === 301 || res.statusCode === 302) { @@ -48,21 +45,20 @@ describe('URL encoding for version paths', () => { }) test('URL encoding in other parts of URL is preserved', async () => { - // Only @ symbols in version paths should be decoded, other encoding should be preserved + // A literal @ in the version segment must not decode unrelated URL encoding. const encodedUrl = '/en/enterprise-cloud@latest/copilot/concepts/some%20page' const res = await get(encodedUrl) - // This might 404 if the page doesn't exist, but shouldn't break due to encoding + // Missing pages may 404, but unrelated URL encoding must not break the request. expect(res.statusCode).not.toBe(500) }) test('Express URL properties are correctly updated after decoding', async () => { - // Test that req.path, req.query, etc. are properly updated when req.url is modified + // Updating req.url must also refresh Express request properties such as req.path and req.query. const encodedUrl = '/en/enterprise-cloud%40latest/copilot/concepts/chat?test=value' const res = await get(encodedUrl) - // Should work correctly (200 or redirect) - the middleware should properly update - // req.path from '/en/enterprise-cloud%40latest/...' to '/en/enterprise-cloud@latest/...' + // Middleware updates req.path from enterprise-cloud%40latest to enterprise-cloud@latest. expect([200, 301, 302]).toContain(res.statusCode) }) }) diff --git a/src/ghes-releases/lib/enterprise-dates.json b/src/ghes-releases/lib/enterprise-dates.json index db9a92ca6830..5e80070978bf 100644 --- a/src/ghes-releases/lib/enterprise-dates.json +++ b/src/ghes-releases/lib/enterprise-dates.json @@ -247,7 +247,7 @@ }, "3.17": { "releaseDate": "2025-05-13", - "deprecationDate": "2026-09-22", + "deprecationDate": "2026-09-24", "releaseCandidateDate": "2025-05-13", "generalAvailabilityDate": "2025-06-03" }, diff --git a/src/ghes-releases/lib/parse-release-notes.ts b/src/ghes-releases/lib/parse-release-notes.ts index e235430cd314..b27fe866693f 100644 --- a/src/ghes-releases/lib/parse-release-notes.ts +++ b/src/ghes-releases/lib/parse-release-notes.ts @@ -1,7 +1,5 @@ -/** - * Pure parsing/extraction functions used by generate-release-notes.ts. - * Extracted here so they can be unit-tested without triggering the CLI. - */ +// generate-release-notes.ts uses side-effect-free parsers. +// Tests can import them without starting the CLI. import fs from 'fs' import { load } from 'js-yaml' @@ -11,14 +9,11 @@ export interface NoteEntry { sourceUrl: string } -/** - * Looks for ```yaml ... ``` blocks, or falls back to lines starting with "- heading:" - */ +// Agent output can omit fences, so fall back to the first line starting with "- heading:". export function extractYaml(agentOutput: string): string | null { const fenced = agentOutput.match(/```ya?ml\s*\n([\s\S]*?)```/) if (fenced) return fenced[1].trim() - // Fall back: look for lines that look like YAML note entries const lines = agentOutput.split('\n') const yamlLines: string[] = [] let inYaml = false @@ -27,7 +22,7 @@ export function extractYaml(agentOutput: string): string | null { inYaml = true } if (inYaml) { - // Stop if we hit a non-YAML line (not indented, not a list item, not a comment, not empty) + // End fallback YAML when the response resumes prose. if (line.trim() && !line.match(/^[\s#-]/) && !line.match(/^\s+\w+:/)) { break } @@ -66,17 +61,12 @@ export function parseNoteEntries(yamlStr: string, sourceUrl: string): NoteEntry[ } } } catch { - // Malformed YAML returns no entries rather than throwing. + // Bad agent YAML produces no entries so one issue cannot stop the run. } return entries } -/** - * Parse an existing release notes YAML file and extract NoteEntry[] from it, - * along with a set of source issue URLs already covered. - * Returns { entries, coveredUrls } or null if the file doesn't exist. - */ export function loadExistingEntries(yamlPath: string): { entries: NoteEntry[] coveredUrls: Set<string> @@ -87,16 +77,8 @@ export function loadExistingEntries(yamlPath: string): { return loadExistingEntriesFromString(content) } -/** - * Parse release notes YAML content (as a string) and extract NoteEntry[] from it. - * This is the testable core, with no file I/O. - * - * Note: This uses manual line-by-line parsing instead of js-yaml because we need - * to preserve the `# https://github.com/.../issues/NNN` source URL comments that - * precede each note. YAML comments are stripped by `load()` and aren't part - * of the YAML data model, so a standard parser can't track the comment-to-note - * relationship we rely on for incremental mode and deduplication. - */ +// Parse release notes YAML manually so source issue URL comments stay attached to notes. +// js-yaml strips comments, which would break incremental mode and deduplication. export function loadExistingEntriesFromString(content: string): { entries: NoteEntry[] coveredUrls: Set<string> @@ -150,7 +132,7 @@ export function loadExistingEntriesFromString(content: string): { if (currentSection === 'changes') currentHeading = 'Changes' else if (currentSection === 'closing_down') currentHeading = 'Closing down' else if (currentSection === 'retired') currentHeading = 'Retired' - else currentHeading = null // known_issues: skip + else currentHeading = null // Known issues stay in the placeholder template. continue } @@ -213,10 +195,6 @@ export function loadExistingEntriesFromString(content: string): { return { entries, coveredUrls } } -/** - * Append YAML lines for a list of note entries at a given indentation level. - * Handles the `# sourceUrl`, `- |`, and multi-line note content pattern. - */ export function appendNoteLines(lines: string[], noteEntries: NoteEntry[], indent: string): void { for (const entry of noteEntries) { lines.push(`${indent}# ${entry.sourceUrl}`) @@ -263,7 +241,6 @@ export function buildReleaseNotesYaml( lines.push('sections:') - // Features (grouped by heading). const featureEntries = noteEntries.filter((e) => featureHeadings.includes(e.heading)) const otherEntries = noteEntries.filter((e) => !featureHeadings.includes(e.heading)) @@ -291,7 +268,6 @@ export function buildReleaseNotesYaml( lines.push(' # TODO: Add feature notes') } - // Changes. const changeEntries = otherEntries.filter((e) => !['Closing down', 'Retired'].includes(e.heading)) if (changeEntries.length > 0) { lines.push('') @@ -299,14 +275,12 @@ export function buildReleaseNotesYaml( appendNoteLines(lines, changeEntries, ' ') } - // Known issues. lines.push('') lines.push(' known_issues:') lines.push(' # TODO: Add known issues from "GHES Release Note Tracking" project') lines.push(' - |') lines.push(' ...') - // Closing down. const closingEntries = otherEntries.filter((e) => e.heading === 'Closing down') if (closingEntries.length > 0) { lines.push('') @@ -314,7 +288,6 @@ export function buildReleaseNotesYaml( appendNoteLines(lines, closingEntries, ' ') } - // Retired. const retiredEntries = otherEntries.filter((e) => e.heading === 'Retired') if (retiredEntries.length > 0) { lines.push('') diff --git a/src/ghes-releases/lib/release-issues.ts b/src/ghes-releases/lib/release-issues.ts index 91e0d89cb3bd..f56c37f01956 100644 --- a/src/ghes-releases/lib/release-issues.ts +++ b/src/ghes-releases/lib/release-issues.ts @@ -7,9 +7,6 @@ interface IssueLike { labels: { name: string }[] } -/** - * Parse and validate the issue state filter. Defaults to "all". - */ export function parseIssueState(value?: string): IssueState { if (!value) return 'all' @@ -41,9 +38,7 @@ export function buildReleaseIssueListArgs(version: string, issueState: IssueStat ] } -/** - * Excludes release issues that should not produce GHES release notes. - */ +// "public roadmap" and "not planned" issues never produce GHES release notes. export function isExcludedReleaseIssue(issue: IssueLike): boolean { return issue.labels.some((l) => EXCLUDED_RELEASE_LABELS.has(l.name.toLowerCase())) } diff --git a/src/ghes-releases/scripts/create-enterprise-issue.ts b/src/ghes-releases/scripts/create-enterprise-issue.ts index b19db2e4366c..2d3ef022f680 100644 --- a/src/ghes-releases/scripts/create-enterprise-issue.ts +++ b/src/ghes-releases/scripts/create-enterprise-issue.ts @@ -1,7 +1,8 @@ -/** - * @purpose Writer tool - * @description Create release tracking issues for a new GHES version - */ +// @purpose Writer tool +// @description Create release tracking issues for a new GHES version +// +// Creates release and deprecation issues in github/docs-content and github/technical-content. +// Skips a release or deprecation when its issue already exists. import { readFileSync } from 'fs' import { basename } from 'path' import { Liquid } from 'liquidjs' @@ -45,25 +46,15 @@ interface IssueSearchOpts { titleMatch?: string } -// Required by github() to authenticate if (!process.env.GITHUB_TOKEN) { throw new Error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.') } const octokit = github() const liquid = new Liquid() -// [start-readme] -// -// This script creates enterprise release and deprecation issues in the -// github/docs-content and github/technical-content repositories. -// The script checks if an issue already exists for the release or deprecation. -// -// [end-readme] run() async function run() { - // This script requires one parameters with the value - // of either 'release' or 'deprecation' const releaseType = process.argv[2] if (releaseType !== 'release' && releaseType !== 'deprecation') { throw new Error( @@ -82,7 +73,6 @@ async function run() { async function createDeprecationIssue() { const repo = 'github/technical-content' console.log('Next deprecation number: ', oldestSupported) - // If an issue already exists for this release, do nothing const issueExists = await isExistingIssue(repo, { titleMatch: `Enterprise Server ${oldestSupported} deprecation steps`, labels: ['enterprise deprecation'], @@ -124,7 +114,6 @@ async function createReleaseIssue() { const releaseNumber = getNextReleaseNumber(releaseDates) console.log('Next release number: ', releaseNumber) - // If an issue already exists for this release, do nothing if ( await isExistingIssue(repo, { labels: ['ghes-release-automation', `GHES ${releaseNumber}`], @@ -136,8 +125,6 @@ async function createReleaseIssue() { const releaseInfo = releaseDates[releaseNumber] const rcDate = releaseInfo.release_candidate - // Only open an issue if today is within 30 days before - // the release candidate date if (getNumberDaysUntilMilestone(rcDate || '') > 30) { console.log( `The ${releaseNumber} release candidate is not until ${rcDate}! An issue will be opened 30 days prior to the release candidate date.`, @@ -147,8 +134,7 @@ async function createReleaseIssue() { const releaseTemplates = getReleaseTemplates() - // Set shell issues with placeholder title and body - // Need all issue numbers before filling in liquid templates + // Create placeholder issues first because Liquid templates need every issue URL. for (const templateName of Object.keys(releaseTemplates)) { const issue = await createIssue( repo, @@ -161,7 +147,6 @@ async function createReleaseIssue() { releaseTemplates[templateName].issue = issue.data } - // Go back and update title and body with rendered liquid templates const releaseTemplateContext = getReleaseTemplateContext( releaseNumber, releaseInfo, @@ -201,7 +186,6 @@ async function createIssue( throw error } if (issue.status === 201) { - // Write the values to disk for use in the workflow. console.log( `Issue #${issue.data.number} for the ${releaseNumber} ${releaseType} was opened: ${issue.data.html_url}`, ) @@ -305,15 +289,12 @@ function getReleaseTemplateContext( 'release-code-freeze-date': releaseInfo.code_freeze || '', 'release-rc-target-date': releaseInfo.release_candidate || '', } - // Add a context variable for each issue url for (const [templateName, template] of Object.entries(releaseTemplates)) { if (template.issue) { context[`${templateName}-url`] = template.issue.html_url } } - // Create a context variable for each of the - // 7 days before release-rc-target-date if (releaseInfo.release_candidate) { const rcTargetDate = new Date(releaseInfo.release_candidate).getTime() for (let i = 1; i <= 7; i++) { @@ -356,10 +337,6 @@ function getNextReleaseNumber(releaseDates: ReleaseDates): string { return Object.keys(releaseDates)[indexOfNext] } -// examples: -// searchQuery: 'author:docs-bot is:open' -// labels: ['enterprise deprecation', 'ghes 3.0'] -// titleMatch: 'GHES 3.0' async function isExistingIssue( repo: string, opts: IssueSearchOpts = { labels: undefined, searchQuery: undefined, titleMatch: undefined }, diff --git a/src/ghes-releases/scripts/deprecate/archive-version.ts b/src/ghes-releases/scripts/deprecate/archive-version.ts index 9c1f807ba498..46b0a8183f18 100755 --- a/src/ghes-releases/scripts/deprecate/archive-version.ts +++ b/src/ghes-releases/scripts/deprecate/archive-version.ts @@ -1,10 +1,5 @@ -// [start-readme] -// -// Run this script during the Enterprise deprecation process to download -// static copies of all pages for the oldest supported Enterprise version. -// See the Enterprise deprecation issue template for instructions. -// -// [end-readme] +// Run during Enterprise deprecation to download static pages for the oldest supported version. +// The Enterprise deprecation issue template owns the operational checklist. import path from 'path' import fs from 'fs' @@ -90,7 +85,6 @@ async function main() { } } - // remove temp directory await fs.promises.rm(tmpArchivalDirectory, { recursive: true, force: true }) const app = createApp() @@ -103,8 +97,7 @@ async function main() { await scrape({ urls, urlFilter: (url: string) => { - // Do not download assets from other hosts like S3 or octodex.github.com - // (this will keep them as remote references in the downloaded pages) + // Leave assets on other hosts as remote references in downloaded pages. return url.startsWith(`http://localhost:${port}/`) }, directory: tmpArchivalDirectory, @@ -126,7 +119,7 @@ async function main() { console.log(`\n\ndone scraping! added files to ${tmpArchivalDirectory}\n`) if (!singlePage) { - // create redirect html files to preserve frontmatter redirects + // Redirect files preserve frontmatter redirects after static scraping. await createRedirectsFile(pageList, path.join(tmpArchivalDirectory, version)) console.log(`next step: deprecate ${version} in lib/enterprise-server-releases.ts`) } else { @@ -148,9 +141,9 @@ async function createRedirectsFile(pageList: PageList, outputDirectory: string) const redirectEntries: Array<[string, string]> = Object.entries(redirects) for (let [oldPath, newPath] of redirectEntries) { - // remove any liquid variables that sneak in + // Redirect paths can include Liquid version variables. oldPath = oldPath.replace('/{{ page.version }}', '').replace('/{{ currentVersion }}', '') - // ignore any old paths that are not in this version + // Keep only redirects for the archived Enterprise version. if ( !( oldPath.includes(`/enterprise-server@${version}`) || diff --git a/src/ghes-releases/scripts/deprecate/collapse-blank-lines.ts b/src/ghes-releases/scripts/deprecate/collapse-blank-lines.ts index 52815b0b91d8..29290bf4a2b4 100644 --- a/src/ghes-releases/scripts/deprecate/collapse-blank-lines.ts +++ b/src/ghes-releases/scripts/deprecate/collapse-blank-lines.ts @@ -1,13 +1,9 @@ import fs from 'fs' import { execSync } from 'child_process' -// Removing deprecated Liquid conditionals leaves behind extra blank lines. -// The content team flags these every deprecation, and the MD012 linter rule -// is off so nothing catches them automatically. This collapses any run of -// two or more consecutive blank lines down to one, but only in the markdown -// files the deprecation actually changed. Single blank lines are left alone: -// removed Liquid can introduce one in a place where it doesn't belong, so a -// human still reviews each removal site one at a time. +// Deprecated Liquid conditionals leave extra blank lines that content reviewers flag. +// MD012 does not catch them in this repo. Collapse runs of two or more blank lines only in +// markdown files changed by deprecation. Single blank lines still need human review. function getChangedMarkdownFiles(): string[] { const commands = [ @@ -21,7 +17,7 @@ function getChangedMarkdownFiles(): string[] { try { output = execSync(command, { encoding: 'utf8' }) } catch { - // origin/main may not be fetched locally; skip that source. + // Skip origin/main when it is not fetched locally. continue } for (const line of output.split('\n')) { @@ -35,7 +31,6 @@ function getChangedMarkdownFiles(): string[] { return [...files].sort() } -// Collapses any run of 2+ blank lines into a single blank line. function collapse(contents: string): string { const lines = contents.split('\n') const result: string[] = [] diff --git a/src/ghes-releases/scripts/deprecate/create-docs-ghes-version-repo.sh b/src/ghes-releases/scripts/deprecate/create-docs-ghes-version-repo.sh index 76f7be8ef6d9..b357b9add60e 100755 --- a/src/ghes-releases/scripts/deprecate/create-docs-ghes-version-repo.sh +++ b/src/ghes-releases/scripts/deprecate/create-docs-ghes-version-repo.sh @@ -1,13 +1,11 @@ -# This script creates a new repository for an archived version of GitHub Enterprise Server documentation. -# Please update the version variable first. -# You may wish to run this script a little bit at a time instead of all at once incase there are any errors. +# Creates a repository for an archived GitHub Enterprise Server documentation version. +# Pass the version as the first argument, and run sections one at a time when inspecting failures. version=$1 cd ~/Documents/gh/github -# Teams are addressed by numeric ID because IDs survive team renames and slugs do not. -# Some APIs (repo creation, CODEOWNERS, custom properties) only accept slugs, so -# resolve the current slug from the ID at runtime rather than hardcoding it. +# Numeric team IDs survive team renames; slugs do not. +# Repo creation, CODEOWNERS, and custom properties require slugs, so resolve current slugs. org_id=9919 docs_team_id=325922 docs_eng_team_id=3935808 diff --git a/src/ghes-releases/scripts/deprecate/rewrite-asset-paths.ts b/src/ghes-releases/scripts/deprecate/rewrite-asset-paths.ts index 0ae4cc3cd8ad..a04414779db9 100644 --- a/src/ghes-releases/scripts/deprecate/rewrite-asset-paths.ts +++ b/src/ghes-releases/scripts/deprecate/rewrite-asset-paths.ts @@ -24,6 +24,7 @@ export class RewriteAssetPathsPlugin { this.replaceUrl = replaceUrl } + // HTML and CSS asset paths must point at the archive site unless local-dev leaves them relative. apply( registerAction: (event: string, callback: (args: ResourceSavedArgs) => Promise<void>) => void, ) { @@ -35,12 +36,8 @@ export class RewriteAssetPathsPlugin { const text = resource.getText() let newBody = text - // Rewrite HTML asset paths. Example: - // ../assets/images/foo/bar.png -> - // https://github.github.com/docs-ghes-3.10/assets/images/foo/bar.png - if (resource.isHtml()) { - // Remove nextjs scripts and manifest.json link + // Next.js runtime files break static archives. newBody = newBody.replace( /<script\ssrc="(\.\.\/)*_next\/static\/[\w]+\/(_buildManifest|_ssgManifest).js?".*?><\/script>/g, '', @@ -58,11 +55,6 @@ export class RewriteAssetPathsPlugin { } } - // Rewrite CSS asset paths. Example - // url("../assets/fonts/alliance/alliance-no-1-regular.woff") -> - // url("https://github.github.com/docs-ghes-3.10/assets/fonts/alliance/alliance-no-1-regular.woff") - // url(../../../assets/cb-303/images/octicons/search-24.svg) -> - // url(https://github.github.com/docs-ghes-3.10/assets/cb-303/images/octicons/search-24.svg) if (resource.isCss()) { if (!this.localDev) { newBody = newBody.replace( diff --git a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts index 46c1f1a0ae04..c237d016948c 100755 --- a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts +++ b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts @@ -1,13 +1,6 @@ -// [start-readme] -// -// This script adds and removes placeholder data files in the -// automation pipelines data directories and -// data/release-notes/enterprise-server directories. This script -// uses the supported and deprecated versions to determine what -// directories should exist. This script also modifies the `api-versions` -// key if it exists in a pipeline's lib/config.json file. -// -// [end-readme] +// Adds and removes placeholder data for automation pipelines and GHES release notes +// from the supported and deprecated GHES versions. +// Updates api-versions in each pipeline lib/config.json when that key exists. import { existsSync, rmSync } from 'fs' import { mkdir, readFile, readdir, writeFile, cp } from 'fs/promises' @@ -20,8 +13,8 @@ const pipelines = JSON.parse(await readFile('src/automated-pipelines/lib/config. 'automation-pipelines' ] -// If the config file for a pipeline includes `api-versions` update that list -// based on the supported and deprecated releases. +// Pipelines with api-versions copy previous calendar date variants to the current release. +// Deprecated variants are dropped. export async function updateAutomatedConfigFiles() { for (const pipeline of pipelines) { const configFilepath = `src/${pipeline}/lib/config.json` @@ -29,12 +22,10 @@ export async function updateAutomatedConfigFiles() { const apiVersions = configData['api-versions'] if (!apiVersions) continue for (const key of Object.keys(apiVersions)) { - // Copy the previous release's calendar date versions to the new release if (key.endsWith(previousReleaseNumber)) { const newKey = key.replace(previousReleaseNumber, currentReleaseNumber) apiVersions[newKey] = apiVersions[key] } - // Remove any deprecated versions for (const deprecatedRelease of deprecated) { if (key.endsWith(deprecatedRelease)) { delete apiVersions[key] @@ -49,15 +40,9 @@ export async function updateAutomatedConfigFiles() { } export async function updateAutomatedPipelines() { - // The allVersions object uses the 'api-versions' data stored in the - // src/rest/lib/config.json file. We want to update 'api-versions' - // before the allVersions object is created so we need to import it - // after calling updateAutomatedConfigFiles. + // Import allVersions after config updates so src/rest/lib/config.json changes take effect. const { allVersions } = await import('@/versions/lib/all-versions') - // Gets all of the base names (e.g., ghes-) in the allVersions object - // Currently, this is only ghes- but if we had more than one type of - // numbered release it would get all of them. const numberedReleaseBaseNames = Array.from( new Set( Object.values(allVersions) @@ -66,12 +51,7 @@ export async function updateAutomatedPipelines() { ), ) - // A list of currently supported versions (calendar date inclusive) - // in the format using the short name rather than full format - // (e.g., enterprise-server@). The list is filtered - // to only include versions that have numbered releases (e.g. ghes-). - // The list is generated from the `apiVersions` key in allVersions. - // This is currently only needed for the rest and github-apps pipelines. + // rest and github-apps read calendar-date versions from allVersions.apiVersions. const versionNamesCalDate = Object.values(allVersions) .filter((version) => version.hasNumberedReleases) .map((version) => @@ -80,16 +60,13 @@ export async function updateAutomatedPipelines() { : version.openApiVersionName, ) .flat() - // A list of currently supported versions in the format using the short name - // rather than the full format (e.g., enterprise-server@). The list is filtered - // to only include versions that have numbered releases (e.g. ghes-). - // Currently, this is used for the graphql and webhooks pipelines. + // graphql and webhooks read numbered versions in ghes-major.minor form. const versionNames = Object.values(allVersions) .filter((version) => version.hasNumberedReleases) .map((version) => version.openApiVersionName) for (const pipeline of pipelines) { - // secret-scanning has a different directory structure than the others + // secret-scanning stores pattern docs outside the shared pipeline data layout. const directoryWithReleases = pipeline === 'secret-scanning' ? 'src/secret-scanning/data/pattern-docs' @@ -101,8 +78,7 @@ export async function updateAutomatedPipelines() { )['api-versions'] const directoryListing = await readdir(directoryWithReleases) - // filter the directory list to only include directories that start with - // basenames with numbered releases (e.g., ghes-). + // Limit pipeline data dirs to numbered release basenames like ghes-. const existingDataDir = directoryListing.filter((directory) => numberedReleaseBaseNames.some((basename) => directory.startsWith(basename)), ) @@ -113,19 +89,15 @@ export async function updateAutomatedPipelines() { const expectedDirectory = isCalendarDateVersioned ? versionNamesCalDate : versionNames - // Get a list of data directories to remove (deprecate) and remove them - // This should only happen if a release is being deprecated. const removeFiles = difference(existingDataDir, expectedDirectory) for (const directory of removeFiles) { console.log(`Removing src/${pipeline}/data/${directory}`) rmSync(`src/${pipeline}/data/${directory}`, { recursive: true, force: true }) } - // Get a list of data directories to create (release) and create them - // This should only happen if a release is being added. const addFiles = difference(expectedDirectory, existingDataDir) - // Verify all new directories belong to the current release + // Reject directories unrelated to the current release before creating them. for (const dir of addFiles) { if (!dir.includes(currentReleaseNumber)) { throw new Error( @@ -136,15 +108,10 @@ export async function updateAutomatedPipelines() { } for (const base of numberedReleaseBaseNames) { - // Find ALL directories to add for this base name (may be multiple - // when a release has more than one calendar-date version). + // Calendar-date releases can add more than one directory for the same base name. const dirsToAdd = addFiles.filter((item) => item.startsWith(base)) for (const dirToAdd of dirsToAdd) { - // Derive the previous release's corresponding directory by replacing - // the current release number with the previous one. This correctly - // maps each calendar-date variant to its predecessor, e.g.: - // ghes-3.20-2022-11-28 -> ghes-3.19-2022-11-28 - // ghes-3.20-2026-03-10 -> ghes-3.19-2026-03-10 + // Keep calendar-date suffixes unchanged when mapping previous dirs to current dirs. const previousDirName = dirToAdd.replace(currentReleaseNumber, previousReleaseNumber) if (!existingDataDir.includes(previousDirName)) { throw new Error( @@ -163,9 +130,7 @@ export async function updateAutomatedPipelines() { } } - // Add and remove the GHES release note data. Once we create an automation - // pipeline for release notes, we can remove this because it will use the - // same directory structure as the other pipeline data directories. + // GHES release notes stay in this path until an automation pipeline owns the same layout. const ghesReleaseNotesDirs = await readdir('data/release-notes/enterprise-server') const supportedHyphenated = supported.map((version) => version.replace('.', '-')) const deprecatedHyphenated = deprecated.map((version) => version.replace('.', '-')) diff --git a/src/ghes-releases/scripts/deprecate/update-content.ts b/src/ghes-releases/scripts/deprecate/update-content.ts index 07422ed63dde..35004217f1a7 100644 --- a/src/ghes-releases/scripts/deprecate/update-content.ts +++ b/src/ghes-releases/scripts/deprecate/update-content.ts @@ -17,9 +17,8 @@ const contentFiles = walkFiles('content', { ignore: ['**/README.md', '**/index.md'], }) -// This module updates the versions frontmatter in content files. -// When a content file contains only deprecated GHES releases, the -// file is deleted and removed from the parent index.md file. +// Updates versions frontmatter during GHES deprecation. +// Deletes GHES-only files with no supported release from their parent index.md. export function updateContentFiles() { for (const file of contentFiles) { const oldContents = fs.readFileSync(file, 'utf8') @@ -35,16 +34,13 @@ export function updateContentFiles() { throw new Error(`Could not load feature versions from ${featureFilePath}`) } - // skip files with no Enterprise Server versions frontmatter if (!data.versions.ghes && !featureData?.versions?.ghes) continue - // skip files with all ghes releases defined if (data.versions.ghes === '*') continue const deprecatedRelease = deprecated[0] const oldestRelease = supported[supported.length - 1] - // If the frontmatter versions.ghes property is now - // applicable to all GHES releases, update the value to '*'. + // Feature-backed content becomes all versions when it applies to FPT, GHEC, and every GHES. const featureAppliesToAllVersions = featureData && featureData.versions.ghec && @@ -55,9 +51,7 @@ export function updateContentFiles() { if (isInAllGhes(data.versions.ghes)) { console.log('Updating GHES version in: ', file) data.versions.ghes = '*' - // To preserve newlines when stringifying, - // you can set the lineWidth option to -1 - // This prevents updates to the file that aren't actual changes. + // lineWidth -1 preserves existing newlines, so only frontmatter changes are written. fs.writeFileSync( file, frontmatter.stringify(content!, data, { lineWidth: -1 } as unknown as Parameters< @@ -73,9 +67,7 @@ export function updateContentFiles() { ghec: '*', ghes: '*', } - // To preserve newlines when stringifying, - // you can set the lineWidth option to -1 - // This prevents updates to the file that aren't actual changes. + // lineWidth -1 preserves existing newlines, so only frontmatter changes are written. fs.writeFileSync( file, frontmatter.stringify(content!, data, { lineWidth: -1 } as unknown as Parameters< @@ -87,9 +79,7 @@ export function updateContentFiles() { const deprecatedRegex = new RegExp(`(<|<=)\\s?${deprecatedRelease}`, 'g') const oldestRegex = new RegExp(`<\\s?${oldestRelease}`, 'g') - // If the frontmatter versions.ghes property is now - // deprecated, remove it. If the content file is only - // versioned for GHES, remove the file and update index.md. + // Remove GHES frontmatter or delete GHES-only files when no supported GHES applies. const featureGhes = featureData?.versions?.ghes || '' const appliesToNoSupportedGhesReleases = deprecatedRegex.test(data.versions.ghes) || @@ -101,7 +91,6 @@ export function updateContentFiles() { if (Object.keys(data.versions).length === 1) { removeFileUpdateParent(file) } else { - // Remove the ghes property from versions Fm and return delete data.versions.ghes console.log('Removing GHES version from: ', file) fs.writeFileSync( @@ -130,15 +119,14 @@ function removeFileUpdateParent(filePath: string) { data: { children: string[] } | undefined } if (!data) return - // Children paths are relative to the index.md file's directory + // Children paths are relative to the index.md file's directory. const childPath = filePath.endsWith('index.md') ? `/${path.basename(path.dirname(filePath))}` : `/${path.basename(filePath, '.md')}` - // Remove the childPath from the parent index.md file's children frontmatter data.children = data.children.filter((child) => child !== childPath) - // If removing the childPath leaves the parent index.md file empty, remove it + // Empty parent indexes must disappear with their last child. if (data.children.length === 0) { removeFileUpdateParent(parentFilePath) } else { @@ -152,15 +140,10 @@ function removeFileUpdateParent(filePath: string) { } } -// Gets the next parent file path. -// If the filePath is an article (e.g., doesn't end with index.md), -// then the parent file is the index.md file in the same directory. -// If the filePath is a category or subcategory (e.g., ends with index.md), -// the parent is the index.md file in the next directory up. +// Articles use the index.md in their directory; index.md files use the parent directory's index.md. +// content/index.md has no parent. function getParentFilePath(filePath: string) { - // This is the root index.md file, it has no parent if (!filePath || filePath === 'content/index.md') return null - // Handle index.md files with index.md parent in directory above if (filePath.endsWith('index.md')) { const pathParts = filePath.split('/') pathParts.pop() @@ -168,6 +151,5 @@ function getParentFilePath(filePath: string) { pathParts.push('index.md') return pathParts.join('/') } - // Handle articles with a parent index.md file return filePath.replace(path.basename(filePath), 'index.md') } diff --git a/src/ghes-releases/scripts/deprecate/update-data.ts b/src/ghes-releases/scripts/deprecate/update-data.ts index 08e91ce9e9b1..9b9badeff590 100644 --- a/src/ghes-releases/scripts/deprecate/update-data.ts +++ b/src/ghes-releases/scripts/deprecate/update-data.ts @@ -31,8 +31,7 @@ export function updateDataFiles() { updateFeatureData() } -// Removes empty data/reusables files and removes the deleted -// reusable from any content or data/reusables files that reference it. +// Remove empty reusable files and their Liquid references so content does not use deleted data. function updateReusableData() { const deletedDataFiles = [] @@ -44,16 +43,12 @@ function updateReusableData() { deletedDataFiles.push(file) } } - // Map the format: - // data/reusables/actions/actions-runner-controller-unsupported-customization.md - // to the format: - // {% data reusables.code-scanning.beta-org-enable-all %} + // Example: data/reusables/actions/runner.md becomes {% data reusables.actions.runner %}. const reusableNames = deletedDataFiles.map( (file) => `{% data ${file.replace('.md', '').split('/').slice(1).join('.')} %}`, ) const existingDataReusables = difference(dataReusables, deletedDataFiles) - // Remove deleted reusables from content and data resuables files for (const file of [...existingDataReusables, ...contentFiles]) { const originalContent = fs.readFileSync(file, 'utf8') let content = originalContent @@ -70,9 +65,7 @@ function updateReusableData() { } } -// Removes deprecated data/feature files and outputs a list of data/features -// available in all versions. That list is only used for review during a GHES -// deprecation. +// Lists all-version data/features for human review during GHES deprecation. function updateFeatureData() { const allFeatureFiles = new Set() diff --git a/src/ghes-releases/scripts/generate-release-notes.ts b/src/ghes-releases/scripts/generate-release-notes.ts index 1ecdd3ab3b21..9da6ed83aada 100644 --- a/src/ghes-releases/scripts/generate-release-notes.ts +++ b/src/ghes-releases/scripts/generate-release-notes.ts @@ -1,13 +1,8 @@ -/** - * @purpose Writer tool - * @description Generate GHES release notes from github/releases issues using Copilot CLI - * - * Generate GHES release notes by: - * 1. Querying github/releases issues labeled "GHES <version>" (all states by default) - * 2. Finding corresponding changelog PRs in github/blog - * 3. Running each through the ghes-release-notes agent via Copilot CLI - * 4. Stitching the YAML outputs into a release notes file - */ +// @purpose Writer tool +// @description Generate GHES release notes from github/releases issues using Copilot CLI +// +// Queries github/releases issues labeled with the GHES release number, all states by default. +// Matches github/blog changelog PRs, runs ghes-release-notes, and writes release note YAML. import { Command } from 'commander' import { execFileSync, spawn, type ChildProcess } from 'child_process' import fs from 'fs' @@ -80,9 +75,7 @@ function loadFeatureHeadings(): string[] { return _featureHeadingsCache } -/** - * Run `gh` CLI commands with native auth (no GITHUB_TOKEN interference) - */ +// Drop GITHUB_TOKEN so gh uses native auth instead of repo workflow auth. function gh(args: string[]): string { const env = { ...process.env } delete env.GITHUB_TOKEN @@ -105,11 +98,8 @@ interface ChangelogInfo { body: string | null } -/** - * Try to extract a changelog PR URL from a release issue body. - * Looks for patterns like: - * 📄 **Changelog post:** https://github.com/github/blog/pull/1234 - */ +// Release issues can link the changelog PR in a Changelog post field. +// Example field: Changelog post. The value is a github/blog pull request URL. function extractChangelogPrUrl(issueBody: string): string | null { const match = issueBody.match(/https:\/\/github\.com\/github\/blog\/pull\/\d+/) return match ? match[0] : null @@ -125,11 +115,8 @@ function fetchChangelogPrBody(prUrl: string): string | null { } } -/** - * Search github/blog for a merged PR that references a release issue number. - * Caches the fetched PR list to avoid redundant API calls. - * Returns URL + body when found. - */ +// Cache recent github/blog changelog PRs because multiple release issues search the same list. +// Each changelog PR body links back to its release issue. let _blogPrsCache: { number: number; body: string; url: string }[] | null = null function searchChangelogPr(issueNumber: number): ChangelogInfo | null { try { @@ -170,10 +157,6 @@ function searchChangelogPr(issueNumber: number): ChangelogInfo | null { return null } -/** - * Find the changelog PR for a release issue, checking the issue body first, - * then falling back to searching github/blog. Returns URL + body. - */ function findChangelogPr(issue: ReleaseIssue): ChangelogInfo | null { const fromBody = extractChangelogPrUrl(issue.body) if (fromBody) { @@ -183,11 +166,8 @@ function findChangelogPr(issue: ReleaseIssue): ChangelogInfo | null { return searchChangelogPr(issue.number) } -/** - * Resolve the Copilot CLI path. - * Result is cached after first call. - */ let _copilotCliPath: string | null = null +// The VS Code extension fallback covers macOS only; otherwise which must find a global install. function findCopilotCli(): string { if (_copilotCliPath) return _copilotCliPath @@ -199,10 +179,6 @@ function findCopilotCli(): string { } } catch {} - // Fallback: check VS Code extension storage locations. - // These paths are macOS-only. On Linux/Windows the `which` check above should - // find Copilot CLI if it's installed globally. If needed, add platform-specific - // paths here (e.g., ~/.config/Code/User/globalStorage/... for Linux). const homeDir = os.homedir() const vsCodePath = path.join( homeDir, @@ -225,11 +201,6 @@ function findCopilotCli(): string { throw new Error('Copilot CLI not found. Install via: npm install -g @github/copilot@prerelease') } -/** - * Run the ghes-release-notes agent on a release issue (+optional changelog PR) - * via Copilot CLI and return the raw output. - * Uses async spawn so SIGINT (Ctrl+C) is not blocked. - */ interface AgentContext { issueUrl: string issueTitle: string @@ -239,32 +210,27 @@ interface AgentContext { featureHeadings: string[] } -/** - * Extract the title tag (e.g., "GA", "Public Preview") from a release issue title. - */ function parseTitleTag(title: string): string | null { const match = title.match(/\[(GA|Public Preview|Beta|Private Preview|Closing Down|Retired)\]/i) return match ? match[1] : null } +// Use async spawn so Ctrl+C can interrupt the agent process. function runAgent(ctx: AgentContext): Promise<string> { const copilotPath = findCopilotCli() const titleTag = parseTitleTag(ctx.issueTitle) - // Build an optimized prompt that pre-includes all context so the agent - // doesn't need to make tool calls to fetch it. + // Preload context so the agent does not need network tools during note generation. let prompt = `You are running in non-interactive mode. Do NOT ask any follow-up questions. ` prompt += `Generate a release note for ${ctx.issueUrl}. ` prompt += `Follow the ghes-release-notes agent instructions. ` prompt += `Return ONLY a single YAML code block (\`\`\`yaml ... \`\`\`). No conversation, no questions, no explanations outside the code block.\n\n` - // Pre-supply the title tag so the agent doesn't need to re-parse if (titleTag) { prompt += `The issue title tag is [${titleTag}].\n\n` } - // Pre-supply valid headings so the agent doesn't need to read PLACEHOLDER-TEMPLATE.yml prompt += `IMPORTANT: Do NOT read PLACEHOLDER-TEMPLATE.yml or data/variables/product.yml — all necessary context is provided below.\n\n` prompt += `Valid feature headings (use ONLY these for feature notes):\n` for (const h of ctx.featureHeadings) { @@ -272,13 +238,11 @@ function runAgent(ctx: AgentContext): Promise<string> { } prompt += `\nFor non-feature notes, use: Changes, Closing down, or Retired.\n\n` - // Pre-supply the issue body so the agent doesn't need to fetch it prompt += `--- RELEASE ISSUE (${ctx.issueUrl}) ---\n` prompt += `Title: ${ctx.issueTitle}\n` prompt += ctx.issueBody.substring(0, 15000) prompt += `\n--- END RELEASE ISSUE ---\n\n` - // Pre-supply the changelog PR body if available if (ctx.changelogUrl && ctx.changelogBody) { prompt += `--- CHANGELOG PR (${ctx.changelogUrl}) ---\n` prompt += ctx.changelogBody.substring(0, 10000) @@ -379,14 +343,8 @@ interface AgentResult { skipWarning?: string } -/** - * Run the agent with retry logic. Retries up to `maxRetries` times on failure. - * Validates that extracted YAML parses into non-empty entries before accepting. - * If the agent tries to skip (returns `# SKIP: reason` + `[]`), treats it as a - * failed attempt and retries, because issues that matched the GHES label filter should - * always get a release note. If all attempts result in skips, the last skip - * reason is attached as a warning. - */ +// Retry agent failures and reject empty YAML so matching GHES issues produce release notes. +// Skip signals become warnings only after every attempt skips. async function runAgentWithRetry(ctx: AgentContext, maxRetries = 2): Promise<AgentResult> { let lastError: Error | null = null let lastRawOutput = '' @@ -397,8 +355,7 @@ async function runAgentWithRetry(ctx: AgentContext, maxRetries = 2): Promise<Age lastRawOutput = output const yamlStr = extractYaml(output) if (yamlStr) { - // Detect agent skip signals and treat as retryable failures. - // The issue already matched GHES labels, so we always want a release note. + // Treat skip signals as retryable because GHES-labeled issues need notes. const skipReason = extractSkipReason(yamlStr) || (Array.isArray(load(yamlStr)) && (load(yamlStr) as unknown[]).length === 0 @@ -418,7 +375,7 @@ async function runAgentWithRetry(ctx: AgentContext, maxRetries = 2): Promise<Age skipWarning: lastSkipReason ?? undefined, } } - // YAML was extracted but is empty or unparseable, so retry. + // Retry empty or unparseable YAML. lastError = new Error( `Agent returned YAML but it contained no valid entries (got: ${yamlStr.substring(0, 80)})`, ) @@ -447,7 +404,7 @@ program ) .requiredOption('-r, --release <version>', 'GHES release number (e.g., 3.20, 3.21)') .option('--rc [boolean]', 'Generate release candidate notes (omit for GA)', (val: string) => { - // Support both `--rc` (no value → true) and `--rc true`/`--rc false` (legacy) + // Accept --rc alone, --rc true, and --rc false. if (val === undefined || val === 'true') return true if (val === 'false') return false return true @@ -462,7 +419,7 @@ program '-i, --issue <value>', 'Process a single issue by number or URL (replaces its entry if it already exists)', (val: string) => { - // Accept a full URL like https://github.com/github/releases/issues/6768 + // Accept full github/releases issue URLs as well as numbers. const urlMatch = val.match(/\/issues\/(\d+)/) if (urlMatch) return parseInt(urlMatch[1], 10) const num = parseInt(val, 10) @@ -496,7 +453,6 @@ program process.exit(1) } - // Prerequisite checks. try { execFileSync('gh', ['--version'], { stdio: 'ignore' }) } catch { @@ -524,7 +480,6 @@ program process.exit(1) } - // Step 1: Fetch release issues. let issues: ReleaseIssue[] if (singleIssue) { @@ -570,12 +525,11 @@ program process.exit(0) } - // GA meta-issues (e.g. "GHES 3.20 GA [GA]") are tracking issues, not features. + // GA meta-issues named GHES X.Y GA or GHES X.Y release are tracking issues. const originalCount = issues.length issues = issues.filter((issue) => { const title = issue.title.trim() const stripped = title.replace(/\s*\[[^\]]*\]/g, '').trim() - // Skip issues whose title is just "GHES X.Y GA" or "GHES X.Y release" if (/^ghes\s+\d+\.\d+\s+ga$/i.test(stripped)) return false if (/^ghes\s+\d+\.\d+\s+release$/i.test(stripped)) return false return true @@ -613,14 +567,14 @@ program const outputDir = path.join(process.cwd(), 'data/release-notes/enterprise-server', dirName) const outputPath = path.join(outputDir, fileName) - // Incremental mode: load existing entries. + // Existing entries make generation incremental by default. const allEntries: NoteEntry[] = [] let existingCoveredUrls = new Set<string>() if (!force && !stdout && fs.existsSync(outputPath)) { const existing = loadExistingEntries(outputPath) if (existing && existing.entries.length > 0) { - // When --issue is specified, remove the old entry for that issue so it gets regenerated + // Regenerate a single issue by dropping its previous source URL entry first. if (singleIssue) { const issueUrl = `https://github.com/github/releases/issues/${singleIssue}` const kept = existing.entries.filter((e) => e.sourceUrl !== issueUrl) @@ -638,7 +592,7 @@ program } } - // Filter out issues already covered by existing file (incremental mode) + // Incremental mode skips release issues already covered by the existing file. if (existingCoveredUrls.size > 0 && !singleIssue) { const beforeCount = issues.length issues = issues.filter((issue) => !existingCoveredUrls.has(issue.url)) @@ -652,7 +606,6 @@ program } } - // Step 2: Find changelog PRs. spinner.start('Finding changelog PRs...') const issueChangelogMap = new Map<number, ChangelogInfo | null>() let changelogFound = 0 @@ -664,14 +617,12 @@ program } spinner.succeed(`Found changelog PRs for ${changelogFound}/${issues.length} issues`) - // Step 3: Run agent on each issue. const failures: { issue: ReleaseIssue; error: string }[] = [] const existingEntryCount = allEntries.length - // Hoisted for clarity. The underlying load is already cached. const featureHeadings = loadFeatureHeadings() - // Helper to write current entries to file (called after each success and on Ctrl+C) + // Flush after each success and on Ctrl+C so long runs keep completed notes. const writeCurrentOutput = () => { if (stdout || allEntries.length === 0) return try { @@ -712,15 +663,14 @@ program ) } - // entries are pre-validated by runAgentWithRetry, but re-parse for the actual list + // Re-parse validated YAML so the main list uses parseNoteEntries output. const entries = parseNoteEntries(result.yamlStr, issue.url) - // Warn about headings that don't match any known feature heading or special section + // Unknown headings fall back to changes after case-insensitive correction fails. const specialHeadings = ['Changes', 'Closing down', 'Retired'] const validHeadings = new Set([...featureHeadings, ...specialHeadings]) for (const entry of entries) { if (!validHeadings.has(entry.heading)) { - // Check for near-matches (case-insensitive) const lowerHeading = entry.heading.toLowerCase() const closeMatch = featureHeadings.find((h) => h.toLowerCase() === lowerHeading) if (closeMatch) { @@ -734,7 +684,7 @@ program } } - // Dedup: avoid duplicate notes if the same issue was partially loaded from an existing file + // Avoid duplicates when an existing file already contributed the same issue note. for (const entry of entries) { const isDuplicate = allEntries.some( (existing) => @@ -765,7 +715,7 @@ program console.log(` Reason: ${skipReason}`) } else { spinner.fail(`${label} — ${msg.substring(0, 100)}`) - // Show raw agent output only for real errors + // Show raw agent output only for real errors. if (err.rawOutput) { console.log('\n --- Raw agent output (last attempt) ---') const truncated = @@ -779,7 +729,6 @@ program } } - // Step 4: Final summary. flushBeforeExit = null const newCount = allEntries.length - existingEntryCount if (existingEntryCount > 0) { @@ -802,7 +751,7 @@ program process.exit(1) } if (singleIssue) { - // For a single issue + stdout, print just the raw note YAML (not the full template) + // Single-issue stdout prints note entries, not the full release template. const newEntries = allEntries.filter( (e) => e.sourceUrl === `https://github.com/github/releases/issues/${singleIssue}`, ) @@ -828,7 +777,7 @@ program console.log('\nNo entries generated — no file written.') } - // Clean up stdin raw mode so the process can exit gracefully + // Clean up stdin raw mode so the process can exit gracefully. if (process.stdin.isTTY) { process.stdin.setRawMode(false) process.stdin.pause() diff --git a/src/ghes-releases/scripts/notify-release-pms.ts b/src/ghes-releases/scripts/notify-release-pms.ts index 79adb25aad38..fb12bd9791ad 100644 --- a/src/ghes-releases/scripts/notify-release-pms.ts +++ b/src/ghes-releases/scripts/notify-release-pms.ts @@ -1,21 +1,10 @@ -/** - * @purpose Writer tool - * @description Notify PMs to review their GHES release notes on a PR - * - * Notify PMs about generated GHES release notes by posting a review comment - * on each source release issue in github/releases. - * - * For each release issue URL found in the YAML file's `# https://...` comments, - * this script posts a comment asking the PM to review the note in the PR and - * react with 🚀 once satisfied. - * - * Usage: - * # Post comments via GitHub Actions (handles auth automatically): - * gh workflow run notify-release-pms.yml -f release=3.20 -f pr=12345 - * - * # Preview locally (dry run, no token needed): - * npm run notify-release-pms -- --release 3.20 --pr 12345 --dry-run - */ +// @purpose Writer tool +// @description Notify PMs to review their GHES release notes on a PR +// +// Posts docs-bot review comments on github/releases source issues from YAML source comments. +// Product managers (PMs) review the PR and react with 🚀 when satisfied. +// GitHub Actions usage: gh workflow run notify-release-pms.yml -f release=<release> -f pr=<pr> +// Local preview: npm run notify-release-pms -- --release <release> --pr <pr> --dry-run import { Command } from 'commander' import { execFileSync } from 'child_process' import fs from 'fs' @@ -27,11 +16,7 @@ export interface SourceNote { issueNumber: number } -/** - * Run read-only `gh` CLI commands. - * Uses DOCS_BOT_PAT_BASE when available (CI), otherwise falls back to - * the caller's native `gh` auth (local). - */ +// DOCS_BOT_PAT_BASE authenticates CI reads; caller gh auth handles local reads. function ghRead(args: string[]): string { const env = { ...process.env } if (env.DOCS_BOT_PAT_BASE) { @@ -46,10 +31,7 @@ function ghRead(args: string[]): string { }) } -/** - * Run `gh` CLI commands authenticated as docs-bot (for posting comments). - * Requires the DOCS_BOT_PAT_BASE environment variable to be set. - */ +// Posting comments requires DOCS_BOT_PAT_BASE so they come from docs-bot. function ghWrite(args: string[]): string { const token = process.env.DOCS_BOT_PAT_BASE if (!token) { @@ -62,7 +44,7 @@ function ghWrite(args: string[]): string { process.exit(1) } const env = { ...process.env, GH_TOKEN: token } - // Ensure GH_TOKEN takes precedence over any pre-existing GITHUB_TOKEN + // GH_TOKEN must take precedence over any pre-existing GITHUB_TOKEN. delete (env as Record<string, string | undefined>).GITHUB_TOKEN return execFileSync('gh', args, { encoding: 'utf8', @@ -72,11 +54,7 @@ function ghWrite(args: string[]): string { }) } -/** - * Parse release notes content and extract source issue URLs. - * Each `# https://github.com/github/releases/issues/NNNN` comment - * maps to the note(s) that follow it. - */ +// Source issue URL comments attach each generated note to its github/releases issue. export function parseSourceNotes(content: string): SourceNote[] { const lines = content.split('\n') const notes: SourceNote[] = [] @@ -86,8 +64,7 @@ export function parseSourceNotes(content: string): SourceNote[] { const match = lines[i].match(/^\s*#\s*(https:\/\/github\.com\/github\/releases\/issues\/(\d+))/) if (match) { const issueNumber = parseInt(match[2], 10) - // Some issues appear multiple times (e.g. in features and changes). Keep the - // first occurrence so the link points to the primary note. + // Keep the first occurrence so duplicate issue links point to the primary note. if (!seen.has(issueNumber)) { seen.add(issueNumber) notes.push({ @@ -116,8 +93,7 @@ export function buildCommentBody( const prUrl = `https://github.com/github/docs-internal/pull/${prNumber}` const fileUrl = `${prUrl}/files` - // Use a marker so we can identify our comments later (for duplicate-prevention). - // Include releaseType so RC and GA comments are distinguishable. + // Mark comments for duplicate detection; releaseType keeps RC and GA distinct. const marker = buildMarker(version, releaseType.toLowerCase() as 'rc' | 'ga') const mentions = assignees.length > 0 ? `${assignees.map((a) => `@${a}`).join(' ')} ` : '' @@ -222,7 +198,7 @@ program process.exit(1) } } else { - // Auto-detect: prefer GA if it exists, otherwise RC (consistent with generate-release-notes) + // Auto-detect prefers GA over RC to match generate-release-notes. if (fs.existsSync(gaPath)) { rc = false yamlPath = gaPath @@ -239,7 +215,6 @@ program const relativeFilePath = path.relative(process.cwd(), yamlPath) - // Step 1: Extract source issue URLs. spinner.start('Parsing release notes file...') const sourceNotes = extractSourceNotes(yamlPath) spinner.succeed(`Found ${sourceNotes.length} unique release issue(s) in ${relativeFilePath}`) @@ -249,7 +224,6 @@ program process.exit(0) } - // Step 2: Check for existing comments (avoid duplicates). const releaseType = rc ? 'rc' : 'ga' const marker = buildMarker(release, releaseType) const alreadyCommented = new Set<number>() @@ -268,7 +242,7 @@ program alreadyCommented.add(note.issueNumber) } } catch { - // If we can't read comments, we'll try to post and handle errors then + // Post anyway when comment reads fail; posting reports permission errors. } } if (alreadyCommented.size > 0) { @@ -282,7 +256,6 @@ program spinner.succeed('No existing notifications found') } - // Step 3: Post comments. const toNotify = sourceNotes.filter((n) => !alreadyCommented.has(n.issueNumber)) if (toNotify.length === 0) { @@ -295,18 +268,17 @@ program for (let i = 0; i < toNotify.length; i++) { const note = toNotify[i] - // Fetch assignees (or fall back to issue author) for the release issue + // Mention assignees, or the non-bot issue author when no assignee exists. let assignees: string[] = [] try { const raw = ghRead(['api', `repos/github/releases/issues/${note.issueNumber}`]) const issue = JSON.parse(raw) assignees = (issue.assignees || []).map((a: { login: string }) => a.login) - // Fall back to the issue author unless they're a bot if (assignees.length === 0 && issue.user?.login && issue.user.type !== 'Bot') { assignees = [issue.user.login] } } catch { - // If we can't fetch the issue, post without mentions + // Post without mentions when the issue fetch fails. } const commentBody = buildCommentBody(release, rc, prNumber, assignees) @@ -342,7 +314,6 @@ program } } - // Summary. console.log(`\n${'─'.repeat(40)}`) console.log(`${dryRun ? '🔍 Dry run' : '✅ Done'}`) console.log( @@ -356,7 +327,7 @@ program }, ) -// Only run CLI when executed directly (not when imported in tests) +// Tests import helpers without running the CLI. if (import.meta.url === `file://${process.argv[1]}`) { program.parse(process.argv) } diff --git a/src/ghes-releases/scripts/release-banner.ts b/src/ghes-releases/scripts/release-banner.ts index 7c2136cb64b4..64f15e74c3f1 100644 --- a/src/ghes-releases/scripts/release-banner.ts +++ b/src/ghes-releases/scripts/release-banner.ts @@ -1,12 +1,5 @@ -/** - * @purpose Writer tool - * @description Create or remove a release candidate banner for a GHES version - */ -// [start-readme] -// -// This script creates or removes a release candidate banner for a specified version. -// -// [end-readme] +// @purpose Writer tool +// @description Create or remove a release candidate banner for a GHES version import fs from 'fs/promises' import { program } from 'commander' diff --git a/src/ghes-releases/scripts/update-enterprise-dates.ts b/src/ghes-releases/scripts/update-enterprise-dates.ts index 2eff3bc7c721..6a3a3bb12f1d 100644 --- a/src/ghes-releases/scripts/update-enterprise-dates.ts +++ b/src/ghes-releases/scripts/update-enterprise-dates.ts @@ -1,13 +1,9 @@ -/** - * @purpose Writer tool - * @description Update enterprise release dates from github/enterprise-releases - */ -// [start-readme] +// @purpose Writer tool +// @description Update enterprise release dates from github/enterprise-releases // -// This script fetches data from https://github.com/github/enterprise-releases/blob/master/releases.json -// and updates `src/ghes-releases/lib/enterprise-dates.json`, which the site uses for various functionality. -// -// [end-readme] +// Fetches https://github.com/github/enterprise-releases/blob/master/releases.json +// and updates src/ghes-releases/lib/enterprise-dates.json. +// enterprise-dates.json supplies site release date behavior. import { fileURLToPath } from 'url' import path from 'path' @@ -17,10 +13,11 @@ import { getContents } from '@/workflows/git-utils' interface EnterpriseDates { [releaseNumber: string]: { - releaseDate: string // For backward compatibility - RC date initially, then GA date once available + // Keep releaseDate as the RC date until a GA date exists for backward compatibility. + releaseDate: string deprecationDate: string - releaseCandidateDate?: string // Release Candidate date - generalAvailabilityDate?: string // General Availability date + releaseCandidateDate?: string + generalAvailabilityDate?: string } } @@ -36,7 +33,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const enterpriseDatesFile = path.join(__dirname, '../lib/enterprise-dates.json') const enterpriseDatesString = await fs.readFile(enterpriseDatesFile, 'utf8') -// check for required PAT +// getContents requires GITHUB_TOKEN. if (!process.env.GITHUB_TOKEN) { throw new Error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.') } @@ -59,7 +56,7 @@ async function main(): Promise<void> { const formattedDates: EnterpriseDates = {} for (const [releaseNumber, releaseObject] of Object.entries(rawDates)) { formattedDates[releaseNumber] = { - // For backward compatibility, keep releaseDate as RC date initially, then GA date once available + // Keep releaseDate as the RC date until a GA date exists for backward compatibility. releaseDate: releaseObject.release_candidate || releaseObject.start, deprecationDate: releaseObject.end, releaseCandidateDate: releaseObject.release_candidate, diff --git a/src/ghes-releases/scripts/version-utils.ts b/src/ghes-releases/scripts/version-utils.ts index 9e71b1d7cd63..f6fe47d63cdb 100644 --- a/src/ghes-releases/scripts/version-utils.ts +++ b/src/ghes-releases/scripts/version-utils.ts @@ -4,15 +4,12 @@ import { supported } from '@/versions/lib/enterprise-server-releases' import getDataDirectory from '@/data-directory/lib/data-directory' import { FeatureData, FrontmatterVersions } from '@/types' -// Return true if lowestSupportedVersion > semVerRange export function isGhesReleaseDeprecated(lowestSupportedVersion: string, semVerRange: string) { const lowestSemver = semver.coerce(lowestSupportedVersion) if (!lowestSemver) return false return semver.gtr(lowestSemver.version, semVerRange) } -// Return true if the semver range is greater than the -// lowest supported GHES version export function isInAllGhes(semverRange: string) { if (semverRange === '*') return true const regexGt = /(>|>=){1}\s?(\d+\.\d+)/g @@ -27,11 +24,8 @@ export function isInAllGhes(semverRange: string) { return semver.lte(minVersion, oldestSupported) } -// A feature is deprecated if it only contains -// GHES releases and all releases are deprecated -// or all releases are supported. +// GHES-only features disappear when their GHES range is fully deprecated. export function isFeatureDeprecated(versions: FrontmatterVersions) { - // All GHES releases are deprecated return ( !!versions.ghes && !versions.fpt && @@ -40,8 +34,6 @@ export function isFeatureDeprecated(versions: FrontmatterVersions) { ) } -// Return true when the feature version is in all versions -// and all GHES releases. export function isAllVersions(versions: FrontmatterVersions) { if ( versions && diff --git a/src/ghes-releases/tests/generate-release-notes.ts b/src/ghes-releases/tests/generate-release-notes.ts index a656f6d0125d..02aee8cf6988 100644 --- a/src/ghes-releases/tests/generate-release-notes.ts +++ b/src/ghes-releases/tests/generate-release-notes.ts @@ -270,7 +270,6 @@ describe('buildReleaseNotesYaml', () => { const reposIdx = yaml.indexOf('- heading: Repositories') expect(actionsIdx).toBeGreaterThan(-1) expect(reposIdx).toBeGreaterThan(-1) - // GitHub Actions comes before Repositories in featureHeadings expect(actionsIdx).toBeLessThan(reposIdx) expect(yaml).toContain('Actions note.') @@ -283,7 +282,6 @@ describe('buildReleaseNotesYaml', () => { ] const yaml = buildReleaseNotesYaml(entries, false, featureHeadings) - // Should appear under changes, not features expect(yaml).toContain(' features:\n # TODO: Add feature notes') expect(yaml).toContain(' changes:') expect(yaml).toContain('# https://example.com/1') @@ -305,7 +303,6 @@ describe('buildReleaseNotesYaml', () => { expect(yaml).toContain('Deprecating X.') expect(yaml).toContain(' retired:\n # https://example.com/2') expect(yaml).toContain('Removed Y.') - // Changes should be omitted since Closing down/Retired are excluded and no other entries exist expect(yaml).not.toContain(' changes:') }) @@ -314,8 +311,7 @@ describe('buildReleaseNotesYaml', () => { expect(yaml).toContain('# TODO: Add feature notes') expect(yaml).toContain('# TODO: Add known issues') - // Empty changes, closing_down, and retired are omitted entirely - // to avoid YAML parsing as null (which fails schema validation) + // Omit empty changes, closing_down, and retired so schema validation does not see null sections. expect(yaml).not.toContain(' changes:') expect(yaml).not.toContain(' closing_down:') expect(yaml).not.toContain(' retired:') diff --git a/src/ghes-releases/tests/notify-release-pms.ts b/src/ghes-releases/tests/notify-release-pms.ts index d9e87a09cd8f..33eea75582e6 100644 --- a/src/ghes-releases/tests/notify-release-pms.ts +++ b/src/ghes-releases/tests/notify-release-pms.ts @@ -100,8 +100,7 @@ describe('buildCommentBody', () => { }) describe('duplicate-prevention filtering', () => { - // This tests the core filtering logic used in the CLI action: - // const toNotify = sourceNotes.filter((n) => !alreadyCommented.has(n.issueNumber)) + // These tests cover duplicate filtering without running the CLI action. const sourceNotes: SourceNote[] = [ { issueUrl: 'https://github.com/github/releases/issues/100', issueNumber: 100 }, @@ -133,7 +132,6 @@ describe('duplicate-prevention filtering', () => { const marker = buildMarker('3.21', 'rc') const commentBody = buildCommentBody('3.21', true, 100, ['octocat']) - // Simulates the duplicate-check logic: comments.includes(marker) expect(commentBody.includes(marker)).toBe(true) }) @@ -145,7 +143,6 @@ describe('duplicate-prevention filtering', () => { }) test('new issues added after initial run are not excluded', () => { - // Simulates: ran script once for issues 100+200, then re-run after adding 300 const alreadyCommented = new Set([100, 200]) const updatedSourceNotes: SourceNote[] = [ ...sourceNotes, diff --git a/src/github-apps/lib/index.ts b/src/github-apps/lib/index.ts index 9fef828acf55..cd3debd429fb 100644 --- a/src/github-apps/lib/index.ts +++ b/src/github-apps/lib/index.ts @@ -16,11 +16,10 @@ interface AppsConfig { // parameter on getAppsData. type AppsData = Record<string, unknown> -// Deduplicated on-disk format types. -// A leaf entry in the shared pool: an operation or permission object. +// A shared pool entry contains one operation or permission object. type SharedAppsEntry = Record<string, unknown> -// Per-page index for permission pages: permName → metadata + indices into the pool. +// Permission pages map each permission name to metadata and shared-entry indices. interface PermissionsPageIndex { [permName: string]: { title: string @@ -29,14 +28,14 @@ interface PermissionsPageIndex { } } -// Per-page index for rest pages: category → indices into the pool. +// REST pages map each category to shared-entry indices. interface RestPageIndex { [category: string]: number[] } type AppsPageIndex = PermissionsPageIndex | RestPageIndex -// version-index.json: version → pageType → page index. +// version-index.json maps each OpenAPI version and page type to a page index. type AppsVersionIndex = Record<string, Record<string, AppsPageIndex>> const logger = createLogger(import.meta.url) @@ -48,9 +47,8 @@ let sharedEntries: SharedAppsEntry[] | null = null let sharedVersionIndex: AppsVersionIndex | null = null let sharedFormatAvailable: boolean | null = null -// A missing shared-format file is expected (per-version files are the fallback), -// but a corrupt or unparseable file should fail loudly rather than silently -// degrade to the per-version files and hide bad generated data. +// A missing shared-format file is expected because per-version files are the fallback. +// Corrupt or unparseable files fail loudly instead of hiding bad generated data. function isFileNotFoundError(err: unknown): boolean { if (!(err instanceof Error) || !('code' in err)) return false const code = (err as NodeJS.ErrnoException).code @@ -66,8 +64,7 @@ function loadSharedAppsFormat(): boolean { sharedVersionIndex = readCompressedJsonFileFallback( path.join(ENABLED_APPS_DIR, 'version-index.json'), ) as AppsVersionIndex - // Freeze pool data so reconstructed objects (which return references into - // the pool) can't be mutated by downstream code and leak across versions. + // Freeze pool entries so downstream mutations cannot leak through shared version objects. Object.freeze(sharedEntries) for (const entry of sharedEntries) Object.freeze(entry) sharedFormatAvailable = true @@ -86,8 +83,7 @@ function loadSharedAppsFormat(): boolean { return sharedFormatAvailable } -// Resolves a pool index into its entry, throwing a clear error if the index is -// out of bounds (e.g. from a stale or corrupt version-index.json). +// Resolve a pool index and report stale or corrupt version-index.json pointers. function resolveSharedEntry( idx: number, pageType: string, @@ -116,7 +112,6 @@ function reconstructAppsFromSharedFormat( const isPermissions = pageType.includes('permissions') if (isPermissions) { - // Reconstruct permission data: { permName: { title, displayTitle, permissions: [...] } } const result: Record<string, { title: string; displayTitle: string; permissions: unknown[] }> = {} for (const [permName, meta] of Object.entries(pageData as PermissionsPageIndex)) { @@ -128,7 +123,6 @@ function reconstructAppsFromSharedFormat( } return result } else { - // Reconstruct rest data: { category: [...operations] } const result: Record<string, unknown[]> = {} for (const [category, indices] of Object.entries(pageData as RestPageIndex)) { result[category] = indices.map((idx) => resolveSharedEntry(idx, pageType, openApiVersion)) @@ -137,8 +131,6 @@ function reconstructAppsFromSharedFormat( } } -// Initialize the Map with the page type keys listed under `pages` -// in the config.json file. const appsDataConfig: AppsConfig = JSON.parse( fs.readFileSync('src/github-apps/lib/config.json', 'utf8'), ) @@ -161,8 +153,7 @@ export async function getAppsData<T extends AppsData = AppsData>( if (data) { pageTypeMap.set(openApiVersion, data) } else { - // Fall back to per-version JSON. - // readCompressedJsonFileFallback checks for both a .br and a .json extension. + // Fall back to per-version .br or .json files when shared data is unavailable. const appDataPath = path.join(ENABLED_APPS_DIR, openApiVersion, filename) pageTypeMap.set(openApiVersion, readCompressedJsonFileFallback(appDataPath) as AppsData) } @@ -198,9 +189,7 @@ export async function getAppsServerSideProps( const titles: string[] = useDisplayTitle ? Object.values(appsItems).map((item) => (item as AppsItemWithDisplayTitle).displayTitle!) : Object.keys(appsItems) - // getAutomatedPageMiniTocItems expects a `Context`, but this code path has - // always passed Next.js's GetServerSidePropsContext at runtime. - // Hence the double assertion. + // This path passes GetServerSidePropsContext where getAutomatedPageMiniTocItems expects Context. const appMiniToc = await getAutomatedPageMiniTocItems(titles, context as unknown as Context) if (appMiniToc) { miniTocItems.push(...appMiniToc) diff --git a/src/github-apps/scripts/enabled-list-schema.ts b/src/github-apps/scripts/enabled-list-schema.ts index 75ee13712d98..11675dac0ee1 100644 --- a/src/github-apps/scripts/enabled-list-schema.ts +++ b/src/github-apps/scripts/enabled-list-schema.ts @@ -1,7 +1,6 @@ -// This schema is used to validate -// src/github-apps/data/server-to-server-rest.json -// src/github-apps/data/user-to-server-rest.json -// and src/github-apps/data/fine-grained-pat.json +// This schema validates src/github-apps/data/server-to-server-rest.json, +// src/github-apps/data/user-to-server-rest.json, +// and src/github-apps/data/fine-grained-pat.json. interface SchemaProperty { description: string diff --git a/src/github-apps/scripts/permission-list-schema.ts b/src/github-apps/scripts/permission-list-schema.ts index a30ecd52c2dc..b9d0cd1af105 100644 --- a/src/github-apps/scripts/permission-list-schema.ts +++ b/src/github-apps/scripts/permission-list-schema.ts @@ -1,6 +1,5 @@ -// This schema is used to validate -// src/github-apps/data/fine-grained-pat-permissions.json -// and src/github-apps/data/server-to-server-permissions.json +// This schema validates src/github-apps/data/fine-grained-pat-permissions.json +// and src/github-apps/data/server-to-server-permissions.json. interface SchemaProperty { type: string @@ -49,7 +48,7 @@ const schema: Schema = { type: 'object', required: ['title', 'displayTitle', 'permissions'], properties: { - // Properties from the source OpenAPI schema that this module depends on + // Generated permission entries combine metadata titles with assembled permission rows. title: { description: 'The name of the permission.', type: 'string', diff --git a/src/github-apps/scripts/sync.ts b/src/github-apps/scripts/sync.ts index b9c1f7d84ae7..22f26242bccd 100755 --- a/src/github-apps/scripts/sync.ts +++ b/src/github-apps/scripts/sync.ts @@ -13,7 +13,7 @@ import { validateJson } from '@/tests/lib/validate-json-schema' const ENABLED_APPS_DIR = 'src/github-apps/data' const CONFIG_FILE = 'src/github-apps/lib/config.json' -// Actor type mapping from generic names to actual YAML values +// Map generic actor names to excluded_actors values. export const actorTypeMap: Record<string, string> = { fine_grained_pat: 'fine_grained_personal_access_token', server_to_server: 'github_app', @@ -134,8 +134,7 @@ export async function syncGitHubAppsData( for (const pageType of Object.keys(appsDataConfig.pages)) { githubAppsData[pageType] = {} } - // Because the information used on the apps page doesn't require any - // rendered content we can parse the dereferenced files directly + // Apps pages only need operation metadata here, so parse dereferenced OpenAPI files directly. for (const [requestPath, operationsAtPath] of Object.entries(schemaData.paths)) { for (const [verb, operation] of Object.entries(operationsAtPath)) { if (!progAccessData[operation.operationId]) continue @@ -191,12 +190,6 @@ export async function syncGitHubAppsData( progAccessData[operation.operationId].permissions, ) - // Filter out metadata permissions when combined with other permissions - // The metadata permission is automatically granted with any other repository permission, - // so documenting it for operations that require additional permissions is misleading. - // This fixes the issue where mutating operations (PUT, DELETE) incorrectly appeared - // to only need metadata access when they actually require write permissions. - // See: https://github.com/github/docs-engineering/issues/5212 if ( shouldFilterMetadataPermission( permissionName, @@ -241,11 +234,7 @@ export async function syncGitHubAppsData( const isExcluded = isActorExcluded(excludedActors, 'fine_grained_pat', actorTypeMap) if (isFineGrainedPat && !isExcluded) { - // Hardcoded exception: exclude repository_projects from fine-grained PAT permissions - // This is because fine-grained PATs can only operate on organization-level Projects (classic), - // not repository-level Projects (classic). Users cannot grant the repository Projects (classic) - // fine-grained permission in the fine-grained PAT UI. - // See: https://github.com/github/docs-engineering/issues/4613 + // Fine-grained PATs grant org Projects (classic), not repo Projects (classic). if (permissionName === 'repository_projects') { continue } @@ -275,7 +264,6 @@ export async function syncGitHubAppsData( const versionName = path.basename(schemaName, '.json') const targetDirectory = path.join(ENABLED_APPS_DIR, versionName) - // When a new version is added, we need to create the directory for it if (!existsSync(targetDirectory)) { await mkdir(targetDirectory, { recursive: true }) } @@ -302,16 +290,16 @@ export async function syncGitHubAppsData( await writeDeduplicatedAppsFormat() } +// The deduplicated format stores repeated operation and permission objects once. +// version-index.json maps each version and page to those shared entries. async function writeDeduplicatedAppsFormat() { console.log(`\n▶️ Writing deduplicated GitHub Apps data...\n`) - // Read all the per-version files we just wrote to build the shared format const versions = fs.readdirSync(ENABLED_APPS_DIR).filter((f) => { const fullPath = path.join(ENABLED_APPS_DIR, f) return fs.statSync(fullPath).isDirectory() && f !== 'shared' }) - // Pool for unique leaf objects (operations and permission entries) const entriesPool: unknown[] = [] const entriesMap = new Map<string, number>() @@ -324,9 +312,7 @@ async function writeDeduplicatedAppsFormat() { return index } - // version-index structure: - // For rest pages: { version: { pageType: { category: number[] } } } - // For permission pages: { version: { pageType: { permName: { title, displayTitle, indices: number[] } } } } + // REST pages map categories to indices; permission pages map names to metadata and indices. const versionIndex: Record<string, Record<string, unknown>> = {} let totalItems = 0 @@ -339,9 +325,7 @@ async function writeDeduplicatedAppsFormat() { const pageType = path.basename(file, '.json') const data = JSON.parse(fs.readFileSync(path.join(versionDir, file), 'utf8')) const isPermissions = pageType.includes('permissions') - if (isPermissions) { - // Permission data: { permName: { title, displayTitle, permissions: [...] } } const pageIndex: Record< string, { title: string; displayTitle: string; indices: number[] } @@ -359,7 +343,6 @@ async function writeDeduplicatedAppsFormat() { } versionIndex[version][pageType] = pageIndex } else { - // Rest data: { category: [...operations] } const pageIndex: Record<string, number[]> = {} for (const [category, operations] of Object.entries( data as Record<string, AppDataOperation[]>, @@ -547,7 +530,7 @@ export function calculateAdditionalPermissions( ) } -// Without this, a mutating operation appears to need only metadata access. +// Metadata is redundant when any other permission applies, so hide it in those rows. export function shouldFilterMetadataPermission( permissionName: string, permissionSets: Array<Record<string, string>>, @@ -621,6 +604,7 @@ async function validateAppData( } } +// Use gitHubSourceDirectory locally; use owner, repo, branch, and path remotely. interface ProgActorResourceContentOptions { owner?: string repo?: string @@ -629,11 +613,6 @@ interface ProgActorResourceContentOptions { gitHubSourceDirectory?: string | null } -// When getting files from the GitHub repo locally (or in a Codespace) -// you can pass the full or relative path to the `github` repository -// directory on disk. -// When the source directory is `rest-api-description` (which is more common) -// you can pass the `owner`, `repo`, `branch`, and `path` (repository path) async function getProgActorResourceContent({ owner, repo, diff --git a/src/github-apps/tests/deduplication.ts b/src/github-apps/tests/deduplication.ts index 3a2503bec523..53cb5756b8dd 100644 --- a/src/github-apps/tests/deduplication.ts +++ b/src/github-apps/tests/deduplication.ts @@ -146,7 +146,7 @@ describe('GitHub Apps deduplication', () => { } const uniqueEntries = entries.length - // We expect at least 70% dedup rate based on issue analysis (~84% reported) + // The 70% threshold leaves room for normal generated-data churn. const dedupRate = 1 - uniqueEntries / totalReferences expect(dedupRate).toBeGreaterThan(0.7) }) diff --git a/src/github-apps/tests/excluded-actors.ts b/src/github-apps/tests/excluded-actors.ts index 7cbaa790f24e..3041a1652146 100644 --- a/src/github-apps/tests/excluded-actors.ts +++ b/src/github-apps/tests/excluded-actors.ts @@ -44,8 +44,6 @@ describe('excluded_actors filtering', () => { expect(isActorExcluded(['UserProgrammaticAccess'], 'fine_grained_pat', actorTypeMap)).toBe(true) expect(isActorExcluded(['github_app'], 'server_to_server', actorTypeMap)).toBe(true) expect(isActorExcluded(['user_access_token'], 'user_to_server', actorTypeMap)).toBe(true) - - // Test fallback when no mapping exists expect(isActorExcluded(['some_unmapped_actor'], 'some_unmapped_actor')).toBe(true) expect(isActorExcluded(['some_unmapped_actor'], 'different_actor')).toBe(false) }) @@ -84,8 +82,6 @@ describe('excluded_actors filtering', () => { ).toBe(true) expect(isActorExcluded(['github_app'], 'server_to_server', actorTypeMap)).toBe(true) expect(isActorExcluded(['user_access_token'], 'user_to_server', actorTypeMap)).toBe(true) - - // Test fallback when no mapping exists expect(isActorExcluded(['some_unmapped_actor'], 'some_unmapped_actor')).toBe(true) expect(isActorExcluded(['some_unmapped_actor'], 'different_actor')).toBe(false) }) diff --git a/src/github-apps/tests/metadata-permissions.ts b/src/github-apps/tests/metadata-permissions.ts index 3bdc2e025b9a..a644ab30629a 100644 --- a/src/github-apps/tests/metadata-permissions.ts +++ b/src/github-apps/tests/metadata-permissions.ts @@ -191,12 +191,11 @@ describe('metadata permissions filtering', () => { expect(shouldFilterMetadataPermission('metadata', metadataInSeparateSet)).toBe(true) }) + // PUT and DELETE /orgs/{org}/actions/permissions/repositories/{repository_id} + // pair metadata with organization_administration. test('filters metadata permissions that match the GitHub issue examples', () => { - // These are examples from the GitHub issue that should be filtered out - // PUT /orgs/{org}/actions/permissions/repositories/{repository_id} const putActionsPermissions = [{ metadata: 'read', organization_administration: 'write' }] - // DELETE /orgs/{org}/actions/permissions/repositories/{repository_id} const deleteActionsPermissions = [{ metadata: 'read', organization_administration: 'write' }] expect(shouldFilterMetadataPermission('metadata', putActionsPermissions)).toBe(true) @@ -278,12 +277,9 @@ describe('metadata permissions filtering', () => { } }) + // PUT and DELETE /orgs/{org}/actions/permissions/repositories/{repository_id} + // pair metadata with organization_administration. test('validates filtering logic matches expected behavior from issue', () => { - // Based on the GitHub issue, these operations should be filtered out from metadata: - // - PUT /orgs/{org}/actions/permissions/repositories/{repository_id} - // - DELETE /orgs/{org}/actions/permissions/repositories/{repository_id} - // Because they have metadata + organization_administration permissions - const progData: ProgAccessData = { userToServerRest: true, serverToServer: true, diff --git a/src/github-apps/tests/rendering.ts b/src/github-apps/tests/rendering.ts index 19ebb9ca7e78..fc3b249cb2ff 100644 --- a/src/github-apps/tests/rendering.ts +++ b/src/github-apps/tests/rendering.ts @@ -70,7 +70,6 @@ describe('REST references docs', () => { apiVersion, ) - // using the static file, generate the expected slug for each operation for (const [key, value] of Object.entries(enabledForApps)) { schemaSlugs.push( ...value.map( @@ -81,7 +80,6 @@ describe('REST references docs', () => { ), ) } - // get all of the href attributes in the anchor tags const contentPath = configContent.pages[page].targetFilename .replace('content/', '') .replace('.md', '') @@ -94,7 +92,6 @@ describe('REST references docs', () => { }) test('loads permission list pages', async () => { - // permissions pages for (const page of permissionPages) { const schemaSlugs: string[] = [] @@ -104,7 +101,6 @@ describe('REST references docs', () => { apiVersion, ) - // using the static file, generate the expected slug for each operation for (const value of Object.values(permissionsData)) { schemaSlugs.push( ...value.permissions.map( @@ -116,7 +112,6 @@ describe('REST references docs', () => { ) } - // get all of the href attributes in the anchor tags const contentPath = configContent.pages[page].targetFilename .replace('content/', '') .replace('.md', '') diff --git a/src/github-apps/tests/sync.ts b/src/github-apps/tests/sync.ts index ec8dad3f1810..2b412bf07141 100644 --- a/src/github-apps/tests/sync.ts +++ b/src/github-apps/tests/sync.ts @@ -128,7 +128,7 @@ describe('getProgAccessData', () => { const expectedData = { userToServerRest: false, serverToServer: true, - fineGrainedPat: false, // false because user_to_server.enabled is false + fineGrainedPat: false, // user_to_server.enabled is false permissions: [{ metadata: 'read' }], allowPermissionlessAccess: true, allowsPublicRead: false, @@ -158,7 +158,6 @@ describe('getProgAccessData', () => { const result = await processProgAccessDataMock(mockProgAccessDataRaw, mockProgActorResources) - // All operation IDs should exist (whitespace should be trimmed) expect(result.progAccessData).toHaveProperty('operation-a') expect(result.progAccessData).toHaveProperty('operation-b') expect(result.progAccessData).toHaveProperty('operation-c') @@ -224,7 +223,7 @@ describe('getProgAccessData', () => { const expectedCommaData = { userToServerRest: true, serverToServer: false, - fineGrainedPat: false, // false because disabled_for_patv2 is true + fineGrainedPat: false, // disabled_for_patv2 is true permissions: [{ metadata: 'write' }], allowPermissionlessAccess: true, allowsPublicRead: false, @@ -236,8 +235,6 @@ describe('getProgAccessData', () => { }) }) -// Helper function to simulate the data processing logic from sync.ts -// without needing to set up the full file system or remote API calls async function processProgAccessDataMock( progAccessDataRaw: ProgAccessDataRaw[], progActorResources: ProgActorResources, @@ -255,7 +252,6 @@ async function processProgAccessDataMock( basicAuth: operation.basic_auth, } - // Handle comma-separated operation IDs const operationIds = operation.operation_ids.split(',').map((id) => id.trim()) for (const operationId of operationIds) { progAccessData[operationId] = operationData diff --git a/src/graphql/components/GraphqlCategoryPage.module.scss b/src/graphql/components/GraphqlCategoryPage.module.scss index c2c980db2ee4..fbd439aba014 100644 --- a/src/graphql/components/GraphqlCategoryPage.module.scss +++ b/src/graphql/components/GraphqlCategoryPage.module.scss @@ -1,22 +1,16 @@ -// Heading rhythm tweaks specific to GraphQL category pages. -// Each schema kind (Objects, Mutations, etc.) is a section H2; individual -// items inside a section are H3; sub-sections within an item (fields, -// arguments, return fields, etc.) are H4. +// GraphQL category pages render kinds as H2, items as H3, and item subsections as H4. .categoryPage { h2 { padding-top: 2.5rem; } - // Items inside a kind section need top spacing so consecutive items don't - // visually run together. The first item under each kind H2 doesn't need - // it: the H2's own padding-top already provides plenty of breathing room. + // Items inside a kind section need top spacing so consecutive items do not run together. + // The first item under each kind H2 relies on the H2 padding instead. h3 { padding-top: 2rem; } - // The very first H2 on the page sits directly below the intro/lead; the - // extra padding-top adds an awkward gap there. Same idea for the first H3 - // in each section, which sits directly under its kind H2. + // Skip top padding where the heading follows the intro or its kind H2, to avoid a double gap. > :first-child h2:first-child, section > h3:first-of-type { padding-top: 0; @@ -26,15 +20,8 @@ padding-top: 0; } - // The schema item description is rendered as HTML and often wraps in a - // single <p>. The trailing paragraph margin leaves a "chin" between the - // description and the next sub-section, so strip it. The - // `graphql-item-description` class is added to the description wrapper - // inside `GraphqlItem` by sibling PR #61435; until that merges, this rule - // is inert (and it's only ever evaluated under `.categoryPage`, which - // doesn't render until the recat PR wires this component up). - // `:global` is required because CSS modules would otherwise rewrite the - // class name. + // GraphQL descriptions often render as one p whose bottom margin leaves a gap. + // CSS modules would rewrite graphql-item-description unless :global keeps the emitted class. :global(.graphql-item-description) > :last-child { margin-bottom: 0; } diff --git a/src/graphql/components/GraphqlCategoryPage.tsx b/src/graphql/components/GraphqlCategoryPage.tsx index f5408a316c06..90ef58893979 100644 --- a/src/graphql/components/GraphqlCategoryPage.tsx +++ b/src/graphql/components/GraphqlCategoryPage.tsx @@ -49,27 +49,16 @@ export type CategorySchema = Partial<{ type Props = { schema: CategorySchema - // All objects across every category. Used by `Interface` to list - // implementers regardless of which category page is being rendered. + // Interface needs objects from every category to list implementers across category pages. allObjects: ObjectT[] } -// Item-level heading level used when items render under a kind section -// heading (`<h2>`). Kept in one place so the matching mini-TOC builder in -// `pages/reference.tsx` can stay in sync with the on-page anchors. +// Keep item headings in sync with the mini-TOC builder in src/graphql/pages/reference.tsx. const ITEM_HEADING_LEVEL = 3 +// src/graphql/pages/reference.tsx sends empty categories to 404, so this renders populated pages. +// GraphqlItem keeps kind labels so deep-linked items remain self-describing outside the section. export function GraphqlCategoryPage({ schema, allObjects }: Props) { - // Render one section per kind, in the canonical `ALL_KIND_KEYS` order. - // Items inside each section are sorted case-insensitively by name. The - // per-kind label pill rendered by `GraphqlItem` is now somewhat redundant - // here (the kind is obvious from the section heading directly above), but - // we keep it for now so items stay visually self-describing if they're - // ever deep-linked or rendered outside the section context. - // - // Empty-category pages are short-circuited to a 404 in - // `pages/reference.tsx`, so this component is only ever rendered with at - // least one section. const sections = ALL_KIND_KEYS.flatMap((kind) => { const items = schema[kind] if (!items || items.length === 0) return [] @@ -123,6 +112,4 @@ function renderItem(kind: SchemaKindKey, item: AnySchemaItem, allObjects: Object } } -// Re-export the kind label map for callers that want to render a label -// outside of the page (e.g. mini-toc or breadcrumbs). export { KIND_LABELS } diff --git a/src/graphql/components/GraphqlItem.tsx b/src/graphql/components/GraphqlItem.tsx index fe05f8f0b74d..f30ff7528de6 100644 --- a/src/graphql/components/GraphqlItem.tsx +++ b/src/graphql/components/GraphqlItem.tsx @@ -12,15 +12,12 @@ type Props = { heading?: string headingLevel?: number children?: React.ReactNode - // When provided, the heading id is prefixed with the kind so two items - // with the same case-insensitive name across kinds get distinct anchors - // on a category page (e.g. `object-repository` vs `query-repository`). + // Prefix heading IDs so names shared across kinds get distinct anchors. + // For example, object-repository and query-repository can coexist. kind?: SchemaKindKey } -// Clamp a numeric heading level to the valid HTML range (2-6). Used to -// build heading tag names like `h2`/`h3` from a numeric `headingLevel` -// prop without producing invalid tags if a caller passes something odd. +// Clamp heading tags to h2 through h6 when callers pass odd headingLevel values. function headingTag(level: number): keyof JSX.IntrinsicElements { const clamped = Math.max(2, Math.min(6, level)) return `h${clamped}` as keyof JSX.IntrinsicElements @@ -31,9 +28,7 @@ export function GraphqlItem({ item, heading, children, headingLevel = 2, kind }: const slug = kind ? `${KIND_SLUG_PREFIX[kind]}-${baseSlug}` : baseSlug const hasNotice = Boolean(item.preview || item.isDeprecated) const kindLabel = kind ? KIND_LABELS[kind] : undefined - // Sub-headings rendered via the `heading` prop should sit one level below - // the item's own heading so the document outline stays well-formed when - // the item itself is nested under a kind section heading on category pages. + // Subheadings sit one level below the item heading to keep category page outlines valid. const SubHeading = headingTag(headingLevel + 1) return ( @@ -61,6 +56,5 @@ export function GraphqlItem({ item, heading, children, headingLevel = 2, kind }: ) } -// Re-exported so per-kind wrappers can build matching sub-sub-headings -// (e.g. Mutation's "Return fields" h-tag) without duplicating the clamp logic. +// Per-kind wrappers share headingTag so Mutation return fields use the same heading clamp. export { headingTag } diff --git a/src/graphql/components/GraphqlPage.tsx b/src/graphql/components/GraphqlPage.tsx index 053109f0cf5f..971200a41f73 100644 --- a/src/graphql/components/GraphqlPage.tsx +++ b/src/graphql/components/GraphqlPage.tsx @@ -28,10 +28,8 @@ type Props = { } export const GraphqlPage = ({ schema, pageName, objects }: Props) => { - const graphqlItems: JSX.Element[] = [] // In the case of the H2s for Queries + const graphqlItems: JSX.Element[] = [] - // The queries page has two heading sections (connections and fields), so add - // the heading component and its children once per section. if (pageName === 'queries') { graphqlItems.push( ...(schema as QueryT[]).map((item) => <Query item={item} key={item.id + item.name} />), diff --git a/src/graphql/data/fpt/category-map.json b/src/graphql/data/fpt/category-map.json index dcbdb8f3c097..707c648eb014 100644 --- a/src/graphql/data/fpt/category-map.json +++ b/src/graphql/data/fpt/category-map.json @@ -130,6 +130,7 @@ "addcloseissuereferences": "issues", "addcomment": "issues", "addlabelstolabelable": "issues", + "addrelatesto": "issues", "addsubissue": "issues", "applypendingissuesuggestions": "issues", "clearlabelsfromlabelable": "issues", @@ -156,6 +157,7 @@ "removeblockedby": "issues", "removecloseissuereferences": "issues", "removelabelsfromlabelable": "issues", + "removerelatesto": "issues", "removesubissue": "issues", "reopenissue": "issues", "replaceactorsforassignable": "issues", @@ -1248,6 +1250,7 @@ "issuefieldupdateoperation": "issues", "issuefieldvisibility": "issues", "issueorderfield": "issues", + "issuerelatestoorderfield": "issues", "issuesearchtype": "issues", "issuestate": "issues", "issuestatereason": "issues", @@ -1576,6 +1579,7 @@ "addcloseissuereferencesinput": "issues", "addcommentinput": "issues", "addlabelstolabelableinput": "issues", + "addrelatestoinput": "issues", "addsubissueinput": "issues", "applypendingissuesuggestionsinput": "issues", "assigneeupdateinput": "issues", @@ -1603,6 +1607,7 @@ "issuefieldvaluefilter": "issues", "issuefilters": "issues", "issueorder": "issues", + "issuerelatestoorder": "issues", "issuestateupdateinput": "issues", "issuetypeorder": "issues", "issuetypeupdateinput": "issues", @@ -1619,6 +1624,7 @@ "removeblockedbyinput": "issues", "removecloseissuereferencesinput": "issues", "removelabelsfromlabelableinput": "issues", + "removerelatestoinput": "issues", "removesubissueinput": "issues", "reopenissueinput": "issues", "replaceactorsforassignableinput": "issues", diff --git a/src/graphql/data/fpt/changelog.json b/src/graphql/data/fpt/changelog.json index 79d9cdf3ee61..79dd889433df 100644 --- a/src/graphql/data/fpt/changelog.json +++ b/src/graphql/data/fpt/changelog.json @@ -1,4 +1,48 @@ [ + { + "schemaChanges": [ + { + "title": "The GraphQL schema includes these changes:", + "changes": [ + "<p>Type <code>AddRelatesToInput</code> was added</p>", + "<p>Input field <code>clientMutationId</code> of type <code>String</code> was added to input object type <code>AddRelatesToInput</code></p>", + "<p>Input field <code>issueId</code> of type <code>ID!</code> was added to input object type <code>AddRelatesToInput</code></p>", + "<p>Input field <code>relatedIssueId</code> of type <code>ID!</code> was added to input object type <code>AddRelatesToInput</code></p>", + "<p>Type <code>AddRelatesToPayload</code> was added</p>", + "<p>Field <code>clientMutationId</code> was added to object type <code>AddRelatesToPayload</code></p>", + "<p>Field <code>issue</code> was added to object type <code>AddRelatesToPayload</code></p>", + "<p>Field <code>relatedIssue</code> was added to object type <code>AddRelatesToPayload</code></p>", + "<p>Type <code>IssueRelatesToOrder</code> was added</p>", + "<p>Input field <code>direction</code> of type <code>OrderDirection!</code> was added to input object type <code>IssueRelatesToOrder</code></p>", + "<p>Input field <code>field</code> of type <code>IssueRelatesToOrderField!</code> was added to input object type <code>IssueRelatesToOrder</code></p>", + "<p>Type <code>IssueRelatesToOrderField</code> was added</p>", + "<p>Enum value 'CREATED_AT<code>was added to enum</code>IssueRelatesToOrderField'</p>", + "<p>Enum value 'RELATES_TO_ADDED_AT<code>was added to enum</code>IssueRelatesToOrderField'</p>", + "<p>Type <code>RemoveRelatesToInput</code> was added</p>", + "<p>Input field <code>clientMutationId</code> of type <code>String</code> was added to input object type <code>RemoveRelatesToInput</code></p>", + "<p>Input field <code>issueId</code> of type <code>ID!</code> was added to input object type <code>RemoveRelatesToInput</code></p>", + "<p>Input field <code>relatedIssueId</code> of type <code>ID!</code> was added to input object type <code>RemoveRelatesToInput</code></p>", + "<p>Type <code>RemoveRelatesToPayload</code> was added</p>", + "<p>Field <code>clientMutationId</code> was added to object type <code>RemoveRelatesToPayload</code></p>", + "<p>Field <code>issue</code> was added to object type <code>RemoveRelatesToPayload</code></p>", + "<p>Field <code>relatedIssue</code> was added to object type <code>RemoveRelatesToPayload</code></p>", + "<p>Field <code>relatesTo</code> was added to object type <code>Issue</code></p>", + "<p>Argument <code>after: String</code> added to field <code>Issue.relatesTo</code></p>", + "<p>Argument <code>before: String</code> added to field <code>Issue.relatesTo</code></p>", + "<p>Argument <code>first: Int</code> added to field <code>Issue.relatesTo</code></p>", + "<p>Argument <code>last: Int</code> added to field <code>Issue.relatesTo</code></p>", + "<p>Argument <code>orderBy: IssueRelatesToOrder</code> (with default value) added to field <code>Issue.relatesTo</code></p>", + "<p>Field <code>addRelatesTo</code> was added to object type <code>Mutation</code></p>", + "<p>Argument <code>input: AddRelatesToInput!</code> added to field <code>Mutation.addRelatesTo</code></p>", + "<p>Field <code>removeRelatesTo</code> was added to object type <code>Mutation</code></p>", + "<p>Argument <code>input: RemoveRelatesToInput!</code> added to field <code>Mutation.removeRelatesTo</code></p>" + ] + } + ], + "previewChanges": [], + "upcomingChanges": [], + "date": "2026-09-28" + }, { "schemaChanges": [ { diff --git a/src/graphql/data/fpt/schema-issues.json b/src/graphql/data/fpt/schema-issues.json index 9a9b69cef8ce..071f422db655 100644 --- a/src/graphql/data/fpt/schema-issues.json +++ b/src/graphql/data/fpt/schema-issues.json @@ -181,6 +181,45 @@ ], "category": "issues" }, + { + "name": "addRelatesTo", + "id": "addrelatesto", + "href": "/graphql/reference/issues#mutation-addrelatesto", + "description": "<p>Adds a 'relates to' relationship between two issues.</p>", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "AddRelatesToInput!", + "id": "addrelatestoinput", + "href": "/graphql/reference/issues#input-object-addrelatestoinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "<p>A unique identifier for the client performing the mutation.</p>" + }, + { + "name": "issue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "<p>The source issue.</p>" + }, + { + "name": "relatedIssue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "<p>The related issue.</p>" + } + ], + "category": "issues" + }, { "name": "addSubIssue", "id": "addsubissue", @@ -1041,6 +1080,45 @@ ], "category": "issues" }, + { + "name": "removeRelatesTo", + "id": "removerelatesto", + "href": "/graphql/reference/issues#mutation-removerelatesto", + "description": "<p>Removes a 'relates to' relationship between two issues.</p>", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "RemoveRelatesToInput!", + "id": "removerelatestoinput", + "href": "/graphql/reference/issues#input-object-removerelatestoinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "<p>A unique identifier for the client performing the mutation.</p>" + }, + { + "name": "issue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "<p>The previously targeted issue.</p>" + }, + { + "name": "relatedIssue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "<p>The previously related issue.</p>" + } + ], + "category": "issues" + }, { "name": "removeSubIssue", "id": "removesubissue", @@ -3601,6 +3679,60 @@ } ] }, + { + "name": "relatesTo", + "description": "<p>A list of issues related to this issue.</p>", + "type": "IssueConnection!", + "id": "issueconnection", + "href": "/graphql/reference/issues#object-issueconnection", + "arguments": [ + { + "name": "after", + "description": "<p>Returns the elements in the list that come after the specified cursor.</p>", + "type": { + "name": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + }, + { + "name": "before", + "description": "<p>Returns the elements in the list that come before the specified cursor.</p>", + "type": { + "name": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + }, + { + "name": "first", + "description": "<p>Returns the first <em>n</em> elements from the list.</p>", + "type": { + "name": "Int", + "id": "int", + "href": "/graphql/reference/other#scalar-int" + } + }, + { + "name": "last", + "description": "<p>Returns the last <em>n</em> elements from the list.</p>", + "type": { + "name": "Int", + "id": "int", + "href": "/graphql/reference/other#scalar-int" + } + }, + { + "name": "orderBy", + "description": "<p>Ordering options for related issues.</p>", + "type": { + "name": "IssueRelatesToOrder", + "id": "issuerelatestoorder", + "href": "/graphql/reference/issues#input-object-issuerelatestoorder" + } + } + ] + }, { "name": "repository", "description": "<p>The repository associated with this node.</p>", @@ -10031,6 +10163,24 @@ ], "category": "issues" }, + { + "name": "IssueRelatesToOrderField", + "id": "issuerelatestoorderfield", + "href": "/graphql/reference/issues#enum-issuerelatestoorderfield", + "description": "<p>Properties by which related issues can be ordered.</p>", + "isDeprecated": false, + "values": [ + { + "name": "CREATED_AT", + "description": "<p>Order related issues by the creation time of the related issue.</p>" + }, + { + "name": "RELATES_TO_ADDED_AT", + "description": "<p>Order related issues by time of when the relates-to relationship was added.</p>" + } + ], + "category": "issues" + }, { "name": "IssueSearchType", "id": "issuesearchtype", @@ -11304,6 +11454,38 @@ ], "category": "issues" }, + { + "name": "AddRelatesToInput", + "id": "addrelatestoinput", + "href": "/graphql/reference/issues#input-object-addrelatestoinput", + "description": "<p>Autogenerated input type of AddRelatesTo.</p>", + "inputFields": [ + { + "name": "clientMutationId", + "description": "<p>A unique identifier for the client performing the mutation.</p>", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "issueId", + "description": "<p>The ID of the issue.</p>", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + }, + { + "name": "relatedIssueId", + "description": "<p>The ID of the related issue.</p>", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "issues" + }, { "name": "AddSubIssueInput", "id": "addsubissueinput", @@ -12436,6 +12618,30 @@ ], "category": "issues" }, + { + "name": "IssueRelatesToOrder", + "id": "issuerelatestoorder", + "href": "/graphql/reference/issues#input-object-issuerelatestoorder", + "description": "<p>Ordering options for related issues.</p>", + "isDeprecated": false, + "inputFields": [ + { + "name": "direction", + "description": "<p>The ordering direction.</p>", + "type": "OrderDirection!", + "id": "orderdirection", + "href": "/graphql/reference/meta#enum-orderdirection" + }, + { + "name": "field", + "description": "<p>The field to order related issues by.</p>", + "type": "IssueRelatesToOrderField!", + "id": "issuerelatestoorderfield", + "href": "/graphql/reference/issues#enum-issuerelatestoorderfield" + } + ], + "category": "issues" + }, { "name": "IssueStateUpdateInput", "id": "issuestateupdateinput", @@ -12960,6 +13166,38 @@ ], "category": "issues" }, + { + "name": "RemoveRelatesToInput", + "id": "removerelatestoinput", + "href": "/graphql/reference/issues#input-object-removerelatestoinput", + "description": "<p>Autogenerated input type of RemoveRelatesTo.</p>", + "inputFields": [ + { + "name": "clientMutationId", + "description": "<p>A unique identifier for the client performing the mutation.</p>", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "issueId", + "description": "<p>The ID of the issue.</p>", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + }, + { + "name": "relatedIssueId", + "description": "<p>The ID of the previously related issue.</p>", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "issues" + }, { "name": "RemoveSubIssueInput", "id": "removesubissueinput", diff --git a/src/graphql/data/fpt/schema.docs.graphql b/src/graphql/data/fpt/schema.docs.graphql index e3c1e2b7b8b9..94fb6074bb62 100644 --- a/src/graphql/data/fpt/schema.docs.graphql +++ b/src/graphql/data/fpt/schema.docs.graphql @@ -1253,6 +1253,46 @@ type AddReactionPayload { subject: Reactable } +""" +Autogenerated input type of AddRelatesTo +""" +input AddRelatesToInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the issue. + """ + issueId: ID! @possibleTypes(concreteTypes: ["Issue"]) + + """ + The ID of the related issue. + """ + relatedIssueId: ID! @possibleTypes(concreteTypes: ["Issue"]) +} + +""" +Autogenerated return type of AddRelatesTo. +""" +type AddRelatesToPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The source issue. + """ + issue: Issue + + """ + The related issue. + """ + relatedIssue: Issue +} + """ Autogenerated input type of AddStar """ @@ -20715,6 +20755,36 @@ type Issue implements Assignable & orderBy: ReactionOrder ): ReactionConnection! + """ + A list of issues related to this issue. + """ + relatesTo( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for related issues + """ + orderBy: IssueRelatesToOrder = {field: RELATES_TO_ADDED_AT, direction: DESC} + ): IssueConnection! + """ The repository associated with this node. """ @@ -22700,6 +22770,36 @@ enum IssueOrderField @docsCategory(name: "issues") { UPDATED_AT } +""" +Ordering options for related issues +""" +input IssueRelatesToOrder @docsCategory(name: "issues") { + """ + The ordering direction. + """ + direction: OrderDirection! + + """ + The field to order related issues by. + """ + field: IssueRelatesToOrderField! +} + +""" +Properties by which related issues can be ordered. +""" +enum IssueRelatesToOrderField @docsCategory(name: "issues") { + """ + Order related issues by the creation time of the related issue + """ + CREATED_AT + + """ + Order related issues by time of when the relates-to relationship was added + """ + RELATES_TO_ADDED_AT +} + """ Type of issue search performed """ @@ -27440,6 +27540,16 @@ type Mutation @docsCategory(name: "meta") { input: AddReactionInput! ): AddReactionPayload @docsCategory(name: "reactions") + """ + Adds a 'relates to' relationship between two issues. + """ + addRelatesTo( + """ + Parameters for AddRelatesTo + """ + input: AddRelatesToInput! + ): AddRelatesToPayload @docsCategory(name: "issues") + """ Adds a star to a Starrable. """ @@ -28913,6 +29023,16 @@ type Mutation @docsCategory(name: "meta") { input: RemoveReactionInput! ): RemoveReactionPayload @docsCategory(name: "reactions") + """ + Removes a 'relates to' relationship between two issues. + """ + removeRelatesTo( + """ + Parameters for RemoveRelatesTo + """ + input: RemoveRelatesToInput! + ): RemoveRelatesToPayload @docsCategory(name: "issues") + """ Removes a star from a Starrable. """ @@ -50403,6 +50523,46 @@ type RemoveReactionPayload { subject: Reactable } +""" +Autogenerated input type of RemoveRelatesTo +""" +input RemoveRelatesToInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the issue. + """ + issueId: ID! @possibleTypes(concreteTypes: ["Issue"]) + + """ + The ID of the previously related issue. + """ + relatedIssueId: ID! @possibleTypes(concreteTypes: ["Issue"]) +} + +""" +Autogenerated return type of RemoveRelatesTo. +""" +type RemoveRelatesToPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The previously targeted issue. + """ + issue: Issue + + """ + The previously related issue. + """ + relatedIssue: Issue +} + """ Autogenerated input type of RemoveStar """ diff --git a/src/graphql/data/ghec/category-map.json b/src/graphql/data/ghec/category-map.json index dcbdb8f3c097..707c648eb014 100644 --- a/src/graphql/data/ghec/category-map.json +++ b/src/graphql/data/ghec/category-map.json @@ -130,6 +130,7 @@ "addcloseissuereferences": "issues", "addcomment": "issues", "addlabelstolabelable": "issues", + "addrelatesto": "issues", "addsubissue": "issues", "applypendingissuesuggestions": "issues", "clearlabelsfromlabelable": "issues", @@ -156,6 +157,7 @@ "removeblockedby": "issues", "removecloseissuereferences": "issues", "removelabelsfromlabelable": "issues", + "removerelatesto": "issues", "removesubissue": "issues", "reopenissue": "issues", "replaceactorsforassignable": "issues", @@ -1248,6 +1250,7 @@ "issuefieldupdateoperation": "issues", "issuefieldvisibility": "issues", "issueorderfield": "issues", + "issuerelatestoorderfield": "issues", "issuesearchtype": "issues", "issuestate": "issues", "issuestatereason": "issues", @@ -1576,6 +1579,7 @@ "addcloseissuereferencesinput": "issues", "addcommentinput": "issues", "addlabelstolabelableinput": "issues", + "addrelatestoinput": "issues", "addsubissueinput": "issues", "applypendingissuesuggestionsinput": "issues", "assigneeupdateinput": "issues", @@ -1603,6 +1607,7 @@ "issuefieldvaluefilter": "issues", "issuefilters": "issues", "issueorder": "issues", + "issuerelatestoorder": "issues", "issuestateupdateinput": "issues", "issuetypeorder": "issues", "issuetypeupdateinput": "issues", @@ -1619,6 +1624,7 @@ "removeblockedbyinput": "issues", "removecloseissuereferencesinput": "issues", "removelabelsfromlabelableinput": "issues", + "removerelatestoinput": "issues", "removesubissueinput": "issues", "reopenissueinput": "issues", "replaceactorsforassignableinput": "issues", diff --git a/src/graphql/data/ghec/schema-issues.json b/src/graphql/data/ghec/schema-issues.json index 9a9b69cef8ce..071f422db655 100644 --- a/src/graphql/data/ghec/schema-issues.json +++ b/src/graphql/data/ghec/schema-issues.json @@ -181,6 +181,45 @@ ], "category": "issues" }, + { + "name": "addRelatesTo", + "id": "addrelatesto", + "href": "/graphql/reference/issues#mutation-addrelatesto", + "description": "<p>Adds a 'relates to' relationship between two issues.</p>", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "AddRelatesToInput!", + "id": "addrelatestoinput", + "href": "/graphql/reference/issues#input-object-addrelatestoinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "<p>A unique identifier for the client performing the mutation.</p>" + }, + { + "name": "issue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "<p>The source issue.</p>" + }, + { + "name": "relatedIssue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "<p>The related issue.</p>" + } + ], + "category": "issues" + }, { "name": "addSubIssue", "id": "addsubissue", @@ -1041,6 +1080,45 @@ ], "category": "issues" }, + { + "name": "removeRelatesTo", + "id": "removerelatesto", + "href": "/graphql/reference/issues#mutation-removerelatesto", + "description": "<p>Removes a 'relates to' relationship between two issues.</p>", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "RemoveRelatesToInput!", + "id": "removerelatestoinput", + "href": "/graphql/reference/issues#input-object-removerelatestoinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "<p>A unique identifier for the client performing the mutation.</p>" + }, + { + "name": "issue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "<p>The previously targeted issue.</p>" + }, + { + "name": "relatedIssue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "<p>The previously related issue.</p>" + } + ], + "category": "issues" + }, { "name": "removeSubIssue", "id": "removesubissue", @@ -3601,6 +3679,60 @@ } ] }, + { + "name": "relatesTo", + "description": "<p>A list of issues related to this issue.</p>", + "type": "IssueConnection!", + "id": "issueconnection", + "href": "/graphql/reference/issues#object-issueconnection", + "arguments": [ + { + "name": "after", + "description": "<p>Returns the elements in the list that come after the specified cursor.</p>", + "type": { + "name": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + }, + { + "name": "before", + "description": "<p>Returns the elements in the list that come before the specified cursor.</p>", + "type": { + "name": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + }, + { + "name": "first", + "description": "<p>Returns the first <em>n</em> elements from the list.</p>", + "type": { + "name": "Int", + "id": "int", + "href": "/graphql/reference/other#scalar-int" + } + }, + { + "name": "last", + "description": "<p>Returns the last <em>n</em> elements from the list.</p>", + "type": { + "name": "Int", + "id": "int", + "href": "/graphql/reference/other#scalar-int" + } + }, + { + "name": "orderBy", + "description": "<p>Ordering options for related issues.</p>", + "type": { + "name": "IssueRelatesToOrder", + "id": "issuerelatestoorder", + "href": "/graphql/reference/issues#input-object-issuerelatestoorder" + } + } + ] + }, { "name": "repository", "description": "<p>The repository associated with this node.</p>", @@ -10031,6 +10163,24 @@ ], "category": "issues" }, + { + "name": "IssueRelatesToOrderField", + "id": "issuerelatestoorderfield", + "href": "/graphql/reference/issues#enum-issuerelatestoorderfield", + "description": "<p>Properties by which related issues can be ordered.</p>", + "isDeprecated": false, + "values": [ + { + "name": "CREATED_AT", + "description": "<p>Order related issues by the creation time of the related issue.</p>" + }, + { + "name": "RELATES_TO_ADDED_AT", + "description": "<p>Order related issues by time of when the relates-to relationship was added.</p>" + } + ], + "category": "issues" + }, { "name": "IssueSearchType", "id": "issuesearchtype", @@ -11304,6 +11454,38 @@ ], "category": "issues" }, + { + "name": "AddRelatesToInput", + "id": "addrelatestoinput", + "href": "/graphql/reference/issues#input-object-addrelatestoinput", + "description": "<p>Autogenerated input type of AddRelatesTo.</p>", + "inputFields": [ + { + "name": "clientMutationId", + "description": "<p>A unique identifier for the client performing the mutation.</p>", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "issueId", + "description": "<p>The ID of the issue.</p>", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + }, + { + "name": "relatedIssueId", + "description": "<p>The ID of the related issue.</p>", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "issues" + }, { "name": "AddSubIssueInput", "id": "addsubissueinput", @@ -12436,6 +12618,30 @@ ], "category": "issues" }, + { + "name": "IssueRelatesToOrder", + "id": "issuerelatestoorder", + "href": "/graphql/reference/issues#input-object-issuerelatestoorder", + "description": "<p>Ordering options for related issues.</p>", + "isDeprecated": false, + "inputFields": [ + { + "name": "direction", + "description": "<p>The ordering direction.</p>", + "type": "OrderDirection!", + "id": "orderdirection", + "href": "/graphql/reference/meta#enum-orderdirection" + }, + { + "name": "field", + "description": "<p>The field to order related issues by.</p>", + "type": "IssueRelatesToOrderField!", + "id": "issuerelatestoorderfield", + "href": "/graphql/reference/issues#enum-issuerelatestoorderfield" + } + ], + "category": "issues" + }, { "name": "IssueStateUpdateInput", "id": "issuestateupdateinput", @@ -12960,6 +13166,38 @@ ], "category": "issues" }, + { + "name": "RemoveRelatesToInput", + "id": "removerelatestoinput", + "href": "/graphql/reference/issues#input-object-removerelatestoinput", + "description": "<p>Autogenerated input type of RemoveRelatesTo.</p>", + "inputFields": [ + { + "name": "clientMutationId", + "description": "<p>A unique identifier for the client performing the mutation.</p>", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "issueId", + "description": "<p>The ID of the issue.</p>", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + }, + { + "name": "relatedIssueId", + "description": "<p>The ID of the previously related issue.</p>", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "issues" + }, { "name": "RemoveSubIssueInput", "id": "removesubissueinput", diff --git a/src/graphql/data/ghec/schema.docs.graphql b/src/graphql/data/ghec/schema.docs.graphql index e3c1e2b7b8b9..94fb6074bb62 100644 --- a/src/graphql/data/ghec/schema.docs.graphql +++ b/src/graphql/data/ghec/schema.docs.graphql @@ -1253,6 +1253,46 @@ type AddReactionPayload { subject: Reactable } +""" +Autogenerated input type of AddRelatesTo +""" +input AddRelatesToInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the issue. + """ + issueId: ID! @possibleTypes(concreteTypes: ["Issue"]) + + """ + The ID of the related issue. + """ + relatedIssueId: ID! @possibleTypes(concreteTypes: ["Issue"]) +} + +""" +Autogenerated return type of AddRelatesTo. +""" +type AddRelatesToPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The source issue. + """ + issue: Issue + + """ + The related issue. + """ + relatedIssue: Issue +} + """ Autogenerated input type of AddStar """ @@ -20715,6 +20755,36 @@ type Issue implements Assignable & orderBy: ReactionOrder ): ReactionConnection! + """ + A list of issues related to this issue. + """ + relatesTo( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for related issues + """ + orderBy: IssueRelatesToOrder = {field: RELATES_TO_ADDED_AT, direction: DESC} + ): IssueConnection! + """ The repository associated with this node. """ @@ -22700,6 +22770,36 @@ enum IssueOrderField @docsCategory(name: "issues") { UPDATED_AT } +""" +Ordering options for related issues +""" +input IssueRelatesToOrder @docsCategory(name: "issues") { + """ + The ordering direction. + """ + direction: OrderDirection! + + """ + The field to order related issues by. + """ + field: IssueRelatesToOrderField! +} + +""" +Properties by which related issues can be ordered. +""" +enum IssueRelatesToOrderField @docsCategory(name: "issues") { + """ + Order related issues by the creation time of the related issue + """ + CREATED_AT + + """ + Order related issues by time of when the relates-to relationship was added + """ + RELATES_TO_ADDED_AT +} + """ Type of issue search performed """ @@ -27440,6 +27540,16 @@ type Mutation @docsCategory(name: "meta") { input: AddReactionInput! ): AddReactionPayload @docsCategory(name: "reactions") + """ + Adds a 'relates to' relationship between two issues. + """ + addRelatesTo( + """ + Parameters for AddRelatesTo + """ + input: AddRelatesToInput! + ): AddRelatesToPayload @docsCategory(name: "issues") + """ Adds a star to a Starrable. """ @@ -28913,6 +29023,16 @@ type Mutation @docsCategory(name: "meta") { input: RemoveReactionInput! ): RemoveReactionPayload @docsCategory(name: "reactions") + """ + Removes a 'relates to' relationship between two issues. + """ + removeRelatesTo( + """ + Parameters for RemoveRelatesTo + """ + input: RemoveRelatesToInput! + ): RemoveRelatesToPayload @docsCategory(name: "issues") + """ Removes a star from a Starrable. """ @@ -50403,6 +50523,46 @@ type RemoveReactionPayload { subject: Reactable } +""" +Autogenerated input type of RemoveRelatesTo +""" +input RemoveRelatesToInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the issue. + """ + issueId: ID! @possibleTypes(concreteTypes: ["Issue"]) + + """ + The ID of the previously related issue. + """ + relatedIssueId: ID! @possibleTypes(concreteTypes: ["Issue"]) +} + +""" +Autogenerated return type of RemoveRelatesTo. +""" +type RemoveRelatesToPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The previously targeted issue. + """ + issue: Issue + + """ + The previously related issue. + """ + relatedIssue: Issue +} + """ Autogenerated input type of RemoveStar """ diff --git a/src/graphql/lib/categories.ts b/src/graphql/lib/categories.ts index ab62decb8d54..76eb35cd02c9 100644 --- a/src/graphql/lib/categories.ts +++ b/src/graphql/lib/categories.ts @@ -1,9 +1,4 @@ -// Canonical mapping of internal schema kinds to: -// - urlKind: the URL/folder segment used in href anchors before categorization -// (kept for backward-compat with `helpers.getFullLink` signature) -// - slugPrefix: the kind-disambiguating slug prefix used on category pages -// so two items sharing a case-insensitive name don't collide -// - label: human-readable label rendered as a Primer Label next to each item +// Schema kind tables keep legacy URL segments, category-page slug prefixes, and visible labels. export type SchemaKindKey = | 'queries' @@ -26,8 +21,7 @@ export const KIND_LABELS: Record<SchemaKindKey, string> = { scalars: 'Scalar', } -// Plural form of `KIND_LABELS`, used as the section heading (and mini-TOC -// parent label) when a GraphQL category page groups its items by kind. +// Plural labels appear in category-page sections and mini-TOC section entries. export const KIND_LABELS_PLURAL: Record<SchemaKindKey, string> = { queries: 'Queries', mutations: 'Mutations', @@ -39,10 +33,7 @@ export const KIND_LABELS_PLURAL: Record<SchemaKindKey, string> = { scalars: 'Scalars', } -// Slug prefix used to disambiguate items across kinds on a category page. -// For example, a `Repository` object and a `repository` query both have id -// `repository`; on a category page they become `object-repository` and -// `query-repository` respectively. +// Category-page anchors prefix the kind, so Repository object and repository query stay distinct. export const KIND_SLUG_PREFIX: Record<SchemaKindKey, string> = { queries: 'query', mutations: 'mutation', @@ -54,9 +45,8 @@ export const KIND_SLUG_PREFIX: Record<SchemaKindKey, string> = { scalars: 'scalar', } -// The "URL kind" / `pageType` value used by `helpers.getTypeKind` and -// `helpers.getFullLink`. `inputObjects` (camelCase internal key) becomes -// `input-objects` in URLs. +// These URL segments match helpers.getTypeKind output and helpers.getFullLink input. +// For example, inputObjects becomes input-objects. export const KIND_URL_SEGMENT: Record<SchemaKindKey, string> = { queries: 'queries', mutations: 'mutations', @@ -79,28 +69,22 @@ export const ALL_KIND_KEYS: SchemaKindKey[] = [ 'scalars', ] -// Reverse map from the URL-kind segment used in hrefs (e.g. `input-objects`) -// to the slug prefix used to disambiguate items in category page anchors -// (e.g. `input-object`). Derived from KIND_URL_SEGMENT + KIND_SLUG_PREFIX so -// the three tables stay in sync automatically. +// Derive URL-segment to slug-prefix mappings from the source tables so anchor helpers stay in sync. +// For example, input-objects maps to input-object. export const SLUG_PREFIX_BY_URL_SEGMENT: Record<string, string> = Object.fromEntries( ALL_KIND_KEYS.map((k) => [KIND_URL_SEGMENT[k], KIND_SLUG_PREFIX[k]]), ) -// Given a URL-kind segment (as returned by helpers.getTypeKind, e.g. -// `objects`, `input-objects`), return the slug prefix used to disambiguate -// items in category page anchors. Falls back to the input for unknown kinds. +// Unknown URL-kind segments fall back to themselves so callers can handle future kinds. export function slugPrefixForUrlKind(urlKind: string): string { return SLUG_PREFIX_BY_URL_SEGMENT[urlKind] ?? urlKind } -// Bucket all items that don't have an upstream `@docsCategory` directive. +// Unannotated upstream schema items fall into the other category. export const OTHER_CATEGORY = 'other' -// Canonical list of categories emitted by the upstream `docs_category` DSL. -// Keep this list in sync with the allowlist in -// `github/github`'s `app/platform/objects/base/docs_category.rb`. -// `other` is a docs-internal bucket for un-annotated types. +// github/github app/platform/objects/base/docs_category.rb must allow each upstream category here. +// The other category belongs to docs-internal for unannotated types. export const CATEGORIES = [ 'actions', 'activity', @@ -153,7 +137,6 @@ export function isValidCategory(slug: string): slug is CategorySlug { return (CATEGORIES as readonly string[]).includes(slug) } -// Human-readable display title for a category. Falls back to slug. export function categoryTitle(slug: string): string { switch (slug) { case 'apps': diff --git a/src/graphql/lib/index.ts b/src/graphql/lib/index.ts index 96a7818f113c..8f0cfd8a8da2 100644 --- a/src/graphql/lib/index.ts +++ b/src/graphql/lib/index.ts @@ -13,27 +13,23 @@ import type { } from '@/graphql/components/types' import { ALL_KIND_KEYS, CATEGORIES, isValidCategory, type SchemaKindKey } from './categories' -// GraphqlContext describes the per-request context object that getMiniToc and -// getGraphqlSchema read language/version from. export interface GraphqlContext { currentLanguage: string currentVersion: string [key: string]: unknown } -// The GraphQL schema JSON is keyed by member type (e.g. "queries", "objects", -// "enums"), each holding a list of schema members. +// GraphQL schema JSON groups members by schema kind. type GraphqlSchemaData = Record<string, GraphqlT[]> export const GRAPHQL_DATA_DIR = 'src/graphql/data' -/* ADD LANGUAGE KEY */ const previews = new Map<string, PreviewT[]>() const upcomingChanges = new Map<string, BreakingChangesT>() const changelog = new Map<string, ChangelogItemT[]>() const changelogMiniTocs = new Map<string, MiniTocItem[]>() -// Per-category schema files. Key: `${graphqlVersion}:${category}` → bucket. +// Per-category schema cache keys combine graphqlVersion and category. const graphqlCategorySchemas = new Map<string, GraphqlSchemaData>() -// All objects across categories (for interface implementer lookup). +// Interface renderers need object items from every category to list implementers. const allObjectsByVersion = new Map<string, GraphqlT[]>() const miniTocs = new Map<string, Map<string, Map<string, MiniTocItem[]>>>() @@ -41,9 +37,7 @@ for (const language of Object.keys(languages)) { miniTocs.set(language, new Map()) } -// Returns the per-category schema bucket `{queries, mutations, ...}` for a -// given category slug (e.g. 'repos', 'issues'). Throws via the loader if the -// category slug is not valid for this version. +// Reject invalid category slugs before the loader reads a missing schema file. export function getGraphqlSchema(version: string, category: string): GraphqlSchemaData { if (!isValidCategory(category)) { throw new Error(`Invalid GraphQL category: ${category}`) @@ -65,9 +59,7 @@ function getGraphqlSchemaByCategory(graphqlVersion: string, category: string): G return graphqlCategorySchemas.get(key)! } -// Returns all object-kind items across every category for the given version. -// Used by the interface renderer to list implementers regardless of which -// category page is being rendered. +// Interface renderers need objects from every category to list implementers. export function getAllGraphqlObjects(version: string): GraphqlT[] { const graphqlVersion: string = getGraphqlVersion(version) if (!allObjectsByVersion.has(graphqlVersion)) { @@ -81,7 +73,6 @@ export function getAllGraphqlObjects(version: string): GraphqlT[] { return allObjectsByVersion.get(graphqlVersion)! } -// Returns the canonical render order of kinds within a category page. export function getKindOrder(): SchemaKindKey[] { return ALL_KIND_KEYS } @@ -100,17 +91,11 @@ export function getGraphqlChangelog(version: string): ChangelogItemT[] { return changelog.get(graphqlVersion)! } -/** - * Return changelog entries filtered by year. - */ export function getGraphqlChangelogByYear(version: string, year: number): ChangelogItemT[] { const all = getGraphqlChangelog(version) return all.filter((entry) => entry.date.startsWith(String(year))) } -/** - * Return the distinct years present in the changelog, sorted descending (newest first). - */ export function getGraphqlChangelogYears(version: string): number[] { const all = getGraphqlChangelog(version) const years = new Set<number>() diff --git a/src/graphql/lib/validator.ts b/src/graphql/lib/validator.ts index 89283c83e5a5..eac02970e166 100644 --- a/src/graphql/lib/validator.ts +++ b/src/graphql/lib/validator.ts @@ -1,5 +1,4 @@ -// the tests in tests/graphql.ts use this schema to ensure the integrity -// of the data in src/graphql/data/*.json +// src/graphql/tests/validate-schema.ts reads these schemas to validate generated data. interface JSONSchema { type?: string @@ -79,7 +78,6 @@ export const upcomingChangesValidator: ValidatorSchema = { }, } -// many GraphQL schema members have these core properties const coreProps: JSONSchema = { properties: { name: { @@ -107,7 +105,6 @@ const coreProps: JSONSchema = { }, } -// some GraphQL schema members have the core properties plus an 'args' object const corePropsPlusArgs = dup(coreProps) corePropsPlusArgs.properties!.args = { @@ -118,7 +115,6 @@ corePropsPlusArgs.properties!.args = { }, } -// the args object can have defaultValue prop corePropsPlusArgs.properties!.args.items!.properties!.defaultValue = { type: 'boolean', } diff --git a/src/graphql/pages/breaking-changes.tsx b/src/graphql/pages/breaking-changes.tsx index b39033517190..1f960e9c4760 100644 --- a/src/graphql/pages/breaking-changes.tsx +++ b/src/graphql/pages/breaking-changes.tsx @@ -49,9 +49,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const schema = getGraphqlBreakingChanges(currentVersion) if (!schema) throw new Error(`No graphql breaking changes schema found for ${currentVersion}`) - // Gets the miniTocItems in the article context. At this point it will only - // include miniTocItems that exist in Markdown pages in - // content/graphql/reference/* + // Start from the current page's Markdown headings, then append the generated ones. const automatedPageContext = getAutomatedPageContextFromRequest(req) const slugger = new GithubSlugger() const headings = Object.fromEntries( @@ -69,7 +67,6 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => ) const titles = Object.values(headings).map((heading) => heading.title) const changelogMiniTocItems = await getAutomatedPageMiniTocItems(titles, req.context!, 2) - // Update the existing context to include the miniTocItems from GraphQL automatedPageContext.miniTocItems.push(...changelogMiniTocItems) return { diff --git a/src/graphql/pages/changelog.tsx b/src/graphql/pages/changelog.tsx index 1aee0f1921bd..74b9bfb9f9e5 100644 --- a/src/graphql/pages/changelog.tsx +++ b/src/graphql/pages/changelog.tsx @@ -69,10 +69,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => } } -/** - * Strip wrapping `<p>` tags from HTML change descriptions to allow - * rendering as `<li>` content without nested block elements. - */ +// Strip wrapping p tags so list items do not contain nested block elements. export function stripParagraphWrappers(schema: ChangelogItemT[]) { for (const item of schema) { for (const group of [item.schemaChanges, item.previewChanges, item.upcomingChanges]) { diff --git a/src/graphql/pages/reference.tsx b/src/graphql/pages/reference.tsx index bef78e785891..77dba5be2983 100644 --- a/src/graphql/pages/reference.tsx +++ b/src/graphql/pages/reference.tsx @@ -40,10 +40,7 @@ export default function GraphqlReferencePage({ allObjects, categorySlug, }: Props) { - // Key the schema content by category slug. Without this, client-side - // navigation between category pages reuses the same React tree and - // dangerouslySetInnerHTML descriptions from a previous category can stick - // around in the DOM. Keying forces a clean unmount/remount on route change. + // Keying by categorySlug prevents navigation from reusing stale dangerouslySetInnerHTML content. const content = <GraphqlCategoryPage key={categorySlug} schema={schema} allObjects={allObjects} /> return ( <MainContext.Provider value={mainContext}> @@ -74,12 +71,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const schema = getGraphqlSchema(currentVersion, page) as CategorySchema const allObjects = getAllGraphqlObjects(currentVersion) as ObjectT[] - // If a category has no types in the current version, 404 the page rather - // than render an empty document. Empty buckets typically happen when a - // category exists in fpt/ghec but not in GHES (or vice versa). The content - // .md files for categories that are empty in every version are removed - // from `content/graphql/reference/`, but the dynamic [page].tsx route would - // still serve them otherwise. This guard makes the response a real 404. + // Return 404 when a version has no types because the route can serve categories without files. const hasAnyTypes = ALL_KIND_KEYS.some((kind) => { const items = (schema as Record<string, unknown[] | undefined>)[kind] return Array.isArray(items) && items.length > 0 @@ -88,12 +80,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => return { notFound: true } } - // Build a two-level mini-TOC mirroring the page's kind sections. Top-level - // entries are kind labels (e.g. "Objects") pointing at the matching section - // heading; nested entries are the items inside each section. The mini-TOC - // React component (`MiniTocs`) renders `item.items` recursively, so pushing - // a nested structure here yields a two-level sidebar even though the - // default heading-collection path is capped at one level globally. + // Build nested kind entries because MiniTocs recurses and default collection stops at one level. const automatedPageContext = getAutomatedPageContextFromRequest(req) for (const kind of ALL_KIND_KEYS) { const kindItems = (schema as Record<string, Array<{ name: string }>>)[kind] diff --git a/src/graphql/pages/schema-previews.tsx b/src/graphql/pages/schema-previews.tsx index f2eda650a55f..23d781ae7cfc 100644 --- a/src/graphql/pages/schema-previews.tsx +++ b/src/graphql/pages/schema-previews.tsx @@ -45,13 +45,10 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const schema = getPreviews(currentVersion) as PreviewT[] if (!schema) throw new Error(`No graphql preview schema found for ${currentVersion}`) - // Gets the miniTocItems in the article context. At this point it will only - // include miniTocItems that exist in Markdown pages in - // content/graphql/reference/* + // Start from the current page's Markdown headings, then append the generated ones. const automatedPageContext = getAutomatedPageContextFromRequest(req) const titles = schema.map((item) => item.title) const changelogMiniTocItems = await getAutomatedPageMiniTocItems(titles, req.context!, 2) - // Update the existing context to include the miniTocItems from GraphQL automatedPageContext.miniTocItems.push(...changelogMiniTocItems) const mainContext = await getMainContext(req, res as unknown as Response) diff --git a/src/graphql/scripts/build-changelog.ts b/src/graphql/scripts/build-changelog.ts index 0b645a4e7f52..319c2a5d6a8b 100644 --- a/src/graphql/scripts/build-changelog.ts +++ b/src/graphql/scripts/build-changelog.ts @@ -60,10 +60,7 @@ interface IgnoredChangesSummary { let lastIgnoredChanges: Change[] = [] -/** - * Tag `changelogEntry` with `date: YYYY-mm-dd`, then prepend it to the JSON - * structure written to `targetPath`. (`changelogEntry` and that file are modified in place.) - */ +// Add today's date to changelogEntry and prepend it to the JSON array at targetPath. export function prependDatedEntry(changelogEntry: ChangelogEntry, targetPath: string): void { const todayString = new Date().toISOString().slice(0, 10) changelogEntry.date = todayString @@ -73,17 +70,13 @@ export function prependDatedEntry(changelogEntry: ChangelogEntry, targetPath: st previousChangelog.unshift(changelogEntry) fs.writeFileSync(targetPath, JSON.stringify(previousChangelog, null, 2)) - // Ensure a content page exists for this entry's year const year = todayString.slice(0, 4) ensureYearPage(year) } const DEFAULT_CHANGELOG_CONTENT_DIR = nodePath.join('content', 'graphql', 'overview', 'changelog') -/** - * If a year-specific content page doesn't exist yet (e.g. 2027.md), - * create it and prepend it to the children list in index.md. - */ +// Create a missing year page and prepend it to the changelog index when its first entry arrives. export function ensureYearPage( year: string, contentDir: string = DEFAULT_CHANGELOG_CONTENT_DIR, @@ -110,12 +103,6 @@ export function ensureYearPage( fs.writeFileSync(indexPath, updated) } -/** - * Compare `oldSchemaString` to `newSchemaString`, and if there are any - * changes that warrant a changelog entry, return a changelog entry. - * Based on the parsed `previews`, identify changes that are under a preview. - * Otherwise, return null. - */ export async function createChangelogEntry( oldSchemaString: string, newSchemaString: string, @@ -159,8 +146,7 @@ export async function createChangelogEntry( ) const addedUpcomingChanges = newUpcomingChanges.filter(function (change): boolean { - // Manually check each of `newUpcomingChanges` for an equivalent entry - // in `oldUpcomingChanges`. + // Match upcoming changes by location, date, and description. return !oldUpcomingChanges.find(function (oldChange) { return ( oldChange.location === change.location && @@ -189,7 +175,6 @@ export async function createChangelogEntry( ) const schemaChange: ChangelogSchemaChange = { title: 'The GraphQL schema includes these changes:', - // Replace single quotes which wrap field/argument/type names with backticks changes: renderedScheamChanges, } changelogEntry.schemaChanges.push(schemaChange) @@ -236,9 +221,7 @@ export async function createChangelogEntry( } } -/** - * Prepare the preview title from github/github source for the docs. - */ +// github/github preview titles need docs-style wording before rendering. export function cleanPreviewTitle(title: string): string { if (title === 'UpdateRefsPreview') { title = 'Update refs preview' @@ -250,10 +233,7 @@ export function cleanPreviewTitle(title: string): string { return title } -/** - * Turn the given title into an HTML-ready anchor. - * (ported from graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L281) - */ +// Anchor generation matches the changelog URL format. export function previewAnchor(previewTitle: string): string { return previewTitle .toLowerCase() @@ -261,29 +241,18 @@ export function previewAnchor(previewTitle: string): string { .replace(/[^\w-]/g, '') } -/** - * Turn changes from graphql-inspector into messages for the HTML changelog. - */ export function cleanMessagesFromChanges(changes: Change[]): string[] { return changes.map(function (change): string { - // replace single quotes around graphql names with backticks, - // to match previous behavior from graphql-schema-comparator + // Wrap quoted GraphQL names in Markdown code spans for changelog rendering. return change.message.replace(/'([a-zA-Z. :!]+)'/g, '`$1`') }) } -/** - * Split `changesToReport` into two parts, - * one for changes in the main schema, - * and another for changes that are under preview. - * (Ported from /graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L230) - */ +// Preview-toggled paths and their ancestors move changes out of the main schema section. export function segmentPreviewChanges( changesToReport: Change[], previews: Preview[], ): SegmentedChanges { - // Build a map of `{ path => previewTitle` } - // for easier lookup of change to preview const pathToPreview: Record<string, string> = {} for (const preview of previews) { for (const path of preview.toggled_on) { @@ -294,8 +263,7 @@ export function segmentPreviewChanges( const changesByPreview: Record<string, PreviewChanges> = {} for (const change of changesToReport) { - // For each change, see if its path _or_ one of its ancestors - // is covered by a preview. If it is, mark this change as belonging to a preview + // Preview ownership applies when the change path or an ancestor path is toggled on. const pathParts = change.path?.split('.') || [] let testPath: string | null = null let previewTitle: string | null = null @@ -303,8 +271,6 @@ export function segmentPreviewChanges( while (pathParts.length > 0 && !previewTitle) { testPath = pathParts.join('.') previewTitle = pathToPreview[testPath] - // If that path didn't find a match, then we'll - // check the next ancestor. pathParts.pop() } if (previewTitle) { @@ -322,11 +288,8 @@ export function segmentPreviewChanges( return { schemaChangesToReport: schemaChanges, previewChangesToReport: changesByPreview } } -// We only want to report changes to schema structure. -// Deprecations are covered by "upcoming changes." -// By listing the changes explicitly here, we can make sure that, -// if the library changes, we don't miss publishing anything that we mean to. -// This was originally ported from graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L35-L103 +// Report only schema-structure changes; deprecations come from upcoming changes. +// Unknown change types log for review instead of appearing in the changelog. const CHANGES_TO_REPORT = [ ChangeType.FieldArgumentDefaultChanged, ChangeType.FieldArgumentTypeChanged, @@ -354,9 +317,6 @@ const CHANGES_TO_REPORT = [ ChangeType.DirectiveUsageFieldDefinitionRemoved, ] -// Anything not in CHANGES_TO_REPORT is logged as ignored rather than reported, -// so a new change type added upstream cannot break this script. - export function getLastIgnoredChanges(): Change[] { return lastIgnoredChanges } diff --git a/src/graphql/scripts/sync.ts b/src/graphql/scripts/sync.ts index 9bcc3257bb38..d7c6b8e2e06b 100755 --- a/src/graphql/scripts/sync.ts +++ b/src/graphql/scripts/sync.ts @@ -58,17 +58,13 @@ const dataFilenames = JSON.parse( await fs.readFile('src/graphql/scripts/utils/data-filenames.json', 'utf8'), ) -// check for required PAT if (!process.env.GITHUB_TOKEN) { throw new Error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.') } const versionsToBuild = Object.keys(allVersions) -// Tracks, per category, the set of docs versions in which the category has at -// least one type. Populated inside the per-version loop and consumed after it -// to manage the per-category content pages. Declared before `main()` runs so -// the loop never reads it in the temporal dead zone. +// Declare categoryPresence before the main() call so the loop never reads it in the temporal dead zone. const categoryPresence: CategoryPresence = new Map() main() @@ -77,12 +73,9 @@ const allIgnoredChanges: IgnoredChange[] = [] async function main() { for (const version of versionsToBuild) { - // Get the relevant GraphQL name for the current version. - // For example, free-pro-team@latest corresponds to dotcom, - // enterprise-server@2.22 corresponds to ghes-2.22. + // Examples: free-pro-team@latest maps to dotcom; enterprise-server@2.22 maps to ghes-2.22. const graphqlVersion = allVersions[version].openApiVersionName - // 1. UPDATE PREVIEWS const previewsPath = getDataFilepath('previews', graphqlVersion) const rawPreviews = load( await getRemoteRawContent(previewsPath, graphqlVersion), @@ -94,7 +87,6 @@ async function main() { path.join(graphqlStaticDir, graphqlVersion, 'previews.json'), ) - // 2. UPDATE UPCOMING CHANGES const upcomingChangesPath = getDataFilepath('upcomingChanges', graphqlVersion) const previousUpcomingChanges = load( await fs.readFile(upcomingChangesPath, 'utf8'), @@ -107,8 +99,6 @@ async function main() { path.join(graphqlStaticDir, graphqlVersion, 'upcoming-changes.json'), ) - // 3. UPDATE SCHEMAS - // note: schemas live in separate files per version const previewFilePath = getDataFilepath('schemas', graphqlVersion) const previousSchemaString = await fs.readFile(previewFilePath, 'utf8') const latestSchema = await getRemoteRawContent(previewFilePath, graphqlVersion) @@ -117,11 +107,7 @@ async function main() { ...preview, toggled_by: [preview.toggled_by].flat(), })) - // Fallback category source for GHES versions that pre-date the upstream - // `@docsCategory` DSL (DSL landed on master 2026-05-07; GHES 3.16-3.21 - // were cut at the 3.21 freeze 2026-03-19). Without this, every type on - // those versions gets bucketed as "other". GHES 3.22+ is expected to - // include the DSL natively so it's excluded from the fallback. + // GHES schemas before 3.22 lack @docsCategory, so fall back to the fpt category map. let fallbackCategoryMap: Record<string, Record<string, string>> | undefined const ghesMatch = /^ghes-(\d+)\.(\d+)$/.exec(graphqlVersion) if (ghesMatch) { @@ -134,8 +120,7 @@ async function main() { ) console.log(`Using fpt/category-map.json as @docsCategory fallback for ${graphqlVersion}`) } catch { - // fpt hasn't been processed yet (shouldn't happen given iteration - // order, but stay defensive). Without it, ghes types fall to "other". + // fpt runs first; if category-map.json is unavailable, GHES types fall back to other. } } } @@ -144,18 +129,13 @@ async function main() { previewsForSchema, fallbackCategoryMap, { currentLanguage: 'en', currentVersion: version }, - ) // This is slow! + ) - // Split the schema by category so the runtime can lazily load only the - // bucket it needs for a given page request. The monolithic `schema.json` - // is no longer written; per-category files are the only on-disk format. + // processSchemas is slow; per-category files are the only on-disk format for scoped loads. const perCategoryFiles = bucketSchemaByCategory(schemaJsonPerVersion) await writeCategoryFiles(path.join(graphqlStaticDir, graphqlVersion), perCategoryFiles) - // Record which categories have at least one type in this version so the - // content pages and their `versions` frontmatter can be managed after the - // loop. `version` is the docs version key (e.g. `enterprise-server@3.22`), - // which is the format `convertVersionsToFrontmatter` expects. + // Store docs version keys so convertVersionsToFrontmatter can update pages after the loop. for (const [cat, bucket] of perCategoryFiles.entries()) { const hasTypes = ALL_KIND_KEYS.some((kind) => (bucket[kind]?.length ?? 0) > 0) if (!hasTypes) continue @@ -163,9 +143,8 @@ async function main() { categoryPresence.get(cat)!.add(version) } - // 4. UPDATE CHANGELOG if (allVersions[version].nonEnterpriseDefault) { - // The changelog is only built for free-pro-team@latest + // Build the changelog only for free-pro-team@latest. const changelogEntry = await createChangelogEntry( previousSchemaString, latestSchema, @@ -180,7 +159,6 @@ async function main() { ) } - // Capture ignored changes for potential workflow notifications const ignoredSummary = getIgnoredChangesSummary() if (ignoredSummary) { allIgnoredChanges.push({ @@ -191,15 +169,11 @@ async function main() { } } - // Manage the per-category content pages (create new categories, delete - // emptied ones, narrow `versions` frontmatter) plus the reference index - // children and disappearance redirects, based on the presence collected above. + // Sync category pages, index children, and disappearance redirects after all versions run. await syncCategoryContentFiles(categoryPresence) - // Run the YAML linter before anything is checked in. execSync('npx prettier -w "**/*.{yml,yaml}"') - // Output ignored changes for GitHub Actions if (allIgnoredChanges.length > 0) { const totalIgnored = allIgnoredChanges.reduce((sum, item) => sum + item.totalCount, 0) const uniqueTypes = [ @@ -221,14 +195,12 @@ async function main() { } } -// get latest from github/github async function getRemoteRawContent(filepath: string, graphqlVersion: string) { const options: GitHubRepoOptions = { owner: 'github', repo: 'github', } - // find the relevant branch in github/github and set it as options.ref let t0 = new Date().getTime() options.ref = await getBranchAsRef(options, graphqlVersion) let took = new Date().getTime() - t0 @@ -244,11 +216,10 @@ async function getRemoteRawContent(filepath: string, graphqlVersion: string) { return contents } -// find the relevant filepath in src/graphql/scripts/util/data-filenames.json function getDataFilepath(id: string, graphqlVersion: string) { const versionType = getVersionName(graphqlVersion) - // for example, dataFilenames['schema']['ghes'] = schema.docs-enterprise.graphql + // Example: dataFilenames.schema.ghes maps to schema.docs-enterprise.graphql. const filename = dataFilenames[id][versionType] return path.join(graphqlStaticDir, graphqlVersion, filename) @@ -268,15 +239,12 @@ async function getBranchAsRef( ghes: `enterprise-${graphqlVersion.replace('ghes-', '')}-release`, } - // the first time this runs, it uses the branch found for the version above if (!branch) branch = branches[versionType] const ref = `heads/${branch}` - // check whether the branch can be found in github/github const exists = await hasMatchingRef(options.owner, options.repo, ref) - // if ref is not found, the branch cannot be found, so try a fallback if (!exists) { const fallbackBranch = defaultBranch return await getBranchAsRef(options, graphqlVersion, fallbackBranch) @@ -284,8 +252,7 @@ async function getBranchAsRef( return ref } -// given a GraphQL version like `ghes-2.22`, return `ghes`; -// given a GraphQL version like `dotcom`, return as is +// Examples: ghes-2.22 returns ghes; dotcom returns dotcom. function getVersionName(graphqlVersion: string) { return graphqlVersion.split('-')[0] } @@ -296,8 +263,7 @@ async function updateFile(filepath: string, content: string) { return fs.writeFile(filepath, content, 'utf8') } -// JSON data from GraphQL schema processing - complex nested structures -// Serialize unknown shapes because the structure varies (arrays, objects, nested schemas, etc.) +// Serialize unknown GraphQL shapes because schema processing returns nested arrays and objects. async function updateStaticFile(json: unknown, filepath: string) { console.log(`Updating static file ${filepath}`) const jsonString = JSON.stringify(json, null, 2) diff --git a/src/graphql/scripts/utils/bucket-by-category.ts b/src/graphql/scripts/utils/bucket-by-category.ts index 794f1b85ea73..78c47487aada 100644 --- a/src/graphql/scripts/utils/bucket-by-category.ts +++ b/src/graphql/scripts/utils/bucket-by-category.ts @@ -9,15 +9,12 @@ import { type SchemaKindKey, } from '@/graphql/lib/categories' -// Item shape from process-schemas; we only need the `category` field here so -// we keep this loose to avoid pulling all the precise interfaces. +// Keep this loose so bucket-by-category does not import every process-schemas interface. type CategorizedItem = { category?: string; name?: string; id?: string } export type CategoryBuckets = Map<string, Partial<Record<SchemaKindKey, CategorizedItem[]>>> -// Matches the legacy href format that process-schemas emits, e.g. -// `/graphql/reference/objects#repository`. Captures the url-kind segment -// and the id so the bucketer can rewrite into the category-aware form. +// Example: /graphql/reference/objects#repository captures url kind objects and id repository. const LEGACY_HREF_RE = /^\/graphql\/reference\/([a-z][a-z-]*)#([a-z0-9-]+)$/ type CategoryLookup = Map<string, Map<string, string>> @@ -46,10 +43,7 @@ function rewriteHref(href: string, lookup: CategoryLookup): string { return `/graphql/reference/${category}#${slugPrefixForUrlKind(urlKind)}-${id}` } -// Walk a processed item recursively, rewriting any string value that looks -// like a legacy `/graphql/reference/<urlKind>#<id>` href into the -// category-aware form. Mutates in place; the monolithic schema.json has -// already been written to disk before this runs. +// rewriteHrefsInPlace mutates processed items so category files link to sibling files. function rewriteHrefsInPlace(value: unknown, lookup: CategoryLookup): void { if (Array.isArray(value)) { for (const v of value) rewriteHrefsInPlace(v, lookup) @@ -68,9 +62,6 @@ function rewriteHrefsInPlace(value: unknown, lookup: CategoryLookup): void { } } -// Group a processed schema (one big `{queries, mutations, ...}` object) into -// one bucket per category. Each bucket only contains the kinds that have -// items in that category. export function bucketSchemaByCategory( schema: Record<SchemaKindKey, CategorizedItem[]>, ): CategoryBuckets { @@ -89,12 +80,7 @@ export function bucketSchemaByCategory( } } - // After grouping, rewrite cross-reference hrefs from the legacy - // `/graphql/reference/<urlKind>#<id>` form into the category-aware - // `/graphql/reference/<category>#<kindPrefix>-<id>` form so per-category - // files link to their sibling files. The monolithic `schema.json` is - // serialized to disk before this runs (see sync.ts), so it keeps the - // legacy hrefs the existing runtime expects. + // Rewriting happens after buckets exist, so hrefs can point to sibling category files. const lookup = buildCategoryLookup(buckets) for (const bucket of buckets.values()) { rewriteHrefsInPlace(bucket, lookup) @@ -103,13 +89,10 @@ export function bucketSchemaByCategory( return buckets } -// Write `schema-<category>.json` files into `dir`. Categories with no items -// for this version get an empty file so the loader has a deterministic file -// to consume (rather than relying on filesystem stat). +// Emit every schema-<category>.json file so the loader never stats missing categories. export async function writeCategoryFiles(dir: string, buckets: CategoryBuckets): Promise<void> { await fs.mkdir(dir, { recursive: true }) - // First, delete any stale schema-*.json files so a category that becomes - // empty in a new sync doesn't leave behind a stale file. + // Remove schema-*.json files before writing, so categories with no items keep no data. let existing: string[] = [] try { existing = await fs.readdir(dir) @@ -121,7 +104,7 @@ export async function writeCategoryFiles(dir: string, buckets: CategoryBuckets): try { await fs.unlink(path.join(dir, file)) } catch { - // ignore + // Keep writing other category files if one stale file cannot be removed. } } } @@ -132,8 +115,7 @@ export async function writeCategoryFiles(dir: string, buckets: CategoryBuckets): await fs.writeFile(filepath, JSON.stringify(bucket, null, 2), 'utf8') } - // Also emit a small category-map.json used at runtime by the GraphQL - // category redirect middleware. Shape: { [kindKey]: { [id]: category } } + // category-map.json shape is { [kindKey]: { [id]: category } } for GraphQL redirects. const categoryMap: Partial<Record<SchemaKindKey, Record<string, string>>> = {} for (const kind of ALL_KIND_KEYS) { const byId: Record<string, string> = {} diff --git a/src/graphql/scripts/utils/process-previews.ts b/src/graphql/scripts/utils/process-previews.ts index 4a9197faa42f..8563ec9f7ccb 100644 --- a/src/graphql/scripts/utils/process-previews.ts +++ b/src/graphql/scripts/utils/process-previews.ts @@ -22,19 +22,17 @@ const inputOrPayload = /(Input|Payload)$/m export default function processPreviews(previews: RawPreview[]): ProcessedPreview[] { return previews.map((raw) => { let title = sentenceCase(raw.title) - .replace(/ -.+/, '') // remove any extra info that follows a hyphen - .replace('it hub', 'itHub') // fix overcorrected `git hub` from sentenceCasing - .replace(' s ', "'s ") // sentenceCase replaces apostrophes with spaces + .replace(/ -.+/, '') + .replace('it hub', 'itHub') // sentenceCase rewrites GitHub as Git hub. + .replace(' s ', "'s ") // sentenceCase replaces apostrophes with spaces. - // Add `preview` to the end of titles if needed title = title.endsWith('preview') ? title : `${title} preview` - // filter out schema members that end in `Input` or `Payload` + // Preview pages omit generated Input and Payload members. const toggled_on = raw.toggled_on.filter( (schemaMember: string) => !inputOrPayload.test(schemaMember), ) - // remove unnecessary leading colon const toggled_by = raw.toggled_by.replace(':', '') const accept_header = `application/vnd.github.${toggled_by}+json` @@ -42,7 +40,6 @@ export default function processPreviews(previews: RawPreview[]): ProcessedPrevie slugger.reset() const href = `/graphql/overview/schema-previews#${slugger.slug(title)}` - // Preserve all original properties except announcement/updates return { title, description: raw.description, diff --git a/src/graphql/scripts/utils/process-schemas.ts b/src/graphql/scripts/utils/process-schemas.ts index 9abfd2995ee2..034691b44248 100755 --- a/src/graphql/scripts/utils/process-schemas.ts +++ b/src/graphql/scripts/utils/process-schemas.ts @@ -22,7 +22,6 @@ interface PreviewInfo { toggled_by: string[] } -// Interface for arguments returned by helpers.getArguments() interface FieldArgumentInfo { name: string // GraphQL scalar default values come through the AST as a string or boolean. @@ -207,23 +206,16 @@ interface ProcessedSchemaData { scalars: ScalarInfo[] } -// All processed items get an optional `category` field once the schema has -// been categorized. Using `& { category: string }` at the type level would -// require touching every interface, so we keep it loose here and rely on the -// runtime guarantee that every emitted item has a category. - +// Category stays loose on emitted items to avoid duplicating it across every output interface. const externalScalarsJSON: Array<{ name: string; description: string }> = JSON.parse( await fs.readFile(path.join(process.cwd(), './src/graphql/lib/non-schema-scalars.json'), 'utf-8'), ) const externalScalars: ScalarInfo[] = await Promise.all( externalScalarsJSON.map(async (scalar): Promise<ScalarInfo> => { - // These live in a local JSON file rather than the versioned schema, and - // their only link is external, so they need no version context. + // Local non-schema scalars have external links and need no docs version context. const description = await baseHelpers.getDescription(scalar.description) const id = baseHelpers.getId(scalar.name) - // External scalars (e.g. Date, URI) are not annotated upstream and live - // in the "other" bucket. Emit the legacy href; bucket-by-category will - // rewrite it to the category-aware form for per-category files. + // External scalars like Date and URI start in other with hrefs for bucket rewriting. const href = baseHelpers.getFullLink('scalars', id) return { name: scalar.name, @@ -235,38 +227,40 @@ const externalScalars: ScalarInfo[] = await Promise.all( }), ) -// Shape of the per-version `category-map.json` used both at runtime by the -// redirect middleware and (here) at build time as a fallback source of -// categories when a schema lacks `@docsCategory` directives. +// category-map.json supplies runtime redirects and build-time fallback. +// The fallback covers schemas without @docsCategory. type CategoryMapFallback = Partial<Record<string, Record<string, string>>> -// Selects and formats the schema data the docs need. Runs in the build step. +// processSchemas assigns GraphQL categories before rendering. Explicit @docsCategory wins. +// category-map.json fills GHES schemas without directives. Mutation inputs inherit their owning +// mutation. Connection and Edge types come from graphql-ruby Relay pagination and inherit from +// node, nodes, or edges. Unannotated enum, union, and input object targets inherit only when +// every referrer resolves to one category. Interfaces do not contribute because they are +// cross-cutting. Input object candidates propagate through nested inputs. +// Examples include IssueTimelineItemsItemType, PullRequestTimelineItemsItemType, +// RepositoryRuleType, RuleParameters, and RuleParametersInput. Candidate sets make derivation +// order-independent and retain later conflicting referrers. +// Input-suffixed objects stay included because docs pages exist outside the v4 sidebar. +// https://developer.github.com/v4/input_object/acceptenterpriseadministratorinvitationinput/ +// Categories missing from CATEGORIES in src/graphql/lib/categories.ts normalize to other. export default async function processSchemas( idl: Buffer | string, previewsPerVersion: PreviewInfo[], - // Optional fallback used when the IDL itself has no `@docsCategory` - // directives (e.g. GHES branches cut before the upstream DSL existed). - // Lookups for type-level categories use the type id; mutations look up - // by mutation field name under the `mutations` key. + // Optional fallback for schemas without @docsCategory. + // Type ids are keys, and the mutations map uses field names. fallbackCategoryMap?: CategoryMapFallback, - // The docs version being generated, e.g. `enterprise-server@3.22`. Without - // it, links inside schema descriptions render without a version segment. + // Context carries the docs version key so schema description links get a version segment. context: Context = {}, ): Promise<ProcessedSchemaData> { const helpers = createSchemaHelpers(context) const schemaAST: DocumentNode = parse(idl.toString()) const schema: GraphQLSchema = buildASTSchema(schemaAST) - // list of objects is used when processing mutations const objectsInSchema = schemaAST.definitions.filter( (def): def is ObjectTypeDefinitionNode => def.kind === 'ObjectTypeDefinition', ) - // PASS 1: Build a typeId -> category map by reading the @docsCategory - // directive on every categorizable definition. Queries derive their - // category from the return type's category; mutations are annotated on - // each Mutation root field rather than on a type, so we collect those - // separately. + // Read @docsCategory before deriving fallback categories. const typeCategoryMap = new Map<string, string>() const mutationFieldCategoryMap = new Map<string, string>() @@ -295,51 +289,32 @@ export default async function processSchemas( } } - // Build a flat fallback id -> cat map across every type-level kind. (We - // exclude queries: query categories are derived from the return type. - // Mutations are kept separately since they're keyed by field name.) + // Fallback type categories skip queries and keep mutations keyed by field name. const fallbackTypeMap: Record<string, string> = {} if (fallbackCategoryMap) { for (const kind of Object.keys(fallbackCategoryMap)) { if (kind === 'queries' || kind === 'mutations') continue const sub = fallbackCategoryMap[kind] || {} for (const id of Object.keys(sub)) { - // First write wins; in practice ids don't collide across kinds. + // First write wins; ids do not collide across kinds in practice. if (!(id in fallbackTypeMap)) fallbackTypeMap[id] = sub[id] } } } const fallbackMutationMap = fallbackCategoryMap?.mutations || {} - // PASS 1.5: derive categories for types that github/github cannot annotate - // directly. Two rules apply, both run before fallback / OTHER assignment so - // they take effect for fpt and ghec (where the IDL has the annotations) and - // also propagate into the per-version category-map.json that GHES <3.22 - // consumes as its fallback. - // - // (a) Input objects inherit from their owning mutation. The DSL can mark - // a mutation field with @docsCategory but the generated *Input type - // isn't annotated; we copy the mutation's category onto each input - // argument's named type. - // (b) Connection / Edge types inherit from their underlying type. These - // are emitted by graphql-ruby's Relay pagination and never get a - // hand-written docs_category. We walk `node`/`nodes`/`edges` to the - // referenced object type and copy its category. - // - // Explicit annotations always win; derivation only fills gaps. + // Derive missing categories before fallback and other assignment so GHES fallback inherits them. const lookupCat = (id: string): string | undefined => typeCategoryMap.get(id) ?? fallbackTypeMap[id] const getMutationCat = (mutFieldName: string): string | undefined => mutationFieldCategoryMap.get(mutFieldName) ?? fallbackMutationMap[mutFieldName.toLowerCase()] - // Walk through a TypeNode chain (NonNull/List wrappers) to the NamedType. const namedTypeName = (typeNode: TypeNode): string | undefined => { let t: TypeNode = typeNode while ('type' in t) t = t.type return t.kind === 'NamedType' ? t.name.value : undefined } - // (a) input objects from mutation field args const mutationDef = schemaAST.definitions.find( (def): def is ObjectTypeDefinitionNode => def.kind === 'ObjectTypeDefinition' && def.name.value === 'Mutation', @@ -362,9 +337,7 @@ export default async function processSchemas( } } - // (b) Connection / Edge types from their underlying type. Run multiple - // passes so an XConnection that points at XEdge can still resolve after - // XEdge itself has been derived (Connection -> Edge -> object). + // Multiple passes let Connection to Edge to object chains inherit the object category. const objectDefs = schemaAST.definitions.filter( (def): def is ObjectTypeDefinitionNode => def.kind === 'ObjectTypeDefinition', ) @@ -378,7 +351,7 @@ export default async function processSchemas( if (!isEdge && !isConn) continue const id = helpers.getId(name) if (lookupCat(id)) continue - // Edge: walk `node`. Connection: prefer `nodes` (direct), else `edges`. + // Edge types use node; Connection types prefer nodes, then edges. const fields = def.fields || [] let underlyingName: string | undefined if (isEdge) { @@ -402,39 +375,7 @@ export default async function processSchemas( if (!changed) break } - // (c) General reference-based inheritance. An un-annotated enum, union, or - // input object inherits the category of the type(s) that reference it, but - // only when every referrer resolves to a single category; ambiguous types - // (referrers disagree, or a referrer is itself ambiguous) stay in `other`. - // This is the derived successor to a static exception list: it catches - // generated/indirect types that github/github never annotates directly while - // still letting the upstream team own the outcome via the parent type's - // `docs_category`. - // - // Examples this resolves today: - // - `IssueTimelineItemsItemType` / `PullRequestTimelineItemsItemType`: - // runtime-generated enums used only as the `itemTypes` argument on - // `Issue.timelineItems` (issues) / `PullRequest.timelineItems` (pulls). - // - `RepositoryRuleType` (enum) and `RuleParameters` (union): referenced - // from the annotated `RepositoryRule` object (repos). - // - `RuleParametersInput` (input): referenced from the annotated - // `RepositoryRuleInput` input object (repos). - // - // A "referrer category" is the category of: - // - the owning object type, for a field's return type or a field argument's - // type (interfaces are intentionally excluded: they are cross-cutting and - // make coincidental single-category matches likely); - // - the Mutation root field, for that field's arguments; - // - the owning input object, for an input field's type. Input objects can - // themselves be uncategorized-but-derivable, so this rule propagates - // transitively through nested inputs. - // - // Implemented as a monotone fixpoint over candidate category *sets* rather - // than committing categories as we go: a type is only assigned once its - // candidate set has stopped growing, so the result is independent of - // definition/derivation order and a later-discovered conflicting referrer - // can never be missed. Explicit annotations and derivations (a)/(b) always - // win: we only compute candidates for ids `lookupCat` still can't resolve. + // Reference-based inheritance assigns a category only when referrers resolve to one category. const derivableTargets = schemaAST.definitions.filter( ( def, @@ -457,10 +398,7 @@ export default async function processSchemas( const candidates = new Map<string, Set<string>>() for (const id of targetIds) candidates.set(id, new Set()) - // Categories a type contributes when it appears as a referrer. Annotated / - // fallback types contribute their single category; an uncommitted derivable - // referrer (only ever an input object here) contributes its current - // candidate set so ambiguity propagates downstream. + // Uncommitted input object referrers contribute candidate sets so ambiguity propagates. const contribution = (referrerId: string): Iterable<string> => { const explicit = lookupCat(referrerId) if (explicit) return [explicit] @@ -481,8 +419,7 @@ export default async function processSchemas( return grew } - // Bounded by the worst-case propagation depth; each pass only adds to sets, - // so this terminates well before the cap. + // Each pass only adds candidates, so maxPasses bounds the propagation depth. const maxPasses = targetIds.size + 2 for (let pass = 0; pass < maxPasses; pass++) { let changed = false @@ -492,8 +429,7 @@ export default async function processSchemas( if (name === 'Query') continue const isMutation = name === 'Mutation' for (const field of def.fields || []) { - // Mutation fields carry their own category and their payload return - // type is already annotated, so (like rule (a)) we only walk args. + // Mutation categories apply to args; payload return types already carry categories. const fieldCats: Iterable<string> = isMutation ? ((c) => (c ? [c] : []))(getMutationCat(field.name.value)) : contribution(helpers.getId(name)) @@ -514,33 +450,18 @@ export default async function processSchemas( if (!changed) break } - // Assign only the targets whose final candidate set is unambiguous. for (const [id, cats] of candidates) { if (cats.size === 1) typeCategoryMap.set(id, [...cats][0]) } } - // Populates the top-level `.category` field on every processed item. The - // bucketer reads `.category` to split the schema into per-category files and - // to rewrite cross-reference hrefs. - // - // Unknown categories (e.g. `:checks`, `:search`, `:packages`, - // `:security_advisories`) normalize to `other`. The upstream gh/gh allowlist - // permits many categories that docs-internal has not built per-category - // landing pages for; without this fallback those types would be silently - // dropped by `writeCategoryFiles` (which only emits files for slugs in - // CATEGORIES) and their redirects would 404. Once a page exists for a - // category, add it to CATEGORIES in src/graphql/lib/categories.ts and types - // will move out of `other` on the next sync. + // Unknown categories normalize to other so writeCategoryFiles does not drop types or redirects. const resolveCategory = (typeId: string): string => { const cat = typeCategoryMap.get(typeId) ?? fallbackTypeMap[typeId] ?? OTHER_CATEGORY return isValidCategory(cat) ? cat : OTHER_CATEGORY } - // process-schemas emits legacy `/graphql/reference/<urlKind>#<id>` hrefs - // throughout so the monolithic `schema.json` stays compatible with the - // existing runtime loader. The bucketer rewrites these to the - // category-aware form when emitting per-category schema files. + // linkTo emits reference hrefs; bucket-by-category rewrites only per-category schema files. const linkTo = (urlKind: string, id: string): string => helpers.getFullLink(urlKind, id) const data: ProcessedSchemaData = { @@ -635,10 +556,7 @@ export default async function processSchemas( mutation.name = field.name.value mutation.id = helpers.getId(mutation.name) - // Mutation fields carry @docsCategory at the field level on the - // Mutation root, not on the payload type, so use the field map. - // Normalize via isValidCategory so an upstream-only category - // doesn't produce hrefs/buckets we don't ship pages for. + // Mutation fields carry @docsCategory on the field, not the payload type. const rawMutationCategory = mutationFieldCategoryMap.get(mutation.name) ?? fallbackMutationMap[mutation.name.toLowerCase()] ?? @@ -661,7 +579,7 @@ export default async function processSchemas( previewsPerVersion, ) - // there is only ever one input field argument, but loop anyway + // Mutation fields have one input argument in practice, but the schema exposes an array. await Promise.all( (field.arguments || []).map(async (arg: InputValueDefinitionNode) => { const inputField: Partial<InputFieldInfo> = {} @@ -679,8 +597,7 @@ export default async function processSchemas( mutation.inputFields = sortBy(inputFields, 'name') - // get return fields - // first get the payload, then find payload object's fields. these are the mutation's return fields. + // Mutation return fields come from the payload object's fields. const returnType = helpers.getType(field) if (!returnType) return const mutationReturnFields = objectsInSchema.find( @@ -731,8 +648,7 @@ export default async function processSchemas( } if (def.kind === 'ObjectTypeDefinition') { - // objects ending with 'Payload' are only used to derive mutation values - // they are not included in the objects docs + // Payload objects provide mutation return fields and stay out of the object docs. if (def.name.value.endsWith('Payload')) return const object: Partial<ObjectInfo> = {} @@ -756,8 +672,7 @@ export default async function processSchemas( previewsPerVersion, ) - // an object's interfaces render in the `Implements` section - // interfaces do not have directives so they cannot be under preview/deprecated + // Implements links carry only name, id, and href, without preview or deprecation data. if (def.interfaces && def.interfaces.length) { await Promise.all( def.interfaces.map(async (graphqlInterface) => { @@ -771,7 +686,7 @@ export default async function processSchemas( ) } - // an object's fields render in the `Fields` section + // Object fields render under Fields. if (def.fields && def.fields.length) { await Promise.all( def.fields.map(async (field: FieldDefinitionNode) => { @@ -835,7 +750,7 @@ export default async function processSchemas( previewsPerVersion, ) - // an interface's fields render in the "Fields" section + // Interface fields render under Fields. if (def.fields && def.fields.length) { await Promise.all( def.fields.map(async (field: FieldDefinitionNode) => { @@ -938,7 +853,7 @@ export default async function processSchemas( previewsPerVersion, ) - // union types do not have directives so cannot be under preview/deprecated + // Union member links carry no preview or deprecation state. await Promise.all( (def.types || []).map(async (type) => { const possibleType: PossibleTypeInfo = { @@ -956,10 +871,7 @@ export default async function processSchemas( return } - // INPUT OBJECTS - // NOTE: input objects ending with `Input` are NOT included in the v4 input objects sidebar - // but they are still present in the docs (e.g., https://developer.github.com/v4/input_object/acceptenterpriseadministratorinvitationinput/) - // so we will include them here + // Include Input-suffixed objects; docs pages exist outside the v4 sidebar. if (def.kind === 'InputObjectTypeDefinition') { const inputObject: Partial<InputObjectInfo> = {} const inputFields: InputFieldDetailInfo[] = [] @@ -1048,7 +960,6 @@ export default async function processSchemas( }), ) - // add non-schema scalars and sort all scalars alphabetically data.scalars = sortBy(data.scalars.concat(externalScalars), 'name') data.queries = sortBy(data.queries, 'name') diff --git a/src/graphql/scripts/utils/schema-helpers.ts b/src/graphql/scripts/utils/schema-helpers.ts index f7914cd309c4..4cbf514a967b 100644 --- a/src/graphql/scripts/utils/schema-helpers.ts +++ b/src/graphql/scripts/utils/schema-helpers.ts @@ -54,13 +54,11 @@ const graphqlTypes: GraphQLTypeInfo[] = JSON.parse( const singleQuotesInsteadOfBackticks = / '(\S+?)' / -// Upstream schema descriptions link with a `${externalDocsUrl}` placeholder, -// but nothing in this pipeline expands it. It ships percent-encoded as -// `href="$%7BexternalDocsUrl%7D/code-security/..."`, which the browser -// resolves against the current page and 404s. Dropping the placeholder leaves -// a root-relative link, which `getDescription` then versions using the -// `context` handed to `createSchemaHelpers`, so a GHES reader stays on GHES. -// The bare `helpers` export has no context and leaves links unversioned. +// Upstream schema descriptions include ${externalDocsUrl}, but this pipeline never expands it. +// It ships as href="$%7BexternalDocsUrl%7D/code-security/..." and resolves against the +// current page, causing 404s. +// Dropping it leaves a root-relative link that getDescription versions with createSchemaHelpers. +// The bare helpers export has no context and leaves links unversioned. const unexpandedExternalDocsUrl = /\$\{externalDocsUrl\}(?=\/)/g function addPeriod(string: string): string { @@ -84,15 +82,12 @@ async function getArguments( arg.defaultValue && 'value' in arg.defaultValue ? arg.defaultValue.value : undefined newArg.description = arg.description ? await getDescription(arg.description.value, context) : '' const typeName = getType(arg) - if (!typeName) continue // Skip if type cannot be determined + if (!typeName) continue type.name = typeName type.id = getId(typeName) const typeKind = getTypeKind(typeName, schema) - if (!typeKind) continue // Skip if type kind cannot be determined - // process-schemas always emits legacy `/graphql/reference/<urlKind>#<id>` - // hrefs. bucket-by-category rewrites them into the category-aware form - // when splitting into per-category files, so monolithic schema.json stays - // byte-stable with what the existing runtime expects. + if (!typeKind) continue + // getFullLink keeps reference hrefs stable; bucket-by-category rewrites only category files. type.href = getFullLink(typeKind, type.id!) newArg.type = type as TypeInfo newArgs.push(newArg as ArgumentInfo) @@ -101,9 +96,7 @@ async function getArguments( return newArgs } -// Build a category-aware anchor link for a type, e.g. -// `/graphql/reference/repos#object-repository`. Exposed for the bucketer's -// href-rewrite pass; process-schemas itself uses the legacy `getFullLink`. +// buildCategoryHref returns anchors like /graphql/reference/repos#object-repository for rewrites. export function buildCategoryHref(category: string, urlKind: string, id: string): string { return `/graphql/reference/${category}#${slugPrefixForUrlKind(urlKind)}-${id}` } @@ -115,10 +108,10 @@ async function getDeprecationReason( ): Promise<string | undefined> { if (!schemaMember.isDeprecated) return - // it's possible for a schema member to be deprecated and under preview + // Deprecated and preview can both apply to one schema member. const deprecationDirective = directives.filter((dir) => dir.name.value === 'deprecated') - // catch any schema members that have more than one deprecation (none currently) + // Multiple deprecation directives indicate upstream schema data needs review. if (deprecationDirective.length > 1) console.log(`more than one deprecation found for ${schemaMember.name}`) @@ -146,8 +139,6 @@ function getFullLink(baseType: string, id: string): string { return `/graphql/reference/${baseType}#${id}` } -// Extract the `@docsCategory(name: "...")` value from a directive list. -// Returns undefined when the directive is absent. function getDocsCategory(directives: readonly ConstDirectiveNode[]): string | undefined { const directive = directives.find((dir) => dir.name.value === 'docsCategory') if (!directive) return @@ -162,7 +153,7 @@ function getId(typeName: string): string { return removeMarkers(typeName).toLowerCase() } -// e.g., given `ObjectTypeDefinition`, get `objects` +// Example: ObjectTypeDefinition maps to objects. function getKind(type: string): string { return graphqlTypes.find((graphqlType) => graphqlType.type === type)!.kind } @@ -174,15 +165,15 @@ async function getPreview( ): Promise<PreviewInfo | undefined> { if (!directives.length) return - // it's possible for a schema member to be deprecated and under preview + // Deprecated and preview can both apply to one schema member. const previewDirective = directives.filter((dir) => dir.name.value === 'preview') if (!previewDirective.length) return - // catch any schema members that are under more than one preview (none currently) + // Log multiple preview directives from the schema AST; the script expects at most one. if (previewDirective.length > 1) console.log(`more than one preview found for ${schemaMember.name}`) - // an input object's input field may have a ListValue directive that is not relevant to previews + // Ignore ListValue preview directives on input fields because previews use string values. const firstArg = previewDirective[0]?.arguments?.[0] if (!firstArg) return const argValue = firstArg.value @@ -196,48 +187,40 @@ async function getPreview( return preview } -// the docs use brackets to denote list types: `[foo]` -// and an exclamation mark to denote non-nullable types: `foo!` -// both single items and lists can be non-nullable -// so the permutations are: -// 1. single items: `foo`, `foo!` -// 2. nullable lists: `[foo]`, `[foo!]` -// 3. non-null lists: `[foo]!`, `[foo!]!` -// see https://github.com/rmosolgo/graphql-ruby/blob/master/guides/type_definitions/lists.md#lists-nullable-lists-and-lists-of-nulls +// GraphQL list and non-null wrappers combine as foo, foo!, [foo], [foo!], [foo]!, +// and [foo!]!. +// See https://github.com/rmosolgo/graphql-ruby/blob/master/guides/type_definitions/lists.md#lists-nullable-lists-and-lists-of-nulls function getType(field: FieldNode): string | undefined { - // 1. single items if (field.type.kind !== 'ListType') { - // nullable item, e.g. `license` query has `License` type + // Nullable item example: license query has License type. if (field.type.kind === 'NamedType') { return field.type.name.value } - // non-null item, e.g. `meta` query has `GitHubMetadata!` type + // Non-null item example: meta query has GitHubMetadata! type. if (field.type.kind === 'NonNullType' && field.type.type.kind === 'NamedType') { return `${field.type.type.name.value}!` } } - // 2. nullable lists if (field.type.kind === 'ListType') { - // nullable items, e.g. `codesOfConduct` query has `[CodeOfConduct]` type + // Nullable list example: codesOfConduct query has [CodeOfConduct] type. if (field.type.type.kind === 'NamedType') { return `[${field.type.type.name.value}]` } - // non-null items, e.g. `severities` arg has `[SecurityAdvisorySeverity!]` type + // Nullable list example: severities arg has [SecurityAdvisorySeverity!] type. if (field.type.type.kind === 'NonNullType' && field.type.type.type.kind === 'NamedType') { return `[${field.type.type.type.name.value}!]` } } - // 3. non-null lists if (field.type.kind === 'NonNullType' && field.type.type.kind === 'ListType') { - // nullable items, e.g. `licenses` query has `[License]!` type + // Non-null list example: licenses query has [License]! type. if (field.type.type.type.kind === 'NamedType') { return `[${field.type.type.type.name.value}]!` } - // non-null items, e.g. `marketplaceCategories` query has `[MarketplaceCategory!]!` type + // Non-null list example: marketplaceCategories query has [MarketplaceCategory!]! type. if ( field.type.type.type.kind === 'NonNullType' && field.type.type.type.type.kind === 'NamedType' @@ -296,12 +279,9 @@ const helpers = { getTypeKind, } -// The three helpers that render Markdown need to know which docs version they -// are rendering for, otherwise `rewrite-local-links` bails out and root-relative -// links ship without a language or version segment. Binding the context once -// here keeps the ~30 call sites in `process-schemas` unchanged, and keeps the -// context per-call rather than in module state, so two versions can never -// render against each other's context. +// Markdown helpers need docs version context, or rewrite-local-links omits language and version. +// Binding context once keeps the ~30 process-schemas call sites unchanged and per-call. +// Per-call context prevents concurrent versions from rendering against each other. export function createSchemaHelpers(context: Context): typeof helpers { return { ...helpers, diff --git a/src/graphql/scripts/utils/sync-category-content.ts b/src/graphql/scripts/utils/sync-category-content.ts index 02a58d150c5d..5e71068971e5 100644 --- a/src/graphql/scripts/utils/sync-category-content.ts +++ b/src/graphql/scripts/utils/sync-category-content.ts @@ -10,36 +10,27 @@ import { } from '@/automated-pipelines/lib/update-markdown' import { CATEGORIES, OTHER_CATEGORY, categoryTitle } from '@/graphql/lib/categories' -// Default directory holding the per-category GraphQL reference content pages. -// Overridable via options for tests; production always uses this path. +// Tests can override the content directory; production uses content/graphql/reference. const DEFAULT_CONTENT_DIR = path.join('content', 'graphql', 'reference') -// Value of the `autogenerated` frontmatter on managed category pages. The -// content-directory helper uses this to know which files it owns (and may -// therefore delete when a category empties). +// updateContentDirectory deletes only pages whose autogenerated frontmatter matches graphql. const AUTOGENERATED_TYPE = 'graphql' // Breadcrumb category the reference pages sit under in the sidebar. const CATEGORY_BREADCRUMB = 'Explore the schema reference' -// Maps a category slug to the set of docs version keys (e.g. -// `free-pro-team@latest`, `enterprise-server@3.22`) in which the category has -// at least one type. Built by sync.ts from the per-version buckets. +// Maps each category slug to docs version keys where at least one type exists; sync.ts builds it. export type CategoryPresence = Map<string, Set<string>> const categoryUrlPath = (cat: string) => `/graphql/reference/${cat}` -// Matches a bare category reference URL (no fragment), e.g. -// `/graphql/reference/code-scanning`. Kind pages like -// `/graphql/reference/queries` also match this shape but are filtered out -// because their slug is not in CATEGORIES. +// CATEGORY_URL_RE matches bare category URLs like /graphql/reference/code-scanning. +// Kind pages like /graphql/reference/queries match this shape but get filtered later. const CATEGORY_URL_RE = /^\/graphql\/reference\/([a-z][a-z0-9-]*)$/ function isPresentInAnyVersion(presence: CategoryPresence, cat: string): boolean { return (presence.get(cat)?.size ?? 0) > 0 } -// Read the `redirect_from` of every managed category page before the content -// helper potentially deletes those files, so redirect chains aren't lost when a -// category disappears. Returns a map of category slug -> redirect_from entries. +// Capture managed category redirects before deletion so disappearance redirects keep resolving. async function captureCategoryRedirects(contentDir: string): Promise<Map<string, string[]>> { const captured = new Map<string, string[]>() let files: string[] = [] @@ -72,10 +63,7 @@ function normalizeRedirects(value: unknown): string[] { return [] } -// Build the `sourceContent` map the content-directory helper expects: -// `{ <targetFile>: { data: <frontmatter>, content: <body> } }`. Only categories -// that are non-empty in at least one version get a page; emptied categories are -// omitted so the helper deletes their stale files. +// buildSourceContent omits empty categories so updateContentDirectory deletes stale pages. async function buildSourceContent(presence: CategoryPresence, contentDir: string) { const sourceContent: Record<string, { data: Record<string, unknown>; content: string }> = {} for (const cat of CATEGORIES) { @@ -84,9 +72,7 @@ async function buildSourceContent(presence: CategoryPresence, contentDir: string const versions = await convertVersionsToFrontmatter([...versionsSet]) const title = categoryTitle(cat) const file = path.join(contentDir, `${cat}.md`) - // For pages that already exist, the helper only refreshes `versions` and the - // autogenerated body, preserving any writer edits to title/intro/category. - // These values therefore only seed brand-new category pages. + // Managed pages keep writer-edited title, intro, and category; values apply only at creation. sourceContent[file] = { data: { title, @@ -102,10 +88,8 @@ async function buildSourceContent(presence: CategoryPresence, contentDir: string return sourceContent } -// Reconcile the reference index `redirect_from` so that a bare category URL -// redirects to the reference root when (and only when) that category is empty in -// every version. Categories present in at least one version must NOT have a -// redirect, otherwise a still-valid versioned page would be shadowed. +// Empty categories redirect to the reference root only when every version lacks that category. +// Present categories must not redirect, or they shadow still-valid versioned pages. async function reconcileIndexRedirects( presence: CategoryPresence, capturedRedirects: Map<string, string[]>, @@ -120,9 +104,7 @@ async function reconcileIndexRedirects( const { data, content } = matter(raw) const existing = normalizeRedirects(data.redirect_from) - // Drop redirects for managed categories that are now present (e.g. a category - // that previously emptied and has since come back). Leave kind-page redirects - // (queries, mutations, ...) and non-category redirects (/v4/reference) intact. + // Drop redirects for categories that returned; keep kind-page and non-category redirects intact. const next = existing.filter((entry) => { const match = CATEGORY_URL_RE.exec(entry) if (!match) return true @@ -131,16 +113,13 @@ async function reconcileIndexRedirects( return !isPresentInAnyVersion(presence, cat) }) - // Add a root redirect for every managed category that is empty in all - // versions. `other` is always present (un-annotated types), so it never - // disappears, but guard against it defensively. + // Add root redirects for categories empty in all versions; other never disappears. for (const cat of CATEGORIES) { if (cat === OTHER_CATEGORY) continue if (isPresentInAnyVersion(presence, cat)) continue const url = categoryUrlPath(cat) if (!next.includes(url)) next.push(url) - // Preserve any redirect_from the deleted category page carried so existing - // inbound redirect chains keep resolving. + // Carry redirect_from from deleted category pages so inbound chains keep resolving. for (const inherited of capturedRedirects.get(cat) ?? []) { if (!next.includes(inherited)) next.push(inherited) } @@ -151,10 +130,8 @@ async function reconcileIndexRedirects( await fs.writeFile(indexFile, matter.stringify(content, data)) } -// Entry point used by sync.ts after it has bucketed every version. Creates, -// updates, and deletes the per-category content pages, refreshes the reference -// index children, and reconciles disappearance redirects. `contentDir` is -// overridable for tests; production uses the default reference directory. +// Creates, updates, and deletes category pages after sync.ts buckets every version. +// Also refreshes index children and disappearance redirects; tests can override contentDir. export async function syncCategoryContentFiles( presence: CategoryPresence, options: { contentDir?: string } = {}, diff --git a/src/graphql/tests/build-changelog.ts b/src/graphql/tests/build-changelog.ts index 420d659ec06d..ed140730d217 100644 --- a/src/graphql/tests/build-changelog.ts +++ b/src/graphql/tests/build-changelog.ts @@ -59,8 +59,6 @@ describe('creating a changelog from old schema and new schema', () => { }) test('ignores unknown change types without throwing errors', async () => { - // Create a minimal test that would generate an unknown change type - // This test ensures the system gracefully handles new change types const oldSchemaString = ` type Query { field: String @@ -76,8 +74,7 @@ describe('creating a changelog from old schema and new schema', () => { } ` - // This should generate TypeDescriptionAdded change type - // which should be silently ignored if not in CHANGES_TO_REPORT + // CHANGES_TO_REPORT omits TypeDescriptionAdded, so createChangelogEntry ignores it. const entry: ChangelogEntry | null = await createChangelogEntry( oldSchemaString, newSchemaString, @@ -86,14 +83,10 @@ describe('creating a changelog from old schema and new schema', () => { [], ) - // Should return null since TypeDescriptionAdded is not in CHANGES_TO_REPORT - // and will be silently ignored without throwing an error expect(entry).toBeNull() }) test('handles new directive usage change types gracefully', async () => { - // Test that verifies the system can handle new directive-related change types - // that were previously causing errors in the pipeline const oldSchemaString = ` directive @example on FIELD_DEFINITION @@ -110,8 +103,7 @@ describe('creating a changelog from old schema and new schema', () => { } ` - // This should generate DirectiveUsage* change types that are not in CHANGES_TO_REPORT - // The system should silently ignore these and not throw errors + // CHANGES_TO_REPORT omits added field directives, so createChangelogEntry ignores this one. const entry: ChangelogEntry | null = await createChangelogEntry( oldSchemaString, newSchemaString, @@ -120,7 +112,6 @@ describe('creating a changelog from old schema and new schema', () => { [], ) - // Should return null since directive usage changes are typically ignored expect(entry).toBeNull() }) @@ -219,12 +210,10 @@ upcoming_changes: describe('Preparing preview links', () => { test('fixes preview names', () => { - // These two are special cases + // UpdateRefsPreview and MergeInfoPreview are hand-written title exceptions. expect(cleanPreviewTitle('UpdateRefsPreview')).toEqual('Update refs preview') expect(cleanPreviewTitle('MergeInfoPreview')).toEqual('Merge info preview') - // Previews that don't end in " preview" have it added expect(cleanPreviewTitle('something interesting')).toEqual('something interesting preview') - // Other things are left as-is expect(cleanPreviewTitle('nice preview')).toEqual('nice preview') }) @@ -253,7 +242,6 @@ describe('updating the changelog file', () => { prependDatedEntry(exampleEntry, testTargetPath) const newContents: string = await fs.readFile(testTargetPath, 'utf8') - // reset the file: await fs.writeFile(testTargetPath, previousContents.toString()) expect(exampleEntry).toEqual({ @@ -299,10 +287,8 @@ describe('ensureYearPage', () => { ensureYearPage('2026', tmpDir) - // Should not modify the existing file const yearPage = await fs.readFile(`${tmpDir}/2026.md`, 'utf8') expect(yearPage).toContain('title: existing') - // index.md should be unchanged const updatedIndex = await fs.readFile(`${tmpDir}/index.md`, 'utf8') expect(updatedIndex).toBe(indexContent) }) @@ -325,7 +311,7 @@ describe('ignored changes tracking', () => { } ` - // This should generate a TypeDescriptionAdded change type that gets ignored + // Ignored-change tracking records TypeDescriptionAdded. await createChangelogEntry(oldSchemaString, newSchemaString, [], [], []) const ignoredChanges: IgnoredChange[] = getLastIgnoredChanges() as unknown as IgnoredChange[] @@ -350,7 +336,7 @@ describe('ignored changes tracking', () => { } ` - // This should generate multiple DirectiveUsage changes that get ignored + // Ignored-change summary groups multiple DirectiveUsage changes under one type. await createChangelogEntry(oldSchemaString, newSchemaString, [], [], []) const summary = getIgnoredChangesSummary() @@ -369,7 +355,6 @@ describe('ignored changes tracking', () => { } ` - // No changes should be generated await createChangelogEntry(schemaString, schemaString, [], [], []) const summary = getIgnoredChangesSummary() diff --git a/src/graphql/tests/derive-categories.ts b/src/graphql/tests/derive-categories.ts index fa3138ef1baa..4a0b675d88b8 100644 --- a/src/graphql/tests/derive-categories.ts +++ b/src/graphql/tests/derive-categories.ts @@ -2,14 +2,12 @@ import { describe, expect, test } from 'vitest' import processSchemas from '../scripts/utils/process-schemas' -// Minimal `@docsCategory` directive declaration so `buildASTSchema` can parse -// the fixtures below. Mirrors the real declaration emitted by github/github. +// Minimal @docsCategory lets buildASTSchema parse fixtures and mirrors github/github output. const DIRECTIVE = ` directive @docsCategory(name: String!) on ENUM | FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | UNION ` -// Run processSchemas over an inline IDL and return a flat name -> category map -// across every kind, so tests can assert where a type landed. +// A flat name to category map lets each fixture assert where every kind landed. async function categoriesFor(idl: string): Promise<Record<string, string>> { const data = await processSchemas(`${DIRECTIVE}\n${idl}`, []) const out: Record<string, string> = {} @@ -21,7 +19,7 @@ async function categoriesFor(idl: string): Promise<Record<string, string>> { return out } -// Every fixture needs a Query root; a `viewer` field keeps it non-empty. +// GraphQL fixtures need a non-empty Query root. const QUERY = ` type Query { viewer: String @@ -53,7 +51,7 @@ describe('reference-based category derivation (PASS 1.5 rule c)', () => { }) test('enum inherits from an annotated object field argument', async () => { - // Mirrors Issue.timelineItems(itemTypes: [IssueTimelineItemsItemType!]). + // Mirrors Issue.timelineItems with itemTypes [IssueTimelineItemsItemType!]. const cats = await categoriesFor(` ${QUERY} type Issue @docsCategory(name: "issues") { @@ -74,7 +72,7 @@ describe('reference-based category derivation (PASS 1.5 rule c)', () => { input NestedParametersInput { value: String } `) expect(cats.RuleParametersInput).toBe('repos') - // Propagates another hop through the still-uncategorized input chain. + // NestedParametersInput proves inheritance propagates through an uncategorized input chain. expect(cats.NestedParametersInput).toBe('repos') }) @@ -117,8 +115,7 @@ describe('reference-based category derivation (PASS 1.5 rule c)', () => { }) test('interfaces are not a referrer source', async () => { - // Interface is annotated and references the enum, but no object does, so - // the enum must not inherit the interface's category. + // Interfaces are not referrer sources, so their referenced enums stay in other. const cats = await categoriesFor(` ${QUERY} interface Rulable @docsCategory(name: "repos") { diff --git a/src/graphql/tests/description-links.ts b/src/graphql/tests/description-links.ts index 19a20e1ea6b2..1f07d84e5aca 100644 --- a/src/graphql/tests/description-links.ts +++ b/src/graphql/tests/description-links.ts @@ -13,8 +13,7 @@ describe('GraphQL description links', () => { test('strips the unexpanded externalDocsUrl placeholder', async () => { const rendered = await helpers.getDescription(PLACEHOLDER_LINK) - // Left in place the placeholder percent-encodes into the href and the - // browser resolves it against the current page, which 404s. + // An unexpanded placeholder becomes a page-relative href and 404s. expect(rendered).not.toContain('externalDocsUrl') expect(rendered).toContain('href="/code-security/code-scanning#levels"') }) diff --git a/src/graphql/tests/server-rendering.ts b/src/graphql/tests/server-rendering.ts index 177bb94f37d3..deace3a4f0fe 100644 --- a/src/graphql/tests/server-rendering.ts +++ b/src/graphql/tests/server-rendering.ts @@ -23,12 +23,9 @@ describe('server rendering certain GraphQL pages', () => { expect.assertions(hrefs.length + 1) }) + // Request the changelog twice because github-slugger state can add suffixes on the second render. + // The mini-TOC hrefs must match those heading IDs. test('minitoc hrefs on changelog match and verify slugger behavior', async () => { - // Testing the minitoc links match the heading ids but also validating - // slugger behavior see docs-engineering/issues#5792. - // Little funky because we need to make 2 requests to the page to test - // the problem behavior where slugger state accumulates across - // requests, it won't fail the first time around. await getDOM('/graphql/overview/changelog') const $ = await getDOM('/graphql/overview/changelog') const links = $('[data-testid="minitoc"] a[href]') diff --git a/src/graphql/tests/sync-category-content.ts b/src/graphql/tests/sync-category-content.ts index ef3690219664..6de96d3afc5b 100644 --- a/src/graphql/tests/sync-category-content.ts +++ b/src/graphql/tests/sync-category-content.ts @@ -17,8 +17,7 @@ const GHEC = 'enterprise-cloud@latest' const REFERENCE_DIR = path.join('content', 'graphql', 'reference') -// The full set of categories present in a steady-state fixture. Returned as a -// fresh Map each call so tests never share mutable state. +// A fresh steady-state presence map prevents tests from sharing mutable state. function steadyPresence(): CategoryPresence { return new Map([ ['actions', new Set([FPT, GHEC])], @@ -28,7 +27,6 @@ function steadyPresence(): CategoryPresence { ]) } -// Write an autogenerated category page with the given versions frontmatter. async function writeCategoryFile( root: string, cat: string, @@ -104,28 +102,23 @@ describe('syncCategoryContentFiles', () => { await writeCategoryFile(root, 'other', { fpt: '*', ghec: '*' }) const presence = steadyPresence() - presence.delete('code-scanning') // emptied -> absent in all versions + presence.delete('code-scanning') // A missing category means it has no types in any version. await syncCategoryContentFiles(presence, { contentDir }) - // Emptied category file is deleted; populated ones remain. expect(existsSync(path.join(root, REFERENCE_DIR, 'code-scanning.md'))).toBe(false) expect(existsSync(path.join(root, REFERENCE_DIR, 'actions.md'))).toBe(true) expect(existsSync(path.join(root, REFERENCE_DIR, 'other.md'))).toBe(true) - // Versions are narrowed to where the category actually has types. const sponsors = matter(await readFile(path.join(root, REFERENCE_DIR, 'sponsors.md'), 'utf8')) expect(sponsors.data.versions).toEqual({ fpt: '*' }) const index = await readIndex(root) - // Sidebar children drop the emptied category. expect(index.data.children).not.toContain('/code-scanning') expect(index.data.children).toContain('/actions') expect(index.data.children).toContain('/sponsors') expect(index.data.children).toContain('/other') - // Disappeared category gets a root redirect; unrelated redirects are kept; - // present categories are never redirected. expect(index.data.redirect_from).toContain('/graphql/reference/code-scanning') expect(index.data.redirect_from).toContain('/v4/reference') expect(index.data.redirect_from).toContain('/graphql/reference/queries') @@ -133,8 +126,7 @@ describe('syncCategoryContentFiles', () => { }) test('recreates a reappearing category and removes its stale redirect', async () => { - // Seed the post-deletion state: code-scanning has no page and carries a - // disappearance redirect on the index. + // Seed the post-deletion state: code-scanning has no page but redirects to the index. await writeIndex( root, ['/actions', '/sponsors', '/other'], @@ -150,7 +142,6 @@ describe('syncCategoryContentFiles', () => { const index = await readIndex(root) expect(index.data.children).toContain('/code-scanning') expect(index.data.redirect_from).not.toContain('/graphql/reference/code-scanning') - // Unrelated redirects survive the reconciliation. expect(index.data.redirect_from).toContain('/v4/reference') }) @@ -161,10 +152,8 @@ describe('syncCategoryContentFiles', () => { await writeCategoryFile(root, 'code-scanning', { fpt: '*', ghec: '*' }) await writeCategoryFile(root, 'other', { fpt: '*', ghec: '*' }) - // First run establishes the canonical steady state for this presence. await syncCategoryContentFiles(steadyPresence(), { contentDir }) const before = await snapshotReferenceDir(root) - // Second run with identical input must be a no-op. await syncCategoryContentFiles(steadyPresence(), { contentDir }) const after = await snapshotReferenceDir(root) diff --git a/src/graphql/tests/validate-schema.ts b/src/graphql/tests/validate-schema.ts index 55ce99991162..19ee80a23e54 100644 --- a/src/graphql/tests/validate-schema.ts +++ b/src/graphql/tests/validate-schema.ts @@ -20,13 +20,11 @@ const upcomingChangesValidate = getJsonValidator(upcomingChangesValidator) describe('graphql json files', () => { vi.setConfig({ testTimeout: 3 * 60 * 1000 }) - // The typeObj is repeated thousands of times across the per-category files - // so cache validated objects to speed this test up significantly. + // typeObj repeats thousands of times across category files. + // Cache validated objects to keep this test fast. const typeObjsTested = new Set<string>() for (const version of graphqlVersions) { - // Merge every per-category schema-*.json into one in-memory shape - // mirroring the legacy monolithic schema.json so the rest of the test - // logic stays unchanged. + // Merge category schema files into the monolithic shape that schemaValidator expects. const schemaJsonPerVersion: Record<string, Array<{ name: string }>> = {} for (const type of graphqlTypes) schemaJsonPerVersion[type] = [] for (const category of CATEGORIES) { @@ -83,7 +81,6 @@ describe('graphql json files', () => { `${GRAPHQL_DATA_DIR}/${version}/upcoming-changes.json`, ) as Record<string, unknown[]> for (const changes of Object.values(upcomingChanges)) { - // each object value is an array of changes for (const changeObj of changes) { const isValid = upcomingChangesValidate(changeObj) let errors: string | undefined diff --git a/src/journeys/components/JourneyTrackNav.tsx b/src/journeys/components/JourneyTrackNav.tsx index 0a8923691aa8..f940fbc01b72 100644 --- a/src/journeys/components/JourneyTrackNav.tsx +++ b/src/journeys/components/JourneyTrackNav.tsx @@ -16,8 +16,7 @@ export function JourneyTrackNav({ context }: Props) { const upNext = nextGuide ?? nextTrackFirstGuide if (!upNext) return null - // In-track, show the next article's title. - // Crossing tracks, show the track name so the reader knows they're moving on. + // Crossing tracks uses the track name so readers know they are moving to another track. const label = nextGuide ? nextGuide.title : nextTrackFirstGuide!.trackTitle const progress = t('up_next_progress') diff --git a/src/journeys/lib/journey-path-resolver.ts b/src/journeys/lib/journey-path-resolver.ts index 566199827fbd..ecb1640357a3 100644 --- a/src/journeys/lib/journey-path-resolver.ts +++ b/src/journeys/lib/journey-path-resolver.ts @@ -59,9 +59,8 @@ type JourneyPage = { }> } -// All computed once, on first use. -// Guide hrefs containing Liquid can't be resolved ahead of time, -// so they're absent from cachedGuidePaths and set hasDynamicGuides instead. +// Static guide paths cache on first use. +// Rendered hrefs stay out of cachedGuidePaths and set hasDynamicGuides. let cachedJourneyPages: JourneyPage[] | null = null let cachedGuidePaths: Set<string> | null = null let hasDynamicGuides = false @@ -134,9 +133,7 @@ async function fetchGuideData( return null } -/** - * Returns null if the article isn't a guide in any journey track. - */ +// Returns null when no journey track applies to the article and current version. export async function resolveJourneyContext( articlePath: string, pages: Record<string, Page>, @@ -159,8 +156,7 @@ export async function resolveJourneyContext( for (const journeyPage of journeyPages) { if (!journeyPage.journeyTracks) continue - // Track articles inherit the landing page's versions, - // so a journey that doesn't apply to the current version has no navigation to show. + // Track articles inherit landing page versions, so unmatched versions show no navigation. if (journeyPage.versions) { const journeyVersions = getApplicableVersions(journeyPage.versions) if (!journeyVersions.includes(context.currentVersion || '')) { @@ -187,8 +183,7 @@ export async function resolveJourneyContext( () => guidePath, ) } catch { - // executeWithFallback rethrows errors it can't fall back from, - // such as any error in English. + // executeWithFallback rethrows non-fallbackable errors and all English content errors. renderedGuidePath = guidePath } } @@ -205,7 +200,7 @@ export async function resolveJourneyContext( const alternativeNextStep = track.guides[guideIndex].alternativeNextStep || '' let renderedAlternativeNextStep = alternativeNextStep - // Rendered with links intact, unlike the hrefs above which use textOnly. + // Render this with links intact, unlike the hrefs above that use textOnly. if (needsRendering(alternativeNextStep)) { try { renderedAlternativeNextStep = await executeWithFallback( @@ -218,8 +213,7 @@ export async function resolveJourneyContext( } } - // fetchGuideData returns null for guides missing in the current version. - // Dropping them keeps the counts and prev/next links correct. + // Drop guides that fail lookup so counts and prev/next links use resolvable guides. const availableGuides = ( await Promise.all( track.guides.map(async (guide, i) => { @@ -285,9 +279,7 @@ export async function resolveJourneyContext( return result } -/** - * Reads journey tracks from frontmatter, rendering any Liquid they contain. - */ +// Journey track frontmatter may contain Liquid, so render it before components use it. export async function resolveJourneyTracks( journeyTracks: JourneyPage['journeyTracks'], context: Context, diff --git a/src/journeys/middleware/journey-track.ts b/src/journeys/middleware/journey-track.ts index dd06002faa59..5e63d0f5ab14 100644 --- a/src/journeys/middleware/journey-track.ts +++ b/src/journeys/middleware/journey-track.ts @@ -38,12 +38,11 @@ export default async function journeyTrack( if (page.journeyTracks) { const resolvedTracks = await resolveJourneyTracks(page.journeyTracks, req.context) - // Read later by getServerSideProps. + // getServerSideProps reads resolvedJourneyTracks from the page object. page.resolvedJourneyTracks = resolvedTracks } - // Unconditional, because guide articles need this - // even though they carry no journeyTracks of their own. + // Resolve every article because guide pages do not carry their own journeyTracks. const journeyContext = await resolveJourneyContext( req.pagePath || '', req.context.pages || {}, diff --git a/src/journeys/tests/journey-path-resolver.ts b/src/journeys/tests/journey-path-resolver.ts index e037a54309b0..e7afb804e4ad 100644 --- a/src/journeys/tests/journey-path-resolver.ts +++ b/src/journeys/tests/journey-path-resolver.ts @@ -4,8 +4,7 @@ import { resolveJourneyContext, resolveJourneyTracks } from '../lib/journey-path import getLinkData from '@/journeys/lib/get-link-data' import type { Page } from '@/types' -// Mock modules since we just want to test journey functions, not their dependencies or -// against real content files +// Mock dependencies so journey functions run without real content files. vi.mock('@/journeys/lib/get-link-data', () => ({ default: vi.fn(async (rawLinks: string | string[] | undefined) => { const path = Array.isArray(rawLinks) ? rawLinks[0] : rawLinks @@ -191,7 +190,6 @@ describe('journey-path-resolver', () => { mockContext, ) - // This should find the same track as the version with leading slash expect(result?.trackId).toBe('getting_started') expect(result?.currentGuideIndex).toBe(1) }) @@ -235,7 +233,7 @@ describe('journey-path-resolver', () => { test('renders liquid templates in titles and descriptions', async () => { const result = await resolveJourneyTracks(mockJourneyTracks, mockContext) - // Should return the content as-is since our mock renderContent is a passthrough + // The mock renderContent returns input unchanged. expect(result[0].title).toBe( 'Getting started with {% data variables.product.company_short %}', ) @@ -252,15 +250,14 @@ describe('journey-path-resolver', () => { expect(result[0].timeCommitment).toBe('{% data variables.product.company_short %} 2-4 hours') expect(result[1].timeCommitment).toBe('4-6 hours') - // The Liquid-bearing timeCommitment should be rendered with { textOnly: true }, - // matching how title/description are rendered. + // Liquid timeCommitment renders with textOnly, matching titles and descriptions. const timeCommitmentCall = mockRenderContent.mock.calls.find( ([content]) => content === '{% data variables.product.company_short %} 2-4 hours', ) expect(timeCommitmentCall).toBeDefined() expect(timeCommitmentCall?.[2]).toEqual({ textOnly: true }) - // Plain (non-Liquid) timeCommitment should not be sent through renderContent + // Plain timeCommitment skips renderContent. const plainCall = mockRenderContent.mock.calls.find(([content]) => content === '4-6 hours') expect(plainCall).toBeUndefined() }) diff --git a/src/landings/components/ArticleList.module.scss b/src/landings/components/ArticleList.module.scss index 4bd4497feaea..11f743e8ba81 100644 --- a/src/landings/components/ArticleList.module.scss +++ b/src/landings/components/ArticleList.module.scss @@ -12,7 +12,6 @@ } } -// Muted secondary copy (article intro, published date) under each link title. .textMuted { color: var(--brand-color-text-muted); } diff --git a/src/landings/components/CategoryLanding.tsx b/src/landings/components/CategoryLanding.tsx index 995261e8edd7..7b4909bee7cc 100644 --- a/src/landings/components/CategoryLanding.tsx +++ b/src/landings/components/CategoryLanding.tsx @@ -17,12 +17,11 @@ export const CategoryLanding = () => { const router = useRouter() const { title, intro, tocItems, spotlight, filters } = useCategoryLandingContext() - // The category filter is always shown. Surface and complexity are opt-in via - // the `filters` frontmatter array on the landing page. + // Always show the category filter; filters frontmatter controls surface and complexity. const showSurface = filters ? filters.includes('surface') : true const showComplexity = filters ? filters.includes('complexity') : false - // tocItems contains directories and its children, we only want the child articles + // Category landing cards use only child articles, not directory nodes. const onlyFlatItems: ArticleCardItems = tocItems.flatMap((item) => item.childTocItems || []) const [searchQuery, setSearchQuery] = useState('') @@ -112,8 +111,7 @@ export const CategoryLanding = () => { <DefaultLayout> <UtmPreserver /> {router.route === '/[versionId]/rest/[category]' && <RestRedirect />} - {/* Position does not matter, because it will - never render anything. It always just return null. */} + {/* ClientSideRedirects renders null, so placement does not affect layout. */} <ClientSideRedirects /> <div className="container-xl px-3 px-md-6 my-4" data-search="article-body"> diff --git a/src/landings/components/CookBookArticleCard.module.scss b/src/landings/components/CookBookArticleCard.module.scss index e1648569a6e3..f4c019e0d5f4 100644 --- a/src/landings/components/CookBookArticleCard.module.scss +++ b/src/landings/components/CookBookArticleCard.module.scss @@ -26,17 +26,13 @@ gap: 0.25rem; } -// Accent colour shared by the card's leading octicon and its title link, so the -// icon matches the title. Sits directly on the Primer React `Link` rather than -// the wrapping `<h3>`: `Link` paints its own `color`, so it does not inherit. +// The accent color belongs on the Primer React Link because h3 cannot override Link color. .linkAccent { color: var(--brand-color-text-link-rest); } -// The leading octicon's tinted circle. Replaces primer/css's -// `bgColor-accent-muted`, which resolves to Primer's blue wash (#388bfd1a in -// dark) behind a brand-blue icon. Brand ships no accent-muted surface token, so -// tint brand's own link blue — the same token the icon itself is painted with. +// bgColor-accent-muted resolves to Primer's blue wash, #388bfd1a in dark mode, +// behind a brand-blue icon. Tint with brand link blue to match the icon. .iconBackdrop { background-color: color-mix( in srgb, @@ -45,7 +41,6 @@ ); } -// The card's description copy. .textMuted { color: var(--brand-color-text-muted); } diff --git a/src/landings/components/HomePageHero.module.scss b/src/landings/components/HomePageHero.module.scss index 7c558975a89d..3e57f1577931 100644 --- a/src/landings/components/HomePageHero.module.scss +++ b/src/landings/components/HomePageHero.module.scss @@ -1,5 +1,5 @@ -// Docs 2026 homepage hero: a left-aligned title block stacked above a muted -// search band, both inside bordered rails. No background image — the mountains +// The homepage hero stacks a left-aligned title block above a muted search band, +// both inside bordered rails. No background image; the mountains // graphic is a separate, out-of-scope band. .hero { border-bottom: var(--brand-borderWidth-thin, 1px) solid @@ -26,9 +26,8 @@ margin: 1rem 0 0; } -// Search entry that mimics an input but opens the shared SearchOverlay on -// activation. A full-width muted band with the placeholder on the left and a -// trailing "/" key hint, separated from the title by a top border. +// The search entry mimics an input but opens the shared SearchOverlay on activation. +// The slash key hint sits in a muted band separated from the title by a top border. .searchRow { display: flex; align-items: center; diff --git a/src/landings/components/ProductSelectionCard.module.scss b/src/landings/components/ProductSelectionCard.module.scss index 5a621578ae8a..422d1f6f5126 100644 --- a/src/landings/components/ProductSelectionCard.module.scss +++ b/src/landings/components/ProductSelectionCard.module.scss @@ -1,10 +1,7 @@ // A single "All Docs" grid cell: category heading at the top, product links -// top-aligned directly beneath it. Cells in a row still stretch to the tallest -// cell's height via the grid. Internal dividers are the cell's left -// border (skipped on the first column of each row, per breakpoint, so they don't -// double the container rail) plus a bottom border for row dividers. The column -// count steps 1 -> 2 -> 3 -> 4, so each breakpoint re-applies the left border to -// every cell and then clears it on the new first-of-row. +// top-aligned directly beneath it. Internal dividers come from the cell's left +// border, skipped on the first column of each row, plus bottom row dividers. +// Each breakpoint re-applies the left border and clears its first column. .cell { display: flex; flex-direction: column; @@ -14,11 +11,9 @@ border-bottom: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-muted); - // Mobile: single column — no internal vertical dividers. border-left: 0; @media (min-width: 34rem) { - // 2 columns. border-left: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-muted); @@ -28,7 +23,6 @@ } @media (min-width: 63.25rem) { - // 3 columns. &:nth-child(n) { border-left: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-muted); @@ -40,7 +34,6 @@ } @media (min-width: 80rem) { - // 4 columns. &:nth-child(n) { border-left: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-muted); diff --git a/src/landings/components/ProductSelectionCard.tsx b/src/landings/components/ProductSelectionCard.tsx index b052b97e68e4..9e0edeb2b35b 100644 --- a/src/landings/components/ProductSelectionCard.tsx +++ b/src/landings/components/ProductSelectionCard.tsx @@ -10,7 +10,7 @@ type ProductSelectionCardProps = { } export const ProductSelectionCard = ({ group }: ProductSelectionCardProps) => { - // Don't display the group if it has no children due to versioning + // Versioning can remove every child, so hide empty groups. if (!group.children || group.children.length === 0) { return null } diff --git a/src/landings/components/ProductSelections.module.scss b/src/landings/components/ProductSelections.module.scss index 4ec64165ad96..6a30d4a823d4 100644 --- a/src/landings/components/ProductSelections.module.scss +++ b/src/landings/components/ProductSelections.module.scss @@ -1,8 +1,6 @@ -// "All Docs" section — bordered gridline layout mirroring the Docs 2026 design. +// The All Docs section uses a bordered gridline layout. // The container supplies the outer left/right rails; internal dividers are drawn -// by each cell (left border, skipping the first column) and each row (bottom -// border). There is deliberately no divider directly under the "All Docs" -// heading. +// by each cell and row. There is no divider directly under the All Docs heading. .section { max-width: 82rem; margin: 0 auto; diff --git a/src/landings/components/SidebarProduct.module.scss b/src/landings/components/SidebarProduct.module.scss index b4d073687e20..842f5e4184c9 100644 --- a/src/landings/components/SidebarProduct.module.scss +++ b/src/landings/components/SidebarProduct.module.scss @@ -1,15 +1,10 @@ -// The tree's typography now comes from @primer/react-brand NavList's own scale -// (level-1 section headers at size-200, leaves at size-100). This class is kept -// as the wrapper hook for `data-testid="sidebar"`. +// Brand NavList owns the tree typography: level-1 section headers use size-200, +// and leaves use size-100. This class stays as the data-testid sidebar hook. .sidebar { - // Brand's NavList draws the active accent bar only on nested leaves — its rule - // explicitly excludes level-1 (`.item--leaf:not(.item--level-1) .link[aria-current]`), - // so a top-level article like "Quickstart" gets only the subtle background pill - // and no green bar. Restore the bar for active top-level leaves to match nested - // items. Brand's classes are hashed, so match on the stable substrings. - // - // This also covers `[data-pending]` — the optimistic marker on a just-clicked item - // (see the pending rules below) — so a clicked top-level leaf gets the bar too. + // Brand NavList excludes level-1 leaves from its active accent bar rule, so + // top-level articles such as Quickstart get only the subtle background pill. + // Match stable hashed-class substrings and restore the bar for aria-current. + // Cover data-pending too, so clicked top-level leaves get the same visual bar. :global([class*="NavList__item--leaf"][class*="NavList__item--level-1"]) :global([class*="NavList__link"])[aria-current]:not( [aria-current="false"] @@ -18,34 +13,26 @@ :global([class*="NavList__link"])[data-pending]::before { content: ""; position: absolute; - // Level-1 links are shorter (~24px) than nested leaves, so use a small inset - // to keep the bar roughly the height of the background pill rather than the - // larger inset brand applies to taller nested items. + // Level-1 links are about 24px tall, so a small inset matches the background pill height. inset-block: var(--base-size-2); // Level-1 leaves have no inline padding, so pull the bar into the NavList's - // own inline padding gutter to sit left of the label — matching where brand - // places the level-1 indicator on expandable section toggles. + // inline padding gutter where Brand places level-1 expandable indicators. inset-inline-start: calc(-1 * var(--base-size-8)); width: var(--base-size-4); border-radius: var(--base-size-2); background-color: var(--brand-NavList-activeIndicator-color); } - // Optimistic "pending" highlight. Article pages are getServerSideProps routes and can - // be slow to load, so router.asPath — and thus aria-current — only updates once the - // page has loaded. We keep aria-current on the *loaded* page (assistive tech must not - // be told a still-loading destination is the current page), and instead mark the - // just-clicked link with `data-pending` for a VISUAL-ONLY accent. Brand couples its - // active styling to `[aria-current]`, so mirror that treatment here for `[data-pending]`. - // data-pending is only set while a *different* page is loading, so it never collides - // with the real aria-current item. + // Article pages can load slowly through getServerSideProps, so aria-current stays + // on the loaded page and data-pending carries a visual-only accent for the clicked + // destination. data-pending is only set for a different loading page, so it never + // collides with the real aria-current item. :global([class*="NavList__link"])[data-pending] { color: var(--brand-color-text-default); background-color: var(--brand-color-canvas-subtle); } - // Nested leaves: brand moves the background pill onto the label and adds a leading - // accent bar (mirrors `.item--leaf:not(.item--level-1) .link[aria-current]`). + // Nested leaves need the same background pill and leading bar as Brand's active rule. :global([class*="NavList__item--leaf"]:not([class*="NavList__item--level-1"])) :global([class*="NavList__link"])[data-pending] { background-color: transparent; diff --git a/src/landings/components/SidebarProduct.tsx b/src/landings/components/SidebarProduct.tsx index 5e66cf0f056d..030a03070d31 100644 --- a/src/landings/components/SidebarProduct.tsx +++ b/src/landings/components/SidebarProduct.tsx @@ -22,10 +22,8 @@ import { flattenDescendants, MAX_NAVLIST_LEVEL } from './sidebar-navlist-depth' import styles from './SidebarProduct.module.scss' -// The nearest ancestor that actually scrolls vertically. Brand's NavList.SubNav -// wrappers use `overflow-y: hidden`, so match only auto/scroll to skip past them -// and land on the sidebar's own overflow container. Returns null when the rail is -// hidden (below the xxl breakpoint it is `display: none`, so nothing scrolls). +// Brand NavList.SubNav wrappers use overflow-y hidden, so match only auto or scroll +// to find the sidebar's own overflow container. Hidden rails return null. function findScrollableAncestor(element: Element): HTMLElement | null { let node = element.parentElement while (node) { @@ -40,12 +38,10 @@ function findScrollableAncestor(element: Element): HTMLElement | null { type Router = ReturnType<typeof useRouter> -// Brand NavList.Item renders a plain <a> (its `as` prop only accepts 'a' | 'button', -// not next/link), so intercept clicks to restore next/link-style client-side -// navigation. Modifier/middle clicks fall through to the browser so open-in-new-tab -// still works, and the <a href> keeps links crawlable for SSR. Mirrors Breadcrumbs.tsx. -// Returns true when it performed a client-side navigation (so the caller can move the -// optimistic selection), false when the click was left to the browser. +// Brand NavList.Item renders a plain anchor, not next/link, so intercept plain +// left-clicks to restore client-side navigation. Modified clicks fall through for +// separate tabs, the href keeps links crawlable for server-side rendering, and true +// tells the caller to move the optimistic selection. Mirrors Breadcrumbs.tsx. function handleNavClick(router: Router, event: MouseEvent<HTMLElement>, href: string): boolean { if ( event.defaultPrevented || @@ -59,24 +55,18 @@ function handleNavClick(router: Router, event: MouseEvent<HTMLElement>, href: st return false } event.preventDefault() - // hrefs already include the locale prefix (e.g. /en/...), so disable Next.js - // locale handling to avoid double-prefixing. + // Locale-prefixed hrefs need locale false so Next.js does not add the locale twice. router.push(href, undefined, { locale: false }) return true } -// The sidebar renders the full product tree (hundreds of nodes) and fully remounts -// on every navigation (key={asPath} in SidebarNav). To keep per-item cost down we -// subscribe to the router ONCE here and hand items a stable routePath plus stable -// navigate/prefetch callbacks, instead of every item calling useRouter itself. +// The sidebar renders hundreds of nodes and remounts on every navigation. Subscribe +// once here so each item gets stable routePath, navigate, and prefetch values instead +// of calling useRouter itself. type SidebarNavValue = { - // The real loaded route. Drives aria-current (the semantic "current page") and the - // auto-expanded active ancestor chain. Both must reflect the page actually loaded. + // The loaded route drives aria-current and the auto-expanded active ancestor chain. routePath: string - // The in-flight click target, or null. Drives a VISUAL-ONLY optimistic accent bar - // (via data-pending) so the click feels acknowledged before the slow - // getServerSideProps page loads, without lying to assistive tech about the current - // page. Once navigation completes, the keyed remount clears it and routePath catches up. + // The in-flight click target drives a visual-only data-pending accent during slow loads. pendingHref: string | null navigate: (event: MouseEvent<HTMLElement>, href: string) => void prefetch: (href: string) => void @@ -91,10 +81,8 @@ function useSidebarNav(): SidebarNavValue { return value } -// Props for a leaf link's <a>: aria-current tracks the loaded page (semantics), while -// data-pending marks the in-flight click so CSS can move the accent bar optimistically -// without changing what screen readers announce as current. data-pending is only set -// while a *different* page is loading, so it never double-marks the already-current item. +// Leaf links keep aria-current on the loaded page and data-pending on a different +// in-flight destination, so screen readers do not hear a loading page as current. function leafLinkProps(nav: SidebarNavValue, href: string) { return { 'aria-current': (nav.routePath === href ? 'page' : false) as 'page' | false, @@ -102,9 +90,8 @@ function leafLinkProps(nav: SidebarNavValue, href: string) { } } -// Separate context for the REST-only scroll-spy state (full asPath with query+hash, -// and query). Kept out of SidebarNavValue so its per-navigation identity churn -// doesn't invalidate the memoized common items — only RestNavListItem consumes it. +// Keep REST-only scroll-spy state out of SidebarNavValue so its per-navigation +// identity churn invalidates only RestNavListItem. type RestNavValue = { asPath: string query: ReturnType<typeof useRouter>['query'] @@ -119,7 +106,6 @@ function useRestNav(): RestNavValue { return value } -// Hover/focus handlers for a leaf link: warm the destination so the click is fast. function prefetchHandlers(prefetch: (href: string) => void, href: string) { return { onMouseEnter: () => prefetch(href), @@ -127,12 +113,13 @@ function prefetchHandlers(prefetch: (href: string) => void, href: string) { } } +// pendingHref survives slow getServerSideProps navigations because SidebarNav remounts +// only after asPath changes. aria-current stays on the loaded route. export const SidebarProduct = () => { const router = useRouter() const { currentProduct, - // For the sidebar we only need the short titles so we can use the - // more "compressed" tree that is as light as possible. + // The sidebar only needs short titles, so MainContext supplies the compressed tree. sidebarTree, sidebarExpanded, } = useMainContext() @@ -141,20 +128,14 @@ export const SidebarProduct = () => { const { asPath, locale, query } = router const routePath = `/${locale}${asPath.split('?')[0].split('#')[0]}` - // Optimistic selection: the href of an in-flight click. Used to move the accent bar - // visually (data-pending) the instant a link is clicked, even while the destination - // page is still loading. This SidebarProduct instance persists during the pending - // fetch (SidebarNav keys it on asPath, which only changes once navigation completes), - // so the state survives the wait and is discarded by the keyed remount when the new - // route lands. aria-current is NOT derived from this: it stays on the loaded route. + // pendingHref moves only the visual accent while aria-current stays on the loaded route. const [pendingHref, setPendingHref] = useState<string | null>(null) const prefetchHref = usePrefetchOnInteraction() - // Stable callbacks so memoized items don't re-render on unrelated changes. + // Stable callbacks keep memoized items from re-rendering on unrelated changes. const navigate = useCallback( (event: MouseEvent<HTMLElement>, href: string) => { - // Only move the optimistic highlight on a real client-side nav, not on a - // modifier/middle click that opens a new tab (the current page stays put). + // Move the optimistic highlight only for client-side navigation, not modified clicks. if (handleNavClick(router, event, href)) setPendingHref(href) }, [router], @@ -168,10 +149,7 @@ export const SidebarProduct = () => { const rootRef = useRef<HTMLDivElement>(null) useEffect(() => { - // Clear the optimistic highlight if a navigation genuinely fails, so it doesn't - // stick on a page that never loaded. Skip cancellations (err.cancelled): those - // fire when a second click supersedes the first, and pendingHref already points at - // that newer target, which we want to keep highlighted. + // Failed navigations clear pendingHref; cancellations keep the newer click highlighted. const clearPending = (err: { cancelled?: boolean }) => { if (!err?.cancelled) setPendingHref(null) } @@ -180,27 +158,19 @@ export const SidebarProduct = () => { }, [router.events]) useEffect(() => { - // Skip all sidebar scroll adjustments when the URL carries landing-page - // article filters (search/category/page). Those are shallow same-page - // updates that must not move the reader (the article grid manages its own - // scroll position). + // Article filter query params are shallow same-page updates; the grid manages their scroll. if (/[?&]articles-(filter|category|page)=/.test(router.asPath)) return - // Brand NavList auto-expands the whole ancestor chain of the active item, so - // scroll to the item marked aria-current="page" (the active article) rather - // than the top-most expanded section. + // Brand expands every active ancestor, so scroll to the aria-current page item. const activeArticle = rootRef.current?.querySelector('[aria-current="page"]') if (!activeArticle) return - // Scroll the sidebar's own overflow container by hand. `scrollIntoView` would - // scroll every scrollable ancestor, including the document, which cancels the - // browser's scroll to a #anchor on load and leaves the reader at the top of - // the article. See BreadcrumbsScroller for the same approach. + // Scroll by hand to preserve hash anchors; BreadcrumbsScroller does the same. const container = findScrollableAncestor(activeArticle) if (!container) return const containerRect = container.getBoundingClientRect() const activeRect = activeArticle.getBoundingClientRect() - // Setting to the top doesn't give enough context of surrounding categories + // Centering shows surrounding categories. const delta = activeRect.top - containerRect.top - (container.clientHeight - activeRect.height) / 2 container.scrollBy({ top: delta, behavior: 'instant' }) @@ -263,9 +233,8 @@ export const SidebarProduct = () => { ) } -// Wraps a brand NavList expandable item (renders as a <button> toggle) with -// controlled, cookie-persisted expand state. Encapsulating the hook here -// keeps it out of the conditional leaf/branch logic in the callers. +// Wrap the Brand NavList button toggle with controlled, cookie-persisted expand +// state so callers keep hooks out of conditional leaf and branch logic. function ExpandableItem({ title, nodeKey, @@ -288,25 +257,17 @@ function ExpandableItem({ ) } -// Brand NavList picks its starting nesting level by *statically* introspecting its -// direct children for a NavList.SubNav (see the `m = d ? 1 : 2` check in the brand -// esm source). Our items are custom wrapper components, so brand can't see their -// SubNavs, treats the list as flat, and starts numbering at level 2 — wasting one of -// its 5 available levels. Docs content nests 5 levels deep, so that lost level pushes -// the deepest articles over brand's cap and the depth guard flattens them (#6757). -// Brand exposes no `startLevel` prop, so this hidden sentinel gives brand a real -// top-level SubNav to detect, making it number from level 1 and freeing the level the -// deep content needs. +// Brand NavList statically inspects direct children for NavList.SubNav in its ESM source. +// Custom wrapper components hide their SubNavs, so Brand treats the list as flat and +// starts at level 2. Docs content nests 5 levels deep, and the lost level pushes +// deepest articles over Brand's cap. This hidden +// sentinel gives Brand a real top-level SubNav to detect, so it numbers from level 1. // -// The detector accepts a NavList.SubNav nested in ANY direct child's props.children, -// not only a NavList.Item — so we use a plain <li> we fully control rather than a -// NavList.Item. Brand's NavList.Item forwards style/aria-hidden to its inner <button>, -// NOT the outer <li>, so a NavList.Item sentinel would leave a visible, focusable 40px -// container (and trip the sibling-separator styles) at the top of every sidebar. A -// hidden native <li> keeps the whole sentinel, container included, out of layout and -// the a11y tree. It MUST be spread inline (returned by this factory), not rendered as a -// <Component/>: brand's detector never renders function components, so a wrapper would -// stay invisible to it. +// The detector accepts NavList.SubNav in any direct child's props.children, not only +// NavList.Item. Use a controlled native li because NavList.Item forwards style and +// aria-hidden to its inner button, which leaves a visible, focusable 40px outer li +// and trips sibling separators. Return this inline because Brand's detector never +// renders function components. function navListLevelSentinel() { return ( <li aria-hidden="true" style={{ display: 'none' }}> @@ -347,18 +308,15 @@ const NavListItem = memo(function NavListItem({ const hasChildren = childPage.childPages.length > 0 const specialCategory = childPage.layout === 'category-landing' const canNest = level < MAX_NAVLIST_LEVEL - // sidebarLink.href lacks the locale prefix; normalize once so the rendered - // href, aria-current check, and click navigation all agree. + // sidebarLink.href lacks a locale prefix; add it so href, aria-current, and navigation agree. const sidebarLinkHref = childPage.sidebarLink ? `/${locale}${childPage.sidebarLink.href}` : '' - // Leaf: a real anchor with client-side navigation. Brand draws the active - // accent bar off aria-current="page". + // Leaf nodes use anchors so Brand draws the active bar from aria-current page. if (!hasChildren) { return <LeafLink node={childPage} /> } - // At the nesting cap: render this node and its whole subtree as flat leaf links - // so nothing becomes unreachable (brand would otherwise drop a level-5 SubNav). + // At Brand's nesting cap, flatten descendants so level-5 SubNav content stays reachable. if (!canNest) { return ( <> @@ -370,9 +328,7 @@ const NavListItem = memo(function NavListItem({ ) } - // Expandable: brand renders this as a <button> accordion toggle (href/as are - // not allowed here). The category's own landing page is only surfaced when the - // content explicitly opts in via `sidebarLink` or a category-landing layout. + // Expandable categories use button toggles; sidebarLink or category-landing adds a landing page. return ( <ExpandableItem title={childPage.title} @@ -414,13 +370,11 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) { const { routePath, navigate, prefetch } = nav const { asPath, query } = useRestNav() const [visibleAnchor, setVisibleAnchor] = useState('') - // Read the automated-page context unconditionally so hook order is stable across route - // changes. It is null on conceptual REST pages (no provider), which is fine, since those - // pages use `[]` anyway. + // Read automated-page context unconditionally so hook order stays stable across routes. const automatedPage = useAutomatedPageContextOptional() const miniTocItems = query.productId === 'rest' || - // These pages need the Article Page mini tocs instead of the Rest Pages + // Conceptual REST pages skip the REST in-page mini table of contents. nonAutomatedRestPaths.some((item: string) => asPath.includes(item)) ? [] : (automatedPage?.miniTocItems ?? []) @@ -454,7 +408,6 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) { } }, [miniTocItems]) - // A reference category with no children is a plain link. if (category.childPages.length === 0) { return ( <NavList.Item @@ -479,8 +432,7 @@ function RestNavListItem({ category }: { category: ProductTreeNode }) { {category.childPages.map((childPage) => { const showMiniToc = routePath === childPage.href && miniTocItems.length > 0 - // Active reference article: render as a toggle whose sub-nav is the - // in-page table of contents (you're already on the page). + // Active reference articles render as toggles whose sub-nav is the in-page TOC. if (showMiniToc) { return ( <NavList.Item key={childPage.href} defaultExpanded> diff --git a/src/landings/components/TableOfContents.module.scss b/src/landings/components/TableOfContents.module.scss index 8c551de77d1a..ba2ec5cffe9b 100644 --- a/src/landings/components/TableOfContents.module.scss +++ b/src/landings/components/TableOfContents.module.scss @@ -1,10 +1,8 @@ -// The section title inside the expanded table of contents. It is the sole child -// of an <a>, so this paints link text. +// The expanded table of contents title is the sole link child, so this paints link text. .linkAccent { color: var(--brand-color-text-link-rest); } -// The intro copy rendered beneath each expanded section title. .textMuted { color: var(--brand-color-text-muted); } diff --git a/src/landings/components/TocLanding.tsx b/src/landings/components/TocLanding.tsx index 368de82d9a38..6ec8b4ceabef 100644 --- a/src/landings/components/TocLanding.tsx +++ b/src/landings/components/TocLanding.tsx @@ -33,8 +33,7 @@ export const TocLanding = () => { <DefaultLayout> <UtmPreserver /> {router.route === '/[versionId]/rest/[category]' && <RestRedirect />} - {/* Position does not matter, because it will - never render anything. It always just return null. */} + {/* ClientSideRedirects renders null, so placement does not affect layout. */} <ClientSideRedirects /> <div className="container-xl px-3 px-md-6 my-4"> diff --git a/src/landings/components/bespoke/BespokeLanding.tsx b/src/landings/components/bespoke/BespokeLanding.tsx index ba23ab1d57bc..5b0f3c3bca2e 100644 --- a/src/landings/components/bespoke/BespokeLanding.tsx +++ b/src/landings/components/bespoke/BespokeLanding.tsx @@ -29,7 +29,6 @@ export const BespokeLanding = () => { <div data-search="article-body"> <LandingHero title={title} intro={intro} heroImage={heroImage} introLinks={introLinks} /> - {/* Render carousels */} {carousels && Object.entries(carousels).map(([carouselKey, articles]) => ( <LandingSection key={carouselKey}> diff --git a/src/landings/components/discovery/DiscoveryLanding.tsx b/src/landings/components/discovery/DiscoveryLanding.tsx index 4259f585bf10..e0e8c06a6851 100644 --- a/src/landings/components/discovery/DiscoveryLanding.tsx +++ b/src/landings/components/discovery/DiscoveryLanding.tsx @@ -33,7 +33,6 @@ export const DiscoveryLanding = () => { {router.query.productId === 'rest' && <RestRedirect />} <div data-search="article-body"> <LandingHero title={title} intro={intro} heroImage={heroImage} introLinks={introLinks} /> - {/* Render carousels */} {carousels && Object.entries(carousels).map(([carouselKey, articles]) => ( <LandingSection key={carouselKey}> diff --git a/src/landings/components/journey/JourneyLearningTracks.module.scss b/src/landings/components/journey/JourneyLearningTracks.module.scss index 411b6daeef70..ad49b12adad4 100644 --- a/src/landings/components/journey/JourneyLearningTracks.module.scss +++ b/src/landings/components/journey/JourneyLearningTracks.module.scss @@ -1,4 +1,4 @@ -/* Multi-track journey: a vertical rail of numbered badges with a disclosure card per track. */ +// Multi-track journeys use numbered badges connected by a vertical rail. .tracks { display: flex; flex-direction: column; @@ -7,8 +7,7 @@ padding: 0; } -/* Each track: a numbered badge sitting above the card, with a connector line - running through the gaps between cards (badge x is 12px inside the card edge). */ +// Each badge sits 12px inside the card edge, so connectors run through card gaps. .trackItem { display: flex; flex-direction: column; @@ -30,7 +29,7 @@ line-height: 1.5; } -/* Connector segments are centered under the badge (badge is 24px wide → center at 12px). */ +// A 24px badge centers at 12px, so an 11px margin centers the 1px connector. .connectorAbove, .connectorBelow { width: 1px; @@ -57,7 +56,7 @@ box-shadow 0.2s ease-in-out, transform 0.2s ease-in-out; - // hover lift only while collapsed + // Hover lift applies only while collapsed. &:not([open]):hover { box-shadow: 0 0.25rem 0.5rem 0 rgba(31, 35, 40, 0.12), @@ -147,8 +146,7 @@ padding: 6px 0; } -/* Single-track pages render the list without a surrounding card, so it must sit - flush with the heading rather than inside the card's 24px inset. */ +// Single-track pages have no surrounding card, so the list sits flush with the heading. .trackGuidesFlush { padding: 0; } @@ -180,7 +178,7 @@ margin: 0 0 1rem 0; } -/* Stack the title and its "N articles" pill on narrow viewports. */ +// Narrow viewports stack the title and article count pill. @media (max-width: 767px) { .trackTitleGroup { flex-direction: column; diff --git a/src/landings/components/journey/JourneyLearningTracks.tsx b/src/landings/components/journey/JourneyLearningTracks.tsx index 7941f63970a5..fe3ba98fa4ca 100644 --- a/src/landings/components/journey/JourneyLearningTracks.tsx +++ b/src/landings/components/journey/JourneyLearningTracks.tsx @@ -10,11 +10,10 @@ type JourneyLearningTracksProps = { articlesHeading?: string | null } -// `flush` drops the card inset for the single-track path, which has no card. +// The flush option drops the card inset for the single-track path, which has no card. const renderGuides = (track: JourneyTrack, flush = false) => ( <ol - // `list-style: none` strips list semantics in Safari/VoiceOver; the ordinals - // are decorative badges, so restore them explicitly. + // Safari and VoiceOver lose list semantics when CSS removes list style. role="list" className={flush ? `${styles.trackGuides} ${styles.trackGuidesFlush}` : styles.trackGuides} data-testid="journey-articles" @@ -39,7 +38,7 @@ export const JourneyLearningTracks = ({ tracks, articlesHeading }: JourneyLearni return null } - // Single journey: a plain heading + article list, no numbered cards. + // Single journeys use a plain heading and article list without numbered cards. if (tracks.length === 1) { const track = tracks[0] const headingText = articlesHeading || t('articles_heading') @@ -56,8 +55,7 @@ export const JourneyLearningTracks = ({ tracks, articlesHeading }: JourneyLearni return ( <ol - // `list-style: none` strips list semantics in Safari/VoiceOver; the rail - // numbers are decorative, so the ordering must survive here. + // Decorative rail numbers still need ordered-list semantics in Safari and VoiceOver. role="list" data-testid="journey-tracks" className={styles.tracks} diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.module.scss b/src/landings/components/shared/LandingArticleGridWithFilter.module.scss index 4d4e7d80bd22..96e639132bd6 100644 --- a/src/landings/components/shared/LandingArticleGridWithFilter.module.scss +++ b/src/landings/components/shared/LandingArticleGridWithFilter.module.scss @@ -1,7 +1,7 @@ @import "@primer/css/support/variables/layout.scss"; @import "@primer/css/support/mixins/layout.scss"; -// The section frame (four-sided border) is drawn by the wrapping LandingSection. +// The wrapping LandingSection draws the four-sided frame. // This only owns the internal vertical padding. .gridSection { padding-top: 3rem; @@ -23,34 +23,26 @@ color: var(--brand-color-text-muted); } -// Docs 2026 Articles grid (Figma): a seamless collapsed-border grid, NOT -// separate cards. Cells touch with shared 1px dividers; no per-card radius, -// shadow, or outer top/bottom/side borders. brand Card renders an outer -// wrapper div (Card__outer) that is the actual grid item — so the divider -// nth-child logic must live on `.articleGrid > *`, not on the inner .card -// (which is always :first-child of its own wrapper). +// The article grid uses seamless shared 1px dividers, not separate cards. +// Brand Card renders an outer wrapper as the grid item, so divider nth-child logic +// must live on .articleGrid > *, not on the inner card. .card { border-radius: 0 !important; box-shadow: none !important; padding: 32px !important; - // Top-align the card content so the tags/title sit at a consistent baseline + // Top-align the card content so the tags and title share a consistent baseline // across cards regardless of description length (brand Card otherwise // distributes rows down the full cell height). align-content: start !important; - // Green top border on hover, matching the brand NavList active indicator - // (--brand-color-accent-primary). Uses box-shadow (not border) so it overlays - // the seamless grid divider without shifting the 1px collapsed layout. + // box-shadow overlays the green hover bar on the grid divider without shifting layout. transition: box-shadow 0.1s ease-in-out; &:hover { box-shadow: inset 0 2px 0 0 var(--brand-color-accent-primary) !important; } - // Compress brand Card's generous inter-row margins so cards hug their content - // instead of ballooning. Targets the sub-element classes that merge onto - // Card.Tokens / Heading / Description. Figma tag→content gap is 36px for a - // single tag; shrink it when multiple tags are present (they can wrap and - // already add vertical bulk). + // Compress Brand Card row margins so cards hug content. + // Use a 36px gap for one tag and 16px for multiple tags, which may wrap and add height. :global([class*="Card__tokens"]) { margin-bottom: 36px !important; @@ -61,8 +53,7 @@ :global([class*="Card__heading"]) { margin-block-end: 8px !important; - // Figma card title ("Subheading Medium"): 16px / 550, not brand Card's - // default 22px heading. + // Figma Subheading Medium uses 16px and 550 weight, not Brand Card's 22px default. font-size: 1rem !important; font-weight: 550 !important; line-height: 1.5 !important; @@ -70,15 +61,13 @@ :global([class*="Card__description"]) { margin-block-end: 0 !important; - // Figma card intro ("Body/Small"): 14px, not brand Card's default 16px. + // Figma Body/Small intro uses 14px, not Brand Card's 16px default. font-size: 0.875rem !important; line-height: 1.5 !important; } } -// Filled inset pill tags (Figma "Forms/Label"). className merges onto each -// Token span inside Card.Tokens. 12px / 500 weight; the Figma pill is 23px tall -// with 8px horizontal padding, centered. +// Figma Forms/Label tags are 23px tall pills with 12px text and 8px horizontal padding. .cardToken { display: inline-flex !important; align-items: center !important; @@ -94,12 +83,9 @@ } .filterHeader { - // Mobile: 2-column grid. Row 1 = title (left) + category dropdown (right, on - // the same row as the heading). Row 2 = full-width search spanning both cols. + // Mobile uses two columns: title and category on row 1, search across row 2. display: grid; - // Col 1 (title) takes what it needs; col 2 (category) gets the remaining - // space (1fr) and can shrink to 0 so a long category value ellipsises - // instead of overflowing the row. + // The category column can shrink to 0 so long values ellipsise instead of overflow. grid-template-columns: auto minmax(0, 1fr); align-items: center; column-gap: 0.75rem; @@ -108,8 +94,7 @@ padding-bottom: 1rem; margin-bottom: 1rem; - // Medium screens and up: single row — title on the left, controls on the - // right. + // Medium screens and up put title and controls on one row. @include breakpoint(md) { display: flex; flex-direction: row; @@ -119,9 +104,8 @@ } } -// `display: contents` on mobile so the category + search become direct grid -// items of .filterHeader (category shares row 1 with the title; search spans -// row 2). On md+ it's a normal right-aligned flex group. +// display: contents lets category and search become direct mobile grid items. +// Medium screens and up use a normal right-aligned flex group. .controls { display: contents; @@ -141,22 +125,16 @@ line-height: 2rem; text-align: left; width: auto; - - // All screen sizes: keep title compact @include breakpoint(md) { flex-shrink: 0; width: auto; } } -// Category dropdown styled as a text control (Docs 2026 "Sort by: Newest" -// pattern) rather than a bordered button: muted "Category:" label + bold value, -// Action/Small type, transparent background. +// The category dropdown follows the Sort by pattern: muted label, +// bold value, Action/Small type, and transparent background. .categoryDropdown { - // Mobile: sit at the right end of row 1 (next to the title) and allow the - // control to shrink so a long category value ellipsises rather than pushing - // the row wider. overflow: hidden clips any button content (e.g. the trailing - // caret) that would otherwise spill past the column edge. + // Mobile clips the control inside row 1 so long category values do not widen the row. justify-self: end; min-width: 0; max-width: 100%; @@ -171,7 +149,7 @@ box-shadow: none !important; text-align: left !important; - // Primer's ButtonBase sets min-width: max-content on the button and its + // Primer ButtonBase sets min-width: max-content on the button and its // inner content/label spans, preventing the label from shrinking. Force the // whole track to shrink, and make the label a flex row so the value can // take the remaining space and ellipsise. @@ -181,9 +159,8 @@ justify-content: start !important; } - // The label span holds "Category:" (fixed) + the value (flexible). Make it - // a flex row so the value can take the remaining space and ellipsise. - // Target by class substring (Primer's hashed class is version-specific). + // The label span holds Category: plus the value, so flex lets the value ellipsise. + // Target by class substring because Primer's hashed class is version-specific. :global([class*="Button-Label"]) { display: flex; align-items: baseline; @@ -191,9 +168,8 @@ overflow: hidden; } - // Primer's Button-Content is a grid [label][visual]; let the label track - // shrink so the trailing caret stays inside the button (not pushed past the - // edge by a long, ellipsised value). + // Primer Button-Content uses label and visual tracks; shrink the label + // to keep the caret inside. :global([class*="Button-Content"]) { grid-template-columns: minmax(0, auto) auto; overflow: hidden; @@ -206,7 +182,7 @@ } } -// Keep the "Category:" label whole; it should never be the thing that clips. +// Keep the Category: label whole; clip only the selected value. .categoryLabel { flex-shrink: 0; white-space: nowrap; @@ -214,7 +190,7 @@ font-family: var(--brand-fontStack-sansSerif); font-size: 0.8125rem; font-weight: 500; - // Line height must leave room for descenders — a line-height equal to the + // Line height must leave room for descenders; a line-height equal to the // font size clips them (the value span has overflow: hidden for the ellipsis). line-height: 1.4; letter-spacing: 0.008125rem; @@ -248,7 +224,7 @@ .searchContainer { margin-left: 0; width: auto; - // Mobile: span both grid columns on row 2 (below the title + category row). + // Mobile search spans both columns below the title and category row. grid-column: 1 / -1; input { @@ -262,31 +238,28 @@ .articleGrid { display: grid; - // Bleed out to the frame's vertical border rules (cancel the frame's content - // padding) so the card grid dividers reach the border with no gap. Each card's - // own 32px padding then re-insets the text to align with the section header. + // Cancel the frame padding so card dividers meet its vertical border rules. + // Each card's 32px padding re-insets text to align with the section header. margin-inline: -16px; @include breakpoint(md) { margin-inline: -32px; } - // Mobile: 1 column grid-template-columns: 1fr; - // Shared 1px dividers via each grid item's top + left border. The first row's + // Shared 1px dividers use each grid item's top and left border. The first row's // top and first column's left are removed per breakpoint so only internal // dividers show (no outer top/bottom/side borders). > * { border-top: 1px solid var(--brand-color-border-default); } - // Mobile (1 col): horizontal dividers only; drop the first item's top border. + // One-column layouts use horizontal dividers only. > *:first-child { border-top: none; } - // Tablet: 2 columns @include breakpoint(md) { grid-template-columns: repeat(2, 1fr); @@ -303,7 +276,6 @@ } } - // Desktop: 3 columns @include breakpoint(lg) { grid-template-columns: repeat(3, 1fr); @@ -330,7 +302,7 @@ padding-top: 1rem; gap: 0.75rem; - // Medium screens and up: horizontal layout with space between + // Medium screens and up spread pagination controls horizontally. @include breakpoint(md) { flex-direction: row; justify-content: space-between; diff --git a/src/landings/components/shared/LandingArticleGridWithFilter.tsx b/src/landings/components/shared/LandingArticleGridWithFilter.tsx index ac5ef66891e7..dd69d0f57a02 100644 --- a/src/landings/components/shared/LandingArticleGridWithFilter.tsx +++ b/src/landings/components/shared/LandingArticleGridWithFilter.tsx @@ -25,19 +25,20 @@ type ArticleGridProps = { const ALL_CATEGORIES = 'all_categories' const useResponsiveArticlesPerPage = () => { - const [articlesPerPage, setArticlesPerPage] = useState(9) // Default to desktop + // Default to the desktop 3 by 3 grid. + const [articlesPerPage, setArticlesPerPage] = useState(9) useEffect(() => { const updateArticlesPerPage = () => { const width = window.innerWidth if (width < 768) { - // Mobile: 1 column, show 8 articles per page + // Mobile shows 8 articles in one column. setArticlesPerPage(8) } else if (width < 1012) { - // Tablet: 2 columns, show 8 articles per page (4 rows × 2 columns) + // Tablet shows 8 articles as 4 rows by 2 columns. setArticlesPerPage(8) } else { - // Desktop: 3 columns, show 9 articles per page (3 rows × 3 columns) + // Desktop shows 9 articles as 3 rows by 3 columns. setArticlesPerPage(9) } } @@ -50,6 +51,8 @@ const useResponsiveArticlesPerPage = () => { return articlesPerPage } +// @primer/live-region-element mounts a shadow-DOM live region under document.body, so +// nearby React updates do not make VoiceOver re-announce the focused input. export const ArticleGrid = ({ tocItems, includedCategories, @@ -72,12 +75,10 @@ export const ArticleGrid = ({ const stopWords = useMemo(() => deriveStopWords(allArticles), [allArticles]) - // Filter articles based on includedCategories for discovery landing pages - // For bespoke landing pages, show all articles regardless of includedCategories + // Discovery landings filter to included categories; bespoke landings show every article. const filteredArticlesByLandingType = useMemo(() => { if (landingType === 'discovery' && includedCategories && includedCategories.length > 0) { - // For discovery pages, keep articles that either have a matching category - // or have no category at all (uncategorized articles are still part of the content tree). + // Uncategorized discovery articles stay visible because they remain in the content tree. return allArticles.filter((article) => { if (!article.category || article.category.length === 0) return true return article.category.some((cat) => @@ -85,11 +86,11 @@ export const ArticleGrid = ({ ) }) } - // For bespoke pages or when includedCategories is empty/undefined, return all articles + // Empty includedCategories means the landing page has no category filter. return allArticles }, [allArticles, includedCategories, landingType]) - // Extract unique categories for dropdown from filtered articles (so all dropdown options have matching articles) + // Dropdown options come from filtered articles so every option has matching results. const categories: string[] = useMemo( () => [ ALL_CATEGORIES, @@ -98,7 +99,6 @@ export const ArticleGrid = ({ ) .filter((category: string) => { if (!includedCategories || includedCategories.length === 0) return true - // Case-insensitive comparison for dropdown filtering const lowerCategory = category.toLowerCase() return includedCategories.some((included) => included.toLowerCase() === lowerCategory) }) @@ -145,7 +145,7 @@ export const ArticleGrid = ({ const paginatedResults = filteredResults.slice(startIndex, startIndex + articlesPerPage) const handleSearch = (query: string) => { - // Don't add to history for search filtering + // Search filtering updates the URL without adding browser history entries. updateParams({ 'articles-filter': query || '', 'articles-page': '' }, false) } @@ -173,8 +173,7 @@ export const ArticleGrid = ({ if (!hasMountedRef.current) { hasMountedRef.current = true - // Check if any VALID article grid query params are present on initial load - // Don't scroll if category is invalid (selectedCategoryIndex === 0 means invalid or "all") + // Initial loads scroll only for valid article-grid query params. const hasValidCategory = selectedCategory !== ALL_CATEGORIES && selectedCategoryIndex !== 0 const hasQueryParams = searchQuery || hasValidCategory || currentPage > 1 @@ -182,7 +181,8 @@ export const ArticleGrid = ({ setTimeout(() => { if (headingRef.current) { const elementPosition = headingRef.current.getBoundingClientRect().top + window.scrollY - const offsetPosition = elementPosition - 140 // 140px offset from top + // Keep the heading 140px below the viewport top. + const offsetPosition = elementPosition - 140 window.scrollTo({ top: offsetPosition, behavior: 'smooth', @@ -191,37 +191,34 @@ export const ArticleGrid = ({ }, 100) } } - }, []) // Only run on mount + }, []) useEffect(() => { const pageChanged = currentPage !== prevPageRef.current const isPaginationClick = pageChanged && prevPageRef.current !== 1 - // Scroll if page changed via pagination (not from filter/category reset to page 1) - // This includes: going to page 2+, or going back to page 1 from a higher page + // Pagination scrolls for page 2 and later, or back to page 1 from a higher page. const shouldScroll = pageChanged && (currentPage > 1 || isPaginationClick) if (shouldScroll && headingRef.current) { - // Delay scroll slightly to let router finish and restore scroll position first + // Wait for router scroll restoration before moving the article grid. setTimeout(() => { if (headingRef.current) { const elementPosition = headingRef.current.getBoundingClientRect().top + window.scrollY - const offsetPosition = elementPosition - 140 // 140px offset from top + // Keep the heading 140px below the viewport top. + const offsetPosition = elementPosition - 140 window.scrollTo({ top: offsetPosition, behavior: 'smooth', }) } - }, 150) // Slightly longer than router debounce (100ms) + execution time + }, 150) // Exceed the router's 100ms debounce plus execution time. } prevPageRef.current = currentPage }, [currentPage]) - // Scroll the article grid into view whenever the filter query params change - // (typing in search, choosing a category, or landing on the page with those - // params already set). Debounced so fast typing scrolls once, after the last - // keystroke, rather than on every character. + // Filter query changes debounce one grid scroll so typing does not scroll on every character. const prevFilterRef = useRef({ searchQuery, selectedCategory }) const anchorTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) useEffect(() => { @@ -231,9 +228,7 @@ export const ArticleGrid = ({ prevFilterRef.current = { searchQuery, selectedCategory } if (!filtersChanged) return - // Debounce: cancel any pending scroll from a prior change, schedule a fresh - // one. Do NOT clear on effect cleanup: cleanup runs on unrelated re-renders - // and would cancel the scroll before it fires. + // Keep the timer through unrelated effect cleanup so the scheduled scroll can fire. if (anchorTimeoutRef.current) clearTimeout(anchorTimeoutRef.current) anchorTimeoutRef.current = setTimeout(() => { anchorTimeoutRef.current = null @@ -241,14 +236,9 @@ export const ArticleGrid = ({ if (!heading) return const offsetPosition = heading.getBoundingClientRect().top + window.scrollY - 140 window.scrollTo({ top: Math.max(0, offsetPosition), behavior: 'smooth' }) - }, 250) // after the router debounce (100ms) + its scroll-restore + }, 250) // Run after the router's 100ms debounce and scroll restoration. }, [searchQuery, selectedCategory]) - // Announce search/filter no-results to assistive technologies. - // Uses @primer/live-region-element which renders a <live-region> web component - // with a shadow DOM on document.body, completely isolated from React's component - // tree. This avoids VoiceOver re-announcing the focused input when React re-renders - // cause DOM mutations near the TextInput. const noArticlesFoundMessage = t('article_grid.no_articles_found') useEffect(() => { if (statusTimerRef.current) clearTimeout(statusTimerRef.current) @@ -271,7 +261,7 @@ export const ArticleGrid = ({ </h2> <div className={styles.controls}> - {/* Text-style control, matching the Docs 2026 "Sort by" pattern. */} + {/* Text-style control matches the Sort by pattern. */} <div className={styles.categoryDropdown}> <ActionMenu> <ActionMenu.Button> @@ -376,18 +366,14 @@ const ArticleCard = ({ article, includedCategories }: ArticleCardProps) => { ) : article.category - // Brand Card renders its own native anchor (no `as` prop), so intercept plain - // left-clicks to preserve client-side SPA navigation. Modified clicks - // (cmd/ctrl/shift/middle) fall through to the real href for new-tab/right-click. const handleClick = async (e: React.MouseEvent<HTMLDivElement>) => { + // Intercept Brand Card's anchor only for plain clicks; modified clicks keep browser behavior. if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return e.preventDefault() try { await router.push(article.fullPath) } catch { - // If the client-side navigation is rejected/aborted, fall back to a hard - // navigation so the card never goes dead (we already suppressed the - // anchor's default). Matters most for keyboard users with no obvious retry. + // Hard navigation keeps the card usable after suppressing the native anchor click. window.location.href = article.fullPath } } diff --git a/src/landings/components/shared/LandingCarousel.module.scss b/src/landings/components/shared/LandingCarousel.module.scss index 1453167dd65f..6c6bcce86377 100644 --- a/src/landings/components/shared/LandingCarousel.module.scss +++ b/src/landings/components/shared/LandingCarousel.module.scss @@ -3,7 +3,7 @@ --carousel-transition-duration: 0.1s; } -// Remove top margin for carousels without headings that come after another carousel +// Adjacent carousels without headings share the previous carousel's spacing. .carousel.noHeading { margin-top: 0; @@ -21,7 +21,7 @@ align-items: center; } -// When header only contains navigation (no heading), add top margin to separate from previous carousel +// Navigation-only headers need top spacing between adjacent carousels. .header:has(.navigation):not(:has(.heading)) { margin-top: 1rem; justify-content: flex-end; @@ -45,9 +45,7 @@ } } -// Prev/next controls: plain 16x16 arrow icon buttons (Docs 2026). No longer the -// old `btn btn-sm` bordered buttons — just the muted arrow glyph in a square -// hit target that tints toward default on hover. +// Prev and next controls use plain 16px arrows in 32px hit targets. .navButton { display: inline-flex; align-items: center; @@ -77,18 +75,16 @@ grid-template-columns: 1fr; transition: opacity var(--carousel-transition-duration) ease-in-out; opacity: 1; - // Bleed out to the frame's vertical border rules (cancel the frame's content - // padding) so the card dividers reach the border with no gap. Each card's own - // 32px padding then re-insets the text to align with the section header. + // Cancel the frame padding so card dividers meet its vertical border rules. + // Each card's 32px padding re-insets text to align with the section header. margin-inline: -16px; @media (min-width: 768px) { margin-inline: -32px; } - // Shared 1px dividers via each grid item's top + left border (matches the - // Articles grid). First row's top and first column's left are removed per - // breakpoint so only internal dividers show. + // Match the Articles grid with shared 1px top and left borders. Remove the first + // row's top and first column's left border per breakpoint. > * { border-top: 1px solid var(--brand-color-border-default); } @@ -134,35 +130,29 @@ } } -// Docs 2026 landing cards: seamless collapsed-border grid (dividers drawn by -// .itemsGrid above), NOT separate rounded cards. Flatten brand Card's radius/ -// shadow, reduce padding to 24px, and replace the stretched `1fr` description -// row with `auto` so cards hug their content instead of ballooning to ~365px. +// Landing cards use a seamless collapsed-border grid, not separate cards. +// Flatten Brand Card chrome and use auto rows so cards hug content instead of +// ballooning to about 365px. .card { border-radius: 0 !important; box-shadow: none !important; padding: 32px !important; --Card-grid-template-rows: auto auto auto auto auto auto auto auto !important; - // Top-align the card content so the tag/title sit at a consistent baseline + // Top-align the card content so tags and titles share a consistent baseline // across cards regardless of description length (brand Card otherwise // distributes rows down the full cell height). align-content: start !important; - // Green top border on hover, matching the brand NavList active indicator - // (--brand-color-accent-primary). Uses box-shadow (not border) so it overlays - // the seamless grid divider without shifting the 1px collapsed layout. + // box-shadow overlays the green hover bar on the grid divider without shifting layout. transition: box-shadow 0.1s ease-in-out; &:hover { box-shadow: inset 0 2px 0 0 var(--brand-color-accent-primary) !important; } - // Compress brand Card's generous inter-row margins so cards hug their content - // (~150px in Figma) instead of ballooning. Targets the sub-element classes - // that merge onto Card.Heading / Description. + // Compress Brand Card row margins so cards hug the roughly 150px Figma content height. :global([class*="Card__heading"]) { margin-block-end: 8px !important; - // Figma card title ("Subheading Medium"): 16px / 550, not brand Card's - // default 22px heading. + // Figma Subheading Medium uses 16px and 550 weight, not Brand Card's 22px default. font-size: 1rem !important; font-weight: 550 !important; line-height: 1.5 !important; @@ -170,7 +160,7 @@ :global([class*="Card__description"]) { margin-block-end: 0 !important; - // Figma card intro ("Body/Small"): 14px, not brand Card's default 16px. + // Figma Body/Small intro uses 14px, not Brand Card's 16px default. font-size: 0.875rem !important; line-height: 1.5 !important; } diff --git a/src/landings/components/shared/LandingCarousel.tsx b/src/landings/components/shared/LandingCarousel.tsx index 8251b74546d9..8e9a821cc0dd 100644 --- a/src/landings/components/shared/LandingCarousel.tsx +++ b/src/landings/components/shared/LandingCarousel.tsx @@ -11,24 +11,26 @@ import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML' type LandingCarouselProps = { heading?: string - carouselKey?: string // Optional key for translation lookup (e.g., "recommended") + // Optional key for translation lookup, such as "recommended". + carouselKey?: string carouselArticles?: ResolvedArticle[] } const useResponsiveItemsPerView = () => { - const [itemsPerView, setItemsPerView] = useState(3) // Default to desktop + // Default to the desktop 3-column carousel. + const [itemsPerView, setItemsPerView] = useState(3) useEffect(() => { const updateItemsPerView = () => { const width = window.innerWidth if (width < 768) { - // Mobile: 1 column + // Mobile shows one column. setItemsPerView(1) } else if (width < 1012) { - // Tablet: 2 columns + // Tablet shows two columns. setItemsPerView(2) } else { - // Desktop: 3 columns + // Desktop shows three columns. setItemsPerView(3) } } @@ -66,7 +68,7 @@ export const LandingCarousel = ({ const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null) - // Reset to first page when itemsPerView changes (screen size changes) + // Viewport changes reset to the first page so the changed page count cannot strand the index. useEffect(() => { setCurrentPage(0) }, [itemsPerView]) diff --git a/src/landings/components/shared/LandingHero.module.scss b/src/landings/components/shared/LandingHero.module.scss index 715faa26790b..7d1efeda3908 100644 --- a/src/landings/components/shared/LandingHero.module.scss +++ b/src/landings/components/shared/LandingHero.module.scss @@ -1,14 +1,12 @@ -// Docs 2026 landing hero (Figma node 341:117125). A content column — brand -// Heading + muted lede + a large Button group — with the right-anchored -// isometric banner art on desktop. On mobile the art drops below the content as -// a full-width band (matching the Figma mobile layout). The framing borders are -// drawn by the wrapping LandingSection. +// The landing hero uses Figma node 341:117125. Desktop anchors the isometric +// banner art on the right; mobile moves the art below the content as a full-width band. +// The wrapping LandingSection draws the framing borders. .landingHero { display: flex; flex-direction: column; width: 100%; - // Desktop: right-anchored banner art behind the content column. `auto 100%` + // Desktop anchors banner art behind the content column. auto 100% // scales it to the band's own (content-driven) height; pinned to the right // border box so the right-side text gutter insets only the text, not the art. background-size: auto 100%; @@ -17,7 +15,7 @@ background-origin: border-box; } -// Content column: heading + lede + actions. Only vertical padding here — the +// The content column owns only vertical padding; the wrapping LandingSection // horizontal inset comes from the wrapping LandingSection frame so the hero // content aligns with the other sections' headers/cards. .heroContent { @@ -29,22 +27,21 @@ width: 100%; } -// Brand `Heading size="2"` owns the type scale; this only adds an optical +// Brand Heading size 2 owns the type scale; this only adds an optical // max-width so long titles wrap before the edge. .heroHeading { margin: 0; max-width: 48rem; } -// Wraps the RenderedHTML intro (kept as raw HTML rather than a Text `as="p"`, -// which can't hold block-level intro markup). Brand `Text size="200" muted` -// owns the type/color (16px, per Figma); this only bounds the line length. +// RenderedHTML keeps raw block-level intro markup that Text as p cannot hold. +// Brand Text size 200 muted owns the 16px Figma type and color; this bounds line length. .heroDescription { margin: 0; max-width: 48rem; } -// Slight top gap above the actions (Figma "buttons + text" spacing). +// Figma buttons plus text spacing needs a small top gap above the actions. .heroButtons { margin-top: 0.25rem; } @@ -59,8 +56,8 @@ padding-block: 1.5rem; } - // Stack the CTA buttons full-width (Figma mobile layout). Brand ButtonGroup - // renders a <section> laying children in a row; force a full-width column and + // Figma mobile stacks calls to action full-width. Brand ButtonGroup renders + // a section laying children in a row; force a full-width column and // make each Button fill it. .heroButtons { width: 100%; @@ -79,21 +76,21 @@ } } -// Where the art IS shown, the intro can extend far enough right to overlap the +// Where the art is shown, the intro can extend far enough right to overlap the // right-anchored isometric art (the heading is short enough that it never // does). Instead of reserving a right gutter that shrinks the text column, put -// a frosted-glass panel behind the intro so it stays readable over the art — -// matching the homepage hero treatment (HomePageHero.module.scss `.content`). +// a frosted-glass panel behind the intro so it stays readable over the art, +// matching the homepage hero treatment in HomePageHero.module.scss. // Guarded to >=866px because the art is hidden below that, so no panel is // needed on mobile. @media (min-width: 866px) { .heroIntroScrim { display: inline-block; backdrop-filter: blur(1rem); - // A 70% wash of the page canvas, so the intro stays readable over the + // A 70% wash of the page canvas keeps the intro readable over the // isometric art without reading as a panel of its own. Brand's canvas is - // what `body` now paints (src/frame/stylesheets/index.scss), so - // mixing Brand's token keeps this invisible except where it overlays art. + // what body paints in src/frame/stylesheets/index.scss, so mixing Brand's + // token keeps this invisible except where it overlays art. background-color: color-mix( in srgb, var(--brand-color-canvas-default) 70%, diff --git a/src/landings/components/shared/LandingSection.module.scss b/src/landings/components/shared/LandingSection.module.scss index 7df4cffacdff..d82c3a24cdad 100644 --- a/src/landings/components/shared/LandingSection.module.scss +++ b/src/landings/components/shared/LandingSection.module.scss @@ -1,13 +1,13 @@ -// Docs 2026 "framed section": horizontal rules span the full content column +// Framed sections use horizontal rules that span the full content column, // while the vertical side rules are inset by a gutter, framing the section on -// all four sides. brand border-subtle adapts gray-2 light / gray-6 dark. +// all four sides. Brand border-subtle adapts gray-2 light and gray-6 dark. @import "@primer/css/support/variables/layout.scss"; @import "@primer/css/support/mixins/layout.scss"; // The band spans the full content column and draws the horizontal rules. It -// also holds the minimum horizontal gutter (as padding) so the framed content +// also holds the minimum horizontal gutter as padding so the framed content // never touches the band edges, and the horizontal rules always extend past the -// vertical ones. Each section draws its own top + bottom, so consecutive +// vertical ones. Each section draws its own top and bottom, so consecutive // sections separated by a gap show the paired-rule look from the Figma. .band { padding-inline: 16px; @@ -35,7 +35,7 @@ // The frame draws the vertical side rules and holds the content. It is capped at // container-xl and centered within the band, so on very wide viewports the -// content stays centered rather than hugging the left. Content padding (32px) +// content stays centered rather than hugging the left. Content padding, 32px, // sits inside the border and matches the cards' internal padding so section // headers/pagination align with the card text; the card grid bleeds back out to // the border with a negative margin so its dividers reach the vertical rules. diff --git a/src/landings/components/shared/LandingSection.tsx b/src/landings/components/shared/LandingSection.tsx index ec281e53fa32..9af58a020e16 100644 --- a/src/landings/components/shared/LandingSection.tsx +++ b/src/landings/components/shared/LandingSection.tsx @@ -8,7 +8,7 @@ type LandingSectionProps = { className?: string } -// A Docs 2026 "framed section". The outer band spans the full content column +// In framed sections, the outer band spans the full content column // and draws the horizontal rules; the inner frame is inset by a gutter and // draws the vertical side rules, so the horizontal rules always extend past the // vertical ones. diff --git a/src/landings/components/sidebar-navlist-depth.ts b/src/landings/components/sidebar-navlist-depth.ts index 9185f8fecbcc..6d6e38dd0868 100644 --- a/src/landings/components/sidebar-navlist-depth.ts +++ b/src/landings/components/sidebar-navlist-depth.ts @@ -1,23 +1,20 @@ -// Minimal structural shape needed for depth flattening: a subset of -// ProductTreeNode (which we avoid importing so this module stays free of the -// React/Next dependency chain and can be unit-tested in isolation). +// Depth flattening needs only this ProductTreeNode subset, keeping React and Next +// dependencies out so tests can import the module in isolation. type TreeNodeLike = { childPages: TreeNodeLike[] } // Brand NavList supports up to 5 nesting levels; a level-5 item cannot contain a // SubNav (it is dropped with a warning). SidebarProduct injects a hidden sentinel so // brand numbers its top-level items from level 1 (without it brand starts at level 2 -// and wastes a level, see navListLevelSentinel in SidebarProduct.tsx), so items can -// keep nesting while level < 5. Real docs content currently bottoms out at exactly -// level 5, so the guard that uses this is defensive: if a deeper tree ever appears, +// and wastes a level; see navListLevelSentinel in SidebarProduct.tsx), so items can +// keep nesting while level < 5. Real docs content bottoms out at exactly +// level 5, so this guard is defensive: if a deeper tree appears, // the overflow is flattened into leaf links rather than silently dropped by brand. // -// Kept in its own dependency-free module (no React/SCSS/Next imports) so it can -// be unit-tested without booting the Next.js app. +// Keeping this module dependency-free avoids booting the Next.js app in unit tests. export const MAX_NAVLIST_LEVEL = 5 -// Collects every descendant page of a node into a flat list, depth-first, so an -// over-deep subtree can still be rendered as (reachable) leaf links. Generic over -// the concrete node type so callers keep their richer node shape in the result. +// Flatten descendants depth-first so over-deep subtrees still render as reachable +// leaf links while callers keep their richer node shape in the result. export function flattenDescendants<T extends TreeNodeLike>(node: T): T[] { const out: T[] = [] for (const child of node.childPages as T[]) { diff --git a/src/landings/components/useSidebarExpandState.tsx b/src/landings/components/useSidebarExpandState.tsx index a98ce98733be..d08b29c27838 100644 --- a/src/landings/components/useSidebarExpandState.tsx +++ b/src/landings/components/useSidebarExpandState.tsx @@ -5,21 +5,20 @@ import Cookies from '@/frame/components/lib/cookies' import { SIDEBAR_EXPANDED_COOKIE_NAME } from '@/frame/lib/constants' // Persists the docs sidebar's expand/collapse state across navigations. The tree -// remounts on every route change (SidebarNav renders <SidebarProduct key={asPath} />), -// so per-node open state can't live in ordinary component state — it's kept in a +// remounts on every route change, so per-node open state cannot live in component state. +// SidebarNav renders SidebarProduct keyed by asPath, so state is kept in a // cookie, read once per mount, and shared through context. // // Semantics: a category is open when the user has explicitly toggled it (their // choice wins and persists); otherwise it follows the active chain: the ancestor // path of the current page auto-opens. Because brand NavList only auto-expands the -// aria-current chain for *uncontrolled* items, a controlled item must fold that in -// itself, which is what the `onActiveChain` fallback does here. +// aria-current chain for uncontrolled items, a controlled item must fold that in +// itself, which is what the onActiveChain fallback does here. // -// SSR-safety: the cookie is read server-side in getMainContext and passed to the -// provider as `initial`, so the very first render (server + client hydration) already -// reflects the persisted state and markup matches, with no post-mount flash. When rendered -// without an `initial` (e.g. outside the SSR data path), it falls back to reading the -// cookie client-side via the SSR-safe cookie lib. +// Server-side rendering reads the cookie in getMainContext and passes it as initial, +// so the first server and client render match with no post-mount flash. Without +// initial, for example outside the server-side data path, it falls back to the +// SSR-safe cookie lib on the client. type ExpandedStore = Record<string, boolean> @@ -43,8 +42,7 @@ function persistStore(store: ExpandedStore) { try { Cookies.set(SIDEBAR_EXPANDED_COOKIE_NAME, JSON.stringify(store)) } catch { - // Cookie writes may fail (disabled cookies, etc.), so degrade to non-persisted - // state rather than throwing. + // Cookie write failures degrade to non-persisted state instead of throwing. } } @@ -55,8 +53,7 @@ export function SidebarExpandStateProvider({ children: ReactNode initial?: ExpandedStore | null }) { - // Seed from the SSR-read cookie value so server and first client render agree. - // When no initial is supplied, fall back to reading the cookie client-side. + // Seed from the server-read cookie, or read the cookie client-side when initial is absent. const [store, setStore] = useState<ExpandedStore>(() => initial ?? readStore()) const setExpanded = useCallback((key: string, expanded: boolean) => { @@ -77,20 +74,14 @@ export function SidebarExpandStateProvider({ return <ExpandStateContext.Provider value={value}>{children}</ExpandStateContext.Provider> } -/** - * Controlled expand state for one NavList category, backed by a cookie. - * @param key Stable per-node identifier (the node's locale-prefixed href). - * @param onActiveChain Whether this node is an ancestor of the current page. - * @returns `[expanded, onExpandedChange]` to spread onto a brand `NavList.Item`. - */ +// Controls one NavList category by its stable locale-prefixed href and active-chain state. +// Returns expanded state and the NavList.Item change handler, backed by a cookie. export function useSidebarExpandState( key: string, onActiveChain: boolean, ): [boolean, (expanded: boolean) => void] { const ctx = useContext(ExpandStateContext) - // Fallback keeps the tree interactive if a NavList is ever rendered outside the - // provider: expand/collapse works via local state (seeded from the active chain), - // just without cross-navigation persistence. + // Outside the provider, local state keeps the tree interactive without persistence. const [localExpanded, setLocalExpanded] = useState(onActiveChain) const expanded = ctx ? ctx.isExpanded(key, onActiveChain) : localExpanded const onExpandedChange = useCallback( diff --git a/src/landings/context/LandingContext.tsx b/src/landings/context/LandingContext.tsx index 1034bca70fef..c2145df52749 100644 --- a/src/landings/context/LandingContext.tsx +++ b/src/landings/context/LandingContext.tsx @@ -19,16 +19,13 @@ export type LandingContextT = { renderedPage: string currentLayout: string heroImage?: string - // For landing pages with carousels carousels?: Record< string, Array<{ title: string; intro: string; href: string; category: string[] }> > introLinks?: Record<string, string> | null - // For journey landing pages journeyTracks?: JourneyTrack[] journeyArticlesHeading?: string | null - // For article grid category filtering includedCategories?: string[] } @@ -77,8 +74,7 @@ export const getLandingContextFromRequest = async ( ? (page.carousels as LandingContextT['carousels']) : {} - // Note: Journey tracks are resolved in middleware and added to the request - // context to avoid the error using server side apis client side + // Middleware resolves journey tracks because server-side APIs cannot run client-side. const journeyTracks: JourneyTrack[] = Array.isArray(context.journeyTracks) ? context.journeyTracks : Array.isArray(page.resolvedJourneyTracks) diff --git a/src/landings/lib/article-search.ts b/src/landings/lib/article-search.ts index 477fe5205a34..b38c6ba12f1f 100644 --- a/src/landings/lib/article-search.ts +++ b/src/landings/lib/article-search.ts @@ -3,8 +3,7 @@ import { fuzzyMatchScore, stripStopWords } from '@/landings/lib/fuzzy-match' const STOP_WORD_THRESHOLD = 0.8 -// Recursively flatten nested TOC items into leaf articles. -// Excludes index pages (pages with childTocItems). +// Parents with childTocItems are index pages, not article cards. const flattenArticlesRecursive = (articles: (TocItem | ChildTocItem)[]): ArticleCardItems => { const flattened: ArticleCardItems = [] @@ -19,7 +18,6 @@ const flattenArticlesRecursive = (articles: (TocItem | ChildTocItem)[]): Article return flattened } -// Flatten, deduplicate by fullPath, and sort alphabetically by title. export const flattenArticles = (articles: (TocItem | ChildTocItem)[]): ArticleCardItems => { const flattened = flattenArticlesRecursive(articles) const seen = new Set<string>() @@ -31,8 +29,8 @@ export const flattenArticles = (articles: (TocItem | ChildTocItem)[]): ArticleCa return deduped.sort((a, b) => a.title.localeCompare(b.title)) } -// Find words appearing in a high percentage of article titles/intros. -// These add little signal to search since they match nearly everything. +// Words that appear in most titles or intros add little signal because they +// match nearly every article. export const deriveStopWords = ( articles: ArticleCardItems, threshold = STOP_WORD_THRESHOLD, @@ -49,9 +47,8 @@ export const deriveStopWords = ( return [...wordCounts.entries()].filter(([, count]) => count >= minCount).map(([word]) => word) } -// Score and rank articles against a search query, returning only matches. -// Searches title, intro, and category fields. Returns all articles (scored 0.5) -// when the query consists entirely of stop words. +// Search matches title, intro, and category fields; queries made only of stop +// words return every article with a neutral 0.5 score. export const searchArticles = ( articles: ArticleCardItems, query: string, diff --git a/src/landings/lib/count-articles.ts b/src/landings/lib/count-articles.ts index 7934b31b43d7..852aaac1dd54 100644 --- a/src/landings/lib/count-articles.ts +++ b/src/landings/lib/count-articles.ts @@ -1,6 +1,5 @@ import type { ProductTreeNode } from '@/frame/components/context/MainContext' -// Recursively counts all leaf articles (nodes without children) under a given node export const countArticles = (node: ProductTreeNode): number => { if (node.childPages.length === 0) { return 1 diff --git a/src/landings/lib/featured-links.ts b/src/landings/lib/featured-links.ts index d024a02ce482..de1694c406c1 100644 --- a/src/landings/lib/featured-links.ts +++ b/src/landings/lib/featured-links.ts @@ -14,9 +14,6 @@ type ReqWithFeaturedLinks = { } } -// Helper that reshapes the resolved featured-link data placed on the request -// by the `featuredLinks` middleware into the FeaturedLink shape consumed by -// landing page contexts (toc, category, discovery, journey, bespoke). export const getFeaturedLinksFromReq = (req: unknown): Record<string, Array<FeaturedLink>> => { const { context } = req as ReqWithFeaturedLinks return Object.fromEntries( diff --git a/src/landings/lib/fuzzy-match.ts b/src/landings/lib/fuzzy-match.ts index e1f2d525be27..667d3c1f6b2c 100644 --- a/src/landings/lib/fuzzy-match.ts +++ b/src/landings/lib/fuzzy-match.ts @@ -1,16 +1,16 @@ -// 70% threshold: Raised from 60% to reduce false positives from short queries. -// At 60%, "billing" matched "installing" and "pricing" matched "writing pr descriptions". -// 70% still captures singular/plural ("agent"→"agents" = 80%, "repository"→"repositories" = 73%) -// while filtering the worst noise (both of those false positives were at 67%). +// The 70% threshold keeps singular/plural matches like "agent" to "agents" at 80% +// and "repository" to "repositories" at 73%. +// It rejects noisy matches like "billing" to "installing" and "pricing" to +// "writing pr descriptions", both 67%. const BIGRAM_COVERAGE_THRESHOLD = 0.7 -// Short search terms produce very few bigrams, making spurious matches likely. -// Require exact substring match for terms with 4 or fewer non-space characters. +// Terms with 4 or fewer non-space characters need exact substring matches +// because they produce too few bigrams. const SHORT_TERM_MAX_LENGTH = 4 const bigramCache = new Map<string, Set<string>>() -// Extract character bigrams from a string, e.g. "agent" -> ["ag", "ge", "en", "nt"]. +// Bigrams are adjacent character pairs, so "agent" becomes "ag", "ge", "en", and "nt". const getBigrams = (str: string): Set<string> => { const key = str.toLowerCase() if (bigramCache.has(key)) { @@ -27,8 +27,8 @@ const getBigrams = (str: string): Set<string> => { return bigrams } -// Coverage: what percentage of search bigrams are found in text -// Better for matching short queries against long text +// Bigram coverage measures how many search bigrams appear in text. +// This works better than Jaccard for short queries against longer text. export const bigramCoverage = (text: string, search: string): number => { const textBigrams = getBigrams(text) const searchBigrams = getBigrams(search) @@ -39,19 +39,15 @@ export const bigramCoverage = (text: string, search: string): number => { return found / searchBigrams.size } -// Returns a match score: 1 for exact match, 0-1 for bigram coverage, -1 for no match +// Returns 1 for a substring match, bigram coverage at or above the threshold, or -1 otherwise. export const fuzzyMatchScore = (text: string, searchTerm: string): number => { const lowerText = text.toLowerCase() const lowerSearch = searchTerm.toLowerCase() if (lowerText.includes(lowerSearch)) return 1 - // Short search terms (e.g., "mcp", "pr", "test") produce too few bigrams - // for reliable fuzzy matching, so require exact substring only. if (lowerSearch.replace(/\s+/g, '').length <= SHORT_TERM_MAX_LENGTH) return -1 - // Bigram coverage works better than Jaccard when the text is much longer than - // the search term. const score = bigramCoverage(text, searchTerm) return score >= BIGRAM_COVERAGE_THRESHOLD ? score : -1 } @@ -60,9 +56,8 @@ export const fuzzyMatch = (text: string, searchTerm: string): boolean => { return fuzzyMatchScore(text, searchTerm) >= 0 } -// Strip stop words from a string, preserving other words. -// On product-specific landing pages (e.g. /copilot), the product name appears -// in nearly every article, drowning out the actual query. +// Product-specific landing pages, such as /copilot, repeat the product name in nearly +// every article, so stop words keep the product name from drowning out the query. export const stripStopWords = (text: string, stopWords: string[]): string => text .split(/\s+/) diff --git a/src/landings/lib/octicons.ts b/src/landings/lib/octicons.ts index 2f8d194c1692..78c246bb210e 100644 --- a/src/landings/lib/octicons.ts +++ b/src/landings/lib/octicons.ts @@ -14,8 +14,7 @@ import { LockIcon, } from '@primer/octicons-react' -// The single source of truth for supported octicons. The type and the validation -// array below are both derived from it. +// Derive the type and validation array from this map so supported octicons stay in sync. export const OCTICON_COMPONENTS = { bug: BugIcon, lightbulb: LightBulbIcon, @@ -40,7 +39,7 @@ export function isValidOcticon(octicon: string | null): octicon is ValidOcticon return octicon !== null && (octicon as ValidOcticon) in OCTICON_COMPONENTS } -// Falls back to CopilotIcon for an unknown name. +// Unknown names render CopilotIcon instead of breaking page rendering. export function getOcticonComponent(octicon: ValidOcticon | undefined) { if (!octicon || !isValidOcticon(octicon)) { return CopilotIcon diff --git a/src/landings/middleware/featured-links.ts b/src/landings/middleware/featured-links.ts index 0f5d91e2d381..8c41b53ea1ec 100644 --- a/src/landings/middleware/featured-links.ts +++ b/src/landings/middleware/featured-links.ts @@ -3,32 +3,13 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest, FeaturedLinkExpanded } from '@/types' import getLinkData from '@/frame/lib/get-link-data' -/** - * This is the max. number of featured links, by any category, that we - * display on index landing pages (homepage and TOC landings). - * The reason it's variable is that some featured links are conditional. - * For example: - * - * '/authentication/troubleshooting-ssh', - * '/authentication/connecting-to-github-with...', - * '/authentication/connecting-to-github-with-ssh/a...', - * '{% ifversion ghec %}/authentication/connecting-to-...{% endif %}', - * '/authentication/managing-commit-signature-verif...' - * - * In this case, if we'd "prematurely" sliced that list to the first 4, - * the final result might be 3 items because that conditional one - * would end up being blank and thus omitted. - * - * The reason we don't want to display too many is because it might - * make the landing page columns that lists links far too - * long ("high"). - */ +// getLinkData stops after MAX_FEATURED_LINKS resolved links, not frontmatter entries. +// For example, "{% ifversion ghec %}/authentication/troubleshooting-ssh{% endif %}" +// can render blank. +// That keeps a conditional entry that renders blank from leaving a category short, +// while still preventing landing-page columns from growing too tall. const MAX_FEATURED_LINKS = 4 -// This middleware resolves `featuredLinks` from the page's frontmatter into -// a `req.context.featuredLinks` object consumed by the homepage and toc -// landing renderers. It runs for any `index.md` page that defines -// `featuredLinks` in frontmatter. export default async function featuredLinks( req: ExtendedRequest, res: Response, @@ -54,8 +35,7 @@ export default async function featuredLinks( { title: true, intro: true, fullTitle: true }, MAX_FEATURED_LINKS, ) - // We need to use a type assertion here because the Page interfaces are incompatible - // between our local types and the global types, but the actual runtime objects are compatible + // Local and global Page interfaces differ, but the runtime featured-link objects match. req.context.featuredLinks[key] = (linkData || []) as unknown as FeaturedLinkExpanded[] } diff --git a/src/landings/pages/home.module.scss b/src/landings/pages/home.module.scss index 2e385e593710..4119d553523d 100644 --- a/src/landings/pages/home.module.scss +++ b/src/landings/pages/home.module.scss @@ -1,6 +1,5 @@ -// Full-bleed gap between the hero and the "All Docs" section. The hero supplies -// the top border (its own border-bottom); this band adds the responsive height -// and the bottom border, both spanning edge-to-edge. +// Full-bleed gap between hero and All Docs. The hero supplies the top border. +// This band adds responsive height and an edge-to-edge bottom border. .sectionGap { height: 1.5rem; border-bottom: var(--brand-borderWidth-thin, 1px) solid @@ -15,9 +14,8 @@ } } -// Full-bleed border closing off the bottom of the "All Docs" grid, mirroring the -// gap band at the top. The grid cells only draw rail-width borders, so this -// spans edge-to-edge before the footer. +// Full-bleed border closes the bottom of All Docs to mirror the top gap. +// Grid cells draw rail-width borders, so this spans edge-to-edge before the footer. .sectionEnd { height: 4rem; border-top: var(--brand-borderWidth-thin, 1px) solid diff --git a/src/landings/pages/home.tsx b/src/landings/pages/home.tsx index d5d4ad0d1acb..605bc008ebe5 100644 --- a/src/landings/pages/home.tsx +++ b/src/landings/pages/home.tsx @@ -23,8 +23,7 @@ type FeaturedLink = { type Props = { mainContext: MainContextT - // Retained in getServerSideProps so the "Getting started" / "Popular" lists - // can be restored later; the Docs 2026 homepage body is just the grid. + // getServerSideProps keeps Getting started and Popular data so the page can restore those lists. popularLinks: Array<FeaturedLink> gettingStartedLinks: Array<FeaturedLink> productGroups: Array<ProductGroupT> diff --git a/src/landings/pages/product.tsx b/src/landings/pages/product.tsx index 4039ed73e486..3a1ba5781d87 100644 --- a/src/landings/pages/product.tsx +++ b/src/landings/pages/product.tsx @@ -6,8 +6,7 @@ import { useRouter } from 'next/router' import type { ExtendedRequest } from '@/types' import type { JourneyTrack } from '@/journeys/lib/journey-path-resolver' -// "legacy" javascript needed to maintain existing functionality -// typically operating on elements **within** an article. +// Article pages need these scripts for behavior inside rendered article content. import copyCode from '@/frame/components/lib/copy-code' import toggleAnnotation from '@/frame/components/lib/toggle-annotations' @@ -72,9 +71,9 @@ const GlobalPage = ({ const router = useRouter() useEffect(() => { - // https://stackoverflow.com/a/67063998 - initiateArticleScripts() // on initiate page - router.events.on('routeChangeComplete', initiateArticleScripts) // on client side route + // Mount and route-change init; Next.js keeps this mounted: https://stackoverflow.com/a/67063998 + initiateArticleScripts() + router.events.on('routeChangeComplete', initiateArticleScripts) return () => { router.events.off('routeChangeComplete', initiateArticleScripts) } @@ -118,9 +117,7 @@ const GlobalPage = ({ </ArticleContext.Provider> ) } else { - // In local dev, when Next.js needs the initial compiled version - // it will request `/_next/static/webpack/$HASH.webpack.hot-update.json` - // or `/_next/webpack-hmr` and then we just let the `content` be undefined. + // Let Next.js hot-reload probes render empty content during local development. if ( !(router.asPath.startsWith('/_next/static/') || router.asPath.startsWith('/_next/webpack')) ) { @@ -144,15 +141,14 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const additionalUINamespaces: string[] = [] - // This looks a little funky, but it's so we only send one context's data to the client + // Send only the active page context to the client to avoid unused page data. if (currentLayoutName === 'bespoke-landing') { props.bespokeContext = await getLandingContextFromRequest(req, 'bespoke') additionalUINamespaces.push('product_landing', 'carousels') } else if (currentLayoutName === 'journey-landing') { props.journeyContext = await getLandingContextFromRequest(req, 'journey') - // journey tracks are resolved in middleware and added to the request - // so we need to add them to the journey context here + // Middleware resolves journey tracks, so add them to the journey context before rendering. const page = req.context?.page as { resolvedJourneyTracks?: JourneyTrack[] } | undefined if (page?.resolvedJourneyTracks) { props.journeyContext.journeyTracks = page.resolvedJourneyTracks @@ -173,7 +169,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => ) } } else if (props.mainContext.page) { - // All articles that might have hover cards needs this + // Articles need the popovers namespace for hover cards. additionalUINamespaces.push('popovers') props.articleContext = getArticleContextFromRequest( diff --git a/src/landings/tests/article-search.ts b/src/landings/tests/article-search.ts index 50618a5a3f0c..04bc447873c1 100644 --- a/src/landings/tests/article-search.ts +++ b/src/landings/tests/article-search.ts @@ -93,7 +93,7 @@ describe('deriveStopWords', () => { ] // "copilot" appears in 3/3 = 100%, always a stop word expect(deriveStopWords(articles, 0.5)).toContain('copilot') - // At threshold 1.0, only words in every single article qualify + // With threshold 1.0, only words in every article qualify. const strict = deriveStopWords(articles, 1.0) expect(strict).toContain('copilot') expect(strict).not.toContain('agents') diff --git a/src/landings/tests/count-articles.ts b/src/landings/tests/count-articles.ts index b773744da5b3..3434c908f0a9 100644 --- a/src/landings/tests/count-articles.ts +++ b/src/landings/tests/count-articles.ts @@ -21,7 +21,7 @@ describe('countArticles', () => { }) test('counts all nested leaf articles recursively', () => { - // Structure: parent -> 2 sections -> each with 3 articles = 6 total + // Two sections with three articles each produce six leaf articles. const section1 = createNode([createNode(), createNode(), createNode()]) const section2 = createNode([createNode(), createNode(), createNode()]) const parent = createNode([section1, section2]) @@ -30,7 +30,7 @@ describe('countArticles', () => { }) test('handles deeply nested structure', () => { - // 3 levels deep: parent -> section -> subsection -> 2 articles + // Three nested levels end in two leaf articles. const subsection = createNode([createNode(), createNode()]) const section = createNode([subsection]) const parent = createNode([section]) @@ -39,7 +39,7 @@ describe('countArticles', () => { }) test('handles mixed depth structure', () => { - // parent -> section with 2 articles + section with subsection with 3 articles = 5 total + // Two direct leaves plus three nested leaves produce five articles. const section1 = createNode([createNode(), createNode()]) const subsection = createNode([createNode(), createNode(), createNode()]) const section2 = createNode([subsection]) diff --git a/src/landings/tests/featured-links.ts b/src/landings/tests/featured-links.ts index bb05cff07f97..f4e0eb8fbc36 100644 --- a/src/landings/tests/featured-links.ts +++ b/src/landings/tests/featured-links.ts @@ -12,7 +12,7 @@ describe('featuredLinks', () => { test('Enterprise get-started landing renders', async () => { const $ = await getDOM('/en/enterprise-server@latest/get-started') - // get-started uses discovery-landing, so it has hero/spotlight, not article-list. + // discovery-landing renders get-started hero and spotlight instead of article-list. expect($('h1').text()).toMatch(/Getting started/) }) }) @@ -24,7 +24,7 @@ describe('homepage', () => { const $ = await getDOM('/en') const $search = $('[data-testid=homepage-search]') expect($search).toHaveLength(1) - // The redesigned homepage no longer renders the featured article lists. + // The homepage renders the product grid, not featured article lists. expect($('[data-testid=article-list]')).toHaveLength(0) }) @@ -32,7 +32,6 @@ describe('homepage', () => { const $ = await getDOM('/en') const $grid = $('[data-testid=product]') expect($grid).toHaveLength(1) - // Category group headings and their product links. expect($grid.find('h3').length).toBeGreaterThan(0) expect($grid.find('a').length).toBeGreaterThan(0) }) diff --git a/src/landings/tests/fuzzy-match.ts b/src/landings/tests/fuzzy-match.ts index f96b0d3339a7..177069baf689 100644 --- a/src/landings/tests/fuzzy-match.ts +++ b/src/landings/tests/fuzzy-match.ts @@ -30,15 +30,12 @@ describe('fuzzyMatch', () => { }) test('short terms (<=4 chars) require exact substring match', () => { - // "test" is 4 chars, so exact substring only. expect(fuzzyMatch('Writing tests', 'test')).toBe(true) expect(fuzzyMatch('Generating tables', 'test')).toBe(false) - // "mcp" is 3 chars expect(fuzzyMatch('Using the GitHub MCP Server', 'mcp')).toBe(true) expect(fuzzyMatch('Coding agents', 'mcp')).toBe(false) - // "pr" is 2 chars, so exact substring only. expect(fuzzyMatch('Writing PR descriptions', 'pr')).toBe(true) - // "pr" is a substring of "enterprise", so this still matches (exact match) + // "pr" matches inside "enterprise" because short terms match substrings. expect(fuzzyMatch('Enterprise setup', 'pr')).toBe(true) }) @@ -59,7 +56,7 @@ describe('fuzzyMatch', () => { }) test('handles edge cases gracefully', () => { - expect(fuzzyMatch('GitHub Copilot', '')).toBe(true) // empty search matches anything + expect(fuzzyMatch('GitHub Copilot', '')).toBe(true) // Empty search matches anything. expect(fuzzyMatch('', 'copilot')).toBe(false) expect(fuzzyMatch('', '')).toBe(true) @@ -79,16 +76,13 @@ describe('fuzzyMatchScore', () => { }) test('returns bigram coverage score for fuzzy matches', () => { - // Bigram coverage should give a score between 0.7 and 1 const score = fuzzyMatchScore('About Copilot memory features', 'memory copilot') expect(score).toBeGreaterThanOrEqual(0.7) expect(score).toBeLessThan(1) }) test('matches singular vs plural via bigrams', () => { - // "agents" bigrams: ag, ge, en, nt, ts (5) - // "agent" in text has: ag, ge, en, nt (4) - // Coverage: 4/5 = 0.8, which is > 0.7 threshold + // "agents" has ag, ge, en, nt, ts; "agent" covers 4 of 5, so coverage is 0.8. const score = fuzzyMatchScore('GitHub Copilot agent', 'agents') expect(score).toBeGreaterThanOrEqual(0.7) }) @@ -120,17 +114,13 @@ describe('bigramCoverage', () => { }) test('handles singular vs plural with high coverage', () => { - // "agents" bigrams: ag, ge, en, nt, ts (5) - // "agent" in text has: ag, ge, en, nt (4) - // Coverage: 4/5 = 0.8 + // "agents" has ag, ge, en, nt, ts; "agent" covers 4 of 5, so coverage is 0.8. const coverage = bigramCoverage('agent', 'agents') expect(coverage).toBeCloseTo(4 / 5, 2) }) test('calculates partial coverage correctly', () => { - // Text "hello" has bigrams: he, el, ll, lo - // Search "help" has bigrams: he, el, lp - // Found: he, el (2 of 3) = 0.67 + // "hello" has he, el, ll, lo; "help" matches he and el, so coverage is 2 of 3. const coverage = bigramCoverage('hello', 'help') expect(coverage).toBeCloseTo(2 / 3, 2) }) diff --git a/src/landings/tests/octicons.test.ts b/src/landings/tests/octicons.test.ts index 845607848216..279b88906057 100644 --- a/src/landings/tests/octicons.test.ts +++ b/src/landings/tests/octicons.test.ts @@ -77,7 +77,7 @@ describe('octicons reference', () => { }) test('returns CopilotIcon as fallback for invalid octicons', () => { - // TypeScript should prevent this, but test runtime behavior + // Runtime content can bypass TypeScript, so invalid names still need a fallback. expect(getOcticonComponent('invalid' as ValidOcticon)).toBe(CopilotIcon) }) }) @@ -127,11 +127,7 @@ describe('octicons reference', () => { }) test('adding new octicon only requires updating OCTICON_COMPONENTS', () => { - // This test documents the single source of truth approach - // If you add a new octicon to OCTICON_COMPONENTS: - // 1. ValidOcticon type automatically includes it - // 2. VALID_OCTICONS array automatically includes it - // 3. All validation functions work with it + // OCTICON_COMPONENTS drives the type, validation array, and validation helpers. const componentCount = Object.keys(OCTICON_COMPONENTS).length const validOcticonsCount = VALID_OCTICONS.length diff --git a/src/landings/tests/sidebar-custom-links.ts b/src/landings/tests/sidebar-custom-links.ts index c2952eb79990..d170c3e354ae 100644 --- a/src/landings/tests/sidebar-custom-links.ts +++ b/src/landings/tests/sidebar-custom-links.ts @@ -12,7 +12,7 @@ describe('sidebar custom links', () => { }) test('page without sidebarLink frontmatter does not show custom link', async () => { - // Using a page that's not in the get-started section to avoid seeing the foo sidebarLink + // The /actions page avoids the get-started section, which has fixture sidebarLink data. const $ = await getDOM('/actions') const customLinks = $('[data-testid="sidebar"] a:contains("All sidebar test items")') @@ -22,7 +22,6 @@ describe('sidebar custom links', () => { test.skip('sidebarLink with custom text appears correctly', async () => { const $ = await getDOM('/get-started/sidebar-test') - // The fixture sidebar-test page should have "All sidebar test items" as custom text const customLink = $('[data-testid="sidebar"] a:contains("All sidebar test items")') expect(customLink.text().trim()).toBe('All sidebar test items') }) @@ -37,7 +36,7 @@ describe('sidebar custom links', () => { const testSection = customLink.closest('[role="group"], ul') const allLinks = testSection.find('a') const customLinkIndex = allLinks.index(customLink) - expect(customLinkIndex).toBe(0) // Should be the first link in the subnav + expect(customLinkIndex).toBe(0) // Custom sidebar links appear first in their subnav. }) test.skip('sidebar custom link has correct aria attributes', async () => { @@ -46,13 +45,12 @@ describe('sidebar custom links', () => { const customLink = $('[data-testid="sidebar"] a:contains("All sidebar test items")') expect(customLink.length).toBe(1) - // Verify the custom link has proper attributes (aria-current depends on current page logic) expect(customLink.attr('href')).toBeDefined() expect(customLink.text().trim()).toBe('All sidebar test items') }) test('sidebar custom link does not appear on unrelated pages', async () => { - // Using actions page which is completely unrelated to get-started/foo + // The /actions page avoids the get-started section, which has fixture sidebarLink data. const $ = await getDOM('/actions') const customLink = $('[data-testid="sidebar"] a:contains("All sidebar test items")') diff --git a/src/landings/tests/sidebar-navlist-depth.ts b/src/landings/tests/sidebar-navlist-depth.ts index 1d1058f99b61..395389e36f6f 100644 --- a/src/landings/tests/sidebar-navlist-depth.ts +++ b/src/landings/tests/sidebar-navlist-depth.ts @@ -2,11 +2,10 @@ import { describe, expect, test } from 'vitest' import { flattenDescendants, MAX_NAVLIST_LEVEL } from '../components/sidebar-navlist-depth' -// The sidebar renders on @primer/react-brand NavList, which supports at most 5 -// nesting levels, and a level-5 item that contains a SubNav is dropped. SidebarProduct -// guards against this: at MAX_NAVLIST_LEVEL it stops nesting and flattens the -// remaining subtree into leaf links so no page becomes unreachable. These tests -// pin that reachability guarantee. +// @primer/react-brand NavList supports at most 5 nesting levels; a level-5 item +// that contains a SubNav drops its children. SidebarProduct stops nesting at +// MAX_NAVLIST_LEVEL and flattens the remaining subtree into leaf links so every +// page stays reachable. These tests protect that guarantee. type TestNode = { title: string; href: string; childPages: TestNode[] } @@ -35,8 +34,7 @@ describe('sidebar NavList depth guard', () => { }) test('an over-deep subtree loses no pages when flattened', () => { - // A chain deeper than the cap: every node past the cap must still be reachable - // as a flat leaf, i.e. flattening the capped node surfaces all of them. + // Nodes deeper than the cap must surface as flat leaves instead of disappearing. const deepLeaf = node('/1/2/3/4/5/6/7') const chain = node('/1', [ node('/1/2', [ @@ -47,9 +45,8 @@ describe('sidebar NavList depth guard', () => { ]) const flattened = flattenDescendants(chain).map((n) => n.href) - // Nothing is dropped: the deepest page is present. expect(flattened).toContain('/1/2/3/4/5/6/7') - // And the count equals the total descendant node count (6 below the root). + // Six descendants below the root must remain reachable. expect(flattened).toHaveLength(6) }) }) diff --git a/src/landings/types.ts b/src/landings/types.ts index 0f69a4b861d8..e875c24ff4f3 100644 --- a/src/landings/types.ts +++ b/src/landings/types.ts @@ -1,6 +1,6 @@ import { ValidOcticon, isValidOcticon } from './lib/octicons' -// Re-export ValidOcticon and isValidOcticon for compatibility with existing imports +// Keep these re-exports for existing imports. export type { ValidOcticon } export { isValidOcticon } @@ -19,7 +19,7 @@ export type BaseTocItem = { intro?: string | null } -// Recursive: children can have their own children. +// Child items can nest recursively. export type ChildTocItem = BaseTocItem & { octicon?: ValidOcticon | null category?: string[] | null @@ -40,8 +40,7 @@ export type TocItem = BaseTocItem & { export type ArticleCardItems = ChildTocItem[] -// Matches the data shape returned by getTocItems(), including every property that -// may be present in the source data. +// Preserve every getTocItems() property that landings receive from source data. export type RawTocItem = { title: string fullPath: string diff --git a/src/languages/lib/correct-translation-content.ts b/src/languages/lib/correct-translation-content.ts index d7966b6a3598..b93717184066 100644 --- a/src/languages/lib/correct-translation-content.ts +++ b/src/languages/lib/correct-translation-content.ts @@ -1811,6 +1811,8 @@ export function correctTranslatedContentStrings( content = content.replaceAll('<b></b>', '') content = content.replaceAll('<u></u>', '') + content = content.replace(/<\/?c\d+\s*\/?>/g, '') + content = content.replace(/(\{%-? )ifversion-([a-z][\w-]*\s*%\})/g, '$1ifversion $2') content = content.replaceAll('["AUTOTITLE]', '"[AUTOTITLE]') diff --git a/src/languages/tests/correct-translation-content.ts b/src/languages/tests/correct-translation-content.ts index 5a49ba4c2cdb..485d5778cec9 100644 --- a/src/languages/tests/correct-translation-content.ts +++ b/src/languages/tests/correct-translation-content.ts @@ -2998,6 +2998,32 @@ Para más información, consulta "[AUTOTITLE](/path)". }) }) + describe('universal: strips leftover CAT-tool <cN> placeholder tags', () => { + test('strips paired <c0>...</c0> tags around text', () => { + expect(fix('Use the <c0>Create an issue</c0> endpoint.', 'es')).toBe( + 'Use the Create an issue endpoint.', + ) + }) + + test('strips self-closing <c0/> and unmatched closing tags', () => { + expect(fix('See the note.<c0/> More text.</c1>', 'ja')).toBe('See the note. More text.') + }) + + test('strips nested and numbered <cN> tags', () => { + expect( + fix( + '<c1>repositorio especificado octocat/Spoon-Knife<c3>.<c4> Reemplace `REPO-NAME`', + 'es', + ), + ).toBe('repositorio especificado octocat/Spoon-Knife. Reemplace `REPO-NAME`') + }) + + test('leaves content without <cN> tags unchanged', () => { + const correct = 'Use the Create an issue endpoint.' + expect(fix(correct, 'es')).toBe(correct) + }) + }) + describe('ja: github-token-scope-descriptions.md per-file fix', () => { test('restores the missing endif in the security-events row', () => { const broken = diff --git a/src/links/scripts/action-injections.ts b/src/links/scripts/action-injections.ts index 9764df2ce79c..e5b1365a1163 100644 --- a/src/links/scripts/action-injections.ts +++ b/src/links/scripts/action-injections.ts @@ -1,5 +1,4 @@ -// Dependency injection for scripts that call .github/actions/ code. -// Swaps the Actions-platform pieces for local-machine equivalents. +// Scripts that call .github/actions code locally use these Actions-platform replacements. import fs from 'fs' import path from 'path' @@ -15,7 +14,6 @@ export type CoreInject = { setOutput: (name: string, value: unknown) => void setFailed: (message: string) => void } -// Directs core logging to console export function getCoreInject(debug: boolean): CoreInject { return { info: console.log, @@ -36,7 +34,7 @@ export function getCoreInject(debug: boolean): CoreInject { } } -// Writes strings that would be uploaded as artifacts to a local logs/ directory +// Local runs write would-be artifacts to logs/ when debug output is enabled. const cwd = new URL('', import.meta.url).pathname const logsPath = path.join(cwd, '..', '..', 'logs') if (!fs.existsSync(logsPath)) { @@ -54,5 +52,5 @@ export function getUploadArtifactInject(debug: boolean) { } } -// Uses local process.env GITHUB_TOKEN to create an octokit instance +// Local scripts authenticate with process.env.GITHUB_TOKEN through the shared GitHub client. export const octokitInject = github() diff --git a/src/links/scripts/check-github-github-links.ts b/src/links/scripts/check-github-github-links.ts index e610830834c8..ef6b0b397c89 100755 --- a/src/links/scripts/check-github-github-links.ts +++ b/src/links/scripts/check-github-github-links.ts @@ -1,14 +1,6 @@ -// [start-readme] -// -// Run this script to get all broken docs.github.com links in github/github -// -// To run this locally, you'll generate a PAT and create an environment -// variable called GITHUB_TOKEN. -// Easiest is to create a *classic* Personal Access Token and make sure -// it has all "repo" scopes. You also have to press the "Configure SSO" -// for it. -// -// [end-readme] +// Finds broken docs.github.com links in github/github. +// Usage: npm run check-github-github-links [-- --check] [output-file]. +// Set GITHUB_TOKEN; a classic PAT with all repo scopes and SSO authorization is easiest. import fs from 'fs/promises' @@ -34,25 +26,12 @@ program main(program.opts(), program.args) -// The way `got` does retries: -// -// sleep = 1000 * Math.pow(2, retry - 1) + Math.random() * 100 -// -// So, it means: -// -// 1. ~1000ms -// 2. ~2000ms -// 3. ~4000ms -// -// ...if the limit we set is 3. -// Our own timeout, in @/frame/middleware/timeout.ts defaults to 10 seconds. -// So there's no point in trying more attempts than 3 because it would -// just timeout on the 10s. (i.e. 1000 + 2000 + 4000 + 8000 > 10,000) +// got waits 1000 * 2^(retry - 1) ms plus jitter between retries, so three retries add +// about 7s of backoff on top of each 3s request timeout. const retryConfiguration = { limit: 3, } -// Datadog puts the average time for the `archive_enterprise_proxy` metric at -// around 70ms, excluding spikes, well under the 3s request timeout below. +// Datadog averages archive_enterprise_proxy around 70ms outside spikes, below the 3s timeout. const timeoutConfiguration = { request: 3000, } @@ -122,7 +101,7 @@ async function main(opts: MainOptions, args: string[]) { helpIndices.push(...getIndicesOf('GitHub.developer_help_url', contents)) if (docsIndices.length > 0) { for (const numIndex of docsIndices) { - // Assuming we don't have links close to 500 characters long + // Read 500 characters because github/github docs links are not expected to be longer. const docsLink = contents.substring(numIndex, numIndex + 500).match(urlRegEx) if (!docsLink) return const linkURL = new URL(docsLink[0].toString().replace(/[^a-zA-Z0-9]*$|\\n$/g, '')) @@ -133,13 +112,13 @@ async function main(opts: MainOptions, args: string[]) { if (helpIndices.length > 0) { for (const numIndex of helpIndices) { - // There are certain links like #{GitHub.help_url}#{learn_more_path} and #{GitHub.developer_help_url}#{learn_more_path} that we should skip + // Skip interpolated help URLs without static paths, including learn_more_path values. if ( (contents.substring(numIndex, numIndex + 11) === 'GitHub.help' && contents.charAt(numIndex + 16) === '#') || (contents.substring(numIndex, numIndex + 16) === 'GitHub.developer' && contents.charAt(numIndex + 26) === '#') || - // See internal issue #2180 + // Skip /github/#{...} interpolation because it does not resolve to a docs path. contents.slice(numIndex, numIndex + 'GitHub.help_url}/github/#{'.length) === 'GitHub.help_url}/github/#{' ) { @@ -147,9 +126,7 @@ async function main(opts: MainOptions, args: string[]) { } const startSearchIndex = contents.indexOf('/', numIndex) - // Looking for the closest '/' after GitHub.developer_help_url or GitHub.help_url - // There are certain links that don't start with `/` so we want to skip those. - // If there's no `/` within 30 characters of GitHub.help_url/GitHub.developer_help_url, skip + // Skip help_url values with no slash within 30 characters; those are not docs paths. if (startSearchIndex - numIndex < 30) { const linkPath = contents .substring( @@ -162,7 +139,6 @@ async function main(opts: MainOptions, args: string[]) { ) .trim() - // Certain specific links can be ignored as well if (['/deprecation-1'].includes(linkPath)) { return } @@ -184,13 +160,11 @@ async function main(opts: MainOptions, args: string[]) { file: string }[] = [] - // Break up the long list of URLs to test into batches for (const batch of [...Array(Math.floor(docsLinksFiles.length / BATCH_SIZE)).keys()]) { const slice = docsLinksFiles.slice(batch * BATCH_SIZE, batch * BATCH_SIZE + BATCH_SIZE) await Promise.all( slice.map(async ({ linkPath, file }) => { - // This isn't necessary but if it can't be constructed, it'll - // fail in quite a nice way and not "blame fetch". + // Constructing the URL here points URL failures at parsing instead of fetch. const url = new URL(BASE_URL + linkPath) try { await fetchWithRetry( diff --git a/src/links/scripts/check-links-external.ts b/src/links/scripts/check-links-external.ts index 5c69d366d614..9417386cb86b 100644 --- a/src/links/scripts/check-links-external.ts +++ b/src/links/scripts/check-links-external.ts @@ -1,21 +1,12 @@ -/** - * External Link Checker - * - * Validates external URLs in content files. - * Designed to run weekly with aggressive caching. - * - * Usage: - * npm run check-links-external - * npm run check-links-external -- --max 100 - * - * Environment variables: - * GITHUB_TOKEN - For creating issue reports and GitHub API repo checks - * ACTION_RUN_URL - Link to the action run - * CREATE_REPORT - Whether to create an issue report (default: false) - * REPORT_REPOSITORY - Repository to create report issues in - * CACHE_MAX_AGE_DAYS - How long to cache URL check results (default: 7) - * DOMAIN_CONCURRENCY - Number of domains to process concurrently (default: 10) - */ +// Validates external URLs in content files with caching for weekly runs. +// Usage: npm run check-links-external +// Usage: npm run check-links-external -- --max 100 +// GITHUB_TOKEN creates issue reports and checks GitHub API repo URLs. +// ACTION_RUN_URL links to the action run. +// CREATE_REPORT creates an issue report when true, default false. +// REPORT_REPOSITORY sets the repository for report issues. +// CACHE_MAX_AGE_DAYS sets how long to cache URL results, default 7. +// DOMAIN_CONCURRENCY sets how many domains run concurrently, default 10. import { program } from 'commander' import chalk from 'chalk' @@ -39,7 +30,7 @@ const CACHE_MAX_AGE_DAYS = parseInt(process.env.CACHE_MAX_AGE_DAYS || '7', 10) const CACHE_MAX_AGE_MS = CACHE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000 const REQUEST_TIMEOUT_MS = 30000 -const REQUEST_DELAY_MS = 100 // Avoids rate limiting a single domain. +const REQUEST_DELAY_MS = 100 // Spaces requests to avoid rate limiting a single domain. const DEFAULT_DOMAIN_CONCURRENCY = 10 const excludedLinksSet = new Set(excludedLinks.map(({ is }) => is).filter(Boolean)) @@ -67,14 +58,8 @@ interface LinkOccurrence { href: string } -/** - * Normalize a URL for deduplication purposes: - * - Remove URL fragment (#anchor) - * - Remove trailing slash only for origin/root URLs - * - * For example, https://www.githubstatus.com and https://www.githubstatus.com/ - * are treated as the same URL. - */ +// Normalizes URLs for deduplication by dropping fragments and root trailing slashes. +// Example: https://www.githubstatus.com/ becomes https://www.githubstatus.com. function normalizeUrl(href: string): string { const withoutFragment = href.split('#')[0] try { @@ -83,7 +68,7 @@ function normalizeUrl(href: string): string { return parsed.origin } } catch { - // Keep original if URL parsing fails. + // Malformed URLs stay unchanged so the checker can report them later. } return withoutFragment } @@ -114,10 +99,10 @@ async function checkUrl( const headers = { 'User-Agent': 'GitHub-Docs-Link-Checker/1.0' } - // Try HEAD first (faster, less data) + // HEAD transfers less data, so try it before GET. let response = await fetchWithTimeout(url, 'HEAD', headers) - // Fall back to GET if HEAD fails (some servers don't support HEAD properly) + // Some servers reject HEAD, so retry failing HTTP responses with GET. if (response && !response.ok && response.status >= 400) { response = await fetchWithTimeout(url, 'GET', headers) } @@ -166,9 +151,6 @@ async function fetchWithTimeout( } } -/** - * Return the owner/repo if the URL is exactly github.com/<owner>/<repo>, else null. - */ function isGithubRepoRootUrl(url: string): { owner: string; repo: string } | null { try { const parsed = new URL(url) @@ -176,16 +158,12 @@ function isGithubRepoRootUrl(url: string): { owner: string; repo: string } | nul const segments = parsed.pathname.split('/').filter(Boolean) if (segments.length === 2) return { owner: segments[0], repo: segments[1] } } catch { - // ignore malformed URLs + // Malformed URLs are not GitHub repo-root URLs. } return null } -/** - * Check a github.com/<owner>/<repo> URL via the REST API instead of hitting - * the main website. Verifies the repo exists and that html_url in the response - * matches the original link (catches renames/redirects). - */ +// GitHub repo-root URLs use the REST API to check that the repository exists and is public. async function checkGithubRepoUrl( url: string, owner: string, @@ -269,9 +247,7 @@ async function checkGithubRepoUrl( } } - // Only cache successful results. A failed API check may mean the URL is - // not actually a repo (e.g. github.com/settings/tokens), so we leave the - // cache empty for failures and let the checkUrl fallback handle caching. + // Cache only successful API repo checks; direct HTTP classifies non-repo URLs like github.com/settings/tokens. if (result.ok) { cache.urls[url] = { timestamp: Date.now(), @@ -378,8 +354,7 @@ async function main() { console.log('Extracting external links from content files...') const allLinks = await extractAllExternalLinks() - // Separate docs.github.com links. They're self-referential, since this repo is the docs - // site, and get reported separately as candidates for conversion to internal links. + // Report docs.github.com links separately because they can become internal links. const selfReferentialLinks = new Map<string, LinkOccurrence[]>() for (const [url, occurrences] of allLinks) { if (isDocsGithubUrl(url)) { @@ -416,8 +391,7 @@ async function main() { ) let malformedCount = 0 - // Group URLs by hostname so we can check multiple domains in parallel - // while keeping requests to any single domain sequential. + // Group by hostname to check domains in parallel without overlapping requests to one domain. const urlsByDomain = new Map<string, string[]>() for (let i = 0; i < maxUrls; i++) { const url = urls[i] @@ -450,7 +424,7 @@ async function main() { `Checking ${plannedTotal} URLs across ${urlsByDomain.size} domains (up to ${domainConcurrency} domains at once)...`, ) - // Check all URLs for one domain sequentially, respecting the per-request delay. + // One domain runs sequentially to respect REQUEST_DELAY_MS. async function checkDomainUrls(domainUrls: string[]): Promise<void> { for (const url of domainUrls) { const occurrences = allLinks.get(url)! @@ -465,8 +439,7 @@ async function main() { if (repoInfo && process.env.GITHUB_TOKEN) { result = await checkGithubRepoUrl(url, repoInfo.owner, repoInfo.repo, db.data) - // Fall back to direct HTTP checks only when the API result is not - // definitive (e.g. API/network failures or private-repo responses). + // Fall back to direct HTTP for API, network, or private-repo failures. if (!result.ok && result.fallbackAllowed) { result = await checkUrl(url, db.data) } @@ -511,9 +484,7 @@ async function main() { } } - // Distribute domains round-robin across DOMAIN_CONCURRENCY workers. Each worker - // processes its assigned domains sequentially, so we get parallelism across - // domains without hammering any single domain. + // Round-robin domains across workers for parallelism without overlapping one domain. const domainQueues = Array.from(urlsByDomain.values()) const workers: string[][][] = Array.from({ length: domainConcurrency }, () => []) for (let i = 0; i < domainQueues.length; i++) { diff --git a/src/links/scripts/check-links-internal.ts b/src/links/scripts/check-links-internal.ts index 3de89ec9b7dc..baeafc633154 100644 --- a/src/links/scripts/check-links-internal.ts +++ b/src/links/scripts/check-links-internal.ts @@ -1,22 +1,13 @@ -/** - * Internal Link Checker - * - * Comprehensive check of all internal links across all versions and languages. - * Designed to run as a scheduled workflow (twice weekly). - * - * Usage: - * npm run check-links-internal - * npm run check-links-internal -- --version free-pro-team@latest --language en - * - * Environment variables: - * VERSION - Version to check (e.g., free-pro-team@latest) - * LANGUAGE - Language to check (e.g., en) - * GITHUB_TOKEN - For creating issue reports - * ACTION_RUN_URL - Link to the action run - * CREATE_REPORT - Whether to create an issue report (default: false) - * REPORT_REPOSITORY - Repository to create report issues in - * CHECK_ANCHORS - Whether to check anchor links (default: true) - */ +// Checks all internal links across all versions and languages on a schedule. +// Usage: npm run check-links-internal +// Usage: npm run check-links-internal -- --version free-pro-team@latest --language en +// VERSION sets the version to check, for example free-pro-team@latest. +// LANGUAGE sets the language to check, default en. +// GITHUB_TOKEN creates issue reports. +// ACTION_RUN_URL links to the action run. +// CREATE_REPORT creates an issue report when true, default false. +// REPORT_REPOSITORY sets the repository for report issues. +// CHECK_ANCHORS controls anchor link checks, default true. import fs from 'fs' import os from 'os' @@ -71,15 +62,8 @@ interface CheckResult { totalLinksChecked: number } -/** - * Count how many lines the frontmatter block occupies in the raw source file. - * `page.markdown` has frontmatter stripped, so line numbers from markdown - * parsing are relative to the body. Adding this offset converts them to - * actual file line numbers. - * - * Results are cached by fullPath, so the file is read once per page across - * both getLinksFromMarkdown() and checkAnchorsOnPage(). - */ +// page.markdown has frontmatter stripped, so source positions need the raw-file offset. +// Cache by fullPath so each page file is read once for link and anchor checks. const frontmatterLineOffsetCache = new Map<string, number>() function getFrontmatterLineOffset(fullPath: string): number { @@ -93,31 +77,24 @@ function getFrontmatterLineOffset(fullPath: string): number { const lines = raw.split('\n') for (let i = 1; i < lines.length; i++) { if (lines[i].trimEnd() === '---') { - // i is the 0-based index of the closing `---`; adding 1 gives the - // 1-based line number of that delimiter, which is the total number - // of frontmatter lines. Body content starts on the next line. + // Offset points body links at their raw-file source positions. offset = i + 1 break } } } } catch { - // Ignore: fall back to no offset. + // Fall back to no offset when the raw file cannot be read. } frontmatterLineOffsetCache.set(fullPath, offset) return offset } -/** - * Extract all internal links from the markdown source with accurate line numbers. - * - * Links are discovered from the Liquid-rendered content (which expands {% data reusables.xxx %} - * and respects {% ifversion %} for the current version), so coverage matches the original - * HTML-based checker. Line numbers are resolved against the raw markdown source to avoid - * drift caused by Liquid post-processing (blank-line collapsing). Links that originate - * from a reusable file rather than the page itself fall back to line 0. - */ +// Extract links from Liquid-rendered content, then map each one to the raw Markdown source. +// Raw source positions avoid drift from Liquid post-processing, such as blank-line collapsing. +// Example: /{% ifversion fpt %}enterprise-cloud@latest/{% endif %}/path renders before lookup. +// Reusable-origin links fall back to 0 because this file has no matching source position. async function getLinksFromMarkdown( page: Page, context: Context, @@ -126,13 +103,7 @@ async function getLinksFromMarkdown( ): Promise<{ href: string; text: string | undefined; line: number; fragment?: string }[]> { const fmOffset = getFrontmatterLineOffset(page.fullPath) - // Build a map of raw-markdown line numbers per href, plus a parallel index - // map to consume them in encounter order without shifting (O(1) per lookup). - // - // When a raw href contains Liquid tags (e.g. `/{% ifversion fpt %}enterprise-cloud@latest/{% endif %}/path`), - // the rendered href will differ from the raw string, so rawLinesByHref.get() would miss. - // To fix this, we lazily import renderLiquid once and use it to resolve those hrefs to - // their canonical (rendered) form before keying the map — matching what extractLinksWithLiquid produces. + // Render Liquid hrefs before keying the map so raw and rendered extraction use the same href. const rawResult = precomputedRawResult ?? extractLinksFromMarkdown(page.markdown) const needsLiquidHrefResolution = @@ -150,11 +121,10 @@ async function getLinksFromMarkdown( let canonicalHref = link.href if (renderLiquidFn && (canonicalHref.includes('{%') || canonicalHref.includes('{{'))) { try { - // Render only the href string so we get the same canonical href that - // extractLinksWithLiquid will produce, without affecting line positions. + // Render only the href so Liquid changes do not shift raw source positions. canonicalHref = (await renderLiquidFn(canonicalHref, context)).trim() } catch { - // Fall back to the raw href if rendering fails. + // Keep the raw href when Liquid rendering fails. } } const existing = rawLinesByHref.get(canonicalHref) @@ -165,9 +135,7 @@ async function getLinksFromMarkdown( } } - // Liquid-prefixed links (href starts with `{%`) are absent from internalLinks because - // INTERNAL_LINK_PATTERN requires a leading '/'. Render each href to its canonical form - // and, if the result is an internal path, add it to the map so lookups don't miss. + // Render Liquid-prefixed hrefs because the raw extractor only treats leading slashes as internal paths. if (renderLiquidFn) { for (const link of rawResult.liquidPrefixedLinks) { try { @@ -181,17 +149,14 @@ async function getLinksFromMarkdown( } } } catch { - // Skip: can't resolve a line number for this link. + // Skip links with no resolvable source position. } } } - // Tracks how many line numbers have been consumed for each href. + // Track repeated hrefs so each rendered occurrence gets the next raw source position. const rawLinesIndex = new Map<string, number>() - // The Liquid-rendered set drives which links are actually checked (expands - // reusables, excludes version-gated links that don't apply here). - // extractLinksWithLiquid already catches Liquid render failures internally and - // falls back to raw extraction with a warning, so no outer try/catch is needed. + // The Liquid-rendered set controls checks; extractLinksWithLiquid handles render failures. const renderedResult = prerenderedResult ?? (await extractLinksWithLiquid(page.markdown, context)) const renderedLinks = renderedResult.internalLinks.map((l) => ({ href: l.href, @@ -208,16 +173,8 @@ async function getLinksFromMarkdown( }) } -/** - * Check anchor links on a page using fast heading ID computation from Liquid-rendered - * markdown. Avoids the expensive full HTML render previously used. - * - * Uses github-slugger (the same library as rehype-slug in the render pipeline) to compute - * heading anchor IDs, producing results that match the live site. - * - * `headingIds` is precomputed once per page in checkPage and shared with the cross-page - * anchor cache, so this function only checks same-page (`#fragment`) links here. - */ +// Check same-page anchors with Liquid-rendered headings and github-slugger, matching the live site. +// checkPage shares headingIds with cross-page validation, so this only checks same-page fragments. function checkAnchorsFromHeadings( page: Page, rawResult: LinkExtractionResult, @@ -226,7 +183,7 @@ function checkAnchorsFromHeadings( ): BrokenLink[] { const fmOffset = getFrontmatterLineOffset(page.fullPath) - // Build line-number map from the raw (pre-Liquid) source for accurate file line numbers. + // Raw source positions point same-page anchor flaws at the file a writer edits. const anchorLineMap = new Map<string, number>() for (const link of rawResult.anchorLinks) { if (!anchorLineMap.has(link.href)) { @@ -234,8 +191,7 @@ function checkAnchorsFromHeadings( } } - // Check only the anchor links that actually appear in the Liquid-rendered output - // (respects {% ifversion %} gates, so links in non-applicable blocks are not checked). + // Check only anchors that survive Liquid version gates. const brokenAnchors: BrokenLink[] = [] for (const link of renderedResult.anchorLinks) { const { href } = link @@ -254,10 +210,7 @@ function checkAnchorsFromHeadings( return brokenAnchors } -/** - * Process a single page: extract links, validate them, and optionally check anchors. - * Receives its own context object so it is safe to run concurrently with other pages. - */ +// Each page gets its own context object, so concurrent checks cannot share mutable page state. async function checkPage( page: Page, permalink: Permalink, @@ -278,19 +231,13 @@ async function checkPage( const rawMarkdownLinks = extractLinksFromMarkdown(page.markdown) - // Render through Liquid once; share the result between link extraction and anchor - // checking to avoid paying the Liquid render cost twice per page. + // Share one Liquid render between link extraction and anchor checks. const { renderedMarkdown, result: renderedLinkResult } = await renderAndExtractLinks( page.markdown, pageContext, ) - // Compute this page's heading anchor IDs once from the Liquid-rendered markdown. - // Autogenerated pages (REST/GraphQL/webhooks) derive their anchors from OpenAPI - // operation IDs, not markdown headings, so we can't compute them here. Leave them - // out of the cache so links into them are never flagged (they resolve at runtime). - // Skip the work entirely when anchor checking is disabled: nothing downstream reads - // the heading cache in that mode. + // REST, GraphQL, and webhook pages use OpenAPI operation IDs, so cache only Markdown headings. const headingIds = options.checkAnchors && !page.autogenerated ? computeHeadingIds(renderedMarkdown) : null @@ -338,9 +285,7 @@ async function checkPage( requiresVersionContext: result.requiresVersionContext, }) } else if (options.checkAnchors && link.fragment) { - // Direct (non-redirect) hit with a fragment: defer a cross-page anchor check. - // We can't validate it now because the target page may not have been rendered - // yet, so collect it and validate after the whole version finishes. + // Defer cross-page fragments until this version finishes; some targets have no cache entry. const targetKey = resolveInternalLinkKey( link.href, pageMap, @@ -373,10 +318,9 @@ async function checkPage( return { brokenLinks, redirectLinks, linksChecked: links.length, headingIds, crossPageAnchors } } -/** - * Check all pages for a given version and language, processing pages concurrently - * up to `concurrency` at a time. - */ +// checkVersion renders every page before validating cross-page anchors. +// Target pages may not have cached headings when an earlier page links to them. +// Skip targets outside this run; the scheduled matrix does not cover every version. async function checkVersion( version: string, language: string, @@ -400,9 +344,7 @@ async function checkVersion( ` Checking ${relevantPages.length} pages for ${version}/${language} (concurrency: ${options.concurrency})`, ) - // Build a base context once per version: feature flags and version info are the same - // for all pages. - // Each page gets a shallow copy so concurrent tasks don't share the mutable `page` property. + // Give each page a shallow context copy so concurrent workers do not share mutable page state. const baseContext = { currentVersion: version, currentLanguage: language, @@ -418,19 +360,10 @@ async function checkVersion( let totalPagesChecked = 0 let totalLinksChecked = 0 - // Cross-page anchor validation is a two-pass process within the version: - // pass 1: render every page, caching its heading IDs and collecting the - // cross-page anchor links it contains (target may not be rendered yet) - // pass 2: after all pages are rendered, validate each collected anchor against - // the now-complete heading cache - // The cache is keyed by pageMap key (lang + version + path). A link whose target - // resolves to a different version isn't in this run's cache and is skipped here; - // it's validated when the workflow runs the checker for that target version. const headingIdsByPageKey = new Map<string, Set<string>>() const pendingCrossPageAnchors: PendingCrossPageAnchor[] = [] - // Bounded concurrency: process up to `options.concurrency` pages simultaneously. - // All workers drain from the same shared iterator, so no page is processed twice. + // All workers drain a shared iterator, so bounded concurrency never processes a page twice. const queue = relevantPages.entries() async function worker() { @@ -438,8 +371,7 @@ async function checkVersion( const permalink = page.permalinks?.find((p) => p.pageVersion === version) if (!permalink) continue - // Each concurrent task gets its own context copy with the page set. - // pageMap and redirects are read-only and safe to share. + // Each worker gets a context copy with its own page; pageMap and redirects are read-only. const pageContext = { ...baseContext, page } as Context const result = await checkPage(page, permalink, pageContext, pageMap, redirects, { @@ -448,8 +380,7 @@ async function checkVersion( language, }) - // Merging results here is safe: JS is single-threaded so array pushes - // between await points cannot interleave with another worker's pushes. + // JS runs between awaits without interleaving another worker's array pushes. allBrokenLinks.push(...result.brokenLinks) allRedirectLinks.push(...result.redirectLinks) if (result.headingIds) headingIdsByPageKey.set(permalink.href, result.headingIds) @@ -465,10 +396,9 @@ async function checkVersion( } } - // Launch `concurrency` workers that all drain from the same shared queue iterator. await Promise.all(Array.from({ length: options.concurrency }, worker)) - // Pass 2: validate cross-page anchors now that every page's headings are cached. + // Validate cross-page anchors after every page has cached its headings. if (options.checkAnchors) { allBrokenLinks.push(...validateCrossPageAnchors(pendingCrossPageAnchors, headingIdsByPageKey)) } @@ -604,8 +534,7 @@ async function main() { console.log(`Created report issue: ${newReport.html_url}`) } - // Don't exit with an error. The issue report is how docs-content hears about broken - // links, whereas a failing exit code only triggers docs-alerts. + // Avoid a failing exit code; report issues notify docs-content, while failures only notify docs-alerts. console.log('') console.log( chalk.yellow( diff --git a/src/links/scripts/check-links-pr.ts b/src/links/scripts/check-links-pr.ts index ffe4f35479c9..3499a5e7bb39 100644 --- a/src/links/scripts/check-links-pr.ts +++ b/src/links/scripts/check-links-pr.ts @@ -1,20 +1,11 @@ -/** - * PR Link Checker - * - * Fast validation of internal links in changed files. - * Designed to run in <10 minutes on typical PRs. - * - * Usage: - * npm run check-links-pr - * npm run check-links-pr -- --files content/actions/index.md content/repos/index.md - * - * Environment variables: - * FILES_CHANGED - JSON array of changed files (from GitHub Actions) - * GITHUB_TOKEN - For posting PR comments - * ACTION_RUN_URL - Link to the action run - * SHOULD_COMMENT - Whether to post PR comments (default: false) - * FAIL_ON_FLAW - Exit with error code if broken links found (default: true) - */ +// Validates internal links in changed files and targets typical PR runs under 10 minutes. +// Usage: npm run check-links-pr +// Usage: npm run check-links-pr -- --files content/actions/index.md content/repos/index.md +// FILES_CHANGED passes a JSON array of changed files from GitHub Actions. +// GITHUB_TOKEN posts PR comments. +// ACTION_RUN_URL links to the action run. +// SHOULD_COMMENT posts PR comments when true, default false. +// FAIL_ON_FLAW exits with an error when broken links are found, default true. import { program } from 'commander' import chalk from 'chalk' @@ -123,17 +114,10 @@ async function checkFile( return { file: filePath, brokenLinks, redirectLinks, totalLinksChecked } } -/** - * Validate cross-page anchor links (`/path#fragment`) in a changed source page. - * - * Unlike the page-existence checks above (which run in a single version), anchors are - * checked in every version the source page renders in, because a version-gated link or a - * version-specific heading can be broken in one version and fine in another. For each - * link with a fragment we resolve the target page, pick the version the link points to - * (an explicit `/enterprise-*` prefix, else the source version), render that target on - * demand, and confirm the fragment matches a real heading. Autogenerated and glossary - * targets are skipped (their anchors aren't static Markdown headings). - */ +// Cross-page anchors must pass in every version the source page renders in. +// Version-gated links and headings can break in one version while passing in another. +// Explicit enterprise prefixes choose the target version; other links use the source version. +// Autogenerated and glossary targets are skipped because their anchors are not static Markdown headings. async function checkFileAnchors( filePath: string, sourcePage: Page, @@ -150,13 +134,11 @@ async function checkFileAnchors( const file = getRelativePath(filePath) const versions = sourcePage.applicableVersions ?? [] - // Resolve each broken link to its stable source line(s) from the raw markdown. Rendered - // line numbers drift between versions (ifversion blocks expand differently), so keying on - // them would report the same occurrence multiple times; the raw source line is stable. + // Raw source positions dedupe the same occurrence across version-specific renders. const rawLinesFor = (hrefWithFragment: string): number[] => findLinkLines(content, hrefWithFragment) - // Dedupe by target (file + href#fragment), aggregating the versions it breaks in. + // Aggregate versions by target so one broken fragment reports once per file and href. const flaws = new Map< string, { href: string; file: string; lines: number[]; text?: string; versionSet: Set<string> } @@ -168,22 +150,16 @@ async function checkFileAnchors( for (const link of result.internalLinks) { if (!link.fragment) continue - // `#top` is always valid: browsers scroll to the top of the document when nothing - // carries that ID, so it never appears in computed heading IDs. Mirrors the - // same-page checker in check-links-internal.ts, which skips `#` and `#top`. + // #top is valid without a heading ID; check-links-internal.ts applies the same rule. if (link.fragment === 'top') continue - // resolveLinkKeyForVersion only returns direct (non-redirect) page hits, so - // redirects, archived versions, and broken paths fall out here. They're not - // anchor-scope flaws. Unversioned hrefs are retried against the version the source - // page is currently rendered in, so GHEC/GHES-only targets resolve too. + // Redirects, archived versions, and broken paths drop out; unversioned hrefs retry in source version. const targetKey = resolveLinkKeyForVersion(link.href, version, pageMap) if (!targetKey) continue const targetPage = pageMap[targetKey] if (!targetPage) continue - // Check the target in the version the link points to: an explicit version prefix if - // present, otherwise the version the source page is currently rendered in. + // Explicit version prefixes choose the target version; other links use the source version. const checkVersion = versionFromResolvedKey(targetKey) ?? version if (!targetPage.applicableVersions?.includes(checkVersion)) continue if (!isAnchorCheckableTarget(targetPage)) continue @@ -202,8 +178,7 @@ async function checkFileAnchors( if (existing) { existing.versionSet.add(checkVersion) } else { - // Fall back to the rendered line when the raw scan misses (e.g. a version-idiom - // href like `/{% ifversion %}...{% endif %}path` that isn't a literal string). + // Fall back to link.line when a Liquid href like /{% ifversion %}...{% endif %}path has no raw match. const lines = rawLinesFor(href) flaws.set(href, { href, @@ -223,21 +198,20 @@ async function checkFileAnchors( } function getChangedFiles(cliFiles?: string[]): string[] { - // CLI args take precedence if (cliFiles && cliFiles.length > 0) { return cliFiles } const filesChanged = process.env.FILES_CHANGED if (filesChanged) { - // Try parsing as JSON first + // FILES_CHANGED can be a JSON array. try { const parsed = JSON.parse(filesChanged) if (Array.isArray(parsed)) { return parsed } } catch { - // Not JSON, treat as space-separated string (tj-actions/changed-files format) + // tj-actions/changed-files provides a space-separated string. return filesChanged.split(/\s+/).filter(Boolean) } } @@ -248,8 +222,7 @@ function getChangedFiles(cliFiles?: string[]): string[] { function filterContentFiles(files: string[]): string[] { return files.filter((file) => { if (!file.endsWith('.md')) return false - // Skip README.md files. They're developer docs, not published pages, and use - // repo-relative paths (e.g. /src/...) that aren't valid site links. + // Skip README.md files because repo-relative developer-docs paths like /src/... are not site links. if (file === 'README.md' || file.endsWith('/README.md')) return false if (file.startsWith('content/') || file.startsWith('data/')) return true return false @@ -283,7 +256,7 @@ async function commentOnPR( anchorsBlocking: process.env.FAIL_ON_ANCHOR_FLAW === 'true', }) - // Find any existing comment we previously posted (identified by the hidden marker) + // The hidden marker identifies this bot's previous PR comment. const marker = '<!-- link-checker-pr-comment -->' const { data: comments } = await octokit.rest.issues.listComments({ owner, @@ -294,9 +267,7 @@ async function commentOnPR( if (!comment) { console.log('No broken links to report') - // Links are now clean: remove any stale comment from an earlier commit. - // Best-effort: a concurrent run may have already deleted it (404), and - // cleanup should never turn an otherwise-passing run into a failure. + // Delete stale comments best-effort because cleanup must not fail a clean link check. if (existingComment) { try { await octokit.rest.issues.deleteComment({ @@ -349,7 +320,7 @@ async function main() { let files = getChangedFiles(options.files) if (options.all) { - // For testing: check all content files (limited) + // Limit --all mode for local testing. const { globSync } = await import('node:fs') files = globSync('content/**/*.md').sort().slice(0, 50) console.log(`Checking ${files.length} files (--all mode, limited to 50)`) @@ -372,15 +343,13 @@ async function main() { `Loaded ${Object.keys(pageMap).length} pages, ${Object.keys(redirects).length} redirects`, ) - // Index en pages by their content-relative path so a changed file can be matched to its - // Page (needed to know which versions to check its anchors in). + // Index English pages by content path so anchor checks know each changed file's versions. const pageByRelativePath = new Map<string, Page>() for (const page of pageList) { if (page.languageCode === 'en') pageByRelativePath.set(page.relativePath, page) } - // Cross-page anchor checking renders target pages on demand; the cache dedupes that - // work across links and files (keyed by version + target path). + // Cache on-demand target renders by version and path across changed files. const checkAnchors = process.env.CHECK_ANCHORS !== 'false' const headingCache = new Map<string, Set<string>>() @@ -405,8 +374,7 @@ async function main() { allRedirectLinks.push(...result.redirectLinks) totalLinksChecked += result.totalLinksChecked - // Anchor validation only applies to published pages (data/ reusables have no versions - // of their own), so skip any changed file that isn't a Page. + // Skip changed files outside pageByRelativePath because data and reusables have no versions of their own. const sourcePage = pageByRelativePath.get(getRelativePath(filePath)) if (checkAnchors && sourcePage) { allBrokenAnchors.push( @@ -425,7 +393,7 @@ async function main() { allBrokenAnchors.length === 0 ) { console.log(chalk.green('✅ All links valid!')) - // Remove any stale comment posted on an earlier commit, now that links are clean + // Clean runs remove stale comments only when PR commenting is enabled. if (process.env.SHOULD_COMMENT === 'true') { try { await commentOnPR([], [], process.env.ACTION_RUN_URL) @@ -471,8 +439,7 @@ async function main() { } } - // Write artifact for debugging. Best-effort: a reporting/API failure must - // never fail the build. Only broken links (below) should fail the PR. + // Upload broken-link artifacts best-effort so reporting failures cannot fail the PR. const allFlaws = [...allBrokenLinks, ...allRedirectLinks] try { await uploadArtifact('broken-links.json', JSON.stringify(groupBrokenLinks(allFlaws), null, 2)) @@ -483,7 +450,7 @@ async function main() { console.warn('Could not upload broken-links artifact:', err) } - // Post PR comment if configured. Best-effort for the same reason. + // Post PR comments best-effort for the same reason. const shouldComment = process.env.SHOULD_COMMENT === 'true' if (shouldComment) { const actionUrl = process.env.ACTION_RUN_URL @@ -494,9 +461,7 @@ async function main() { } } - // Exit with error if broken links found. Broken page links block by default; cross-page - // anchors are non-blocking during rollout unless FAIL_ON_ANCHOR_FLAW is explicitly set, - // so we can measure false positives before turning them into a hard gate. + // Anchor flaws stay opt-in via FAIL_ON_ANCHOR_FLAW while false positives are measured. const failOnFlaw = process.env.FAIL_ON_FLAW !== 'false' const failOnAnchorFlaw = process.env.FAIL_ON_ANCHOR_FLAW === 'true' const shouldFail = diff --git a/src/links/scripts/combine-link-reports.ts b/src/links/scripts/combine-link-reports.ts index af4c72c4a6f0..e9d20fcff4dc 100644 --- a/src/links/scripts/combine-link-reports.ts +++ b/src/links/scripts/combine-link-reports.ts @@ -1,12 +1,7 @@ #!/usr/bin/env tsx -/** - * Combine every version's link report into one deduplicated Markdown report. - * - * The workflow used to `cat` each version's rendered Markdown together, so a link broken in - * every version produced an identical section per version. That multiplied the report by the - * size of the matrix and pushed it past the issue body limit, where it got truncated. - */ +// Combines per-version reports so one target broken in many versions appears once. +// Deduplication keeps the Markdown report under the issue body limit. import fs from 'fs' import path from 'path' @@ -18,7 +13,7 @@ import { type LinkReport, } from '@/links/lib/link-report' -// `link-report-free-pro-team@latest-en.json` -> `free-pro-team@latest en` +// Example: link-report-free-pro-team@latest-en.json -> free-pro-team@latest en const REPORT_FILE = /^link-report-(.+)-([a-z]{2})\.json$/ interface VersionedReport { diff --git a/src/links/scripts/update-internal-links.ts b/src/links/scripts/update-internal-links.ts index 2531b7852f82..4302cd2c203e 100755 --- a/src/links/scripts/update-internal-links.ts +++ b/src/links/scripts/update-internal-links.ts @@ -1,11 +1,5 @@ -// [start-readme] -// -// Run this script to update content's internal links. -// It can correct the title part or the URL part or both. -// -// Best way to understand how to use it is to run it with `--help`. -// -// [end-readme] +// Updates content internal links by correcting titles, hrefs, or both. +// Usage: npm run update-internal-links -- --help import fs from 'fs' import path from 'path' @@ -53,6 +47,8 @@ type Options = { exclude: string[] filesOrDirectories?: string[] } +// main computes every link update before writing files. +// updateInternalLinks returns planned edits only, so one broken link can fail before files change. async function main(files: string[], opts: Options) { const { debug } = opts @@ -100,7 +96,7 @@ async function main(files: string[], opts: Options) { console.log(chalk.bold(`Updating internal links in ${actualFiles.length} found files...`)) } - // The updateInternalLinks doesn't use "negatives" for certain options + // Commander negative flags map to positive library options here. const options = { setAutotitle: !opts.dontSetAutotitle, fixHref: !opts.dontFixHref, @@ -109,19 +105,10 @@ async function main(files: string[], opts: Options) { keepStaleFragments: !!opts.keepStaleFragments, } - // Remember, updateInternalLinks() doesn't actually change the files - // on disk. That's the responsibility of the caller, i.e. this CLI script. - // The reason why is that updateInternalLinks() can then see if ALL - // improvements are going to work. For example, if you tried run - // it across 10 links and the 7th one had a corrupt broken link that - // can't be corrected, it needs to fail there and then instead of - // leaving 6 of the 10 files changed. const results = await updateInternalLinks(actualFiles, options) let exitCheck = 0 - // Serializing can throw, and a throw halfway through the loop would leave a - // half-updated checkout. Every output is computed first so a failure on the last - // file means nothing was written at all, which is what the comment above promises. + // Serialize every output before writing, so a late failure leaves the checkout unchanged. const pendingWrites: { file: string; output: string }[] = [] for (const { file, @@ -165,8 +152,7 @@ async function main(files: string[], opts: Options) { output: serializeYaml(newContent, newData, differentContent, differentData), }) } else { - // Remember the `content` and `newContent` is the "meat" of the - // Markdown page. To save it you need the frontmatter data too. + // serializeMarkdown needs rawContent to preserve frontmatter around the updated body. pendingWrites.push({ file, output: serializeMarkdown(rawContent, content, newContent, newData, differentData), @@ -184,7 +170,7 @@ async function main(files: string[], opts: Options) { } } - // Every serializer succeeded, so the writes can't be interrupted by one of them. + // Every serializer succeeded, so file writes cannot be interrupted by serialization errors. for (const { file, output } of pendingWrites) { fs.writeFileSync(file, output, 'utf-8') } @@ -245,8 +231,7 @@ function printObjectDifference( rawContent: string, parentKey = '', ) { - // Assume both object are of the same shape, but if a key's value is - // an array, and it's different, print that difference. + // Callers pass matching frontmatter shapes; this reports only differing array values. for (const [key, value] of Object.entries(objFrom)) { const combinedKey = `${parentKey}.${key}` const otherValue = objTo[key] @@ -255,7 +240,7 @@ function printObjectDifference( for (let i = 0; i < value.length; i++) { const entry = value[i] const otherEntry = otherValue[i] - // If it was an array of objects, we need to go deeper! + // Recurse into array objects so nested frontmatter values report at their parent key. if (isObject(entry) && isObject(otherEntry)) { printObjectDifference(entry, otherEntry, rawContent, combinedKey) } else { @@ -278,7 +263,7 @@ function printObjectDifference( } } -// This assumes them to be the same shape with possibly different node values +// equalObject expects matching shapes and compares leaf values recursively. function equalObject(obj1: Record<string, unknown>, obj2: Record<string, unknown>) { if (!equalSet(new Set(Object.keys(obj1)), new Set(Object.keys(obj2)))) { return false @@ -287,7 +272,7 @@ function equalObject(obj1: Record<string, unknown>, obj2: Record<string, unknown const otherValue = obj2[key] if (Array.isArray(value)) { if (!Array.isArray(otherValue)) return false - // Can't easily compare two arrays because the entries might be objects. + // Array entries can be objects, so compare them recursively. if (value.length !== otherValue.length) return false let i = 0 for (const each of value) { diff --git a/src/links/scripts/upload-artifact.ts b/src/links/scripts/upload-artifact.ts index b37beb31103a..de592707074a 100644 --- a/src/links/scripts/upload-artifact.ts +++ b/src/links/scripts/upload-artifact.ts @@ -1,7 +1,6 @@ import fs from 'fs' -// Writes a string to a file for the workflow to upload as an artifact. -// Useful for debugging, or for passing results to a downstream action. +// Writes workflow artifacts to disk so later steps can upload or inspect them. export async function uploadArtifact(name: string, contents: string) { if (!fs.existsSync('./artifacts')) { fs.mkdirSync('./artifacts/') diff --git a/src/links/scripts/validate-github-github-docs-urls/generate-new-json.ts b/src/links/scripts/validate-github-github-docs-urls/generate-new-json.ts index c30ebaef2e3f..6fbc8a298d96 100644 --- a/src/links/scripts/validate-github-github-docs-urls/generate-new-json.ts +++ b/src/links/scripts/validate-github-github-docs-urls/generate-new-json.ts @@ -22,8 +22,7 @@ export function generateNewJSON( for (const [identifier, url] of Object.entries(destination)) { const check = checks.find((foundCheck) => foundCheck.identifier === identifier) if (check) { - // At the moment, the only possible correction is if the URL is - // found but required a redirect. + // Redirects are the only automatic docs URL correction. if (check.redirect) { destination[identifier] = check.redirect console.log( @@ -36,8 +35,7 @@ export function generateNewJSON( if (countChanges > 0) { const writeTo = options.output || destinationFilePath - // It's important that this serializes exactly like the Ruby code - // that is the CLI script `script/add-docs-url` in github/github. + // Match github/github script/add-docs-url JSON formatting exactly. const serialized = `${JSON.stringify(destination, null, 2)}\n` fs.writeFileSync(writeTo, serialized, 'utf-8') console.log(`Wrote ${countChanges} change${countChanges === 1 ? '' : 's'} to ${writeTo}`) diff --git a/src/links/scripts/validate-github-github-docs-urls/post-pr-comment.ts b/src/links/scripts/validate-github-github-docs-urls/post-pr-comment.ts index 4b2d38a1786b..3c3822342794 100644 --- a/src/links/scripts/validate-github-github-docs-urls/post-pr-comment.ts +++ b/src/links/scripts/validate-github-github-docs-urls/post-pr-comment.ts @@ -10,16 +10,12 @@ type PostPRCommentOptions = { repository: string dryRun: boolean failOnError?: boolean - // If someone uses ` ... --changed-files`, Commander will set this to - // boolean `true`. - // If someone uses ` ... --changed-files foo bar`, the value - // becomes `['foo', 'bar']`. - // And since it defaults to an env var called `CHANGED_FILES`, - // it could be a string like `'foo bar'`. + // --changed-files foo bar becomes a string array; bare --changed-files becomes true. + // The CHANGED_FILES default can also arrive as a space-separated string. changedFiles?: string | string[] | true } -// This function is designed to be able to run and potentially do nothing. +// postPRComment may exit without posting when filtered checks are clean. export async function postPRComment(filePath: string, options: PostPRCommentOptions) { if (!options.dryRun) { if (!options.issueNumber) { @@ -34,14 +30,13 @@ export async function postPRComment(filePath: string, options: PostPRCommentOpti } } - // See note on `PostPRCommentOptions` type about this + // Reject bare --changed-files before reading checks. if (options.changedFiles === true) { throw new Error( 'If you use --changed-files, you must provide at least one file path. For example, --changed-files foo.md bar.md', ) } - // Exit early if there's absolutely nothing to "complain" about const checks: Check[] = JSON.parse(fs.readFileSync(filePath, 'utf8')) const changedFiles: string[] = [] @@ -71,25 +66,17 @@ export async function postPRComment(filePath: string, options: PostPRCommentOpti ) } - // Really bad. This could lead to a 404 from links in GitHub. + // Missing pages can make github/github generate 404 links. const failedChecks = checksFiltered.filter((check) => !check.found) - // Bad. This could lead to the fragment not finding the right - // heading in the found page. + // Missing fragments keep github/github links from reaching the intended heading. const failedFragmentChecks = checksFiltered.filter( (check) => check.found && check.fragment && !check.fragmentFound, ) const body: string[] = [] - // Suppose, the first time the PR is created, we post a comment about - // some failing fragments for example. Then, the PR author addresses - // that and commits more to the PR. Now, perhaps there are no more failing - // checks. Then we're going to update the previously posted comment. - // But(!) suppose there were never any failing checks. Then, we don't - // want to bother posting a comment at all since it's just noise to - // say "This PR introduces no failing checks.". Especially, since this - // will be the case for the large majority of PRs in this repo. + // Clean results update a previous failure comment but never create a new noise-only comment. const onlyIfAlreadyPosted = failedChecks.length === 0 && failedFragmentChecks.length === 0 if (onlyIfAlreadyPosted) { @@ -154,8 +141,7 @@ export async function postPRComment(filePath: string, options: PostPRCommentOpti if (options.dryRun) { console.log(body.join('\n')) } else { - // We must inject this into the comment we're about to start so that it - // can be possible to find a previously posted comment. + // Add the marker only when posting, so later runs can find this bot comment. body.push(`<!-- ${needle} -->`) const issueNumber = parseInt(options.issueNumber as string, 10) @@ -185,7 +171,7 @@ Remember, this workflow check is not required because it's not guaranteed to be function contentFileMatchesURL(filePath: string, url: string) { if (!filePath.startsWith('content/')) return false - // This strips and omits any query string or hash + // Match content paths against the URL path, ignoring query strings and fragments. const pathname = new URL(url, 'https://docs.github.com').pathname const fileUrl = filePath.replace('content', '').replace('/index.md', '').replace(/\.md$/, '') @@ -257,10 +243,7 @@ async function updateIssueComment( } } - // There is no comment to edit, so this would create one, but `onlyIfAlreadyPosted` - // is true so it does nothing. That matters when a PR previously had failing checks, - // got more commits, and no longer does: the old comment should be updated, but a - // PR that never failed should not gain one. + // With onlyIfAlreadyPosted, clean PRs without an existing bot comment stay silent. if (onlyIfAlreadyPosted) { console.warn(`Deliberately not creating a new comment`) return diff --git a/src/links/scripts/validate-github-github-docs-urls/validate.ts b/src/links/scripts/validate-github-github-docs-urls/validate.ts index f6e9fb5ef081..5458d1dec3d5 100644 --- a/src/links/scripts/validate-github-github-docs-urls/validate.ts +++ b/src/links/scripts/validate-github-github-docs-urls/validate.ts @@ -27,7 +27,7 @@ export async function validate(filePath: string, options: Options) { console.log(prefix, `✅ ${check.url} (${check.identifier})`) } } else { - // A 404: the page does not exist. + // A missing page counts as failure unless --ignore-not-found is set. if (options.ignoreNotFound) { console.log(prefix, `⚠️ ${check.url} (${check.identifier})`) } else { diff --git a/src/observability/lib/failbot.ts b/src/observability/lib/failbot.ts index e2b90878c26e..dbd89c203edb 100644 --- a/src/observability/lib/failbot.ts +++ b/src/observability/lib/failbot.ts @@ -26,6 +26,8 @@ async function retryingFetch(input: RequestInfo | URL, init?: RequestInit): Prom return response } +// Failbot additional_data only accepts flat string and number values, so keep requestUuid. +// https://github.com/github/failbotg/blob/main/docs/api.md#additional-data export function report(error: Error, metadata?: Record<string, unknown>) { if (!process.env.HAYSTACK_URL) { return @@ -42,9 +44,6 @@ export function report(error: Error, metadata?: Record<string, unknown>) { backends, }) - // Metadata can only be a flat object with string & number values, - // so only add the requestUuid. - // https://github.com/github/failbotg/blob/main/docs/api.md#additional-data const loggerContext = getLoggerContext() return failbot.report(error, { @@ -53,7 +52,7 @@ export function report(error: Error, metadata?: Record<string, unknown>) { }) } -// Kept so legacy callers can keep doing `FailBot.report(myError)`. +// Preserves FailBot.report(error) for existing callers. export default { report, } diff --git a/src/observability/lib/handle-package-not-found.ts b/src/observability/lib/handle-package-not-found.ts index 8bb738532c08..80c6c122d19b 100644 --- a/src/observability/lib/handle-package-not-found.ts +++ b/src/observability/lib/handle-package-not-found.ts @@ -1,14 +1,8 @@ -/* -This file adds a custom error message if a package is missing -to prompt the contributor to run `npm ci`. -This handler must be separate from handle-exceptions.ts in order to function. -It's imported in package.json in nodemonConfig, -whereas that is imported in start-server.ts. -This file must not import any packages. -We are suggesting `npm ci` to contributors -to avoid unexpected changes to the package-lock.json file. -All other errors should fall through to the error handler in handle-exceptions.ts. -*/ +// Shows a custom npm ci prompt for missing-package errors. +// Recommend npm ci instead of npm install to avoid unexpected package-lock.json changes. +// package.json loads this file through nodemonConfig before start-server.ts loads handle-exceptions.ts. +// Keep it dependency-free so missing packages can reach this handler. +// Other uncaught exceptions fall through to handle-exceptions.ts. type ErrorWithCode = { code: string diff --git a/src/observability/lib/runtime-metrics.ts b/src/observability/lib/runtime-metrics.ts index 42740357f5af..695681cceefa 100644 --- a/src/observability/lib/runtime-metrics.ts +++ b/src/observability/lib/runtime-metrics.ts @@ -1,13 +1,7 @@ -/** - * Periodically emits Node.js runtime metrics to Datadog via StatsD. - * - * Covers three categories that are otherwise invisible: - * 1. V8 heap: used vs limit, so we can spot memory pressure before OOMs. - * 2. GC: pause duration, so we can correlate latency spikes with GC. - * 3. Event-loop delay: p50/p99, so we can see when the loop is blocked. - * - * Only activates when StatsD is sending real metrics (MODA_PROD_SERVICE_ENV). - */ +// Emits runtime metrics that StatsD does not capture elsewhere: +// V8 heap usage and limit for memory pressure, GC pause duration for latency correlation, +// and event-loop p50 and p99 delay for blocked-loop detection. +// Starts only when StatsD sends real metrics through MODA_PROD_SERVICE_ENV. import v8 from 'node:v8' import { monitorEventLoopDelay, PerformanceObserver } from 'node:perf_hooks' @@ -21,9 +15,7 @@ function isMetricsEnabled(): boolean { return process.env.MODA_PROD_SERVICE_ENV === 'true' && process.env.NODE_ENV !== 'test' } -/** - * Call once at server start. Safe to call multiple times (no-op after first). - */ +// Safe to call from multiple server-start paths; calls after the first are no-ops. export function startRuntimeMetrics(): void { if (started) return started = true @@ -43,8 +35,7 @@ export function startRuntimeMetrics(): void { const gcObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const kind = (entry as unknown as { detail?: { kind?: number } }).detail?.kind - // kind: 1 = Scavenge (minor), 2 = Mark-Sweep-Compact (major), - // 4 = Incremental marking, 8 = Process weak callbacks, 15 = All + // perf_hooks GC kinds: 1 minor, 4 major, 8 incremental, 16 weak callbacks. const tag = kind === 1 ? 'minor' : kind === 2 ? 'major' : 'other' statsd.histogram('node.gc.pause', entry.duration, [`gc_type:${tag}`]) } diff --git a/src/observability/lib/statsd.ts b/src/observability/lib/statsd.ts index ff8254d18ffe..5690a54237df 100644 --- a/src/observability/lib/statsd.ts +++ b/src/observability/lib/statsd.ts @@ -11,17 +11,15 @@ const { const mock = NODE_ENV === 'test' || MODA_PROD_SERVICE_ENV !== 'true' -// MODA_APP_NAME gets set when the deploy target is Moda +// Moda deploys set MODA_APP_NAME for tagging. const modaApp = MODA_APP_NAME ? `moda_app_name:${MODA_APP_NAME}` : false const tagCandidates = ['app:docs', modaApp] export const tags: string[] = tagCandidates.filter((tag): tag is string => Boolean(tag)) const statsd = new StatsD({ - // hot-shots falls back to DD_AGENT_HOST and DD_DOGSTATSD_PORT, - // then to localhost:8125. - // Moda defines DD_DOGSTATSD_PORT but not DD_AGENT_HOST, - // and needs the host set to the Kubernetes node name from KUBE_NODE_HOSTNAME. + // hot-shots falls back to localhost:8125 when neither host variable is set. + // Moda sets only DD_DOGSTATSD_PORT, so use KUBE_NODE_HOSTNAME as the DogStatsD host. host: DD_AGENT_HOST || KUBE_NODE_HOSTNAME, port: DD_DOGSTATSD_PORT ? parseInt(DD_DOGSTATSD_PORT, 10) : undefined, prefix: 'docs.', @@ -31,10 +29,8 @@ const statsd = new StatsD({ export default statsd -// hot-shots v14 changed asyncTimer/timer to inject a TimerContext as the -// final argument of the wrapped function. This adapter lets callers keep -// passing functions with their original signatures by appending an ignored -// TimerContext parameter. +// hot-shots asyncTimer and timer append TimerContext to wrapped functions. +// This adapter preserves callers' original signatures by dropping that extra argument. export function adaptForTimer<P extends unknown[], R>( fn: (...args: P) => Promise<R>, ): (...args: [...P, TimerContext]) => Promise<R> { diff --git a/src/observability/lib/tracing.browser.ts b/src/observability/lib/tracing.browser.ts index c9413916387a..4eb75bd8ce42 100644 --- a/src/observability/lib/tracing.browser.ts +++ b/src/observability/lib/tracing.browser.ts @@ -1,3 +1 @@ -// Browser stub for tracing.ts: OTel is server-only. -// This file is aliased in by Next.js webpack and Turbopack for client bundles. -// It's a no-op: tracing.ts is a side-effect-only module with no exports. +// Next.js aliases tracing.ts to this empty client-bundle stub because tracing is server-only. diff --git a/src/observability/lib/tracing.ts b/src/observability/lib/tracing.ts index 91f4e9ecb874..524a0b77db32 100644 --- a/src/observability/lib/tracing.ts +++ b/src/observability/lib/tracing.ts @@ -1,18 +1,10 @@ -// OpenTelemetry distributed tracing setup for docs-internal, -// following the same pattern as github/alloy and github/github-ui. -// -// The instrumentation list (HTTP, Express, Undici/fetch) is explicit -// instead of `getNodeAutoInstrumentations()`. -// The "auto" helper enables ~30 instrumentations, -// including ones that patch Node core modules (`fs`, `net`, `dns`) on every server. -// Several of these are known to cause performance and listener-leak issues, -// and OTel itself recommends disabling `instrumentation-fs` in production. -// We only have HTTP traffic and outbound fetch in this app, -// so we wire those up explicitly. -// -// References: -// - https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/ -// - https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/github-telemetry-js-user-guide/ +// Follows the github/alloy and github/github-ui tracing pattern. +// Uses explicit HTTP, Express, and Undici instrumentation instead of getNodeAutoInstrumentations. +// The auto helper enables about 30 instrumentations, including fs, net, and dns patches. +// These can hurt performance or leak listeners; OTel recommends disabling instrumentation-fs in production. +// This app only needs inbound HTTP and outbound fetch tracing. +// See https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/ +// See https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/github-telemetry-js-user-guide/ import { CompositePropagator, @@ -51,7 +43,7 @@ if (process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) { }) } - // Uses `once` to prevent duplicate shutdown if SIGTERM is delivered multiple times. + // once prevents duplicate shutdown when SIGTERM arrives more than once. process.once('SIGTERM', async () => { try { await sdk.shutdown() diff --git a/src/observability/logger/index.ts b/src/observability/logger/index.ts index 3495a799c2d0..d7cf8ff87288 100644 --- a/src/observability/logger/index.ts +++ b/src/observability/logger/index.ts @@ -45,7 +45,7 @@ function formatContext(ctx: Record<string, unknown>): string { return parts.length > 0 ? ` ${parts.join(' ')}` : '' } -// Handles file:// URLs (from import.meta.url) and plain string labels. +// Accepts file URLs from import.meta.url and plain string labels. function resolveFilePath(filePath: string): string { try { const parsed = new URL(filePath) @@ -76,13 +76,8 @@ interface LoggerMethod { (message: string, ...args: (string | number | boolean | Error | IncludeContext | object)[]): void } -/* -Call this function with `import.meta.url` as the argument to create a logger for a specific file. - -e.g. `const logger = createLogger(import.meta.url)` - -Logs will be output to the console in development, and in `logfmt` format to stdout in production. -*/ +// Pass import.meta.url so logs identify the caller file. +// Development logs go to console; production logs use logfmt on stdout. export function createLogger(filePath: string) { if (!filePath) { throw new Error('createLogger must be called with the import.meta.url argument') @@ -151,7 +146,7 @@ export function createLogger(filePath: string) { } const currentLogLevel = getLogLevelNumber() if (LOG_LEVELS[level] > currentLogLevel) { - return // Do not log if the requested level is lower priority + return // Higher numbers are more verbose. } const loggerContext = getLoggerContext() @@ -171,7 +166,7 @@ export function createLogger(filePath: string) { const includedContextWithFormattedError = {} as IncludeContext for (const [key, value] of Object.entries(includeContext)) { if (typeof value === 'object' && value instanceof Error) { - // Errors don't serialize well to JSON, so just log the message + stack trace + // Errors need explicit fields because JSON serialization drops message and stack. includedContextWithFormattedError[key] = value.message includedContextWithFormattedError[`${key}_code`] = (value as NodeJS.ErrnoException).code includedContextWithFormattedError[`${key}_name`] = value.name diff --git a/src/observability/logger/lib/log-levels.ts b/src/observability/logger/lib/log-levels.ts index fcf3fd40a419..5e63c3b14e7c 100644 --- a/src/observability/logger/lib/log-levels.ts +++ b/src/observability/logger/lib/log-levels.ts @@ -1,8 +1,5 @@ -/* -The log level is controlled by the `LOG_LEVEL` environment variable, where lower -log levels = more verbose. If log level is 'info', only 'info', 'warn', and -'error' logs are output. -*/ +// LOG_LEVEL controls verbosity. Lower numbers are higher priority. +// For LOG_LEVEL=info, info, warn, and error logs are emitted. export const LOG_LEVELS = { error: 0, warn: 1, @@ -17,10 +14,8 @@ function isValidLogLevel(level: string): level is LogLevel { return level in LOG_LEVELS } -// Defaults when LOG_LEVEL isn't set: -// - 'info' in development -// - 'debug' in production -// - 'debug' in test, because `vitest` turns off logs unless --silent=false is passed +// Default LOG_LEVEL is info in development and debug in production. +// Tests default to debug because vitest suppresses logs unless --silent=false is passed. export function getLogLevelNumber(): LogLevelValue { let defaultLogLevel: LogLevel = 'info' if ( diff --git a/src/observability/logger/lib/logger-context.ts b/src/observability/logger/lib/logger-context.ts index baff1072e685..07b07308b334 100644 --- a/src/observability/logger/lib/logger-context.ts +++ b/src/observability/logger/lib/logger-context.ts @@ -1,9 +1,7 @@ import { AsyncLocalStorage } from 'async_hooks' import type { NextFunction, Request, Response } from 'express' -// Think of this like a Redux store, but for the backend. -// An early middleware calls asyncLocalStorage.run(store, ...), -// which lets all downstream middleware read the store via `getLoggerContext`. +// AsyncLocalStorage carries request fields from early middleware to downstream log calls. export const asyncLocalStorage = new AsyncLocalStorage() export type LoggerContext = { @@ -43,19 +41,14 @@ export function updateLoggerContext(newContext: Partial<LoggerContext>): void { } const INCLUDE_HEADERS = [ - // Device / UA 'user-agent', 'sec-ch-ua', 'sec-ch-ua-platform', - // Language 'x-user-language', 'accept-language', - // Version 'x-user-version', - // Host 'host', 'x-host', - // Cache control 'cache-control', ] diff --git a/src/observability/logger/lib/to-logfmt.ts b/src/observability/logger/lib/to-logfmt.ts index c1b5f1648243..fd0f06574818 100644 --- a/src/observability/logger/lib/to-logfmt.ts +++ b/src/observability/logger/lib/to-logfmt.ts @@ -1,18 +1,5 @@ -/* - Flattens a JSON object and converts it to a logfmt string - Nested objects are flattened with a dot separator, e.g. requestContext.path=/en - This is because Splunk doesn't support nested JSON objects. - - Example - { - "a": 1, - "b": { - "c": 2 - } - } - becomes - a=1 b.c=2 -*/ +// Flattens nested context for Splunk, which cannot query nested JSON objects. +// Example: { requestContext: { path: "/en" } } becomes requestContext.path=/en. // Matches the original node-logfmt library's quoting and escaping behavior. function stringify(data: Record<string, unknown>): string { @@ -46,7 +33,6 @@ function stringify(data: Record<string, unknown>): string { line += `${key}=${stringValue} ` } - // trim trailing space return line.substring(0, line.length - 1) } diff --git a/src/observability/logger/middleware/get-automatic-request-logger.ts b/src/observability/logger/middleware/get-automatic-request-logger.ts index 946695ace75e..5cef1caecd88 100644 --- a/src/observability/logger/middleware/get-automatic-request-logger.ts +++ b/src/observability/logger/middleware/get-automatic-request-logger.ts @@ -44,7 +44,7 @@ export function getAutomaticRequestLogger() { } else if (shouldEnableAutomaticDevLogging()) { const logLevelNum = getLogLevelNumber() - // Don't log `/_next/` requests unless LOG_LEVEL is `debug` or higher + // Suppress /_next/ requests unless LOG_LEVEL is debug or more verbose. if (url?.startsWith('/_next/') && logLevelNum < 3) { return originalEnd.apply(this, args as Parameters<typeof originalEnd>) } diff --git a/src/observability/middleware/handle-errors.ts b/src/observability/middleware/handle-errors.ts index f43857cffdf1..e0b100026de2 100644 --- a/src/observability/middleware/handle-errors.ts +++ b/src/observability/middleware/handle-errors.ts @@ -44,6 +44,8 @@ async function logException(error: ErrorWithCode, req: ExtendedRequest) { } } +// For asset 404s, handleError sets short cache headers because Fastly caches 404 responses. +// https://docs.fastly.com/en/guides/how-caching-and-cdns-work#http-status-codes-cached-by-default async function handleError( error: ErrorWithCode | number, req: ExtendedRequest, @@ -54,14 +56,8 @@ async function handleError( if (req.path.startsWith('/assets') || req.path.startsWith('/_next/static')) { if (!responseDone) { - // Fastly caches 404s by default, so cache 404'ing assets conservatively: - // a short Cache-Control, plus the default surrogate key - // in case the 404 was a mistake. - // https://docs.fastly.com/en/guides/how-caching-and-cdns-work#http-status-codes-cached-by-default errorCacheControl(res) - // Unsets the manual surrogate key assumed earlier in the middleware chain. - // Falls back to `no-language` when `req.language` isn't set yet, - // e.g. errors before language detection. + // Clear any earlier manual surrogate key; pre-language errors fall back to no-language. setFastlySurrogateKey(res, makeLanguageSurrogateKey(req.language), true) } } else if (DEBUG_MIDDLEWARE_TESTS) { @@ -74,7 +70,7 @@ async function handleError( await logException(error, req) } - // We MUST delegate to the default Express error handler + // Delegate once headers are sent or the request aborted, so Express closes the connection. next(error) return } @@ -83,7 +79,7 @@ async function handleError( req.context = {} } - // Special handling for when a middleware calls `next(404)` + // Middleware may signal a normal 404 by calling next(404). if (error === 404) { errorCacheControl(res) setFastlySurrogateKey(res, makeLanguageSurrogateKey(req.language), true) @@ -94,29 +90,26 @@ async function handleError( throw new Error("Don't use next(xxx) where xxx is any other number than 404") } - // display error on the page in development and staging, but not in production + // Development and staging pages show the error; production pages do not. if (!process.env.MODA_PROD_SERVICE_ENV) { req.context.error = error } - // Errors with a status code usually come from a middleware like `express.json()`. + // Status-code errors usually come from middleware such as express.json. if (error.statusCode) { res.sendStatus(error.statusCode) return } res.statusCode = 500 - // Local dev doesn't need the pretty HTML rendering of 500.tsx. - // Also, as of Jan 2024, calling nextApp.renderError hangs forever - // when `NODE_ENV` is 'development'. We can't fully explain it, - // and it's moot because in local dev the full stack trace is more useful. + // In development, nextApp.renderError hangs forever and the stack trace is more useful. if (process.env.NODE_ENV === 'development') { next(error) return } else { nextApp.renderError(error, req, res, req.path) - // Report to Failbot AFTER responding to the user + // Report to Failbot after responding to the user. await logException(error, req) } } catch (handlingError) { diff --git a/src/observability/middleware/trigger-error.ts b/src/observability/middleware/trigger-error.ts index bcd1fee9157c..7009748fe058 100644 --- a/src/observability/middleware/trigger-error.ts +++ b/src/observability/middleware/trigger-error.ts @@ -2,19 +2,15 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest } from '@/types' -// This module is for testing our handling of uncaught async rejections on incoming requests +// Tests use this route to exercise uncaught async rejections on incoming requests. -// IMPORTANT: Leave this function as `async` even though it doesn't need to be! +// Keep triggerError async and unwrapped so it rejects like async middleware. export default async function triggerError( req: ExtendedRequest, res: Response, next: NextFunction, ) { - // IMPORTANT: - // Do NOT wrap this method's contents in the usual `try-catch+next(error)` - // pattern used on async middleware! This is an intentional omission! - - // prevent this from being used in production + // Block intentional errors in production. if (process.env.NODE_ENV === 'production' && process.env.MODA_PROD_SERVICE_ENV === 'true') return next() diff --git a/src/observability/tests/failbot.ts b/src/observability/tests/failbot.ts index 4aef8b831345..2084a27abab7 100644 --- a/src/observability/tests/failbot.ts +++ b/src/observability/tests/failbot.ts @@ -34,14 +34,11 @@ describe('FailBot', () => { process.env.HAYSTACK_URL = 'https://haystack.example.com' const err = new Error('Kaboom') const backendPromises = FailBot.report(err, { foo: 'bar' }) - // Production code doesn't need to await what `FailBot.report()` returns. - // In vitest we await now, - // so we can assert the POST requests happened. + // Tests await backend promises so assertions observe the POST request. if (backendPromises) { await Promise.all(await backendPromises) } - // What `.report()` returns doesn't matter, only that it POSTed. expect(requestBodiesSent.length).toBe(1) expect(requestBodiesSent[0]).toMatchObject({ diff --git a/src/observability/tests/get-automatic-request-logger.ts b/src/observability/tests/get-automatic-request-logger.ts index 0151d35321d2..1204552d4591 100644 --- a/src/observability/tests/get-automatic-request-logger.ts +++ b/src/observability/tests/get-automatic-request-logger.ts @@ -176,7 +176,7 @@ describe('getAutomaticRequestLogger', () => { await new Promise((resolve) => setTimeout(resolve, 20)) - expect(consoleLogs).toHaveLength(0) // Should be filtered out + expect(consoleLogs).toHaveLength(0) }) it('should log _next requests when debug level is set', async () => { @@ -244,7 +244,6 @@ describe('getAutomaticRequestLogger', () => { expect(consoleLogs).toHaveLength(1) const logOutput = consoleLogs[0] - // Should include context fields (even if empty due to mocking) expect(logOutput).toContain('requestUuid=') expect(logOutput).toContain('path=') }) @@ -259,7 +258,6 @@ describe('getAutomaticRequestLogger', () => { }) it('should not log in test environment by default', async () => { - // Explicit environment settings for CI stability. vi.stubEnv('NODE_ENV', 'test') vi.stubEnv('ENABLE_DEV_LOGGING', '') vi.stubEnv('LOG_LIKE_PRODUCTION', '') @@ -314,7 +312,7 @@ describe('getAutomaticRequestLogger', () => { await new Promise((resolve) => setTimeout(resolve, 20)) expect(consoleLogs).toHaveLength(1) - expect(consoleLogs[0]).toContain('-') // Should show '-' for missing content length + expect(consoleLogs[0]).toContain('-') }) it('should handle missing status code', async () => { @@ -327,7 +325,7 @@ describe('getAutomaticRequestLogger', () => { await new Promise((resolve) => setTimeout(resolve, 20)) expect(consoleLogs).toHaveLength(1) - expect(consoleLogs[0]).toContain('200') // Should default to 200 + expect(consoleLogs[0]).toContain('200') }) it('should prefer originalUrl over url', async () => { @@ -351,7 +349,6 @@ describe('getAutomaticRequestLogger', () => { const startTime = Date.now() middleware(mockReq as Request, mockRes as Response, mockNext) - // Simulate some processing time await new Promise((resolve) => setTimeout(resolve, 50)) ;(mockRes as MockResponseWithEnd).end() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -367,7 +364,6 @@ describe('getAutomaticRequestLogger', () => { if (responseTimeMatch) { const loggedTime = parseInt(responseTimeMatch[1], 10) - // Should be reasonably close to actual duration (within 20ms tolerance) expect(loggedTime).toBeGreaterThanOrEqual(40) expect(loggedTime).toBeLessThanOrEqual(actualDuration + 20) } diff --git a/src/observability/tests/logger-integration.ts b/src/observability/tests/logger-integration.ts index c602e7243d15..9071ee9f0d74 100644 --- a/src/observability/tests/logger-integration.ts +++ b/src/observability/tests/logger-integration.ts @@ -16,7 +16,6 @@ function expectDevLog(logs: string[], level: string, message: string): void { expect(match, `Expected a log containing "${level}" and "${message}"`).toBeDefined() } -// Integration tests that use real dependencies without mocks describe('logger integration tests', () => { let originalConsoleLog: typeof console.log let originalConsoleError: typeof console.error @@ -50,7 +49,6 @@ describe('logger integration tests', () => { describe('logger context integration', () => { it('should use empty context when no async local storage is set', () => { - // Set production mode to see the context in the output vi.stubEnv('LOG_LIKE_PRODUCTION', 'true') vi.stubEnv('NODE_ENV', 'development') @@ -60,7 +58,6 @@ describe('logger integration tests', () => { expect(consoleLogs).toHaveLength(1) const logOutput = consoleLogs[0] - // Real getLoggerContext returns empty strings for fields when no context is set expect(logOutput).toContain('level=info') expect(logOutput).toContain('message="Test without context"') expect(logOutput).toContain('timestamp=') @@ -68,7 +65,6 @@ describe('logger integration tests', () => { }) it('should use context from async local storage when available', async () => { - // Set production mode to see the context in the output vi.stubEnv('LOG_LIKE_PRODUCTION', 'true') vi.stubEnv('NODE_ENV', 'development') @@ -88,12 +84,9 @@ describe('logger integration tests', () => { const mockRes = {} as unknown as Response - // Use a Promise to handle the async local storage execution const result = await new Promise<void>((resolve, reject) => { - // Create a next function that will execute our test logic within the async context const mockNext = () => { try { - // Update the context with additional values (simulating subsequent middleware) updateLoggerContext({ language: 'es', userLanguage: 'es', @@ -144,7 +137,7 @@ describe('logger integration tests', () => { logger.warn('Warn message') logger.error('Error message') - // With 'info' level, debug should be filtered out (debug=3, info=2, so debug > info) + // LOG_LEVEL numbers increase with verbosity, so debug 3 is filtered by info 2. const allClean = consoleLogs.map(stripAnsi).join('\n') expect(allClean).not.toContain('Debug message') expectDevLog(consoleLogs, 'INFO', 'Info message') @@ -167,7 +160,7 @@ describe('logger integration tests', () => { logger.warn('Warn message') logger.error('Error message') - // With 'error' level (0), only error should be logged + // error 0 filters every higher-verbosity level. const allClean = consoleLogs.map(stripAnsi).join('\n') expect(allClean).not.toContain('Debug message') expect(allClean).not.toContain('Info message') @@ -197,10 +190,10 @@ describe('logger integration tests', () => { consoleLogs.length = 0 consoleErrors.length = 0 - // Test NODE_ENV=production (but not in CI) + // CI disables production logging unless LOG_LIKE_PRODUCTION is true. vi.stubEnv('NODE_ENV', 'production') - vi.stubEnv('CI', '') // Ensure CI is not set - vi.stubEnv('LOG_LIKE_PRODUCTION', '') // Clear this to test production detection + vi.stubEnv('CI', '') + vi.stubEnv('LOG_LIKE_PRODUCTION', '') const logger = createLogger('file:///path/to/test.js') logger.info('Real production logging test') diff --git a/src/observability/tests/logger.ts b/src/observability/tests/logger.ts index 3b446c459575..69052a424bc6 100644 --- a/src/observability/tests/logger.ts +++ b/src/observability/tests/logger.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createLogger } from '@/observability/logger' -// Mock only the logger-context for most tests, but we'll test integration without mocks +// Most tests mock logger context; integration coverage lives in logger-integration.ts. vi.mock('@/observability/logger/lib/logger-context') function stripAnsi(s: string): string { @@ -212,11 +212,11 @@ describe('createLogger', () => { logger.error('Multiple errors', error1, error2) - // In development mode, each error triggers a separate console.log + console.error + // Development logging prints one console.log and one console.error per Error. expect(consoleLogs).toHaveLength(2) expect(consoleErrors).toHaveLength(2) - // Both log entries should have the same message + // Both Error entries share the combined message. expectDevLog(consoleLogs, 'ERROR', 'Multiple errors: First error, Second error') expect(consoleErrors[0]).toBe(error1) expect(consoleErrors[1]).toBe(error2) @@ -430,7 +430,7 @@ describe('createLogger', () => { vi.stubEnv('KUBE_NODE_HOSTNAME', 'ghe-k8s-node-42') vi.stubEnv('LOG_LIKE_PRODUCTION', 'true') - // Reset modules so pod-identity is re-evaluated with the new env vars + // Reset modules so pod-identity reads the stubbed environment. vi.resetModules() const { createLogger: freshCreateLogger } = await import('@/observability/logger') @@ -530,7 +530,7 @@ describe('createLogger', () => { const logOutput = consoleLogs[0] expect(logOutput).toContain('included.error="Cannot read property"') expect(logOutput).toContain('included.error_name=TypeError') - // When .code is undefined, error_code is present but empty + // Undefined error.code serializes as an empty error_code field. expect(logOutput).toMatch(/included\.error_code= /) expect(logOutput).toContain('included.error_stack=') }) diff --git a/src/observability/tests/to-error.ts b/src/observability/tests/to-error.ts index 819621bd6428..71e88aa7dc84 100644 --- a/src/observability/tests/to-error.ts +++ b/src/observability/tests/to-error.ts @@ -38,8 +38,7 @@ describe('toError', () => { it('should convert undefined to an Error via JSON.stringify', () => { const result = toError(undefined) expect(result).toBeInstanceOf(Error) - // JSON.stringify(undefined) returns undefined (not a string), - // so new Error(undefined) has an empty message + // JSON.stringify(undefined) makes new Error(undefined) use an empty message. expect(result.message).toBe('') }) @@ -54,7 +53,6 @@ describe('toError', () => { circular.self = circular const result = toError(circular) expect(result).toBeInstanceOf(Error) - // String() on an object returns '[object Object]' expect(result.message).toBe('[object Object]') }) }) diff --git a/src/observability/tests/to-logfmt.test.ts b/src/observability/tests/to-logfmt.test.ts index c75ff6f08df2..0f39a3cfc200 100644 --- a/src/observability/tests/to-logfmt.test.ts +++ b/src/observability/tests/to-logfmt.test.ts @@ -208,7 +208,7 @@ describe('toLogfmt', () => { const result = toLogfmt(obj) expect(result).toContain('name=test') - expect(result).toContain('self=[Circular]') // Our implementation marks circular refs + expect(result).toContain('self=[Circular]') }) it('should handle Date objects', () => { diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 311fc39c040b..92d962b3d572 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -3,23 +3,18 @@ import Document, { Html, Head, Main, NextScript } from 'next/document' import { defaultCSSTheme } from '@/color-schemes/components/useTheme' import { colorModeScript } from '@/color-schemes/lib/color-mode-script' +// MyDocument leaves SSR theme attributes at defaultCSSTheme so the HTML stays shared-cacheable. +// colorModeScript updates them from the color_mode cookie before the browser's first paint. +// colorModeScript injects executable JS, not content HTML, so RenderedHTML and hast do not apply. export default class MyDocument extends Document { render() { return ( <Html - // These values are always the SSR rendering defaults. - // Before the browser's first paint, the inline `colorModeScript` - // below updates them on the client from the `color_mode` cookie, so - // the page paints with the user's real theme and doesn't flash. The - // SSR defaults stay constant, so the HTML remains shared-cacheable. data-color-mode={defaultCSSTheme.colorMode} data-light-theme={defaultCSSTheme.lightTheme} data-dark-theme={defaultCSSTheme.darkTheme} > <Head> - {/* Inline color-mode script must run before paint to avoid a flash; it - injects executable JS (not content HTML), so RenderedHTML/hast do - not apply here. */} {/* eslint-disable-next-line custom-rules/no-dangerously-set-inner-html */} <script dangerouslySetInnerHTML={{ __html: colorModeScript }} /> </Head> diff --git a/src/pages/_error.tsx b/src/pages/_error.tsx index 7beaf0d627bf..6f0624d72718 100644 --- a/src/pages/_error.tsx +++ b/src/pages/_error.tsx @@ -18,27 +18,24 @@ function Error() { return <GenericError /> } +// Importing @/observability/lib/failbot here would pull it into the client bundle. +// renderPage middleware attaches FailBot to SSR requests. +// Excluding it in next.config.ts complicates Next.js upgrades. Error.getInitialProps = async (ctx: NextPageContext) => { - // `.res` only exists during SSR, - // so its presence is how we know to send this error to Failbot. + // ctx.res only exists during SSR, so it gates FailBot reporting. const { err, req, res } = ctx let statusCode = 500 if (res?.statusCode) { statusCode = res.statusCode } - // `err` is falsy for a 404, which `pages/404.tsx` handles instead. + // Missing pages become 404 responses in render-page, so this only reports real errors. if (err && res && req) { - // We can't import `@/observability/lib/failbot` here, - // because webpack pulls this file into the client bundle. - // Excluding it in next.config.ts would work but makes future Next.js upgrades harder. - // Instead the contextualizers attach FailBot to the Express request, - // so it exists only in SSR. const expressRequest = req as unknown as ExpressRequestExtensions const FailBot = expressRequest.FailBot if (FailBot) { try { - // Allowlist: these headers carry no PII. + // Report only the request headers listed in OK_HEADER_KEYS. const OK_HEADER_KEYS = ['user-agent', 'referer', 'accept-encoding', 'accept-language'] const reported = FailBot.report(err, { path: req.url || '', @@ -60,8 +57,7 @@ Error.getInitialProps = async (ctx: NextPageContext) => { ), }) - // `FailBot.report()` returns undefined when no backends are configured, - // otherwise an array of promises. + // FailBot.report returns undefined without backends, or an array of promises. if (!reported) { console.warn( 'The FailBot.report() returned undefined which means the error was NOT sent to Failbot.', @@ -71,8 +67,7 @@ Error.getInitialProps = async (ctx: NextPageContext) => { reported.length && reported.every((thing) => thing instanceof Promise) ) { - // Await even though we ignore the results. - // Leaving these to the event loop produces cryptic errors when one rejects. + // Await ignored results so rejected reports do not surface later as unclear errors. try { await Promise.all(reported) } catch (error) { @@ -80,8 +75,7 @@ Error.getInitialProps = async (ctx: NextPageContext) => { } } } catch (error) { - // This catch exists so a FailBot problem can't stop the error page from rendering. - // It doesn't mean the report failed to send. + // Keep FailBot problems from blocking rendering; the report may still have sent. console.warn('Failed to send error to FailBot.', error) } } diff --git a/src/products/lib/all-products.ts b/src/products/lib/all-products.ts index fd7dd1035eaa..7bc174d5cc89 100644 --- a/src/products/lib/all-products.ts +++ b/src/products/lib/all-products.ts @@ -22,7 +22,7 @@ export interface ProductMap { [productId: string]: Product } -// Both internal and external products are specified in content/index.md +// content/index.md specifies both internal and external products. const homepage = path.posix.join(ROOT, 'content/index.md') export const { data } = frontmatter(fs.readFileSync(homepage, 'utf8')) @@ -35,7 +35,7 @@ for (const productId of productIds) { const relPath = productId const dir = path.join(ROOT, 'content', relPath) - // Early Access may not exist in the current checkout + // Early Access can be absent from the current checkout. try { fs.readdirSync(dir) } catch { diff --git a/src/products/lib/get-product-groups.ts b/src/products/lib/get-product-groups.ts index eb62a7ee8e08..e5c24643a3bd 100644 --- a/src/products/lib/get-product-groups.ts +++ b/src/products/lib/get-product-groups.ts @@ -10,6 +10,9 @@ import languages from '@/languages/lib/languages-server' type PageMap = Record<string, Page> +// childGroups stores ids such as code-security/dependabot, not localized, versioned hrefs. +// Try the localized current-version href first, then fall back to product.versions[0] +// from productMap. async function getPage( id: string, lang: string, @@ -21,15 +24,6 @@ async function getPage( const external = product.external || false - // We only have an `id` like 'code-security/dependabot', - // so the href has to be guessed. - // On `/fr/enterprise-server@3.9` we try - // `/fr/enterprise-server@3.9/code-security/dependabot` first. - // Pages aren't available in every version, - // so the fallback is `product.versions[0]` from the `productMap`, - // not to be confused with the `pageMap`. - // That keeps the current version when it applies, - // and degrades to the first when it doesn't. let href = product.href let name = product.name @@ -39,9 +33,6 @@ async function getPage( if (!external) { href = removeFPTFromPath(path.posix.join('/', lang, context.currentVersion, id)) if (!pageMap[href]) { - // Fall back to `product.versions[0]`. - // For example, you're on `/en/enterprise-server@3.1` - // but `/foo/bar` only exists in `enterprise-cloud@latest`. if (!product.versions) throw new Error(`Product ${productId} has no versions`) href = removeFPTFromPath(path.posix.join('/', lang, product.versions[0], id)) } @@ -52,8 +43,7 @@ async function getPage( ) } - // Some should not be included for the current version, and returning - // undefined here means this entry will be filtered out by the caller. + // Returning undefined lets the caller filter products unavailable in the current version. const isFPT = context.currentVersion === 'free-pro-team@latest' if (!isFPT && !page.applicableVersions.includes(context.currentVersion)) { return @@ -65,16 +55,14 @@ async function getPage( throwIfEmpty: false, }) } - // Either the page didn't have a `rawShortTitle` or it was empty when - // rendered out with Liquid. Either way, have to fall back to `rawTitle`. + // Fall back to rawTitle when rawShortTitle is missing or Liquid renders it empty. if (!name || !page.rawShortTitle) { name = await renderContentWithFallback(page, 'rawTitle', context, { textOnly: true, }) } } - // Return only the props needed for the ProductSelectionCard, since - // that's the only place this is ever used. + // ProductSelectionCard only needs these props. return { id, name, @@ -147,7 +135,7 @@ export async function getProductGroups( lang: string, context: Context, ): Promise<ProductGroup[]> { - // Always use English version for structure (octicon, children) + // Use English childGroups for octicons and children; localized files supply names only. const englishChildGroups = data?.childGroups || [] const localizedByOcticon = await getLocalizedGroupNames(lang) @@ -160,7 +148,7 @@ export async function getProductGroups( name: localizedName, icon: group.icon || null, octicon: group.octicon || null, - // Typically the children are product IDs, but we support deeper page paths too + // Children are usually product IDs, but deeper page paths are also valid. children: ( await Promise.all(group.children.map((id: string) => getPage(id, lang, pageMap, context))) ).filter(Boolean) as ProductGroupChild[], diff --git a/src/products/tests/get-product-groups.ts b/src/products/tests/get-product-groups.ts index 86630f7ca13a..49f15a53d13e 100644 --- a/src/products/tests/get-product-groups.ts +++ b/src/products/tests/get-product-groups.ts @@ -6,7 +6,7 @@ import { getLocalizedGroupNames, } from '@/products/lib/get-product-groups' -// `name` is required here to match what the library expects. +// name matches the helper's required ProductGroupData shape. interface MockProductGroupData { name: string octicon?: string @@ -67,7 +67,7 @@ describe('get-product-groups helper functions', () => { const localizedByOcticon: { [key: string]: string } = { RocketIcon: 'Empezar', ShieldLockIcon: 'Seguridad', - CopilotIcon: 'GitHub Copilot', // Some names stay the same + CopilotIcon: 'GitHub Copilot', } const nameMap: { [key: string]: string } = mapEnglishToLocalizedNames( @@ -90,7 +90,7 @@ describe('get-product-groups helper functions', () => { const localizedByOcticon: { [key: string]: string } = { RocketIcon: 'Empezar', - // MissingIcon is not in the localized map + // MissingIcon has no localized entry. } const nameMap: { [key: string]: string } = mapEnglishToLocalizedNames( @@ -105,16 +105,16 @@ describe('get-product-groups helper functions', () => { }) test('handles different ordering between English and localized groups', () => { - // English groups in one order + // English groups use one order. const englishGroups: MockProductGroupData[] = [ { name: 'Get started', octicon: 'RocketIcon', children: [] }, { name: 'Security', octicon: 'ShieldLockIcon', children: [] }, ] - // Localized groups in different order (but mapped by octicon) + // Localized groups use a different order and still map by octicon. const localizedByOcticon: { [key: string]: string } = { - ShieldLockIcon: 'Seguridad', // Security comes first in localized - RocketIcon: 'Empezar', // Get started comes second + ShieldLockIcon: 'Seguridad', + RocketIcon: 'Empezar', } const nameMap: { [key: string]: string } = mapEnglishToLocalizedNames( @@ -142,7 +142,7 @@ describe('get-product-groups helper functions', () => { { name: 'GitHub Copilot', octicon: 'CopilotIcon', children: ['copilot'] }, ] - // Simulate what would come from a Spanish localized file + // Spanish localized index data supplies translated names with matching octicons. const mockLocalizedChildGroups: MockProductGroupData[] = [ { name: 'Empezar', octicon: 'RocketIcon', children: ['get-started'] }, { name: 'Seguridad', octicon: 'ShieldLockIcon', children: ['code-security'] }, @@ -170,7 +170,7 @@ describe('get-product-groups helper functions', () => { expect(finalResult[1].name).toBe('Seguridad') expect(finalResult[2].name).toBe('GitHub Copilot') - // Technical data should remain unchanged + // Technical data remains unchanged. expect(finalResult[0].octicon).toBe('RocketIcon') expect(finalResult[0].children).toEqual(['get-started']) }) diff --git a/src/redirects/lib/exception-redirects.ts b/src/redirects/lib/exception-redirects.ts index 131c3e175f9f..8f26a20af657 100644 --- a/src/redirects/lib/exception-redirects.ts +++ b/src/redirects/lib/exception-redirects.ts @@ -2,9 +2,8 @@ import fs from 'fs' type Redirects = Record<string, string> -// Parses the exception redirects .txt format: a bare line is a destination, and -// each following line starting with `-` is a source that redirects to it. -// Lines starting with `#` are comments. +// Exception redirect files use bare destinations followed by - source lines. +// Lines starting with # are comments. export default function getExceptionRedirects(exceptionsTxtFile: string): Redirects { const exceptions: Redirects = {} const exceptionRedirectsLines = fs diff --git a/src/redirects/lib/get-redirect.ts b/src/redirects/lib/get-redirect.ts index 502d37fd3631..07bca4cfff59 100644 --- a/src/redirects/lib/get-redirect.ts +++ b/src/redirects/lib/get-redirect.ts @@ -39,13 +39,10 @@ export default function getRedirect(uri: string, context: Context): string | und const [language, withoutLanguage] = splitPathByLanguage(uri, userLanguage) if (withoutLanguage.startsWith('/github-ae@latest')) { - // It has a different business logic that the rest because it's a - // version that now will always redirect. Just a question of where to - // exactly. + // githubAERedirect maps GitHub AE URLs to a non-AE destination. const nonAERedirect = githubAERedirect(uri, context) if (nonAERedirect.includes('/github-ae@latest')) { - // If this happened some redirect in there didn't completely - // get away from github-ae. + // GitHub AE redirects must not point back to GitHub AE. throw new Error('Still going to github-ae@latest URL') } return nonAERedirect @@ -53,15 +50,9 @@ export default function getRedirect(uri: string, context: Context): string | und let destination: string | undefined - // `redirects` is sourced from more than one thing. The primary use - // case is gathering up the `redirect_from` frontmatter key. - // But we also have `developer.json` which contains legacy redirects. - // For example, the `developer.json` will have entries such - // `/enterprise/v4/enum/auditlogorderfield` which clearly is using - // the old formatting of the version. So to leverage the redirects - // from `developer.json` we'll look at it right away. + // Check redirects first because developer.json has /enterprise/v4/enum/auditlogorderfield. if (withoutLanguage in redirects) { - // But only inject the language if it's NOT an external redirect + // External redirects already include their full destination. if (redirects[withoutLanguage].includes('://')) { return redirects[withoutLanguage] } @@ -71,10 +62,10 @@ export default function getRedirect(uri: string, context: Context): string | und let basicCorrection: string | undefined if (withoutLanguage.startsWith(nonEnterpriseDefaultVersionPrefix)) { - // E.g. '/free-pro-team@latest/foo/bar' or '/free-pro-team@latest' + // Example: /free-pro-team@latest/actions or /free-pro-team@latest basicCorrection = `/${language}${withoutLanguage.replace(nonEnterpriseDefaultVersionPrefix, '')}` } else if (withoutLanguage.replace('/', '') in allVersions && !languagePrefixRegex.test(uri)) { - // E.g. just '/github-ae@latest' or '/enterprise-cloud@latest' + // Example: /enterprise-cloud@latest basicCorrection = `/${language}${withoutLanguage}` return basicCorrection } @@ -83,23 +74,22 @@ export default function getRedirect(uri: string, context: Context): string | und withoutLanguage === '/enterprise-server' || withoutLanguage.startsWith('/enterprise-server/') ) { - // E.g. '/enterprise-server' or '/enterprise-server/3.0/foo' + // Example: /enterprise-server or /enterprise-server/3.0/admin basicCorrection = `/${language}${withoutLanguage.replace( '/enterprise-server', `/enterprise-server@${latestStable}`, )}` - // If it's now just the version, without anything after, exit here + // Version home pages need no redirect lookup. if (withoutLanguage === '/enterprise-server') { return basicCorrection } } else if (withoutLanguage.startsWith('/enterprise-server@latest')) { - // E.g. '/enterprise-server@latest' or '/enterprise-server@latest/3.3/foo' + // Example: /enterprise-server@latest or /enterprise-server@latest/3.3/admin basicCorrection = `/${language}${withoutLanguage.replace( '/enterprise-server@latest', `/enterprise-server@${latestStable}`, )}` - // If it was *just* '/enterprise-server@latest' all that's needed is - // the language but with 'latest' replaced with the value of `latest` + // Version home pages need only the language and resolved latest version. if (withoutLanguage === '/enterprise-server@latest') { return basicCorrection } @@ -107,14 +97,10 @@ export default function getRedirect(uri: string, context: Context): string | und withoutLanguage.startsWith('/enterprise/') && supportedAndRecentlyDeprecated.includes(withoutLanguage.split('/')[2]) ) { - // E.g. '/enterprise/3.3' or '/enterprise/3.3/foo' or '/enterprise/3.0/foo - - // If the URL is without a language, and no redirect is necessary, - // but it has as version prefix, the language has to be there - // otherwise it will never be found in `req.context.pages` + // Example: /enterprise/3.3/admin needs a language prefix for req.context.pages lookup. const version = withoutLanguage.split('/')[2] if (withoutLanguage === `/enterprise/${version}`) { - // E.g. `/enterprise/3.0` + // Example: /enterprise/3.0 basicCorrection = `/${language}${withoutLanguage.replace( `/enterprise/${version}`, `/enterprise-server@${version}`, @@ -127,22 +113,19 @@ export default function getRedirect(uri: string, context: Context): string | und )}` } } else if (withoutLanguage === '/enterprise') { - // E.g. `/enterprise` exactly + // Example: /enterprise basicCorrection = `/${language}/enterprise-server@${latest}` return basicCorrection } else if ( withoutLanguage.startsWith('/enterprise/') && !supported.includes(withoutLanguage.split('/')[2]) ) { - // E.g. '/en/enterprise/user/github/foo' - // If the URL is without a language, and no redirect is necessary, - // but it has as version prefix, the language has to be there - // otherwise it will never be found in `req.context.pages` + // Example after language removal: /enterprise/user/github/actions needs a language prefix. basicCorrection = `/${language}${withoutLanguage .replace(`/enterprise/`, `/enterprise-server@${latest}/`) .replace('/user/', '/')}` } else if (withoutLanguage.startsWith('/insights')) { - // E.g. '/insights/foo' + // Example: /insights/admin basicCorrection = uri.replace('/insights', `${language}/enterprise-server@${latest}/insights`) } @@ -162,7 +145,7 @@ export default function getRedirect(uri: string, context: Context): string | und withoutLanguage.split('/')[1].includes('@') && withoutLanguage.split('/')[1] in allVersions ) { - // E.g. '/enterprise-server@latest' or '/github-ae@latest' or '/enterprise-server@3.3' + // The first segment is a known version, such as /enterprise-server@3.XX. const majorVersion = withoutLanguage.split('/')[1].split('@')[0] const split = withoutLanguage.split('/') const version = split[1].split('@')[1] @@ -181,20 +164,20 @@ export default function getRedirect(uri: string, context: Context): string | und suffix = tryReplacements(prefix, suffix, context) || suffix } } else { - // If version is not supported, we still need to set these values + // Unsupported versions still need prefix and suffix values for the fallback lookup. prefix = `/${majorVersion}@${version}` suffix = `/${split.slice(2).join('/')}` } const newURL = prefix + suffix if (newURL !== withoutLanguage) { - // At least the prefix changed! + // Prefix changes can target either a redirect or a live URL. destination = redirects[newURL] || newURL } else { destination = redirects[newURL] } } else if (withoutLanguage.startsWith('/desktop/guides/')) { - // E.g. /desktop/guides/contributing-and-collaborat + // Example: /desktop/guides/contributing-and-collaboration const newURL = withoutLanguage.replace('/desktop/guides/', '/desktop/') destination = redirects[newURL] || newURL } else { @@ -202,8 +185,7 @@ export default function getRedirect(uri: string, context: Context): string | und } if (destination !== undefined) { - // There's hope! Now we just need to attach the correct language - // to the destination URL. + // Redirect destinations need the resolved language prefix. return `/${language}${destination}` } @@ -214,31 +196,26 @@ function githubAERedirect(uri: string, context: Context): string { const { redirects, userLanguage, pages } = context if (!redirects || !pages) { - // Fallback to home page if context is incomplete + // Incomplete context cannot choose an equivalent GitHub AE page. const [language] = splitPathByLanguage(uri, userLanguage) return `/${language}` } const [language, withoutLanguage] = splitPathByLanguage(uri, userLanguage) - // From now on, github-ae@latest redirects to enterprise-cloud or - // fpt or the home page. + // Try Enterprise Cloud and Free/Pro/Team equivalents before redirect and home-page fallbacks. const cloudEquivalent = uri.replace('/github-ae@latest', '/enterprise-cloud@latest') const fptEquivalent = uri.replace('/github-ae@latest', '') const withoutVersion = withoutLanguage.replace('/github-ae@latest', '') if (!withoutVersion) { - // That means the version home page. - // Don't even need to check if that exists. - // But if it was without language, inject the language as - // we go to the enterprise-cloud equivalent + // GitHub AE home redirects to Enterprise Cloud without checking pages. if (uri.startsWith('/github-ae@latest')) { return `/${language}${cloudEquivalent}` } return cloudEquivalent } - // What if the only missing thing is a language prefix, then - // it's easy too. + // Language-less GitHub AE URLs can still match a translated equivalent. if (uri.startsWith('/github-ae@latest')) { const languageCloudEquivalent = `/${language}${cloudEquivalent}` if (languageCloudEquivalent in pages) { @@ -250,7 +227,7 @@ function githubAERedirect(uri: string, context: Context): string { return languageFptEquivalent } } else { - // If you're here it means the URL did start with a language. + // Language-prefixed GitHub AE URLs can check equivalent pages directly. if (cloudEquivalent in pages) { return cloudEquivalent } @@ -259,8 +236,7 @@ function githubAERedirect(uri: string, context: Context): string { } } - // There are redirect exceptions the specifically spell out github-ae - // in the redirect. + // Exception redirects can point GitHub AE URLs to a non-AE destination. const legacyRedirect = redirects[withoutLanguage] if (legacyRedirect && !legacyRedirect.includes('/github-ae@latest')) { if (legacyRedirect.includes('://')) { @@ -269,9 +245,7 @@ function githubAERedirect(uri: string, context: Context): string { return `/${language}${legacyRedirect}` } - // The `redirects` are "pure" and don't specific a specific version. - // For example `/articles/stuff` to `/get-started/new/name` - // We look for those and try enterprise-cloud in it. + // Versionless redirects can still land on Enterprise Cloud or Free/Pro/Team equivalents. if (redirects[withoutVersion]) { const cloudCandidate = `/${language}/enterprise-cloud@latest${redirects[withoutVersion]}` if (cloudCandidate in pages) { @@ -279,8 +253,7 @@ function githubAERedirect(uri: string, context: Context): string { } const fptCandidate = `/${language}${redirects[withoutVersion]}` - // The lookup of redirects might yield a versioned URL, whose version - // might be github-ae or enterprise-server. Skip those. + // GitHub AE and Enterprise Server candidates would keep the reader on the wrong version. if (fptCandidate in pages) { const versionFromCandidate = getVersionStringFromPath(fptCandidate) if ( @@ -294,16 +267,11 @@ function githubAERedirect(uri: string, context: Context): string { } } - // Note that this includes completely unknown pages + // Unknown GitHub AE pages fall back to the localized home page. return `/${language}` } -// Over time, we've developed multiple ambiguous patterns of URLs -// You can't simply assume that all `/admin/guides` should become -// `/admin` for example. -// This function tries different string replacement on the suffix -// (the pathname after the language and version part) until it -// finds one string replacement that yields either a page or a redirect. +// Ambiguous suffixes like /admin/guides need the first replacement that hits a page or redirect. function tryReplacements(prefix: string, suffix: string, context: Context): string | undefined { const { pages, redirects } = context @@ -312,9 +280,7 @@ function tryReplacements(prefix: string, suffix: string, context: Context): stri } const test = (testSuffix: string): boolean => { - // This is a generally broad search and replace and this particular - // replacement has never been present in api documentation only enterprise - // admin documentation, so we're excluding the REST api pages + // REST API paths are outside the Enterprise Admin replacement patterns. if (testSuffix.includes('/rest')) { return false } diff --git a/src/redirects/lib/graphql-category-redirect.ts b/src/redirects/lib/graphql-category-redirect.ts index b31e1365766b..cd31f5578685 100644 --- a/src/redirects/lib/graphql-category-redirect.ts +++ b/src/redirects/lib/graphql-category-redirect.ts @@ -1,12 +1,8 @@ -// Dynamic redirect from legacy kind-based GraphQL reference URLs -// (e.g. `/graphql/reference/scalars#boolean`) to the per-category -// reference URLs (e.g. `/graphql/reference/other#scalar-boolean`) -// introduced when docs-internal adopted the upstream `@docsCategory` -// directive. +// Redirect legacy kind-based GraphQL reference URLs, such as +// /graphql/reference/scalars#boolean, to per-category reference URLs, such as +// /graphql/reference/other#scalar-boolean. // -// Resolved in a single hop so the type-name (in the URL fragment) is not -// lost: fragments are not sent on subsequent requests, so we can't chain a -// /v4 redirect to a removed kind page and recover the type later. +// Resolve in one hop because URL fragments are not sent on subsequent requests. import fs from 'fs' import path from 'path' @@ -21,15 +17,15 @@ import { } from '@/graphql/lib/categories' import { supported as supportedGhes } from '@/versions/lib/enterprise-server-releases' -// URL kind segment (e.g. "input-objects") -> internal kind key (e.g. "inputObjects"). +// URL kind segment input-objects maps to internal kind key inputObjects. const URL_TO_KIND_KEY: Record<string, SchemaKindKey> = Object.fromEntries( ALL_KIND_KEYS.map((k) => [KIND_URL_SEGMENT[k], k]), ) -// Set of legacy kind URL segments we redirect from. +// Accept only these legacy kind segments for redirect parsing. const LEGACY_KIND_SEGMENTS = new Set(Object.keys(URL_TO_KIND_KEY)) -// Per-version lookup: kind key -> id (lowercased) -> category slug. +// Per-version lookup maps kind key to lowercased ID to category slug. type CategoryMap = Partial<Record<SchemaKindKey, Record<string, string>>> const dataDir = path.join(process.cwd(), 'src/graphql/data') @@ -48,42 +44,36 @@ function loadCategoryMap(version: string): CategoryMap | null { return map } -// Map URL version segment to a graphql data directory name. Returns null for -// unsupported / archived versions so the caller can pass them through. +// Unsupported and archived versions pass through because they have no GraphQL data directory. function versionUrlToDataDir(versionSegment: string | null): string | null { if (!versionSegment || versionSegment === 'free-pro-team@latest') return 'fpt' if (versionSegment === 'enterprise-cloud@latest') return 'ghec' const m = /^enterprise-server@(\d+\.\d+)$/.exec(versionSegment) if (m && supportedGhes.includes(m[1])) return `ghes-${m[1]}` - // enterprise-server@latest also has category data via its current alias, - // but middleware order means we shouldn't see it here. Pass through. + // enterprise-server@latest has category data, but earlier middleware resolves it. return null } const LANGUAGE_RE = new RegExp(`^(${languageKeys.join('|')})$`) const VERSION_RE = /^(free-pro-team@latest|enterprise-cloud@latest|enterprise-server@[\d.]+)$/ -// Parse a docs URL path into its segments. Returns null if the path is not a -// graphql-reference legacy kind URL. +// Parse only legacy GraphQL reference kind URLs. interface ParsedLegacyUrl { language: string | null version: string | null kindSegment: string - // Type id (lowercased) parsed from the URL fragment, if any. + // Lowercased type ID from the URL fragment, if any. typeId: string | null } function parseLegacyUrl(input: string): ParsedLegacyUrl | null { - // We accept `redirect` strings that may include a `#fragment` but should not - // include a query string at this point (callers pass the path-only form). + // Callers pass path-only redirects, with optional fragments but no query strings. const hashIndex = input.indexOf('#') const pathPart = hashIndex >= 0 ? input.slice(0, hashIndex) : input const fragment = hashIndex >= 0 ? input.slice(hashIndex + 1) : '' const segments = pathPart.split('/').filter(Boolean) - // Expect segments to look like: - // [<lang>?, <version>?, "graphql", "reference", "<kind>"] - // with optional language and optional version. + // The legacy shape allows optional language and version segments before graphql/reference/kind. const refIdx = segments.indexOf('reference') if (refIdx < 0) return null if (segments[refIdx - 1] !== 'graphql') return null @@ -92,7 +82,7 @@ function parseLegacyUrl(input: string): ParsedLegacyUrl | null { const kindSegment = segments[refIdx + 1] if (!LEGACY_KIND_SEGMENTS.has(kindSegment)) return null - // The bits before "graphql" can be: nothing, [lang], [version], or [lang, version]. + // Segments before graphql can be empty, language, version, or language plus version. const preface = segments.slice(0, refIdx - 1) let language: string | null = null let version: string | null = null @@ -118,13 +108,9 @@ function buildPrefix(language: string | null, version: string | null): string { return out } -// Resolve a legacy URL to its category-based equivalent. Returns null if the -// input is not a legacy URL or its version is not supported (so callers leave -// it alone). +// Resolve legacy type URLs to category pages and bare kind URLs to the reference root. // -// `fallbackLanguage` is used when the input URL has no language segment so the -// rewritten URL is still valid against the language-prefixed `req.context.pages` -// lookup downstream. +// fallbackLanguage keeps language-less inputs valid against req.context.pages. export function applyGraphqlCategoryRedirect( redirect: string, fallbackLanguage: string = 'en', @@ -138,8 +124,7 @@ export function applyGraphqlCategoryRedirect( const language = parsed.language ?? fallbackLanguage const prefix = buildPrefix(language, parsed.version) - // No fragment: legacy bare kind page (e.g. /graphql/reference/scalars). The - // kind index doesn't exist anymore; send the user to the reference root. + // Legacy bare kind pages like /graphql/reference/scalars redirect to the reference root. if (!parsed.typeId) { return `${prefix}/graphql/reference` } @@ -152,8 +137,7 @@ export function applyGraphqlCategoryRedirect( return `${prefix}/graphql/reference/${category}#${slugPrefix}-${parsed.typeId}` } -// Test-only helper to reset the per-version cache so unit tests can reload -// fixture maps. Not exported through the public barrel. +// Tests reset the per-version cache to reload fixture maps without exporting through the barrel. export function __resetGraphqlCategoryCacheForTests(): void { lookupCache.clear() } diff --git a/src/redirects/lib/permalinks.ts b/src/redirects/lib/permalinks.ts index 6974dce431b4..00946ac6440e 100644 --- a/src/redirects/lib/permalinks.ts +++ b/src/redirects/lib/permalinks.ts @@ -5,6 +5,10 @@ import type { Permalink } from '@/types' type Redirects = Record<string, string> +// Versionless redirect fallbacks use the first supported version in lib/all-versions.ts order. +// Input: /billing/managing-billing-for-your-github-account/managing-invoices-for-your-enterprise +// Output: +// /enterprise-cloud@latest/billing/managing-billing-for-your-github-account/managing-invoices-for-your-enterprise export default function permalinkRedirects( permalinks: Permalink[], redirectFrom: string[], @@ -12,19 +16,12 @@ export default function permalinkRedirects( const redirects: Redirects = {} if (!permalinks.length) return redirects - // The following is handling for versionless redirect fallbacks! - // We put an entry into `redirects` without any version prefix that goes to the first supported - // version in the lib/all-versions.ts order. For example, we want this versionless path: - // /billing/managing-billing-for-your-github-account/managing-invoices-for-your-enterprise - // to redirect to its first supported version, which is GHEC: - // /enterprise-cloud@latest/billing/managing-billing-for-your-github-account/managing-invoices-for-your-enterprise if (permalinks[0].pageVersion !== nonEnterpriseDefaultVersion) { redirects[getPathWithoutVersion(permalinks[0].hrefWithoutLanguage)] = permalinks[0].hrefWithoutLanguage } - // For every "old" path in a content file's redirect_from frontmatter, also add that path to - // the redirects object as a key, where the value is the content file's permalink. + // redirect_from entries need both versionless and version-prefixed redirect keys. for (let frontmatterOldPath of redirectFrom) { if (!frontmatterOldPath.startsWith('/')) { throw new Error( @@ -32,23 +29,18 @@ export default function permalinkRedirects( ) } - // Exceptions where the `redirect_from` entries are too old - // Only replace /enterprise/ when it's at the start of the path followed by /admin/ - // This handles legacy patterns like /enterprise/admin/... → /admin/... - // but preserves paths like /early-access/enterprise/... where enterprise is a directory name + // Collapse /admin/guides/ and leading /enterprise/admin/, but preserve nested /enterprise/. frontmatterOldPath = frontmatterOldPath .replace('/admin/guides/', '/admin/') .replace(/^\/enterprise\/admin\//, '/admin/') for (let index = 0; index < permalinks.length; index++) { const permalink = permalinks[index] - // For the first supported permalink (the order is determined by lib/all-versions), - // put an entry into `redirects` without any version prefix. + // Versionless frontmatter redirects use the first supported permalink. if (index === 0) { redirects[frontmatterOldPath] = permalink.hrefWithoutLanguage } - // For every permalink, put an entry into `redirects` with the version prefix. redirects[`/${permalink.pageVersion}${frontmatterOldPath}`] = permalink.hrefWithoutLanguage } } diff --git a/src/redirects/lib/precompile.ts b/src/redirects/lib/precompile.ts index ccd055b194a1..3c74615a4bd9 100644 --- a/src/redirects/lib/precompile.ts +++ b/src/redirects/lib/precompile.ts @@ -8,8 +8,7 @@ const EXCEPTIONS_FILE = './src/redirects/lib/static/redirect-exceptions.txt' type Redirects = Record<string, string> -// This function runs at server warmup and precompiles possible redirect routes. -// It outputs them in key-value pairs within a neat JavaScript object: { oldPath: newPath } +// Server warmup precompiles redirect routes as oldPath to newPath pairs. export async function precompileRedirects(pageList: Page[]): Promise<Redirects> { const allRedirects = readCompressedJsonFileFallback( './src/redirects/lib/static/developer.json', @@ -20,47 +19,25 @@ export async function precompileRedirects(pageList: Page[]): Promise<Redirects> ) as Redirects Object.assign(allRedirects, externalRedirects) - // create backwards-compatible old paths for page permalinks and frontmatter redirects + // Page permalinks and frontmatter redirects need backward-compatible paths. for (const page of pageList.filter((xpage) => xpage.languageCode === 'en')) { Object.assign(allRedirects, page.buildRedirects()) } - // Remove any redirect whose source URL is also a real page permalink. - // This prevents redirect_from entries from clobbering live pages when a - // new page (versioned broadly) declares a redirect_from that overlaps - // with an older page that still exists in some versions. + // Live page permalinks win over redirect_from entries that overlap older page versions. for (const page of pageList.filter((xpage) => xpage.languageCode === 'en')) { for (const permalink of page.permalinks) { delete allRedirects[permalink.hrefWithoutLanguage] } } - // NOTE: Exception redirects **MUST COME AFTER** pageList redirects above in order - // to properly override them. Exception redirects are unicorn one-offs that are not - // otherwise handled by the versionless redirect fallbacks (see lib/all-versions.ts). - // - // Examples of exceptions: - // * We deprecate the FPT version of a page, and we want the FPT version to redirect - // to a different version that goes against the order in lib/all-versions.ts. - // * We deprecate a non-FPT version of a page, and we want the old version to redirect - // to a different version. Because the order in lib/all-versions.ts only covers - // versionless links (like `/foo`), we need to specify an exception for the old - // versioned links (like `/enterprise-cloud@latest/foo`). - // * We deprecate a version of a page, and instead of falling back to the next - // available version, we want to redirect that version to a different page entirely. - // - // The advantage of the exception redirects file is that it's encoded in plain - // text so it's possible to write comments and it's also possible to write 1 - // destination URL once for each N redirect origins. + // The plain text format keeps one destination URL next to its many redirect origins. const exceptions = getExceptionRedirects(EXCEPTIONS_FILE) as Redirects + // Apply exceptions last so versioned paths override fallback order or target another page. Object.assign(allRedirects, exceptions) for (const [fromURI, toURI] of Object.entries(allRedirects)) { - // If the destination URL has a hardcoded `enterprise-server@latest` in - // it we need to rewrite that now. - // We never want to redirect to that as the final URL (in the 301 response) - // but it might make sense for it to be in the `developer.json` - // file since that is static. + // Static redirects can name enterprise-server@latest, but 301 responses need a real version. if (toURI.includes('/enterprise-server@latest')) { allRedirects[fromURI] = toURI.replace( '/enterprise-server@latest', diff --git a/src/redirects/lib/version-preference.ts b/src/redirects/lib/version-preference.ts index 8227879fabd2..c6f089ba1a79 100644 --- a/src/redirects/lib/version-preference.ts +++ b/src/redirects/lib/version-preference.ts @@ -2,25 +2,20 @@ import { allVersions, allVersionKeys } from '@/versions/lib/all-versions' import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' import { getPathWithoutLanguage } from '@/frame/lib/path-utils' -// Every version the reader can actually prefer over the unversioned form. -// `detect-version.ts` already refuses cookie values outside `allVersionKeys`, -// so anything reaching here is a real version. +// detect-version.ts rejects cookie values outside allVersionKeys. const alternateVersions = allVersionKeys.filter((v) => v !== nonEnterpriseDefaultVersion) -// Segments that name a version rather than a product, in any form we have ever -// served. `allVersions` is not enough on its own: it has no key for -// `enterprise-server@latest`, for deprecated releases like `enterprise-server@3.0`, -// or for the older `/enterprise/3.3/` and `/enterprise-server/3.9/` shapes. Those -// still have to count as an explicit request, because a URL naming a version must -// beat the cookie even when we no longer publish that version. +// Explicit version URLs must beat the cookie. allVersions omits enterprise-server@latest, +// deprecated releases like enterprise-server@3.0, and legacy shapes like /enterprise/3.3/ +// and /enterprise-server/3.9/. const VERSION_PLANS = new Set([ ...Object.values(allVersions).map((v) => v.plan), 'github-ae', 'enterprise', ]) -// Does this path name a version itself, rather than leaving it implied? -// Anything with an `@` is a version segment, because no article slug contains one. +// A path naming a version beats the cookie. +// Anything with @ is a version segment, because article slugs do not contain it. // The plan names cover the legacy unsuffixed shapes. export function pathNamesAVersion(path: string): boolean { const firstSegment = getPathWithoutLanguage(path).split('/')[1] @@ -29,32 +24,23 @@ export function pathNamesAVersion(path: string): boolean { } export type VersionPreference = { - // True when some version cookie value would have changed the response, so the - // response has to vary on `x-user-version` even when this particular reader has - // no cookie. Varying only for cookie holders would let a no-cookie reader's - // cached page be served to someone who should have been redirected. + // True when any cookie could change the response, even when this reader has none. + // Without this Vary, caches can serve a no-cookie response to a reader who needs a redirect. vary: boolean - // Where to send this reader, if their preference applies and the article exists there. + // Preference destination, when the article exists there. redirectTo?: string } const NOTHING: VersionPreference = { vary: false } -// Work out whether a reader's version preference applies to a request. +// A version cookie behaves like language: it is only the default. // -// The rule, decided in github/technical-content#7227, is that version behaves exactly -// like language: the cookie is only a default, a version named in the URL always wins, -// and an article that does not exist in the preferred version silently stays put. +// A version named in the URL wins, and missing preferred-version articles stay put. // -// `requestPath` is the URL as asked for, and is what decides whether a version was -// named. It has to be, because `getRedirect` strips an explicit `/free-pro-team@latest` -// prefix before we get here. Reading the resolved path instead would make an explicit -// request for Free/Pro/Team indistinguishable from no request at all, and a reader who -// has set a cookie could never deliberately look at the Free/Pro/Team article again. +// requestPath decides whether the URL named a version because getRedirect strips an +// explicit /free-pro-team@latest prefix before this point. // -// `resolvedPath` is where the ordinary redirect logic decided to send them, and is what -// the versioned candidate is built from, so a renamed article resolves in one hop -// instead of two. +// resolvedPath builds the versioned candidate so renamed articles resolve in one hop. export function getVersionPreference( requestPath: string, resolvedPath: string, @@ -66,15 +52,11 @@ export function getVersionPreference( if (pathNamesAVersion(requestPath) || pathNamesAVersion(resolvedPath)) return NOTHING - // Always a `URL.pathname` from the caller, so it always starts with `/` and this is - // always the first segment. On a path that never got a language prefix this reads some - // article slug as the language, and the candidate lookup below simply misses, because - // every key in `pages` is language-prefixed. + // Language-less paths read a slug as the language and miss the language-prefixed pages lookup. const language = resolvedPath.split('/')[1] const withoutLanguage = getPathWithoutLanguage(resolvedPath) - // `pages` is keyed by permalink, which carries no `.md` extension. Keep the extension - // aside so a `.md` request redirects to the `.md` form of the versioned article. + // pages keys omit .md, but .md requests must redirect to .md versioned articles. const extension = withoutLanguage.endsWith('.md') ? '.md' : '' const lookupSuffix = extension ? withoutLanguage.slice(0, -extension.length) : withoutLanguage @@ -83,8 +65,7 @@ export function getVersionPreference( for (const version of alternateVersions) { if (!(`/${language}/${version}${lookupSuffix}` in pages)) continue - // At least one version of this article exists that the cookie could select, so the - // response depends on the cookie whether or not this reader has one. + // Any selectable version makes the response depend on x-user-version. vary = true if (version === userVersion) { redirectTo = `/${language}/${version}${lookupSuffix}${extension}` diff --git a/src/redirects/middleware/handle-redirects.ts b/src/redirects/middleware/handle-redirects.ts index d1e0d0d58427..0b25be9fb03c 100644 --- a/src/redirects/middleware/handle-redirects.ts +++ b/src/redirects/middleware/handle-redirects.ts @@ -13,21 +13,26 @@ import { } from '@/frame/middleware/cache-control' import { ExtendedRequest, URLSearchParamsTypes } from '@/types' +// Version preference redirects stay in their own branch so cookie-dependent paths use 302. +// A 301 would cache one reader's version preference in the browser. +// Deep links from search, the product UI, or bookmarks otherwise ignore the cookie and serve +// Free/Pro/Team. Measured corrective switching showed 1,288 moves to Enterprise Cloud and 500 +// moves to Enterprise Server per 24 hours. +// When getVersionPreference returns vary without redirectTo, append Vary: x-user-version, +// because the 200 response depends on the cookie. +// Use append, not set, to keep existing Vary values. +// src/versions/tests/version-cookie.ts verifies the served header. export default function handleRedirects(req: ExtendedRequest, res: Response, next: NextFunction) { if (!req.context) throw new Error('Request not contextualized') - // Any double-slashes in the URL should be removed first - // This must be done before checking if the path - // is an asset (patterns.assetPaths) + // Collapse duplicate slashes before patterns.assetPaths, so //example.com cannot bypass handling. if (req.path.includes('//')) { return res.safeRedirect(301, req.path.replace(/\/+/g, '/')) } - // never redirect assets if (patterns.assetPaths.test(req.path)) return next() - // All /api/ endpoints handle their own redirects - // such as /api/pageinfo redirects to /api/pageinfo/v1 + // API endpoints handle their own redirects. if (req.path.startsWith('/api/')) return next() if (req.path === '/') { @@ -50,12 +55,7 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex let redirect = req.path let queryParams = req.originalUrl.includes('?') ? req.originalUrl.split('?')[1] : null - // Redirect `/some/uri?q=stuff` to `/en/search?query=stuff` - // Redirect `/some/uri?query=stuff` to `/en/search?query=stuff` - // Redirect `/fr/version@latest/some/uri?query=stuff` - // to `/fr/version@latest/search?query=stuff` - // The `q` param is deprecated, but we still need to support it in case - // there are links out there that use it. + // Route page searches to the search endpoint, and rename legacy q to query. const onSearch = req.path.endsWith('/search') || req.path.startsWith('/api/search') const hasQ = 'q' in req.query const hasQuery = 'query' in req.query @@ -71,9 +71,7 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex const { currentVersion } = req.context if (currentVersion !== 'free-pro-team@latest') { redirectTo += `/${currentVersion}` - // The `req.context.currentVersion` is just the portion of the URL - // pathname. It could be that the currentVersion is something - // like `enterprise` which needs to be redirected to its new name. + // currentVersion comes from the path, so legacy names such as enterprise need getRedirect. redirectTo = getRedirect(redirectTo, req.context) || redirectTo } @@ -81,21 +79,18 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex return res.safeRedirect(301, redirectTo) } - // have to do this now because searchPath replacement changes the path as well as the query params if (queryParams) { queryParams = `?${queryParams}` } - // remove query params temporarily so we can find the path in the redirects object + // Redirect keys omit query strings. let redirectWithoutQueryParams = removeQueryParams(redirect) const redirectTo = getRedirect(redirectWithoutQueryParams, req.context) redirectWithoutQueryParams = redirectTo || redirectWithoutQueryParams - // Resolve legacy `/graphql/reference/<kind>(#<name>)?` URLs to their - // per-category equivalent. Done before query-param re-application so the - // fragment parsing in the helper is unambiguous. + // Parse legacy GraphQL fragments before reapplying query params, so fragment parsing stays clear. const graphqlRewrite = applyGraphqlCategoryRedirect( redirectWithoutQueryParams, req.context.userLanguage || 'en', @@ -107,16 +102,9 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex redirect = queryParams ? redirectWithoutQueryParams + queryParams : redirectWithoutQueryParams if (!redirectTo && !pathLanguagePrefixed(req.path)) { - // No redirect necessary, but perhaps it's to a known page, and the URL - // currently doesn't have a language prefix, then we need to add - // the language prefix. - // We can't always force on the language prefix because some URLs - // aren't pages. They're other middleware endpoints such as - // `/healthcheck` which should never redirect. - // But for example, a `/authentication/connecting-to-github-with-ssh` - // needs to become `/en/authentication/connecting-to-github-with-ssh` + // Add a language prefix only for pages or deprecated versions; /healthcheck passes through. const possibleRedirectTo = `/en${req.path}` - // Pages are keyed without .md, so strip it before lookup + // Pages are keyed without .md, so strip the extension before lookup. const lookupPath = possibleRedirectTo.endsWith('.md') ? possibleRedirectTo.replace(/\.md$/, '') : possibleRedirectTo @@ -124,25 +112,13 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex if (lookupPath in req.context.pages || isDeprecatedVersion(req.path)) { const language = getLanguage(req) - // Note, it's important to use `req.url` here and not `req.path` - // because the full URL can contain query strings. - // E.g. `/foo?json=breadcrumbs` + // Use req.url here so redirects preserve query strings such as ?json=breadcrumbs. redirect = `/${language}${req.url}` } } if (!req.context.pages) throw new Error('req.context.pages not yet set') - // Honor the reader's version preference on a URL that does not name a version. - // - // Without this, the cookie is only ever consulted on the bare homepage, so a deep link - // from search, the product UI, or a bookmark silently serves Free/Pro/Team. See - // github/technical-content#7227 for the measurements. - // - // This is deliberately its own branch rather than a tweak to `redirect` below, because - // the ordinary path would emit a 301 for a language-prefixed URL. A redirect that - // depends on a cookie has to stay a 302, or a browser caches one reader's preference - // forever. if (!redirect.includes('://')) { const preference = getVersionPreference( req.path, @@ -151,18 +127,6 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex req.context.pages, ) if (preference.vary && !preference.redirectTo) { - // Only needed when we do not redirect. The redirect below calls - // `languageAndVersionCacheControl`, which already lists `x-user-version`. - // - // We set it even though this response is not a redirect, because it still depends - // on the cookie: a cached copy without this header would be served to readers whose - // preference we should have honored. - // - // `append`, not `set`, so this survives the cache-control call that whatever - // handles the request downstream makes. Those all append too, so nothing clobbers - // it. The `varies on the cookie even for readers who have not set one` test in - // `src/versions/tests/version-cookie.ts` asserts the served 200 really does carry - // the header, so this holds even if that stops being true. res.append('vary', 'x-user-version') } if (preference.redirectTo) { @@ -175,7 +139,7 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex return next() } - // do not redirect if the redirected page can't be found + // Skip internal redirects whose target page is missing. if ( !( req.context.pages[removeQueryParams(redirect).replace(/\.md$/, '')] || @@ -183,15 +147,14 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex ) && !redirect.includes('://') ) { - // display error on the page in development, but not in production - // include final full redirect path in the message + // Development responses expose the missing redirect target; production keeps the page clean. if (process.env.NODE_ENV !== 'production' && req.context) { req.context.redirectNotFound = redirect } return next() } - // do the redirect if the from-URL already had a language in it + // Language-prefixed and external redirects do not vary by language preference. if (pathLanguagePrefixed(req.path) || redirect.includes('://')) { defaultCacheControl(res) } else { @@ -203,27 +166,17 @@ export default function handleRedirects(req: ExtendedRequest, res: Response, nex } function getLanguage(req: ExtendedRequest, default_ = 'en') { - // req.context.userLanguage, if it truthy, is always a valid supported - // language. It's whatever was in the user's request in lib/languages.ts + // detect-language.ts limits userLanguage to supported cookie or Accept-Language values. return req.context!.userLanguage || default_ } function usePermanentRedirect(req: ExtendedRequest) { - // If the redirect was to essentially swap `enterprise-server@latest` - // for `enterprise-server@3.x` then, we definitely don't want to - // do a permanent redirect. - // When this is the case, we don't want a permanent redirect because - // it could overzealously cache in the users' browser which could - // be bad when whatever "latest" means changes. + // Redirects from enterprise-server@latest stay temporary because latest changes over time. if (req.path.includes('/enterprise-server@latest')) return false - // If the redirect involved injecting a language prefix, then don't - // permanently redirect because that could overly cache in users' - // browsers if we some day want to make the language redirect - // depend on a cookie or 'Accept-Language' header. + // Language-prefixed paths redirect permanently here; injected prefixes fall through to temporary. if (pathLanguagePrefixed(req.path)) return true - // The default is to *not* do a permanent redirect. return false } @@ -231,13 +184,10 @@ function removeQueryParams(redirect: string) { return new URL(redirect, 'https://docs.github.com').pathname } +// Deprecated enterprise-server releases in deprecatedWithFunctionalRedirects have functional +// redirects but no lookup entries or active req.context.pages for custom Next.js paths such as +// /admin/release-notes. function isDeprecatedVersion(path: string) { - // When we rewrote how redirects work, from a lookup model to a - // functional model, the enterprise-server releases that got - // deprecated since then fall between the cracks. Especially - // for custom NextJS page-like pages like /admin/release-notes - // These URLs don't come from any remaining .json lookup file - // and they're not active pages either (e.g. req.context.pages) const split = path.split('/') for (const version of deprecatedWithFunctionalRedirects) { if (split.includes(`enterprise-server@${version}`)) { diff --git a/src/redirects/middleware/language-code-redirects.ts b/src/redirects/middleware/language-code-redirects.ts index d4561c734497..57fc324efffb 100644 --- a/src/redirects/middleware/language-code-redirects.ts +++ b/src/redirects/middleware/language-code-redirects.ts @@ -7,9 +7,7 @@ import { ExtendedRequest } from '@/types' const redirectPatterns = Object.values(languages) .map((language) => language.redirectPatterns || []) .flat() -// If the enabled languages have `.redirectPatterns`, combine them -// into one which we only need to use to determine if we should bother -// doing the redirect at all. +// Combine enabled language redirectPatterns for a cheap precheck before scanning every pattern. const combinedRedirectPatternRegex = redirectPatterns.length > 0 ? new RegExp(redirectPatterns.map((rex) => rex.source).join('|')) @@ -19,26 +17,20 @@ const allRedirectPatterns = Object.values(languages) .map((language) => (language.redirectPatterns || []).map((redirectPattern) => [language.code, redirectPattern]), ) - .flat() as [string, RegExp][] // Seems TypeScript didn't understand the .flat() + .flat() as [string, RegExp][] // flat() loses tuple type inference. -// This middleware handles redirects for mistyped language codes -// -// Examples: -// /jp* -> /ja* -// /zh-TW* -> /zh* +// Redirect mistyped language codes, for example /jp* -> /ja* and /zh-TW* -> /zh*. export default function languageCodeRedirects( req: ExtendedRequest, res: Response, next: NextFunction, ) { - // Only in the unlikely event that the `req.path` starts with one of these - // prefixes do we bother looking up what the redirect should be. + // Only paths matching the combined precheck need per-language pattern lookup. if (req.path.startsWith('/_next/static')) return next() if (!combinedRedirectPatternRegex) return next() if (!combinedRedirectPatternRegex.test(req.path)) return next() - // This loop is almost never ever used so it doesn't have to be - // particularly smart or fast. + // This rare path favors clarity over optimizing the pattern scan. const matched = allRedirectPatterns.find(([, pattern]) => pattern.test(req.path)) if (matched) { const [code, pattern] = matched diff --git a/src/redirects/scripts/get-new-dotcom-path.ts b/src/redirects/scripts/get-new-dotcom-path.ts index 57c87016b571..89ce2a21c208 100644 --- a/src/redirects/scripts/get-new-dotcom-path.ts +++ b/src/redirects/scripts/get-new-dotcom-path.ts @@ -1,9 +1,5 @@ -// [start-readme] -// -// Pass this script any old dotcom path (e.g., `articles/foo` or `foo.md`) and it -// will output the new path in the content/github directory. -// -// [end-readme] +// Finds the content/github path for an old dotcom path. +// content/github no longer exists, so this script currently fails. import assert from 'assert' import { last } from 'lodash-es' @@ -22,7 +18,6 @@ let filename: string = oldPath if (filename.includes('/')) filename = last(filename.split('/')) as string -// first check whether name is a category const categoryDir = `${newDotcomDir}/${filename.replace(markdownRegex, '')}` if (fs.existsSync(categoryDir)) { @@ -30,7 +25,6 @@ if (fs.existsSync(categoryDir)) { process.exit(0) } -// otherwise add extension and check whether it's a file if (!filename.endsWith(markdownExtension)) filename = filename + markdownExtension const newPath: string = execSync(`find ${newDotcomDir} -name ${filename}`).toString() diff --git a/src/redirects/tests/content/redirect-orphans.ts b/src/redirects/tests/content/redirect-orphans.ts index 5219ede4c86c..c975fa64181a 100644 --- a/src/redirects/tests/content/redirect-orphans.ts +++ b/src/redirects/tests/content/redirect-orphans.ts @@ -5,12 +5,11 @@ import { describe, expect, test, vi } from 'vitest' import { loadPages } from '@/frame/lib/page-data' describe('redirect orphans', () => { - // Because calling `loadPages` will trigger a warmup, this can potentially - // be very slow in CI. So we need a timeout. + // loadPages warms up the page cache, which can be slow in CI, so this test needs a timeout. vi.setConfig({ testTimeout: 60 * 1000 }) test('no redirect_from entry has a trailing slash', async () => { - // Only doing English because they're the only files we do PRs for. + // Only English files receive pull requests, so test English redirect_from entries. const pageList = await loadPages(undefined, ['en']) const errors = [] diff --git a/src/redirects/tests/ghae.ts b/src/redirects/tests/ghae.ts index 300931301887..da17f2875475 100644 --- a/src/redirects/tests/ghae.ts +++ b/src/redirects/tests/ghae.ts @@ -1,9 +1,5 @@ -// We entirely removed GHAE but we still have to support legacy links. -// The unit tests cover `getRedirect()` directly. These are end-to-end tests -// that span the middleware, which uses `getRedirect()` internally. -// -// They matter because ghae is gone from the `allVersions` config object, so -// these redirects are all that is left of it. +// GitHub AE legacy links redirect through middleware; github-ae is absent from allVersions. +// These end-to-end tests cover the middleware path; unit tests cover getRedirect directly. import { describe, expect, test } from 'vitest' @@ -31,7 +27,7 @@ describe('ghae redirects', () => { test('ghae release notes', async () => { const res = await head('/en/github-ae@latest/admin/release-notes') expect(res.statusCode).toBe(301) - // There is not an "equivalent" release notes page for enterprise-cloud + // No enterprise-cloud release notes equivalent exists. expect(res.headers.location).toMatch('/en') }) }) diff --git a/src/redirects/tests/redirects.ts b/src/redirects/tests/redirects.ts index 93d5ebc52eae..138303aeb376 100644 --- a/src/redirects/tests/redirects.ts +++ b/src/redirects/tests/redirects.ts @@ -67,8 +67,7 @@ describe('redirects', () => { }) test('have faq= not converted to query=', async () => { - // Don't confuse `?faq=` for `?q=` just because they both start with `q=` - // Docs internal #21945 + // Keep faq intact while q becomes query. const res = await get('/en/enterprise/admin?faq=pulls') expect(res.statusCode).toBe(301) const expected = `/en/enterprise-server@${enterpriseServerReleases.latest}/admin?faq=pulls` @@ -115,7 +114,7 @@ describe('redirects', () => { const res = await get('/') expect(res.statusCode).toBe(302) expect(res.headers.location).toBe('/en') - // language specific caching + // Language redirects vary by language preference. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=\d+/) expect(res.headers.vary).toContain('accept-language') @@ -222,15 +221,12 @@ describe('redirects', () => { test('frontmatter redirect', async () => { const res = await get('/enterprise/2.12/user/articles/github-flavored-markdown') - expect(res.statusCode).toBe(302) // because it doesn't have a language + expect(res.statusCode).toBe(302) // Missing language keeps this redirect temporary. expect(res.headers.location).toBe('/enterprise/2.12/user/categories/writing-on-github/') }) }) describe('enterprise admin', () => { - // firstRestoredAdminGuides = 2.21 - // lastBeforeRestoredAdminGuides = 2.20 - // (these won't change but it's more convenient to use constants) const { firstRestoredAdminGuides, getPreviousReleaseNumber, latest } = enterpriseServerReleases const lastBeforeRestoredAdminGuides = getPreviousReleaseNumber(firstRestoredAdminGuides) const enterpriseAdmin = `/en/enterprise-server@${latest}/admin` @@ -321,7 +317,7 @@ describe('redirects', () => { expect(res.headers.location).toBe(userArticle) }) - // 2.16 was the first version where we moved /articles/foo to /github/<category>/foo + // Enterprise 2.16 is the redirect boundary for /articles/foo to /github/<category>/foo. test('no product does not redirect to GitHub.com product in <=2.15', async () => { const res = await get('/en/enterprise/2.15/user/articles/set-up-git') expect(res.statusCode).toBe(200) @@ -427,7 +423,7 @@ describe('redirects', () => { test('no domain redirect on //example.com/', async () => { const res = await get(`//example.com/`) expect(res.statusCode).toBe(301) - expect(res.headers.location).toBe(`/example.com`) // should not be //example.com + expect(res.headers.location).toBe(`/example.com`) // Guards against //example.com. const res2 = await get(res.headers.location) expect(res2.statusCode).toBe(404) }) diff --git a/src/redirects/tests/routing/developer-site-redirects.ts b/src/redirects/tests/routing/developer-site-redirects.ts index a3e6d6e9bbb8..dd8d4cdb113b 100644 --- a/src/redirects/tests/routing/developer-site-redirects.ts +++ b/src/redirects/tests/routing/developer-site-redirects.ts @@ -10,9 +10,7 @@ describe('developer redirects', () => { vi.setConfig({ testTimeout: 60 * 1000 }) beforeAll(async () => { - // The first page load takes a long time so let's get it out of the way in - // advance to call out that problem specifically rather than misleadingly - // attributing it to the first test + // Warm up the first page load so later failures point to the redirect under test. await get('/v4') }) @@ -70,27 +68,23 @@ describe('developer redirects', () => { expectedFinalPath = '/en/rest' expect(res.headers.location).toBe(expectedFinalPath) - // REST subresources like activity notifications don't have their own page - // anymore, so redirect to an anchor on the resource page + // REST subresource paths like activity notifications resolve under the resource page. res = await get('/en/v3/activity') expect(res.statusCode).toBe(301) expectedFinalPath = '/en/rest/activity' expect(res.headers.location).toBe(expectedFinalPath) - // REST subresources like activity notifications don't have their own page - // anymore, so redirect to an anchor on the resource page + // REST subresource paths like activity notifications resolve under the resource page. res = await get('/en/v3/activity/notifications') expect(res.statusCode).toBe(301) expectedFinalPath = '/en/rest/activity/notifications' expect(res.headers.location).toBe(expectedFinalPath) - // trailing slashes are handled separately by the `slashes` module; - // any request to a /v3 URL with a trailing slash will be redirected twice + // The slashes middleware removes trailing slash first, causing two redirects for /v3 URLs. res = await get('/en/v3/activity/notifications/') expect(res.statusCode).toBe(301) expect(res.headers.location).toBe('/en/v3/activity/notifications') - // non-reference redirects (e.g. guides) res = await get('/en/v3/guides/basics-of-authentication') expect(res.statusCode).toBe(301) expectedFinalPath = @@ -107,11 +101,9 @@ describe('developer redirects', () => { } if (!(label in FIXTURES)) throw new Error('unrecognized label') const fixtures = readJsonFile(FIXTURES[label as keyof typeof FIXTURES]) - // Don't use a `Promise.all()` because it's actually slower - // because of all the eventloop context switching. + // Avoid Promise.all here; event loop context switching makes it slower. for (let [oldPath, newPath] of Object.entries(fixtures as Record<string, string>)) { - // REST and GraphQL developer Enterprise paths with a version are only supported up to 2.21. - // We make an exception to always redirect versionless paths to the latest version. + // Versioned developer Enterprise paths support up to 2.21; versionless paths use latest. newPath = (newPath as string).replace( '/enterprise-server/', `/enterprise-server@${enterpriseServerReleases.latest}/`, diff --git a/src/redirects/tests/routing/versionless-redirects.ts b/src/redirects/tests/routing/versionless-redirects.ts index 6b62bc92f518..56cc1fb0b313 100644 --- a/src/redirects/tests/routing/versionless-redirects.ts +++ b/src/redirects/tests/routing/versionless-redirects.ts @@ -12,9 +12,8 @@ const VERSIONLESS_REDIRECTS_FILE = path.join( '../../../../src/fixtures/fixtures/versionless-redirects.txt', ) -// This test checks the default versioning redirect fallbacks described in lib/all-versions.ts. -// The fixture now contains mock URLs instead of live URLs to prevent test failures when content is moved. -// This ensures the redirect logic works correctly without being dependent on real content files. +// These tests cover default versioning redirect fallbacks from lib/all-versions.ts. +// The fixture uses mock URLs, so moved content cannot break them. describe('versioned redirects', () => { vi.setConfig({ testTimeout: 60 * 1000 }) diff --git a/src/redirects/tests/unit/get-redirect.ts b/src/redirects/tests/unit/get-redirect.ts index 07a94b790293..25ce8fca5b1e 100644 --- a/src/redirects/tests/unit/get-redirect.ts +++ b/src/redirects/tests/unit/get-redirect.ts @@ -18,13 +18,9 @@ type TestContext = { const previousEnterpriserServerVersion = supported[1] describe('getRedirect basics', () => { + // Static developer.json redirects must win before legacy enterprise prefixes are normalized. + // For /enterprise/3.0/foo/bar, lookup happens before /enterprise-server@3.0 rewriting. test('should sometimes not correct the version prefix', () => { - // This essentially tests legacy entries that come from the - // `developer.json` file. Normally, we would have first - // rewritten `/enterprise/3.0` to `/enterprise-server@3.0` - // and then, from there, worried about the remaining `/foo/bar` - // part. - // But some redirects from `developer.json` as old and static. const uri = '/enterprise/3.0/foo/bar' const ctx: TestContext = { pages: {}, @@ -72,7 +68,7 @@ describe('getRedirect basics', () => { }, } expect(getRedirect('/free-pro-team@latest', ctx as unknown as Context)).toBe('/en') - // Language is fine, but the version needs to be "removed" + // free-pro-team@latest is versionless, so the language prefix remains. expect(getRedirect('/en/free-pro-team@latest', ctx as unknown as Context)).toBe('/en') expect(getRedirect('/free-pro-team@latest/pizza', ctx as unknown as Context)).toBe('/en/pizza') expect(getRedirect('/free-pro-team@latest/foo', ctx as unknown as Context)).toBe('/en/bar') @@ -108,7 +104,7 @@ describe('getRedirect basics', () => { }, redirects: {}, } - // Replacing `/user` with `` worked because there exits a page of such name. + // The /user prefix can drop because the resulting page exists. expect( getRedirect( `/enterprise-server@${previousEnterpriserServerVersion}/user/foo/bar`, @@ -142,12 +138,12 @@ describe('getRedirect basics', () => { ctx as unknown as Context, ), ).toBe(`/en/enterprise-server@${previousEnterpriserServerVersion}/something`) - // but also respect redirects if there are some + // Respect redirects after normalizing the old enterprise prefix. expect( getRedirect(`/enterprise/${previousEnterpriserServerVersion}/foo`, ctx as unknown as Context), ).toBe(`/en/enterprise-server@${previousEnterpriserServerVersion}/bar`) - // Unique snowflake pattern + // /enterprise/github paths map to enterprise-server github/admin paths. expect(getRedirect('/enterprise/github/admin/foo', ctx as unknown as Context)).toBe( `/en/enterprise-server@${latest}/github/admin/foo`, ) @@ -158,8 +154,7 @@ describe('getRedirect basics', () => { pages: {}, redirects: {}, } - // Nothing's needed here because it's not /admin/guides and - // it already has the enterprise-server prefix. + // No admin/guides rewrite applies when the path already has enterprise-server. expect( getRedirect( `/en/enterprise-server@${latest}/admin/something/else`, @@ -179,16 +174,14 @@ describe('getRedirect basics', () => { [`/enterprise-server@${latestStable}/foo`]: `/enterprise-server@${latestStable}/bar`, }, } - // Nothing's needed here because it's not /admin/guides and - // it already has the enterprise-server prefix. + // enterprise-server without a version resolves to latest stable before redirect lookup. expect(getRedirect('/enterprise-server/foo', ctx as unknown as Context)).toBe( `/en/enterprise-server@${latestStable}/bar`, ) }) + // Functional redirects cover enterprise-server 3.0 and later without lookup entries. test('should work for some deprecated enterprise-server URLs too', () => { - // Starting with enterprise-server 3.0, we have made redirects become - // a *function* rather than a lookup on a massive object. const ctx: TestContext = { pages: {}, redirects: {}, @@ -282,7 +275,7 @@ describe('github-ae@latest', () => { const ctx: TestContext = { pages: { '/en/foo': true, - // Note the lack of an enterprise-cloud page here + // No enterprise-cloud page exists here, so GitHub AE falls back to Free/Pro/Team. }, redirects: { '/food': '/foo', diff --git a/src/redirects/tests/unit/graphql-category-redirect.ts b/src/redirects/tests/unit/graphql-category-redirect.ts index 670cd93cd333..58d09d9333ea 100644 --- a/src/redirects/tests/unit/graphql-category-redirect.ts +++ b/src/redirects/tests/unit/graphql-category-redirect.ts @@ -11,8 +11,7 @@ describe('applyGraphqlCategoryRedirect', () => { }) test('rewrites a legacy scalar URL with language prefix', () => { - // Boolean is a built-in GraphQL scalar with no @docsCategory, so it falls - // to the `other` bucket. + // Built-in GraphQL scalars have no @docsCategory, so they fall into the other bucket. expect(applyGraphqlCategoryRedirect('/en/graphql/reference/scalars#boolean')).toBe( '/en/graphql/reference/other#scalar-boolean', ) @@ -31,7 +30,7 @@ describe('applyGraphqlCategoryRedirect', () => { }) test('uses the category from the schema map when annotated', () => { - // `Repository` is annotated with `@docsCategory(name: "repos")` upstream. + // Repository has @docsCategory(name: "repos") in the upstream schema. expect(applyGraphqlCategoryRedirect('/en/graphql/reference/objects#repository')).toBe( '/en/graphql/reference/repos#object-repository', ) @@ -44,7 +43,7 @@ describe('applyGraphqlCategoryRedirect', () => { }) test('handles `input-objects` kind segment', () => { - // CustomPropertyValueInput is categorized as `repos` in the fpt schema. + // The fpt schema categorizes CustomPropertyValueInput as repos. expect( applyGraphqlCategoryRedirect('/en/graphql/reference/input-objects#custompropertyvalueinput'), ).toBe('/en/graphql/reference/repos#input-object-custompropertyvalueinput') diff --git a/src/redirects/tests/unit/precompile.ts b/src/redirects/tests/unit/precompile.ts index e702ee09205e..b4d884c9b708 100644 --- a/src/redirects/tests/unit/precompile.ts +++ b/src/redirects/tests/unit/precompile.ts @@ -13,8 +13,8 @@ vi.mock('../../lib/exception-redirects', () => ({ const { default: precompileRedirects } = await import('../../lib/precompile') const { default: generateRedirectsForPermalinks } = await import('../../lib/permalinks') -// Minimal stand-in for a Page instance. precompileRedirects() only relies on -// `languageCode`, `permalinks`, and `buildRedirects()`. +// makePage supplies only the Page fields precompileRedirects needs: languageCode, permalinks, and +// buildRedirects. function makePage( languageCode: string, permalinks: { pageVersion: string; hrefWithoutLanguage: string }[], @@ -36,7 +36,7 @@ function makePage( describe('precompileRedirects', () => { test('removes a redirect_from-generated redirect that clobbers a live old-page permalink, but keeps it for versions where the old page is absent', async () => { - // The old page only exists in GHES 3.14. + // The old page exists only in GHES 3.14. const oldPage = makePage( 'en', [ @@ -48,9 +48,7 @@ describe('precompileRedirects', () => { [], ) - // The replacement page exists in both 3.14 and 3.15, and declares - // `redirect_from: ['/foo']`, which would otherwise clobber the old - // page's live permalink in 3.14. + // redirect_from on the replacement page must not clobber the old GHES 3.14 permalink. const newPage = makePage( 'en', [ @@ -68,12 +66,10 @@ describe('precompileRedirects', () => { const redirects = await precompileRedirects([oldPage, newPage]) - // The old page's live permalink in 3.14 must not be clobbered by the - // replacement page's redirect_from. + // The GHES 3.14 old-page permalink wins over the replacement page's redirect_from. expect(redirects['/enterprise-server@3.14/foo']).toBeUndefined() - // But in 3.15, where the old page doesn't exist, the redirect must - // still be there. + // GHES 3.15 lacks the old page, so redirect_from still creates the redirect. expect(redirects['/enterprise-server@3.15/foo']).toBe('/enterprise-server@3.15/bar') }) }) diff --git a/src/redirects/tests/version-preference.ts b/src/redirects/tests/version-preference.ts index f6a9e6a53bfa..f9c27ae37b57 100644 --- a/src/redirects/tests/version-preference.ts +++ b/src/redirects/tests/version-preference.ts @@ -6,9 +6,8 @@ import { latest } from '@/versions/lib/enterprise-server-releases' const GHEC = 'enterprise-cloud@latest' const GHES = `enterprise-server@${latest}` -// A stand-in for `req.context.pages`, which is keyed by full versioned permalink. -// `/actions/versioned` exists in all three versions, `/actions/fpt-only` only in the -// unversioned one. +// pages stands in for req.context.pages, keyed by full versioned permalink. +// /actions/versioned exists in all three versions; /actions/fpt-only exists only unversioned. const pages = Object.fromEntries( [ '/en/actions/versioned', @@ -27,14 +26,13 @@ describe('pathNamesAVersion', () => { ['/en/enterprise-cloud@latest/actions/foo', true], ['/en/free-pro-team@latest/actions/foo', true], ['/en/enterprise-server@latest/actions/foo', true], - // Deprecated releases are not keys of `allVersions`, but naming one is still - // an explicit request and has to beat the cookie. + // Deprecated releases are absent from allVersions, but explicit version paths beat the cookie. ['/en/enterprise-server@3.0/actions/foo', true], ['/en/github-ae@latest/actions/foo', true], - // Legacy shapes that carry no `@`. + // Legacy shapes without @ still name a version. ['/en/enterprise-server/3.9/actions/foo', true], ['/en/enterprise/3.3/actions/foo', true], - // No version named. + // These paths name no version. ['/en/actions/foo', false], ['/actions/foo', false], ['/en', false], @@ -56,9 +54,8 @@ describe('getVersionPreference', () => { expect(getVersionPreference(path, path, GHEC, pages)).toEqual({ vary: false }) }) - // The escape hatch. `getRedirect` strips `/free-pro-team@latest` before the middleware - // gets here, so the resolved path looks unversioned. Only the request path still shows - // that the reader asked for Free/Pro/Team on purpose. + // free-pro-team@latest is an escape hatch: getRedirect strips it before this helper runs. + // The original request path still proves the reader explicitly asked for Free/Pro/Team. test('leaves an explicit free-pro-team URL alone even after the prefix is stripped', () => { expect( getVersionPreference( @@ -88,9 +85,8 @@ describe('getVersionPreference', () => { ).toEqual({ vary: true, redirectTo: `/en/${GHES}/actions/versioned` }) }) - // The partial case: this article has a version the cookie could have selected, just not - // the one this reader asked for. No redirect, but the response still depends on the - // cookie, so it must not be cached as though it were the same for everyone. + // If the cookie names a version the article lacks, the response still depends on the cookie. + // Vary prevents caches from serving that fallback response to readers with other preferences. test('varies without redirecting when the cookie names a version this article lacks', () => { expect( getVersionPreference('/en/actions/ghec-only', '/en/actions/ghec-only', GHES, pages), @@ -139,8 +135,7 @@ describe('getVersionPreference', () => { }) }) - // The redirect target names a version, so the next request short-circuits on - // `pathNamesAVersion` and cannot bounce back. + // Versioned redirect targets short-circuit on pathNamesAVersion, so they cannot bounce back. test('cannot loop', () => { const first = getVersionPreference( '/en/actions/versioned', diff --git a/src/release-notes/lib/release-notes-utils.ts b/src/release-notes/lib/release-notes-utils.ts index 727a59e91cae..8dd3a9a0afcf 100644 --- a/src/release-notes/lib/release-notes-utils.ts +++ b/src/release-notes/lib/release-notes-utils.ts @@ -3,13 +3,8 @@ import { supported, latestStable, latest } from '@/versions/lib/enterprise-serve import { renderContent } from '@/content-render/index' import type { Context, GHESReleasePatch, ReleaseNotes } from '@/types' -/** - * Create an array of release note objects and sort them by number. - * Turn { [key]: { notes, intro, date, sections... } } - * Into [{ version, patches: [ {notes, intro, date, sections... }] }] - */ export function formatReleases(releaseNotes: ReleaseNotes) { - // Dot notation, highest first. + // Sort dot-formatted release numbers from highest to lowest. const sortedReleaseNumbers = Object.keys(releaseNotes) .map((r) => r.replace(/-/g, '.')) .sort((a, b) => supported.indexOf(a) - supported.indexOf(b)) @@ -19,7 +14,7 @@ export function formatReleases(releaseNotes: ReleaseNotes) { const patches = Object.keys(notesPerVersion) .filter((patchNumber) => !notesPerVersion[patchNumber].deprecated) .map((patchNumber) => { - // Change version-rc1 to version-rc.1 to make these proper semver RC versions. + // Normalize rc1 to rc.1 for release-note version strings. const patchNumberSemver = patchNumber.replace(/rc/, 'rc.') return { ...notesPerVersion[patchNumber], @@ -34,20 +29,12 @@ export function formatReleases(releaseNotes: ReleaseNotes) { return { version: releaseNumber, patches, - // Lets callers drop release candidates, - // like the "Supported releases" list on the product landing page. - // An RC only exists while `latestStable` isn't `latest`. + // Lets consumers drop release candidates from supported-release lists. isReleaseCandidate: latest !== latestStable && releaseNumber === latest, } }) } -/** - * Render each note in the given patch, by looping through the - * sections and rendering either `note` or `note.notes` in the - * case of a sub-section. - * Returns [{version, patchVersion, intro, date, sections: { features: [], bugs: []...}}] - */ export async function renderPatchNotes( patches: GHESReleasePatch[], ctx: Context, @@ -58,15 +45,11 @@ export async function renderPatchNotes( const renderedPatch: GHESReleasePatch = { ...patch, sections: {} } renderedPatch.intro = await renderContent(patch.intro, ctx) - // sections looks like { features: [], bugs: [], ... } const renderedSections = Object.fromEntries( await Promise.all( Object.entries(patch.sections).map(async ([sectionType, sectionArray]) => { - // sectionType is things like 'features', 'bugs', etc. - // sectionArray is things like [ { heading, notes: [] } ] const renderedSectionArray = await Promise.all( sectionArray.map(async (note) => { - // `note` is either a string or { heading, notes: [] } if (typeof note === 'string') { return renderContent(note, ctx) } else if (typeof note === 'object' && 'heading' in note && 'notes' in note) { diff --git a/src/release-notes/middleware/get-release-notes.ts b/src/release-notes/middleware/get-release-notes.ts index 897bb218f653..c4887cf91b74 100644 --- a/src/release-notes/middleware/get-release-notes.ts +++ b/src/release-notes/middleware/get-release-notes.ts @@ -1,39 +1,26 @@ import { getDataByLanguage, getDeepDataByLanguage } from '@/data-directory/lib/get-data' import type { GHESReleasePatch, ReleaseNotes } from '@/types' -// Widen the union if we ever support release notes for another product. type ReleaseNotesPrefix = 'enterprise-server' export function getReleaseNotes(prefix: ReleaseNotesPrefix, langCode: string) { - // Use English as the foundation, then we'll try to load each individual - // data/release-notes/**/*.yml file from the translation. - // If the language is 'en', don't even bother merging. + // English release notes define the file set because translation directories can retain stale files. const releaseNotes = getDeepDataByLanguage(`release-notes.${prefix}`, 'en') as ReleaseNotes if (langCode === 'en') { return releaseNotes } - // The reason we're doing this is because we can't trust - // getDeepDataByLanguage() in the translations because it depends on - // loading in all possible files in the directory. Translations often - // don't delete files, so we use the English data as a guide for which - // data files to bother reading. - - // `getDeepDataByLanguage()` returns a mutable object from a memoize cache, - // so build a new one rather than mutating it. + // getDeepDataByLanguage returns a mutable object from a memoize cache. const translatedReleaseNotes: ReleaseNotes = {} for (const [majorVersion, releases] of Object.entries(releaseNotes)) { - // Major version is things like '3-7' translatedReleaseNotes[majorVersion] = {} for (const minorVersion of Object.keys(releases)) { - // Minor version is things like '0-rc1' or '3' const data = getDataByLanguage( `release-notes.${prefix}.${majorVersion}.${minorVersion}`, langCode, ) as GHESReleasePatch - // If any section was mistranslated into something other than an array, - // fall back to English. + // Fall back to English when a translated section is not an array. const validSections = Object.values(data.sections).every((sectionValue) => Array.isArray(sectionValue), ) diff --git a/src/release-notes/middleware/ghes-release-notes.ts b/src/release-notes/middleware/ghes-release-notes.ts index 7b2abe3461ba..6ce4b006be76 100644 --- a/src/release-notes/middleware/ghes-release-notes.ts +++ b/src/release-notes/middleware/ghes-release-notes.ts @@ -17,25 +17,17 @@ export default async function ghesReleaseNotesContext( const [requestedPlan, requestedRelease] = req.context.currentVersion.split('@') if (requestedPlan !== 'enterprise-server') return next() - // Forced to English. - // The Markdown in data/release-notes/**/*.yml spells out product names - // instead of using Liquid variables, - // so translators render "Le GitHubbe Cöpilotte" instead of "GitHub Copilot". - // Revisit once those sources use `{% data variables.product.* %}`. + // Force English because some release-note entries still spell out product names. const ghesReleaseNotes = getReleaseNotes('enterprise-server', 'en') - // If the requested GHES release isn't found in data/release-notes/enterprise-server/*, - // and it IS a valid GHES release, try being helpful and redirecting to the old location. - // Otherwise, 404. + // Valid releases missing local notes redirect to enterprise.github.com; others return 404. if (!Object.keys(ghesReleaseNotes).includes(requestedRelease.replace(/\./, '-'))) { return all.includes(requestedRelease) ? res.safeRedirect(`https://enterprise.github.com/releases/${requestedRelease}.0/notes`) : next() } - // For example, the URL is something like /enterprise-server@3.7/xxx/admin - // or /enterprise-server@3.7/xxxx/release-notes - // Then it should not bother because it'll be a 404 anyway. + // Paths under the right version but wrong page still return 404 through the normal middleware. if (!req.context.page) return next() req.context.ghesReleases = formatReleases(ghesReleaseNotes) @@ -44,22 +36,17 @@ export default async function ghesReleaseNotesContext( if (!matchedReleaseNotes) throw new Error('Release notes not found') const currentReleaseNotes = matchedReleaseNotes.patches - // The release notes themselves are already forced to English. - // This forces the reusables to match, - // while AUTOTITLE links stay in the reader's language. + // Render reusables in English while AUTOTITLE links stay in the reader's language. const originalLanguage = req.context.currentLanguage req.context.autotitleLanguage = originalLanguage req.context.currentLanguage = 'en' try { - // Render the release notes Markdown. req.context.ghesReleaseNotes = await executeWithFallback( req.context, () => renderPatchNotes(currentReleaseNotes, req.context!), (enContext: Context) => { - // Something in the release notes ultimately caused a Liquid - // rendering error. Let's start over and gather the English release - // notes instead. + // Unreachable while currentLanguage is forced to en; rebuild props if that changes. enContext.ghesReleases = formatReleases(ghesReleaseNotes) const enMatchedNotes = enContext.ghesReleases!.find((r) => r.version === requestedRelease) @@ -72,12 +59,11 @@ export default async function ghesReleaseNotesContext( req.context.currentLanguage = originalLanguage } - // GHES release notes on docs started with 2.20 but older release notes exist on enterprise.github.com. - // So we want to use _all_ GHES versions when calculating next and previous releases. + // latestPatch comes from local notes; latestRelease comes from supported release metadata. req.context.latestPatch = req.context.ghesReleaseNotes![0].version req.context.latestRelease = latestStable - // Add convenience props for "Supported releases" section on GHES Admin landing page (NOT release notes). + // Previous-release links include older GHES releases hosted on enterprise.github.com. for (const release of req.context.ghesReleases) { release.firstPreviousRelease = all[all.findIndex((v) => v === release.version) + 1] release.secondPreviousRelease = diff --git a/src/release-notes/pages/release-notes.tsx b/src/release-notes/pages/release-notes.tsx index 8408f65222a2..6b4718ab6a16 100644 --- a/src/release-notes/pages/release-notes.tsx +++ b/src/release-notes/pages/release-notes.tsx @@ -21,9 +21,7 @@ type Props = { } export default function ReleaseNotes({ mainContext, ghesContext }: Props) { if (!ghesContext) { - // (Jan 2024) If we some day have more types of release notes, we'll - // need to make this more forgiving. - // This component used to cater for GHAE too when that existed. + // GHES is the only supported release-notes product. throw new Error('GHES is the only option') } return ( @@ -41,8 +39,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async ( const req = context.req as unknown as ExtendedRequest const res = context.res as unknown as Response - // `allVersions[X]` carries more than the components need, - // so pick only the keys they use. + // allVersions entries carry more than these components need, so pick only the keys they use. const currentVersion = pick(req.context!.allVersions?.[req.context!.currentVersion!] || {}, [ 'plan', 'planTitle', diff --git a/src/release-notes/tests/release-notes.ts b/src/release-notes/tests/release-notes.ts index 1805dd1539d8..1af118febe9a 100644 --- a/src/release-notes/tests/release-notes.ts +++ b/src/release-notes/tests/release-notes.ts @@ -5,8 +5,7 @@ import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases' import { get, getDOM } from '@/tests/helpers/e2etest' import Page from '@/frame/lib/page' -// The English content page's `versions:` frontmatter is the source -// of (convenient) truth about which versions of this page is available. +// The English page versions frontmatter defines the versions this page supports. const page = await Page.init({ basePath: 'content', relativePath: 'admin/release-notes.md', @@ -21,7 +20,7 @@ describe('server', () => { const res = await get('/admin/release-notes') expect(res.statusCode).toBe(302) expect(res.headers.location).toBe( - // Note that English is the default fallback for redirects + // English remains the default fallback for redirects. `/en/enterprise-server@${enterpriseServerReleases.latest}/admin/release-notes`, ) }) diff --git a/src/rest/api/anchor-redirect.ts b/src/rest/api/anchor-redirect.ts index 59d1c8c28831..519919e4187e 100644 --- a/src/rest/api/anchor-redirect.ts +++ b/src/rest/api/anchor-redirect.ts @@ -11,7 +11,6 @@ const clientSideRestAPIRedirects = readCompressedJsonFileFallbackLazily( const router = express.Router() -// Returns a client side redirect if one exists for the given path. const redirects: RequestHandler = (req, res) => { if (!req.query.path) { res.status(400).send("Missing 'path' query string") diff --git a/src/rest/components/ApiVersionPicker.tsx b/src/rest/components/ApiVersionPicker.tsx index 16f07260386f..fb9feaa21bd1 100644 --- a/src/rest/components/ApiVersionPicker.tsx +++ b/src/rest/components/ApiVersionPicker.tsx @@ -13,13 +13,11 @@ const API_VERSION_SUFFIX = ' (latest)' function rememberApiVersion(apiVersion: string) { try { - // We use this cookie to remember which API Version a user chooses - // when they navigate the REST docs. + // Remember the selected REST API version across REST docs pages. const apiVersionNormalized = apiVersion.replace(API_VERSION_SUFFIX, '') Cookies.set(API_VERSION_COOKIE_NAME, apiVersionNormalized) } catch (err) { - // Some browser extensions disallow setting cookies at all, so the - // `document.cookie` setter can throw. Swallow it and move on. + // Some extensions make document.cookie throw, so ignore cookie-write failures. console.warn('Unable to set preferred api version cookie', err) } } @@ -30,8 +28,7 @@ export const ApiVersionPicker = () => { const { allVersions } = useMainContext() const { t } = useTranslation('rest') const basePath = router.asPath.split('#')[0].split('?')[0] - // Use the version from the URL when it's valid, otherwise the latest date. - // RestRedirect is what applies the cookie preference to the URL. + // Use a valid URL version or latest; RestRedirect applies the cookie preference to the URL. const isValidApiVersion = (router.query.apiVersion && typeof router.query.apiVersion === 'string' && @@ -75,7 +72,7 @@ export const ApiVersionPicker = () => { }, }) - // A non-empty `apiVersions` means the version is calendar-date versioned. + // Calendar-date versioned docs expose at least one API version. return allVersions[currentVersion].apiVersions.length > 0 ? ( <div data-testid="api-version-picker"> <Picker diff --git a/src/rest/components/ClientSideRedirectExceptions.tsx b/src/rest/components/ClientSideRedirectExceptions.tsx index 5481431ba6e6..b61800a0036b 100644 --- a/src/rest/components/ClientSideRedirectExceptions.tsx +++ b/src/rest/components/ClientSideRedirectExceptions.tsx @@ -1,16 +1,13 @@ import { useEffect } from 'react' import { useRouter } from 'next/router' -// REST operations have moved around in the docs, so the URLs in the OpenAPI are -// out of sync with where the pages now live. Until those are updated, this -// catches links from elsewhere in the product, such as error-code URLs from the -// APIs. A redirect can be one operation URL to another, or a heading on one page -// to a different page, e.g. /rest/repos#statuses to /rest/commits/statuses. +// OpenAPI URLs can lag REST doc page moves, so this catches product links such as +// API error-code URLs. Redirects can target another operation URL or move a heading, +// for example /rest/repos#statuses to /rest/commits/statuses. export default function ClientSideRedirectExceptions() { const router = useRouter() useEffect(() => { - // The fetch can resolve after the component unmounts and still call - // router.replace, so abort it during cleanup. + // Abort during cleanup because fetch can resolve after unmount and still call router.replace. const controller = new AbortController() const signal = controller.signal @@ -29,8 +26,7 @@ export default function ClientSideRedirectExceptions() { signal, }) - // A missing redirect is a 200 with an empty object, so only a - // successful response is worth parsing. + // Missing redirects return 200 with an empty object; parse only successful responses. if (response.ok) { const { to } = await response.json() if (to) { diff --git a/src/rest/components/ClientSideRedirects.tsx b/src/rest/components/ClientSideRedirects.tsx index ad21a8adb72e..bed8d7d6ac64 100644 --- a/src/rest/components/ClientSideRedirects.tsx +++ b/src/rest/components/ClientSideRedirects.tsx @@ -9,19 +9,16 @@ const ClientSideRedirectExceptions = dynamic( }, ) +// REST API code hardcodes some docs links in a separate repo. Fixing those links +// needs many file changes and team sign-off, so redirect exceptions repair one-offs. +// Hashes are client-only, so wait to load redirect logic until the browser can inspect them. export function ClientSideRedirects() { const { asPath } = useRouter() - // One-off redirects for the REST docs, as a workaround for fixing the - // hardcoded links in the REST API code, which lives in a separate repo and - // needs many file changes and sign-off from several teams. - // - // This decides whether to load the redirecting component at all. It can't - // happen server-side because the URL hash is only known on the client. const [load, setLoad] = useState(false) useEffect(() => { const { hash } = window.location - // Only /rest has these redirects today. More paths may need adding. + // Redirect exceptions apply only under /rest. if (hash && asPath.startsWith('/rest')) { setLoad(true) } diff --git a/src/rest/components/RestAuth.tsx b/src/rest/components/RestAuth.tsx index 3eabc959c6cf..6cc865ee484b 100644 --- a/src/rest/components/RestAuth.tsx +++ b/src/rest/components/RestAuth.tsx @@ -6,7 +6,7 @@ import { Link } from '@/frame/components/Link' import { ProgAccessT } from './types' import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML' -// Documentation paths may be moved around by content team in the future +// Keep these paths centralized because content can move docs pages. const USER_TOKEN_PATH = '/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app' const INSTALLATION_TOKEN_PATH = @@ -24,12 +24,11 @@ export function RestAuth({ progAccess, slug, operationTitle }: Props) { const { currentVersion } = useVersion() const { t } = useTranslation('rest_reference') - // This early return can be removed once GHES 3.9 is deprecated - // The GHES 3.8 and 3.9 releases don't support fine-grained access tokens + // GHES 3.8 and 3.9 lacked fine-grained tokens; both are deprecated, so this never matches. if (currentVersion === 'enterprise-server@3.9' || currentVersion === 'enterprise-server@3.8') return null - // Some operations define no progAccess at all. + // Some operations omit progAccess. if (!progAccess) return null const { userToServerRest, @@ -40,10 +39,7 @@ export function RestAuth({ progAccess, slug, operationTitle }: Props) { } = progAccess const noFineGrainedAccess = !(userToServerRest || serverToServer || fineGrainedPat) - // For endpoints on dotcom that do not support any fine-grained token types - // and allow permissionless (unauthenticated) access, do not render a - // fine-grained access section. Note: allowPermissionlessAccess is dotcom-only; - // GHES versions may still require authentication for these endpoints. + // Hide fine-grained access for dotcom permissionless endpoints; GHES may still require auth. if (!basicAuth && noFineGrainedAccess && allowPermissionlessAccess) return null const heading = basicAuth ? t('basic_auth_heading') : t('fine_grained_access') @@ -77,20 +73,14 @@ type FineGrainedProps = { progAccess: ProgAccessT } +// Each progAccess.permissions object is one acceptable permission set. +// Every key-value pair inside a set is required. function FineGrainedAccess({ progAccess }: FineGrainedProps) { const router = useRouter() const { currentVersion } = useVersion() const { t } = useTranslation('rest_reference') - // progAccess.permissions is an array of objects - // For example: [ {'"Actions" repository permissions': 'read', '"Administration" organization permissions': 'write'}, {'"Secrets" organization permissions"': 'write'} ] - // Each object represents a set of permissions containing one - // or more key-value pairs. All permissions in a set are required. - // If there is more than one set of permissions, any set can be used. const formattedPermissions = progAccess.permissions.map((permissionSet: object, index) => { - // Given the example above, the first object is now an array of tuples - // [['"Actions" repository permissions', 'read'], ['"Administration" organization permissions', 'read']] - // that can be formatted as a string like `"Administration" organization permissions (write)' const permissionSetPairs = Object.entries(permissionSet) const numPermissionSetPairs = permissionSetPairs.length diff --git a/src/rest/components/RestBanner.tsx b/src/rest/components/RestBanner.tsx index abb6fbf75f8b..5033925b58b6 100644 --- a/src/rest/components/RestBanner.tsx +++ b/src/rest/components/RestBanner.tsx @@ -36,8 +36,7 @@ const restRepoCategoryExceptionsTitles = { export const RestBanner = () => { const router = useRouter() const { t } = useTranslation('rest') - // A productId of 'rest' with no category is the product landing page, e.g. - // /en/rest?apiVersion=2022-08-09. + // The /en/rest?apiVersion=2022-08-09 page has productId=rest and no category. const isRestPage = router.query.productId === 'rest' || router.query.category const restPage = router.query.category as string const { currentVersion } = useVersion() @@ -53,8 +52,7 @@ export const RestBanner = () => { versionWithApiVersion = currentVersion } else { if (currentVersionObj.isGHES) { - // If this is a GHES release with no REST versions, - // find out if any GHES releases contain REST versioning yet. + // GHES releases without REST versioning link to the first GHES release that has it. const firstGhesReleaseWithApiVersions = Object.values(allVersions) .reverse() .find((v) => { @@ -72,7 +70,6 @@ export const RestBanner = () => { } } } - // Temporary banner for REST API Versioning if (isRestPage && bannerText !== '') { return ( <div diff --git a/src/rest/components/RestCodeSamples.module.scss b/src/rest/components/RestCodeSamples.module.scss index fd0bf5215c83..b56937e3a651 100644 --- a/src/rest/components/RestCodeSamples.module.scss +++ b/src/rest/components/RestCodeSamples.module.scss @@ -38,7 +38,7 @@ margin-bottom: 0.5rem !important; li + li { - // Same as the Primer CSS selector for segmented control + // Match Primer's segmented-control sibling selector. margin-top: -1px !important; } } diff --git a/src/rest/components/RestCodeSamples.tsx b/src/rest/components/RestCodeSamples.tsx index fba5fc6494c9..07148c2753a4 100644 --- a/src/rest/components/RestCodeSamples.tsx +++ b/src/rest/components/RestCodeSamples.tsx @@ -32,8 +32,7 @@ type Props = { const responseSelectOptions = Object.values(ResponseKeys) -// Map a REST code-sample language to the highlight language name passed to -// <HighlightedCode>. Add cases as needed. +// Map REST code-sample languages to syntax highlighter language names. function getLanguageHighlight(selectedLanguage: string) { return selectedLanguage === CodeSampleKeys.javascript ? 'javascript' : 'curl' } @@ -42,7 +41,6 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { const { t } = useTranslation(['rest_reference']) const { isEnterpriseServer, isEnterpriseCloud } = useVersion() - // Ref for resetting scroll position when switching response views. const scrollRef = useRef<HTMLDivElement>(null) const { currentVersion } = useVersion() @@ -59,13 +57,11 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { const languageSelectOptions: CodeSampleKeys[] = [CodeSampleKeys.curl] - // Management Console and GHES Manage API operations are not supported - // by Octokit + // Management Console and GHES Manage API operations have no Octokit support. if (operation.subcategory !== 'management-console' && operation.subcategory !== 'manage-ghes') { languageSelectOptions.push(CodeSampleKeys.javascript) - // Not all examples support the GH CLI language option. If any of - // the examples don't support it, we don't show GH CLI as an option. + // Hide GitHub CLI when any example lacks GitHub CLI support. if (!languageExamples.some((example) => example.ghcli === undefined)) { languageSelectOptions.push(CodeSampleKeys.ghcli) } @@ -94,8 +90,7 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { } useEffect(() => { - // If the user previously selected a language preference and the language - // is available in this component set it as the selected language + // Honor the saved language preference only when this operation supports that language. const cookieValue = Cookies.get(CODE_SAMPLE_LANGUAGE_COOKIE_NAME) const preferredCodeLanguage = languageSelectOptions.find((item) => item === cookieValue) if (cookieValue && preferredCodeLanguage) { @@ -103,8 +98,7 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { } }, []) - // Reset scroll position to the top when switching between example response and - // response schema. Highlighting is handled React-natively by <HighlightedCode>. + // Reset scroll position when switching between the example response and response schema. useEffect(() => { const scrollElem = scrollRef.current if (scrollElem) { @@ -156,7 +150,6 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { </div> )} - {/* Request example section */} <div className="rounded-1 border"> <div className="my-0 p-3"> <RestMethod verb={operation.verb} requestPath={operation.requestPath} /> @@ -215,7 +208,6 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { </div> </div> - {/* Response section */} <RenderedHTML as="h4" className="mt-5 mb-2 h5" diff --git a/src/rest/components/RestMethod.tsx b/src/rest/components/RestMethod.tsx index c1484fef7036..0d005463553c 100644 --- a/src/rest/components/RestMethod.tsx +++ b/src/rest/components/RestMethod.tsx @@ -9,8 +9,7 @@ type RestMethodT = { } export function RestMethod({ verb, requestPath }: RestMethodT) { - // If the path is long, we want to break it up into multiple lines, - // breaking before the / character. + // Insert word breaks before slashes and after underscores so long paths wrap in narrow layouts. const displayPath = requestPath.length > 25 ? requestPath.replaceAll('/', '<wbr/>/').replaceAll('_', '_<wbr/>') diff --git a/src/rest/components/RestOperation.tsx b/src/rest/components/RestOperation.tsx index ec4d4e622244..2fe12c3a63d8 100644 --- a/src/rest/components/RestOperation.tsx +++ b/src/rest/components/RestOperation.tsx @@ -19,7 +19,7 @@ type Props = { operation: Operation } -// all REST operations have this accept header by default +// Use this as the default Accept header for REST operations. const DEFAULT_ACCEPT_HEADER = { name: 'accept', type: 'string', @@ -38,7 +38,7 @@ export function RestOperation({ operation }: Props) { const titleSlug = slug(operation.title) const { t } = useTranslation('rest_reference') const router = useRouter() - // omit the default header if ghes specific api + // Omit the default Accept header for Management Console and GHES Manage APIs. const headers = operation.subcategory === 'management-console' || operation.subcategory === 'manage-ghes' ? [] diff --git a/src/rest/components/RestRedirect.tsx b/src/rest/components/RestRedirect.tsx index 1cb50cbf5d03..dd7a557bf65e 100644 --- a/src/rest/components/RestRedirect.tsx +++ b/src/rest/components/RestRedirect.tsx @@ -6,8 +6,8 @@ import { useVersion } from '@/versions/components/useVersion' import { useMainContext } from '@/frame/components/context/MainContext' import { API_VERSION_COOKIE_NAME } from '@/frame/lib/constants' -// This component allows us to set the URL Param for the REST API Calendar Date version -// We set a cookie as well to remember what calendar date version the user is on +// RestRedirect adds a valid apiVersion to calendar-date versioned REST URLs. +// It reads a saved version from the cookie and otherwise uses the latest version. export function RestRedirect() { const router = useRouter() const { currentVersion } = useVersion() diff --git a/src/rest/components/RestReferencePage.tsx b/src/rest/components/RestReferencePage.tsx index 28fddddc7f26..e8408e5a066d 100644 --- a/src/rest/components/RestReferencePage.tsx +++ b/src/rest/components/RestReferencePage.tsx @@ -18,10 +18,7 @@ export const RestReferencePage = ({ restOperations }: StructuredContentT) => { const { title, intro, renderedPage, renderedPageHast, permissions, product } = useAutomatedPageContext() - // Scrollable code blocks in our REST API docs and elsewhere aren't accessible - // via keyboard navigation without setting tabindex="0". But we don't want to set - // this attribute on every `<pre>` code block, only the ones where there are scroll - // bars because the content isn't all visible. + // Add tabindex=0 only when pre content overflows, because scrollable code needs keyboard access. useEffect(() => { const codeBlocks = document.querySelectorAll<HTMLPreElement>('pre') @@ -37,8 +34,6 @@ export const RestReferencePage = ({ restOperations }: StructuredContentT) => { return ( <DefaultLayout> - {/* Doesn't matter *where* this is included because it will - never render anything. It always just return null. */} <ClientSideRedirects /> <RestRedirect /> <div className="px-3 px-md-6 my-4 container-xl" data-search="article-body"> diff --git a/src/rest/components/get-rest-code-samples.ts b/src/rest/components/get-rest-code-samples.ts index 7564007ebe33..028479067b69 100644 --- a/src/rest/components/get-rest-code-samples.ts +++ b/src/rest/components/get-rest-code-samples.ts @@ -5,13 +5,12 @@ import type { CodeSample, Operation } from '@/rest/components/types' import { type VersionItem } from '@/frame/components/context/MainContext' function shouldOmitAuthentication(operation: Operation, currentVersion: string): boolean { - // Only omit auth for operations that explicitly allow permissionless access + // Only explicitly permissionless operations can omit auth. if (!operation?.progAccess?.allowPermissionlessAccess) { return false } - // Only omit auth on dotcom versions (free-pro-team, enterprise-cloud) - // GHES and other versions still require authentication + // Dotcom versions can omit auth; GHES and other versions still require authentication. const isDotcomVersion = currentVersion.startsWith('free-pro-team') || currentVersion.startsWith('enterprise-cloud') @@ -26,16 +25,14 @@ function escapeShellValue(value: string): string { type CodeExamples = Record<string, unknown> -// If the content type is application/x-www-form-urlencoded the format of -// the shell example is --data-urlencode param1=value1 --data-urlencode param2=value2 -// For example, this operation: +// Form-encoded shell examples use repeated --data-urlencode flags, such as +// param1=value1 and param2=value2. For example: // https://docs.github.com/en/enterprise/rest/reference/enterprise-admin#enable-or-disable-maintenance-mode const CURL_CONTENT_TYPE_MAPPING: { [key: string]: string } = { 'application/x-www-form-urlencoded': '--data-urlencode', 'multipart/form-data': '--form', 'application/octet-stream': '--data-binary', } -// Generates a curl example for one code sample. export function getShellExample( operation: Operation, codeSample: CodeSample, @@ -52,9 +49,9 @@ export function getShellExample( const omitAuth = shouldOmitAuthentication(operation, currentVersion) - // GHES Manage API requests differ from the dotcom API requests and make use of multipart/form-data and json content types + // GHES Manage requests need special handling for multipart/form-data and JSON content types. if (operation.subcategory === 'manage-ghes') { - // GET requests don't have a requestBody set, therefore let's default them to application/json + // GHES Manage GET operations omit requestBody, so default the content type to JSON. if (operation.verb === 'get') { contentTypeHeader = '-H "Content-Type: application/json"' } else { @@ -83,9 +80,7 @@ export function getShellExample( const contentType = codeSample.request.contentType if (contentType in CURL_CONTENT_TYPE_MAPPING) { requestBodyParams = '' - // Most of the time the example body parameters have a name and value - // and are included in an object. But, some cases are a single value - // and the type is a string. + // Mapped content types can pass a single scalar body instead of named parameters. const { bodyParameters } = codeSample.request if (bodyParameters && typeof bodyParameters === 'object' && !Array.isArray(bodyParameters)) { const paramNames = Object.keys(bodyParameters) @@ -108,15 +103,12 @@ export function getShellExample( : '' let acceptHeader = `-H "Accept: ${getAcceptHeader(codeSample)}"` let urlArg = `${operation.serverUrl}${requestPath}` - // If the `requestPath` contains a `?` character, if you need to escape - // the whole URL otherwise, when you paste it into your terminal, it - // will fail because the `?` is a bash control character. + // Quote URLs containing ? so shells don't expand it as a glob. if (requestPath.includes('?')) { urlArg = `"${urlArg}"` } - // The management-console and manage-ghes APIs don't follow the dotcom - // conventions, so replace the auth, API version and Accept headers. + // Management Console and GHES Manage APIs replace dotcom auth, API version, and Accept headers. if (operation.subcategory === 'management-console' || operation.subcategory === 'manage-ghes') { authHeader = '-u "api_key:your-password"' apiVersionHeader = '' @@ -147,15 +139,13 @@ export function getShellExample( return `curl -L \\\n ${args.join(' \\\n ')}` } -// Generates a GitHub CLI example for one code sample. Returns undefined when -// the operation only supports basic auth, which gh doesn't do. +// Return undefined when basicAuth is set because GitHub CLI does not support basic auth. export function getGHExample( operation: Operation, codeSample: CodeSample, currentVersion: string, allVersions: Record<string, VersionItem>, ) { - // Basic authentication is not supported by GH CLI if (operation?.progAccess?.basicAuth) return const defaultAcceptHeader = getAcceptHeader(codeSample) @@ -175,21 +165,17 @@ export function getGHExample( requestPath += requiredQueryParams ? `?${requiredQueryParams}` : '' let requestBodyParams = '' - // Most of the time the example body parameters have a name and value - // and are included in an object. But, some cases are a single value - // and the type is a string. + // Request bodies can be named object parameters or a single scalar value. const { bodyParameters } = codeSample.request if (bodyParameters) { if (typeof bodyParameters === 'object') { - // Special handling for gist endpoints - use --input for nested file structures + // Gist create and update examples use --input for nested file structures. const isGistEndpoint = !Array.isArray(bodyParameters) && operation.requestPath.includes('/gists') && (operation.title === 'Create a gist' || operation.title === 'Update a gist') - // For top-level arrays or complex objects with arrays, use --input with JSON. - // The gh CLI -f/-F flags can't represent a request body that is itself an array, - // so we fall back to piping the JSON body via --input. + // Use --input for top-level arrays or nested arrays because gh -f and -F cannot encode them. const hasArrays = hasNestedArrays(bodyParameters as NestedObjectParameter) if (hasArrays || isGistEndpoint) { const jsonBody = JSON.stringify( @@ -255,7 +241,7 @@ function handleSingleParameter( ): string { let cliLine = '' const keyString = `${transformKey(key)}` - // When only a value is passed to bodyParameters we don't show the '=' since there isn't a key + // Scalar bodyParameters omit = because they have no key. let separator = '=' if (!key) { separator = '' @@ -289,6 +275,8 @@ function handleSingleParameter( return cliLine } +// handleObjectParameter rejects nested arrays because form-field encoding cannot represent them. +// It expands arrays of objects into separate -f or -F parameters. function handleObjectParameter( objectParams: NestedObjectParameter, transformKey = startTransformKey, @@ -298,16 +286,11 @@ function handleObjectParameter( if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const param = value[i] - // This isn't valid in a REST context, our REST API should not be designed to take - // something like { "letterSegments": [["a", "b", "c"], ["d", "e", "f"]] } - // If this is a possibility, we can update the code to handle it if (Array.isArray(param)) { throw new Error('Nested arrays are not valid in the bodyParameters') } if (typeof param === 'object' && param !== null) { - // When an array of objects, we want to display the key and value as two separate parameters - // E.g. -F "properties[0][property_name]=repo" -F "properties[0][value]=docs-internal" for (const [nestedKey, nestedValue] of Object.entries(param)) { cliLine += handleSingleParameter( `${key}[${i}][${nestedKey}]`, @@ -335,7 +318,9 @@ function handleObjectParameter( return cliLine } -// Generates an octokit.js example for one code sample. +// getJSExample appends query params to mutating URL templates because Octokit only +// auto-sends them for GET and HEAD, for example: +// POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name,label} export function getJSExample( operation: Operation, codeSample: CodeSample, @@ -347,10 +332,7 @@ export function getJSExample( if (codeSample.request) { Object.assign(parameters, codeSample.request.parameters) - // Most of the time the example body parameters have a name and value - // and are included in an object. But some cases are a single scalar value - // or a top-level JSON array, both of which Octokit sends as the raw - // request body via the `data` option. + // Octokit sends scalar bodies and top-level arrays through the data option. if ( codeSample.request.bodyParameters && (typeof codeSample.request.bodyParameters !== 'object' || @@ -364,11 +346,6 @@ export function getJSExample( let queryParameters = '' - // Query parameters are set automatically for GET and HEAD requests, we - // otherwise have to handle it ourselves for other request methods by adding - // the parameters to the request path in URL template format e.g.: - // - // 'POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name,label}' if ( operation.verb === 'delete' || operation.verb === 'patch' || @@ -403,7 +380,7 @@ export function getJSExample( const isBasicAuth = operation?.progAccess?.basicAuth let authString = isBasicAuth ? oauthOctokit : authOctokit - // Use unauthenticated Octokit for endpoints that allow permissionless access + // Permissionless endpoints use unauthenticated Octokit. if (omitAuth) { authString = unauthenticatedOctokit } @@ -413,31 +390,8 @@ export function getJSExample( }${queryParameters}', ${stringify(parameters, null, 2)})` } -// Every code example parameter object can be slightly different depending on the operation. For e.g. for Packages it's something like this: -// [ -// { -// "id": 197, -// "name": "hello_docker", -// "package_type": "container", -// }, -// { -// "id": 198, -// "name": "goodbye_docker", -// "package_type": "container", -// } -// ] -// But for Actions cache it's something like this: -// { -// "total_count": 1, -// "actions_caches": [ -// { -// "id": 505, -// "ref": "refs/heads/main", -// "key": "Linux-node-958aff96db2d75d67787d1e634ae70b659de937b", -// } -// ] -// } -// We need to find the matching key so this is using JSON.stringify to handle the "recursion" to search for the matching key. +// Package responses can be arrays while Actions cache responses nest items under actions_caches. +// JSON.stringify traversal finds the matching required query key in either shape. function findMatchingQueryKey(exampleObj: CodeExamples | CodeExamples[], matchKey: string) { let match: string | null = null JSON.stringify(exampleObj, (_, nestedValue) => { diff --git a/src/rest/components/useClipboard.ts b/src/rest/components/useClipboard.ts index 4d47649e1727..a2efa90f00bd 100644 --- a/src/rest/components/useClipboard.ts +++ b/src/rest/components/useClipboard.ts @@ -1,10 +1,7 @@ import { useState, useEffect } from 'react' interface IOptions { - /** - * Reset the status after a certain number of milliseconds. This is useful - * for showing a temporary success message. - */ + // Reset copied status after this many milliseconds for temporary success messages. successDuration?: number } diff --git a/src/rest/docs.ts b/src/rest/docs.ts index 756ca4ab6a52..4a4d5e3653b0 100755 --- a/src/rest/docs.ts +++ b/src/rest/docs.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import { readFile } from 'fs/promises' import { allVersions } from '@/versions/lib/all-versions' -// Translate the docs versioning nomenclature back to the OpenAPI names +// Map docs version names back to OpenAPI names. const invertedVersionMapping = JSON.parse( await readFile('src/rest/lib/config.json', 'utf8'), ).versionMapping diff --git a/src/rest/lib/code-example-utils.ts b/src/rest/lib/code-example-utils.ts index e828a55cded5..51f75c01f2d4 100644 --- a/src/rest/lib/code-example-utils.ts +++ b/src/rest/lib/code-example-utils.ts @@ -1,6 +1,5 @@ -// The part of a code example these label helpers need. RestCodeSamples copies -// `sample.request.description` up to a top-level `description` and keeps the -// original `request` object as-is. +// RestCodeSamples copies sample.request.description to description and keeps the original request. +// These helpers only require that subset of each code example. export interface CodeExample { request?: { contentType?: string @@ -30,7 +29,7 @@ export function shouldShowResponseContentType(examples: CodeExample[]): boolean ) } -// Labels each example option with whichever content types vary across the set. +// Label each example option with whichever content types vary across the set. export function generateExampleOptions(examples: CodeExample[]): ExampleOption[] { const responseContentTypesDiffer = shouldShowResponseContentType(examples) const requestContentTypesDiffer = shouldShowRequestContentType(examples) diff --git a/src/rest/lib/config.ts b/src/rest/lib/config.ts index d264723a9105..c2f55795847d 100644 --- a/src/rest/lib/config.ts +++ b/src/rest/lib/config.ts @@ -1,8 +1,7 @@ -// Separate from config.json because client-side React components need to -// import static values, while the REST sync scripts need a JSON file they can -// write to. +// Keep this separate from config.json because client React components need static imports +// while REST sync scripts need writable JSON config. -// These paths must match the paths in src/pages/[versionId]/rest +// Keep these paths matching src/pages/[versionId]/rest. export const nonAutomatedRestPaths: readonly string[] = [ '/rest/quickstart', '/rest/about-the-rest-api', @@ -11,5 +10,5 @@ export const nonAutomatedRestPaths: readonly string[] = [ '/rest/guides', ] as const -// ApiVersionPicker links here to explain what API versioning is. +// ApiVersionPicker links here to explain REST API versioning. export const apiVersionPath: string = '/rest/about-the-rest-api/api-versions' diff --git a/src/rest/lib/index.ts b/src/rest/lib/index.ts index d76b96d734f5..05dcc4e7bbf2 100644 --- a/src/rest/lib/index.ts +++ b/src/rest/lib/index.ts @@ -21,9 +21,8 @@ interface RestMiniTocData { restOperationsMiniTocItems: MiniTocItem[] } -// Caches generated mini-TOC data, keyed by language, then docs version, then -// API date, then category, then subcategory. A version with no calendar dates -// uses `not_api_versioned` in place of a date. +// Cache generated mini-TOC data by language, docs version, API date, category, and subcategory. +// Versions without calendar dates use not_api_versioned in place of a date. const NOT_API_VERSIONED = 'not_api_versioned' const brotliDecompressAsync = promisify(brotliDecompress) const restOperationData = new Map< @@ -31,13 +30,14 @@ const restOperationData = new Map< Map<string, Map<string, Map<string, Map<string, RestMiniTocData>>>> >() -// Two-tier cache: fpt and ghec are pinned in a plain Map (never evicted) because -// they account for >90% of traffic and each version needs ~100 slots alone. -// All other versions (ghes) go into a bounded LRU cache. +// Pin fpt and ghec in a plain Map because they account for more than 90% of traffic +// and each version needs roughly 100 slots. GHES versions go into a bounded LRU cache. const PINNED_OPEN_API_VERSIONS = new Set(['fpt', 'ghec']) -export const pinnedCache = new Map<string, Buffer>() // @internal, stores deflate-compressed JSON +// Exported for tests; stores deflate-compressed JSON. +export const pinnedCache = new Map<string, Buffer>() const LRU_MAX_SIZE = Math.max(1, parseInt(process.env.REST_SCHEMA_LRU_SIZE ?? '', 10) || 96) -export const lruCache = new QuickLRU<string, RestOperationCategory>({ maxSize: LRU_MAX_SIZE }) // @internal +// Exported for tests. +export const lruCache = new QuickLRU<string, RestOperationCategory>({ maxSize: LRU_MAX_SIZE }) // In-flight deduplication: concurrent cache misses for the same key share one read. const inflight = new Map<string, Promise<RestOperationCategory>>() @@ -64,11 +64,11 @@ export const categoriesWithoutSubcategories: string[] = fs }) .map((filteredFile: string) => filteredFile.replace('.md', '')) -// version: a docs version, e.g. `enterprise-server@3.5`. -// apiVersion: a REST API calendar date. Not every version has these. -// openApiVersion: the matching OpenAPI name, e.g. `ghes-3.5`. Every docs -// version maps to one, because the two naming schemes differ. - +// getRest accepts a docs version such as enterprise-server@3.5 and an optional REST date. +// getOpenApiVersion maps every version to an OpenAPI name such as ghes-3.5. +// getRest stores pinned fpt and ghec category files as deflate-compressed JSON +// Buffers to save roughly 100 to 500 MB of heap. The bounded LRU cache stores +// parsed objects for lower-traffic GHES files. export default async function getRest( version: string, apiVersion: string | undefined, @@ -81,8 +81,6 @@ export default async function getRest( const isPinned = PINNED_OPEN_API_VERSIONS.has(openApiVersion) - // Pinned cache: store deflate-compressed JSON Buffers to save ~100–500 MB heap. - // LRU cache: store parsed objects (bounded size, low traffic). if (isPinned) { if (pinnedCache.has(lruKey)) { return JSON.parse(inflateSync(pinnedCache.get(lruKey)!).toString()) as RestOperationCategory @@ -115,16 +113,16 @@ export default async function getRest( } // Read asynchronously to avoid blocking the event loop on a cache miss. -// A synchronous read + JSON.parse of a category file (1–2 MB) would stall +// A synchronous read plus JSON.parse of a 1 to 2 MB category file would stall // all in-flight requests on this pod for the duration of the parse. -// Try the brotli-compressed variant first (used in staging), then plain JSON. +// Staging writes the brotli-compressed variant, so try .br before plain JSON. async function loadCategoryFile(basePath: string): Promise<RestOperationCategory> { try { const compressed = await fsPromises.readFile(`${basePath}.br`) const decompressed = await brotliDecompressAsync(compressed) return JSON.parse(decompressed.toString()) as RestOperationCategory } catch { - // .br missing, corrupt, or unreadable, so fall back to plain JSON. + // If .br is missing, corrupt, or unreadable, fall back to plain JSON. const raw = await fsPromises.readFile(basePath, 'utf-8') return JSON.parse(raw) as RestOperationCategory } @@ -140,7 +138,6 @@ export function getRestCategories(version: string, apiVersion?: string): string[ .sort() } -// Generates the miniToc for a rest reference page. export async function getRestMiniTocItems( category: string, subCategory: string, diff --git a/src/rest/pages/category.tsx b/src/rest/pages/category.tsx index 6df55716722b..b4fe3396c757 100644 --- a/src/rest/pages/category.tsx +++ b/src/rest/pages/category.tsx @@ -30,6 +30,8 @@ type Props = { restOperations: Operation[] } +// Category landing pages (index.md) render TocLanding instead of the REST reference +// sidebar because their categories have no mini-TOC items at that level. export default function Category({ mainContext, automatedPageContext, @@ -41,10 +43,6 @@ export default function Category({ return ( <MainContext.Provider value={mainContext}> <AutomatedPageContext.Provider value={automatedPageContext}> - {/* When the page is the rest product landing page, we don't want to - render the rest-specific sidebar because toggling open the categories - won't have the minitoc items at that level. These are pages that have - category - subcategory - and operations */} {relativePath?.endsWith('index.md') ? ( <TocLandingContext.Provider value={tocLandingContext}> <TocLanding /> @@ -68,7 +66,6 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const tocLandingContext = getTocLandingContextFromRequest( req as unknown as Parameters<typeof getTocLandingContextFromRequest>[0], ) - // e.g. the `activity` from `/en/rest/activity/events` const category = context.params!.category as string let subcategory = context.params!.subcategory as string const currentVersion = context.params!.versionId as string @@ -79,8 +76,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => ? queryApiVersion : allVersions[currentVersion].latestApiVersion - // For pages with category level only operations like /rest/billing, we set - // the subcategory's value to be the category for the call to getRest() + // Category-only pages like /rest/billing use the category as the getRest subcategory. if (!subcategory) { subcategory = category } @@ -88,19 +84,14 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const categoryData = await getRest(currentVersion, apiVersion, category) const restOperations = (categoryData && categoryData[subcategory]) || [] - // Build the TocLanding table of contents for every operation in the category. - // The operations come back grouped by subcategory, so walk the subcategories, - // take the minitoc items for each one's operations, and collect them. + // TocLanding needs one child item per operation grouped under each REST subcategory. const restCategoryOperations = categoryData || {} const restCategoryTocItems = [] for (const [subCat, subCatOperations] of Object.entries(restCategoryOperations)) { let versionPathSegment: string - // If 'free-pro-team@latest' is in the URL, after clicking the link the - // sidebar isn't expanded to whatever subcategory or operation you clicked - // and 'free-pro-team@latest' is still in the browser address bar so - // manually removing. + // Omit free-pro-team@latest; otherwise clicked links keep it and the sidebar stays collapsed. if (context.params?.versionId === nonEnterpriseDefaultVersion) { versionPathSegment = '/' } else { @@ -108,12 +99,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => } const fullSubcategoryPath = `/${context.locale}${versionPathSegment}rest/${context.params?.category}/${subCat}` - // The actual page titles are available from the tocLandingContext so we - // can use this information as we build our REST toc items. If we relied - // only on the API information, we would need to cleanup subcategory names - // (e.g. they're all lowercase and use hyphens as word separators) and we - // would also end up using words we wouldn't want to like "Repos" instead - // of "GitHub Repositories" for example. + // Use tocLandingContext titles; OpenAPI slugs turn repos into Repos, not GitHub Repositories. let fullSubcategoryTitle const pageTocItem = tocLandingContext.tocItems.find( @@ -123,9 +109,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => if (pageTocItem) { fullSubcategoryTitle = pageTocItem.title } else { - // Shouldn't happen but provide a reasonable fallback just in case. E.g. - // for Organizations, a subcategory is 'outside-collaborators' and we - // convert that to 'Outside collaborators' for a toc item title. + // Fallback titleizes slugs such as outside-collaborators for missing toc entries. fullSubcategoryTitle = `${subCat[0].toUpperCase()}${subCat.slice(1).replaceAll('-', ' ')}` } @@ -150,23 +134,6 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => }) } - // TocLanding expects a collection of objects that looks like this: - // - // { - // fullPath: '/en/rest/activity/events', - // title: 'Events', - // childTocItems: [ - // { - // fullPath: '/en/rest/activity/events#list-public-events', - // title: 'List public events' - // }, - // { - // fullPath: '/en/rest/activity/events#list-public-events-for-a-network-of-repositories', - // title: 'List public events for a network of repositories' - // }, - // ... - // ] - // } restCategoryTocItems.push({ fullPath: fullSubcategoryPath, title: fullSubcategoryTitle, @@ -174,13 +141,10 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => }) } - // Gets the miniTocItems in the article context. At this point it will only - // include miniTocItems generated from the Markdown pages in - // content/rest/* + // Article context starts with mini-TOC items from content/rest Markdown. const { miniTocItems } = getAutomatedPageContextFromRequest(req) - // Build mini-TOC items from the operation titles, using the request context - // for the language and version, and append them to the article's mini-TOC. + // Append operation title anchors to the article mini-TOC. if (restOperations) { const { restOperationsMiniTocItems } = (await getRestMiniTocItems( category, diff --git a/src/rest/pages/subcategory.tsx b/src/rest/pages/subcategory.tsx index eacb232bbb04..00d7aae56756 100644 --- a/src/rest/pages/subcategory.tsx +++ b/src/rest/pages/subcategory.tsx @@ -42,7 +42,6 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const req = context.req as unknown as ExtendedRequest const res = context.res as unknown as ServerResponse - // e.g. the `activity` from `/en/rest/activity/events` const category = context.params!.category as string let subCategory = context.params!.subcategory as string const currentVersion = context.params!.versionId as string @@ -52,8 +51,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const apiVersion = allVersions[currentVersion].apiVersions.includes(queryApiVersion) ? queryApiVersion : allVersions[currentVersion].latestApiVersion - // For pages with category level only operations like /rest/billing, we set - // the subcategory's value to be the category for the call to getRest() + // Category-only pages like /rest/billing use the category as the getRest subcategory. if (!subCategory) { subCategory = category } @@ -61,13 +59,10 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => const categoryData = await getRest(currentVersion, apiVersion, category) const restOperations = (categoryData && categoryData[subCategory]) || [] - // Gets the miniTocItems in the article context. At this point it will only - // include miniTocItems generated from the Markdown pages in - // content/rest/* + // Article context starts with mini-TOC items from content/rest Markdown. const { miniTocItems } = getAutomatedPageContextFromRequest(req) - // Build mini-TOC items from the operation titles, using the request context - // for the language and version, and append them to the article's mini-TOC. + // Append operation title anchors to the article mini-TOC. if (restOperations) { const { restOperationsMiniTocItems } = (await getRestMiniTocItems( category, diff --git a/src/rest/tests/create-rest-examples.ts b/src/rest/tests/create-rest-examples.ts index b019c34e3010..e97051859eab 100644 --- a/src/rest/tests/create-rest-examples.ts +++ b/src/rest/tests/create-rest-examples.ts @@ -14,10 +14,7 @@ import { } from '../fixtures/create-rest-examples' describe('rest example requests and responses', () => { - // If there is a request with no request body parameters and all of - // the responses have no content, then we can create a docs - // example for just status codes below 300. All other status codes will - // be listed in the status code table in the docs. + // One request with multiple contentless responses yields examples only for statuses below 300. test('check that examples with no content are created', async () => { const examples = mergeExamples(noContent.request, noContent.response) const mergedExamples = JSON.stringify(noContent.merged) diff --git a/src/rest/tests/get-rest-code-samples-2.ts b/src/rest/tests/get-rest-code-samples-2.ts index 98e59b8b3268..ab5218103401 100644 --- a/src/rest/tests/get-rest-code-samples-2.ts +++ b/src/rest/tests/get-rest-code-samples-2.ts @@ -59,7 +59,7 @@ const standardOperation: Operation = { }, } -// Sets allowPermissionlessAccess, like the revoke-credentials endpoint. +// Matches the revoke-credentials endpoint, which allows permissionless access. const unauthenticatedOperation: Operation = { verb: 'post', title: 'Revoke a list of credentials', @@ -341,7 +341,6 @@ describe('REST code samples authentication header handling', () => { expect(result).toContain('-H "Accept: application/vnd.github+json"') expect(result).toContain('-H "X-GitHub-Api-Version: 2022-11-28"') expect(result).toContain('/credentials/revoke') - // GitHub CLI handles authentication automatically, so we don't test for auth headers }) test('returns undefined for operations with basic auth', () => { @@ -467,7 +466,7 @@ describe('REST code samples authentication header handling', () => { mockVersions, ) - // The array must be nested under `data`, not spread as numeric keys ("0", "1"). + // The array must stay under data, not spread as numeric keys. expect(result).toContain('data: [') expect(result).toContain("id: 'MVS-2026-001'") expect(result).not.toMatch(/["']0["']\s*:/) diff --git a/src/rest/tests/lib-index.ts b/src/rest/tests/lib-index.ts index 833673f90d55..b064d8f97f4d 100644 --- a/src/rest/tests/lib-index.ts +++ b/src/rest/tests/lib-index.ts @@ -1,13 +1,11 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' -// These mocks are declared before any dynamic import so that vi.mock hoisting -// places them ahead of the first evaluation of the module under test. +// Declare mocks before dynamic imports so vi.mock hoisting beats module evaluation. vi.mock('fs', async (importOriginal) => { const real = await importOriginal<typeof import('fs')>() const readFile = vi.fn() - // Only intercept readdirSync calls for the REST content dir (used at module - // scope in index.ts). All other callers (all-products, etc.) get real fs. + // Only intercept the REST content dir; all other readdirSync callers get real fs. const readdirSync = vi.fn((...args: Parameters<typeof real.readdirSync>) => { const p = String(args[0]) if (p === 'content/rest' || p.endsWith('/content/rest')) { @@ -35,9 +33,7 @@ vi.mock('@/languages/lib/languages-server', async (importOriginal) => { } }) -// getOpenApiVersion is mocked with a spy; the rest of the module is real so -// transitive dependencies (all-products, non-enterprise-default-version, etc.) -// continue to work correctly. +// Mock getOpenApiVersion only; transitive dependencies keep their real behavior. vi.mock('@/versions/lib/all-versions', async (importOriginal) => { const real = await importOriginal<typeof import('@/versions/lib/all-versions')>() return { @@ -65,8 +61,7 @@ function enoent(path = 'fake'): NodeJS.ErrnoException { const FAKE_DATA: Record<string, string[]> = { ops: ['GET /repos'] } const FAKE_JSON = JSON.stringify(FAKE_DATA) -// Each test re-imports a fresh module instance so that the module-level state -// (pinnedCache, lruCache, inflight) starts out empty. +// Each test re-imports a fresh module so cache state starts empty. type GetRest = ( version: string, @@ -134,7 +129,6 @@ describe('two-tier cache routing', () => { await getRest('enterprise-server@3.10', undefined, 'actions') expect(pinnedCache.size).toBe(0) - // lruCache is a QuickLRU which exposes .size expect(lruCache.size).toBe(1) }) @@ -146,8 +140,7 @@ describe('two-tier cache routing', () => { await getRest('free-pro-team@latest', undefined, 'actions') await getRest('free-pro-team@latest', undefined, 'actions') - // readFile must still have been called exactly twice (once for .br, once for .json) - // on the first call; the second call must be a cache hit. + // The first call reads .br and .json once; the second call must hit cache. expect(vi.mocked(fsMock.promises.readFile)).toHaveBeenCalledTimes(2) }) }) @@ -191,7 +184,7 @@ describe('pinned cache compression', () => { describe('in-flight deduplication', () => { test('N concurrent cold-cache requests for same key share one readFile call', async () => { - // Use a deferred to keep all three getRest() calls in flight simultaneously. + // Use a deferred to keep all three getRest calls in flight simultaneously. let resolveJson!: (v: string) => void const deferred = new Promise<string>((r) => { resolveJson = r @@ -202,8 +195,7 @@ describe('in-flight deduplication', () => { return deferred as unknown as Promise<Buffer> }) - // Launch 3 concurrent calls before the deferred resolves, so all three are - // in flight at once and share the single inflight promise. + // Launch 3 calls before the deferred resolves so they share the inflight promise. const allPromise = Promise.all([ getRest('free-pro-team@latest', undefined, 'actions'), getRest('free-pro-team@latest', undefined, 'actions'), @@ -217,8 +209,7 @@ describe('in-flight deduplication', () => { expect(results[1]).toEqual(FAKE_DATA) expect(results[2]).toEqual(FAKE_DATA) - // All 3 callers share one loadCategoryFile() call, so there are exactly 2 - // readFile calls: one for .br (rejected) and one for .json, not 6. + // One shared loadCategoryFile call means 2 readFile calls, not 6. expect(vi.mocked(fsMock.promises.readFile)).toHaveBeenCalledTimes(2) }) }) @@ -235,7 +226,7 @@ describe('loadCategoryFile brotli fallback', () => { }) test('.br corrupt (bad bytes) → brotliDecompress throws → falls back to .json', async () => { - // Buffer.from('not brotli') is not valid brotli; brotliDecompressAsync will throw. + // Buffer.from('not brotli') is not valid brotli, so decompression throws. vi.mocked(fsMock.promises.readFile) .mockResolvedValueOnce(Buffer.from('not brotli') as unknown as Buffer) // .br (corrupt) .mockResolvedValueOnce(FAKE_JSON as unknown as Buffer) // .json fallback diff --git a/src/rest/tests/merge-all-of.ts b/src/rest/tests/merge-all-of.ts index 9adc92165564..45285155abb2 100644 --- a/src/rest/tests/merge-all-of.ts +++ b/src/rest/tests/merge-all-of.ts @@ -209,8 +209,7 @@ describe('mergeAllOf', () => { const before = JSON.stringify(schema) const merged = mergeAllOf(schema) as { oneOf: { properties: Record<string, unknown> }[] } - // get-body-params merges the oneOf members in place, so this must not - // reach back into the OpenAPI operation the schema came from. + // Mutating merged oneOf members must not change the source OpenAPI operation schema. Object.assign(merged.oneOf[0].properties, merged.oneOf[1].properties) merged.oneOf[0].properties.injected = true diff --git a/src/rest/tests/openapi-schema.ts b/src/rest/tests/openapi-schema.ts index 2ae9e8caee07..f60964ce730a 100644 --- a/src/rest/tests/openapi-schema.ts +++ b/src/rest/tests/openapi-schema.ts @@ -86,10 +86,9 @@ describe('markdown for each rest version', () => { }) test('markdown file exists for every operationId prefix in all versions of the OpenAPI schema', async () => { - // List of categories derived from disk const filenames = new Set( getAutomatedMarkdownFiles('content/rest') - // Gets just category level files (paths directly under /rest) + // Extract the category segment from category and subcategory paths. .map((filename) => filename.split('/')[2]) .sort(), ) @@ -142,10 +141,8 @@ describe('rest file structure', () => { }) describe('OpenAPI schema validation', () => { - // ensure every version defined in allVersions has a correlating static - // decorated file, while allowing decorated files to exist when a version - // is not yet defined in allVersions (e.g., a GHEC static file can exist - // even though the version is not yet supported in the docs) + // Every allVersions entry needs a matching decorated data directory. + // Extra directories, such as GHEC static data, can exist before allVersions exposes them. test('every OpenAPI version must have a schema file in the docs', async () => { const versionDirs = fs .readdirSync(schemasPath, { withFileTypes: true }) diff --git a/src/rest/tests/remove-stale-data-files.ts b/src/rest/tests/remove-stale-data-files.ts index 325491306093..9e09dc5dcc21 100644 --- a/src/rest/tests/remove-stale-data-files.ts +++ b/src/rest/tests/remove-stale-data-files.ts @@ -84,7 +84,6 @@ describe('removeStaleRestDataFiles', () => { const writtenFiles = new Map<string, Set<string>>() writtenFiles.set(nonexistent, new Set(['actions.json'])) - // Should not throw await removeStaleRestDataFiles(writtenFiles) }) }) diff --git a/src/rest/tests/rendering.ts b/src/rest/tests/rendering.ts index 8e698f1b8023..5fe8a6b82578 100644 --- a/src/rest/tests/rendering.ts +++ b/src/rest/tests/rendering.ts @@ -9,9 +9,7 @@ import getRest from '@/rest/lib/index' describe('REST references docs', () => { vi.setConfig({ testTimeout: 3 * 60 * 1000 }) - // This test ensures that the page component and the Markdown file are - // in sync. It checks that every version of the /rest/checks - // page has every operation defined in the openapi schema. + // This keeps the /rest/checks/runs page, Markdown, and OpenAPI runs subcategory in sync. test('loads schema data for all versions', async () => { for (const version of Object.keys(allVersions)) { const calendarDate = allVersions[version].latestApiVersion @@ -26,7 +24,7 @@ describe('REST references docs', () => { } }) - // These tests exist because of issue #1960. + // Legacy free-pro-team@latest REST reference URLs redirect to the current REST URL shape. test('rest subcategory with fpt in URL', async () => { const categories = [ 'migrations', @@ -59,7 +57,6 @@ describe('REST references docs', () => { 'users', ] for (const category of categories) { - // Without language prefix { const res = await get(`/free-pro-team@latest/rest/reference/${category}`) expect(res.statusCode).toBe(302) @@ -68,7 +65,6 @@ describe('REST references docs', () => { res.headers.location === `/en/rest/${category}/${category}`, ) } - // With language prefix { const res = await get(`/en/free-pro-team@latest/rest/reference/${category}`) expect(res.statusCode).toBe(301) @@ -87,14 +83,11 @@ describe('REST references docs', () => { }) test('REST reference pages have DOM markers needed for extracting search content', async () => { - // Pick an arbitrary REST reference page that is build from React const $ = await getDOM('/en/rest/actions/artifacts') const rootSelector = '[data-search=article-body]' const $root = $(rootSelector) expect($root.length).toBe(1) - // Within that, should expect a "lead" text. - // Note! Not all REST references pages have a lead. The one in this - // test does. + // Not all REST references have lead text; this page does. const leadSelector = '[data-search=lead] p' const $lead = $root.find(leadSelector) expect($lead.length).toBe(1) @@ -128,7 +121,6 @@ describe('REST references docs', () => { const rawModeSection = $('#render-a-markdown-document-in-raw-mode--code-samples').parent() expect(rawModeSection.length).toBeGreaterThan(0) - // Several examples means there has to be a selector dropdown. const exampleSelector = rawModeSection.find('select[aria-labelledby], select').first() expect(exampleSelector.length).toBe(1) @@ -138,15 +130,13 @@ describe('REST references docs', () => { .get() .filter((text) => text.length > 0) - // The content types differ between examples, so they show in the labels. + // Differing content types appear in selector labels. expect(optionTexts).toEqual(['Example (text/plain)', 'Rendering markdown (text/x-markdown)']) }) + // All five /rest/meta permissionless operations support every fine-grained token type, + // so noFineGrainedAccess is false and the RestAuth null guard never fires. test('RestAuth component hides auth section for permissionless endpoints', async () => { - // This only checks that a page carrying permissionless endpoints still - // renders. It does not reach the RestAuth null path: all five permissionless - // operations under /rest/meta support every fine-grained token type, so - // `noFineGrainedAccess` is false and the guard never fires. const $ = await getDOM('/en/rest/meta') const html = $.html() expect(html.length).toBeGreaterThan(0) diff --git a/src/rest/tests/sync-changelogs.ts b/src/rest/tests/sync-changelogs.ts index e543c322a2e4..a1db2057c3d7 100644 --- a/src/rest/tests/sync-changelogs.ts +++ b/src/rest/tests/sync-changelogs.ts @@ -180,7 +180,7 @@ describe('syncChangelogs', () => { await rm(tmpDir, { recursive: true, force: true }) }) - // Helper to create a changelog file in the github repo layout: + // The github repo layout stores changelog files under: // <githubDir>/app/api/description/changelogs/<releaseDir>/CHANGELOG.md async function createChangelog(githubDir: string, releaseDir: string, content: string) { const changelogDir = path.join(githubDir, 'app', 'api', 'description', 'changelogs', releaseDir) @@ -245,7 +245,6 @@ No breaking changes.`, test('injects hardcoded initial version when no changelog file exists', async () => { const githubDir = path.join(tmpDir, 'github') - // Only create a changelog for fpt, not ghec or ghes await createChangelog( githubDir, 'api.github.com', @@ -260,7 +259,7 @@ No breaking changes.`, const output = await readFile(outputPath, 'utf-8') expect(output).toContain('{% ifversion fpt %}') - // ghec gets the hardcoded initial version even without a changelog file + // ghec gets the hardcoded initial version even without a changelog file. expect(output).toContain('{% ifversion ghec %}') expect(output).toContain( 'first version of the GitHub Enterprise Cloud REST API after date-based versioning', @@ -270,7 +269,6 @@ No breaking changes.`, test('injects hardcoded initial version when changelog has no version sections', async () => { const githubDir = path.join(tmpDir, 'github') - // fpt has valid sections await createChangelog( githubDir, 'api.github.com', @@ -281,8 +279,7 @@ No breaking changes.`, Content.`, ) - // ghec has a changelog but no version sections, so it still gets the - // hardcoded initial version. + // ghec still gets the hardcoded initial version when its changelog lacks sections. await createChangelog( githubDir, 'ghec', @@ -307,7 +304,7 @@ This file has no version headings yet.`, await syncChangelogs(githubDir, versionNames, outputPath) - // fpt and ghec get hardcoded initial version entries even with no changelog files + // fpt and ghec get hardcoded initial version entries even with no changelog files. const output = await readFile(outputPath, 'utf-8') expect(output).toContain('{% ifversion fpt %}') expect(output).toContain('{% ifversion ghec %}') @@ -340,7 +337,7 @@ No breaking changes.`, const output = await readFile(outputPath, 'utf-8') - // Extract only the fpt ifversion block to avoid counting the hardcoded ghec entry + // Extract only the fpt ifversion block to avoid counting the hardcoded ghec entry. const fptMatch = output.match(/\{%\s*ifversion fpt\s*%\}([\s\S]*?)\{%\s*ifversion /)?.[1] ?? '' const matches = fptMatch.match(/## Version 2022-11-28/g) expect(matches).toHaveLength(1) @@ -380,9 +377,7 @@ No breaking changes.`, expect(output).toContain('{% ifversion fpt %}') expect(output).toContain('{% ifversion ghec %}') - // FPT should have two apiVersion blocks, GHEC should have one. - // Extract the fpt block: everything between {% ifversion fpt %} and the - // next {% ifversion (which starts the ghec block). + // Split out fpt before ghec; fpt has two apiVersion blocks and ghec has one. const afterFpt = output.split('{% ifversion fpt %}')[1] const fptBlock = afterFpt.split('{% ifversion ghec %}')[0] expect(fptBlock).toContain('"2026-03-10"') @@ -415,7 +410,7 @@ Change A`, const output = await readFile(outputPath, 'utf-8') - // Versions should appear in the same order as the changelog (newest first) + // Versions appear in changelog order, newest first. const idx2026_06 = output.indexOf('"2026-06-10"') const idx2026_03 = output.indexOf('"2026-03-10"') const idx2022 = output.indexOf('"2022-11-28"') diff --git a/src/rest/tests/update-markdown.ts b/src/rest/tests/update-markdown.ts index 33b9a6bdea4a..2af73a7edc86 100644 --- a/src/rest/tests/update-markdown.ts +++ b/src/rest/tests/update-markdown.ts @@ -41,8 +41,7 @@ describe('GHES version extraction for update-markdown', () => { }) test('demonstrates the original bug scenario', () => { - // The old substring match found '3.1' inside 'ghes-3.10' and wrongly - // treated 3.10 as deprecated. + // Exact extraction prevents matching 3.1 inside ghes-3.10 and deprecating 3.10. const filePath = 'src/rest/data/ghes-3.10-2022-11-28/schema.json' const extractedVersion = getGHESVersionFromFilepath(filePath) diff --git a/src/search/components/helpers/ai-search-links-json.ts b/src/search/components/helpers/ai-search-links-json.ts index a1114ac1a0f1..9efdee228811 100644 --- a/src/search/components/helpers/ai-search-links-json.ts +++ b/src/search/components/helpers/ai-search-links-json.ts @@ -4,12 +4,8 @@ type LinksJSON = Array<{ product: string }> -// We use this to generate a JSON string that includes all of the links: -// 1. Included in the AI response (inline) -// 2. Used to generate the AI response via an embedding (reference) -// -// We include the JSON string in our analytics events so we can see the -// most popular sourced references, among other things. +// Analytics records inline AI-response links and embedding reference links in one JSON payload. +// The product field lets reports group the most popular sourced references. export function generateAISearchLinksJson( sourcesBuffer: Array<{ url: string }>, aiResponse: string, @@ -37,7 +33,7 @@ export function generateAISearchLinksJson( } function extractMarkdownLinks(markdownResponse: string) { - // Matches markdown links of the form [text](url). + // Example: [Actions](https://docs.github.com/actions) yields the URL. const regex = /\[([^\]]+)\]\(([^)]+)\)/g const urls = [] @@ -67,8 +63,7 @@ function extractProductFromDocsUrl(url: string): string { const segments = pathname.split('/').filter((segment) => segment) - // If the first segment is a language code (2 characters), then product is the next segment. - // Otherwise, assume the first segment is the product. + // This heuristic treats only two-character locale prefixes as localized paths. if (segments.length === 0) { return '' } @@ -77,7 +72,7 @@ function extractProductFromDocsUrl(url: string): string { if (segments.length < 2) { return '' } - // if second segment is a version, then product is the third segment + // Versioned paths put the product after the version segment. if (segments[1].includes('@')) { return segments[2] || '' } diff --git a/src/search/components/helpers/execute-search-actions.ts b/src/search/components/helpers/execute-search-actions.ts index 6b3bb9087d52..161ace92d5b1 100644 --- a/src/search/components/helpers/execute-search-actions.ts +++ b/src/search/components/helpers/execute-search-actions.ts @@ -6,12 +6,9 @@ import { sendEvent } from '@/events/components/events' import { SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups' import { sanitizeSearchQuery } from '@/search/lib/sanitize-search-query' -// Search context values for identifying each search event export const GENERAL_SEARCH_CONTEXT = 'general-search' export const AI_SEARCH_CONTEXT = 'ai-search' -// The logic that redirects to the /search page with the proper query params -// The query params will be consumed in the general search middleware export function executeGeneralSearch( router: NextRouter, currentVersion: string, @@ -37,7 +34,6 @@ export function executeGeneralSearch( if (debug) { params.set('debug', '1') } - // Close the search overlay if (params.has('search-overlay-open')) { params.delete('search-overlay-open') } @@ -64,8 +60,6 @@ export async function executeAISearch(version: string, query: string, debug = fa return response } -// Fetches combined search results: AI autocomplete suggestions plus general -// search suggestions. export async function executeCombinedSearch( router: NextRouter, version: string, @@ -80,10 +74,10 @@ export async function executeCombinedSearch( params.set('debug', '1') } - // Add client_name to identify requests from our frontend + // client_name identifies frontend requests to the search API. params.set('client_name', 'docs.github.com-client') - // Always fetch 4 results for autocomplete + // Autocomplete intentionally requests four results. params.set('size', '4') const response = await fetch(`/api/search/combined-search/v1?${params}`, { diff --git a/src/search/components/helpers/fix-incomplete-markdown.ts b/src/search/components/helpers/fix-incomplete-markdown.ts index 785501e9bf9a..33b38559d3fb 100644 --- a/src/search/components/helpers/fix-incomplete-markdown.ts +++ b/src/search/components/helpers/fix-incomplete-markdown.ts @@ -89,7 +89,6 @@ function fixEmphasis(content: string): string { } } - // Close any remaining tokens in reverse order while (stack.length > 0) { const { token } = stack.pop()! content += token @@ -111,7 +110,7 @@ function fixTables(content: string): string { if (i + 1 < lines.length && /^\s*\|[-\s|:]*$/.test(lines[i + 1])) { inTable = true headerPipeCount = (lines[i].match(/\|/g) || []).length - i += 1 // Move to separator line + i += 1 } else { i += 1 continue diff --git a/src/search/components/hooks/useAISearchAutocomplete.ts b/src/search/components/hooks/useAISearchAutocomplete.ts index 7c098129f203..f83cd4a96292 100644 --- a/src/search/components/hooks/useAISearchAutocomplete.ts +++ b/src/search/components/hooks/useAISearchAutocomplete.ts @@ -25,7 +25,8 @@ type UseCombinedSearchReturn = { clearAutocompleteResults: () => void } -const DEBOUNCE_TIME = 100 // In milliseconds +// Wait 100 milliseconds after typing before fetching autocomplete results. +const DEBOUNCE_TIME = 100 // Cached for the current page session only, so backspacing reuses results // instead of hitting the API again. @@ -100,7 +101,7 @@ export function useCombinedSearchResults({ currentVersion, queryValue, debug, - controller.signal, // Pass in the signal to allow the request to be aborted + controller.signal, ) const results = { @@ -114,8 +115,7 @@ export function useCombinedSearchResults({ setSearchOptions(results) setSearchLoading(false) } catch (error: unknown) { - // Aborted fetch() requests reject with a DOMException (not always an - // Error instance), so match on the name rather than the prototype. + // Aborted fetches can reject with DOMException instead of Error, so match the name. if ( typeof error === 'object' && error !== null && @@ -137,7 +137,6 @@ export function useCombinedSearchResults({ [router, currentVersion, debug], ) - // Entry function called when the user types in the search input const updateAutocompleteResults = useCallback((queryValue: string) => { // Don't debounce an empty input: show the (possibly cached) options at once. if (queryValue === '') { @@ -159,7 +158,6 @@ export function useCombinedSearchResults({ setSearchError(false) }, []) - // Cleanup function to cancel any ongoing requests when unmounting useEffect(() => { return () => { abortControllerRef.current?.abort() diff --git a/src/search/components/hooks/useAISearchLocalStorageCache.ts b/src/search/components/hooks/useAISearchLocalStorageCache.ts index 56710892276d..b7ae9dfd4c14 100644 --- a/src/search/components/hooks/useAISearchLocalStorageCache.ts +++ b/src/search/components/hooks/useAISearchLocalStorageCache.ts @@ -10,9 +10,8 @@ interface CacheIndexEntry { timestamp: number } -// AI Search responses are cached as individual localStorage entries, with a -// separate index tracking the keys. Updating the cache therefore doesn't mean -// reading and parsing one large entry every time a key is accessed. +// AI Search responses are individual localStorage entries with a separate key index. +// Cache updates avoid reading and parsing one large entry on every access. // // Entries live under a prefix and expire after a fixed number of days. export function useAISearchLocalStorageCache<T = unknown>( @@ -24,12 +23,13 @@ export function useAISearchLocalStorageCache<T = unknown>( const generateCacheKey = (query: string, version: string, language: string): string => { query = query.trim().toLowerCase() - // Simple hash function to generate a unique key from the query + // Hashing keeps cache keys short while version and language separate entries. let hash = 0 for (let i = 0; i < query.length; i++) { const char = query.charCodeAt(i) hash = (hash << 5) - hash + char - hash |= 0 // Convert to 32bit integer + // Keep the hash in signed 32-bit range. + hash |= 0 } return `${cacheKeyPrefix}-${Math.abs(hash)}-${version}-${language}` } @@ -83,7 +83,7 @@ export function useAISearchLocalStorageCache<T = unknown>( index = index.filter((entry) => entry.key !== key) index.push({ key, timestamp: now }) - // If cache exceeds max entries, remove oldest entries + // Keep the newest entries when the cache exceeds maxEntries. if (index.length > maxEntries) { index.sort((a, b) => a.timestamp - b.timestamp) const excess = index.length - maxEntries diff --git a/src/search/components/hooks/useMultiQueryParams.ts b/src/search/components/hooks/useMultiQueryParams.ts index 5f5956897439..71ef77b6804b 100644 --- a/src/search/components/hooks/useMultiQueryParams.ts +++ b/src/search/components/hooks/useMultiQueryParams.ts @@ -11,18 +11,16 @@ export type QueryParams = { } const initialKeys: (keyof QueryParams)[] = [ - // Used to persist search state 'search-overlay-input', 'search-overlay-ask-ai', - // Used to debug search result 'debug', - // Used to filter category and search results of Articles on landing pages + // Landing pages filter article lists with these keys. 'articles-category', 'articles-filter', 'articles-page', ] -// When we need to update 2 query params simultaneously, we can use this hook to prevent race conditions +// Updating related query params in one state change prevents router races. export function useMultiQueryParams(options?: { useHistory?: boolean excludeFromHistory?: (keyof QueryParams)[] @@ -30,8 +28,7 @@ export function useMultiQueryParams(options?: { const router = useRouter() const pushTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const useHistory = options?.useHistory ?? false - // These keys keep their current state across a back/forward navigation - // instead of being re-read from the URL, which would race. + // These keys keep current React state during back and forward navigation to avoid URL races. const excludeFromHistory = options?.excludeFromHistory ?? [] const getInitialParams = (): QueryParams => { @@ -52,18 +49,16 @@ export function useMultiQueryParams(options?: { const [params, setParams] = useState<QueryParams>(getInitialParams) - // Only set the initial query param values on page load, the rest of the time we use React state + // React state owns query params after the route path initializes them. useEffect(() => { setParams(getInitialParams()) }, [router.pathname]) - // Listen to browser back/forward button navigation (only if history is being used) useEffect(() => { if (!useHistory) return const handleRouteChange = () => { - // When the route changes (e.g., back button), update state from URL - // But preserve excluded params from current state to avoid race conditions + // Preserve excluded params from current state during back and forward navigation. setParams((currentParams) => { const newParams = getInitialParams() for (const key of excludeFromHistory) { @@ -81,7 +76,7 @@ export function useMultiQueryParams(options?: { const updateParams = useCallback( (updates: Partial<QueryParams>, shouldPushHistory = false) => { - // Use functional state update to avoid depending on params in the closure + // A functional update keeps params out of this callback's dependencies. setParams((currentParams) => { const newParams = { ...currentParams, ...updates } const [asPathWithoutHash] = router.asPath.split('#') @@ -114,12 +109,11 @@ export function useMultiQueryParams(options?: { // Debounce the router push so we don't push a new URL for every keystroke if (pushTimeoutRef.current) clearTimeout(pushTimeoutRef.current) pushTimeoutRef.current = setTimeout(async () => { - // Always preserve scroll position during router update to prevent jumps - // Component-level scroll logic (like pagination scroll) will handle intentional scrolling + // Preserve scroll position so component scroll logic stays in control. const scrollY = window.scrollY const scrollX = window.scrollX - // Use router.push for history entries (category/page changes), router.replace for others (search) + // Category and page changes push history entries; search edits replace the current entry. const routerMethod = shouldPushHistory ? router.push : router.replace await routerMethod(newUrl, undefined, { shallow: true, @@ -127,7 +121,6 @@ export function useMultiQueryParams(options?: { scroll: false, }) - // Restore scroll position after the router update. window.scrollTo(scrollX, scrollY) }, 100) diff --git a/src/search/components/hooks/useQuery.ts b/src/search/components/hooks/useQuery.ts index 0cee0f3860b6..ae4bd8026653 100644 --- a/src/search/components/hooks/useQuery.ts +++ b/src/search/components/hooks/useQuery.ts @@ -1,6 +1,6 @@ export function parseDebug(debug: string | Array<string> | undefined) { if (debug === '') { - // E.g. `?query=foo&debug` should be treated as truthy + // Treat /search?query=secret-scanning&debug as truthy. return true } @@ -8,7 +8,6 @@ export function parseDebug(debug: string | Array<string> | undefined) { return false } - // Now `router.query.debug` is either string or any array of strings if (Array.isArray(debug)) { debug = debug[0] } diff --git a/src/search/components/input/AskAIResults.tsx b/src/search/components/input/AskAIResults.tsx index 753c18c62a19..8766475adf52 100644 --- a/src/search/components/input/AskAIResults.tsx +++ b/src/search/components/input/AskAIResults.tsx @@ -75,7 +75,7 @@ export function AskAIResults({ const [responseLoading, setResponseLoading] = useState(false) const [announcement, setAnnouncement] = useState<string>('') const disclaimerRef = useRef<HTMLDivElement>(null) - // We cache up to 1000 queries, and expire them after 30 days + // Cache up to 1000 queries for 7 days. const { getItem, setItem } = useAISearchLocalStorageCache<{ query: string message: string @@ -128,9 +128,8 @@ export function AskAIResults({ ) } - // On query change, fetch the new results useEffect(() => { - // If we open this window directly (like from a URL), we need to generate a new event group ID + // A direct URL open has no prior Ask AI event group, so create one before reporting. if (!askAIEventGroupId.current) { askAIEventGroupId.current = uuidv4() } @@ -167,7 +166,6 @@ export function AskAIResults({ return } - // Handler for streamed response from GPT async function fetchData() { let messageBuffer = '' let sourcesBuffer: AIReference[] = [] @@ -176,7 +174,7 @@ export function AskAIResults({ try { const response = await executeAISearch(version, query, debug) if (!response.ok) { - // If there is JSON and the `upstreamStatus` key, the error is from the upstream sever (CSE) + // Classified non-OK responses include upstreamStatus from the proxy or upstream. let responseJson try { responseJson = await response.json() @@ -184,7 +182,7 @@ export function AskAIResults({ console.error('Failed to parse JSON:', error) } const upstreamStatus = responseJson?.upstreamStatus - // If there is no upstream status, the error is either on our end or a 500 from CSE, so we can show the error + // Missing upstreamStatus leaves this as an unclassified non-OK response. if (!upstreamStatus) { console.error( `Failed to fetch search results.\nStatus ${response.status}\n${response.statusText}`, @@ -197,10 +195,10 @@ export function AskAIResults({ status: response.status, }) return setAISearchError() - // Query invalid - either sensitive question or spam + // Treat filtered or invalid queries as cannot-answer responses. } else if (upstreamStatus === 400 || upstreamStatus === 422) { return handleAICannotAnswer('', upstreamStatus, t('search.ai.responses.invalid_query')) - // Query too large + // Treat oversized queries as cannot-answer responses. } else if (upstreamStatus === 413) { return handleAICannotAnswer( '', @@ -245,14 +243,14 @@ export function AskAIResults({ const processLine = (parsedLine: ParsedLine) => { switch (parsedLine.chunkType) { - // A conversation ID will still be sent when a question cannot be answered + // The stream sends a conversation ID even when the answer is a canned response. case 'CONVERSATION_ID': conversationIdBuffer = parsedLine.conversation_id ?? '' setConversationId(parsedLine.conversation_id ?? '') break case 'NO_CONTENT_SIGNAL': - // Serve canned response. A question that cannot be answered was asked + // NO_CONTENT_SIGNAL asks the UI to show the cannot-answer response. handleAICannotAnswer(conversationIdBuffer, 200) break @@ -274,7 +272,7 @@ export function AskAIResults({ break case 'INPUT_CONTENT_FILTER': - // Serve canned response. A spam question was asked + // INPUT_CONTENT_FILTER asks the UI to show the invalid-query response. handleAICannotAnswer( conversationIdBuffer, 200, @@ -290,16 +288,13 @@ export function AskAIResults({ const { value, done: readerDone } = await reader.read() done = readerDone - // A newline-delimited JSON record can span stream chunks, so decoded - // text goes into a leftover buffer and is parsed once a whole line - // arrives. "Incomplete" and "leftover" refer to the JSON, not to the - // message. + // Buffer newline-delimited JSON until a whole record arrives; leftover means JSON. if (value) { leftover += decoder.decode(value, { stream: true }) const lines = leftover.split('\n') - // Keep the last item, which may be incomplete, for the next round. + // Keep the last item for the next chunk when it is a partial JSON record. leftover = lines.pop() ?? '' for (const raw of lines) { diff --git a/src/search/components/input/SearchGroups.tsx b/src/search/components/input/SearchGroups.tsx index d3525bbc84c2..9707e0bef149 100644 --- a/src/search/components/input/SearchGroups.tsx +++ b/src/search/components/input/SearchGroups.tsx @@ -30,8 +30,7 @@ export function SearchGroups() { const isInAskAIState = askAIState?.isAskAIState && !askAIState.aiSearchError const isInAskAIStateButNoAnswer = isInAskAIState && askAIState.aiCouldNotAnswer - // This spinner is for both the AI search and the general search results. - // We already show a spinner when streaming AI response, so don't want to show 2 here + // Reuse this spinner for autocomplete; Ask AI streaming shows its own spinner. if (showSpinner && !isInAskAIState) { return ( <div @@ -49,7 +48,7 @@ export function SearchGroups() { const groups = [] - // We want to show general search suggestions above the AI Response section if the AI could not answer + // Show general search suggestions above the Ask AI section when Ask AI cannot answer. if (generalSearchOptions.length || isInAskAIStateButNoAnswer) { const items = [] for (let index = 0; index < generalSearchOptions.length; index++) { @@ -67,9 +66,9 @@ export function SearchGroups() { {option.title} </ActionList.Item>, ) - // There should be no more items after the no results found item + // No-results ends the general list. break - // This is a special case where there is an error loading search results and we want to be able to search the docs using the user's query + // When autocomplete fails, let the user's query fall back to docs search. } else if (option.isSearchDocsOption) { const isActive = selectedIndex === index items.push( @@ -188,10 +187,7 @@ export function SearchGroups() { ) } - // Don't show the bottom divider if: - // 1. We are in the AI could not answer state - // 2. We are in the AI Search error state - // 3. There are no AI suggestions to show in suggestions state + // Hide the bottom divider for no-answer, AI-error, and empty-suggestions states. if ( !isInAskAIState && !askAIState.aiSearchError && diff --git a/src/search/components/input/SearchOverlay.module.scss b/src/search/components/input/SearchOverlay.module.scss index 5eeeee453593..cec958b78bd5 100644 --- a/src/search/components/input/SearchOverlay.module.scss +++ b/src/search/components/input/SearchOverlay.module.scss @@ -15,11 +15,13 @@ $mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76)); --overlay-backdrop-bgColor, var(--color-primer-fg-canvas-backdrop, rgba(31, 35, 40, 0.5)) ); - z-index: 1000; /* Ensure it's above other content other than overlay */ + // Keep the backdrop above page content and below the overlay. + z-index: 1000; } .overlayContainer { - z-index: 1001; /* Above the backdrop */ + // Place the overlay above the backdrop. + z-index: 1001; top: 0; left: 0; width: searchVariables.$smSearchOverlayWidth !important; @@ -46,7 +48,7 @@ $mutedTextColor: var(--fgColor-muted, var(--color-fg-muted, #656d76)); } @include breakpoint(lg) { - // Using header padding: 8px (p-2 padding) x2 + // Offset by twice the header's 8px p-2 padding. top: 16px !important; left: calc(50vw - searchVariables.$lgSearchOverlayWidth / 2) !important; width: searchVariables.$lgSearchOverlayWidth !important; diff --git a/src/search/components/input/SearchOverlay.tsx b/src/search/components/input/SearchOverlay.tsx index 9e1ef69d2183..62e449e7fe91 100644 --- a/src/search/components/input/SearchOverlay.tsx +++ b/src/search/components/input/SearchOverlay.tsx @@ -51,7 +51,6 @@ type Props = { ) => void } -// Upon clicking the SearchInput component this overlay will be displayed export function SearchOverlay({ searchOverlayOpen, parentRef, @@ -69,7 +68,7 @@ export function SearchOverlay({ const inputRef = useRef<HTMLInputElement>(null) const suggestionsListHeightRef = useRef<HTMLUListElement>(null) - // We need an array of refs to the list elements so we can focus them when the user uses the arrow keys + // Keep list item refs so keyboard navigation can scroll the selected option into view. const listElementsRef = React.useRef<Array<HTMLLIElement | null>>([]) const [selectedIndex, setSelectedIndex] = useState<number>(-1) @@ -83,7 +82,7 @@ export function SearchOverlay({ const { hasOpenHeaderNotifications } = useSharedUIContext() - // Group all events between open / close of the overlay together + // Group overlay selection and keyboard events that pass this session ID. const searchEventGroupId = useRef<string>('') const overlayRef = useRef<HTMLDivElement>(null) @@ -96,10 +95,10 @@ export function SearchOverlay({ useEffect(() => { searchEventGroupId.current = uuidv4() }, [searchOverlayOpen]) - // Group all events within an "Ask AI" session together + // Each Ask AI session gets its own event group. const askAIEventGroupId = useRef<string>('') - // When there is a notification above the header, we need to adjust the top position of the overlay to account for it + // Header notifications push the fixed overlay down until the page scrolls past them. useEffect(() => { if (hasOpenHeaderNotifications) { const handleScroll = () => { @@ -157,13 +156,11 @@ export function SearchOverlay({ autoCompleteSearchError, ]) - // Drop the option that duplicates what the user typed. It comes back below - // as a user-query option carrying isUserQuery: true. + // Drop the typed-query duplicate; userInputOptions adds it back with isUserQuery. const filteredAIOptions = aiAutocompleteOptions.filter( (option) => option.term !== urlSearchInputQuery, ) - // Create new arrays that prepend the user input const userInputOptions = urlSearchInputQuery.trim() !== '' ? [ @@ -176,7 +173,6 @@ export function SearchOverlay({ ] : [] - // Combine options for key navigation const [combinedOptions, generalOptionsWithViewStatus, aiOptionsWithUserInput] = useMemo(() => { setAnnouncement('') let generalWithView = [...generalSearchResults] @@ -208,18 +204,16 @@ export function SearchOverlay({ } else { generalWithView = [] } - // NOTE: Order of combinedOptions is important, since 'selectedIndex' is used to navigate the combinedOptions array - // Add general options _before_ AI options + // Keep general options before AI options because selectedIndex indexes this combined array. combined.push(...generalWithView.map((option) => ({ group: 'general', option }))) - // On AI Error, don't include AI suggestions, only user input + // Add AI suggestions and user input only outside Ask AI and AI-error states. if (!aiSearchError && !isAskAIState) { combined.push(...aiWithUser.map((option) => ({ group: 'ai', option }))) } else if (isAskAIState && !aiCouldNotAnswer) { - // When "ask ai" state is reached, we have references that are ActionList items. - // We want to navigate these items via the keyboard, so include them in the combinedOptions array + // Ask AI references become keyboard-navigable options after results replace suggestions. combined.push( ...aiReferences.map((option) => ({ - group: 'reference', // The references are actually article URLs that we want to navigate to + group: 'reference', url: option.url, option: { term: option.title, @@ -241,9 +235,7 @@ export function SearchOverlay({ autoCompleteSearchError, ]) - // Rather than use `initialFocusRef` to have our Primer <Overlay> component auto-focus our input - // We manually focus on open using a useEffect so we can focus _without_ scrolling since we don't want - // to scroll to the top of the page each time the SearchOverlay is opened + // Focus manually with preventScroll because Primer Overlay initialFocusRef scrolls to the top. useEffect(() => { if (searchOverlayOpen) { inputRef.current?.focus({ @@ -260,16 +252,13 @@ export function SearchOverlay({ } updateAutocompleteResults(urlSearchInputQuery) } else { - // When opening the overlay via query params, we don't need to fetch autocomplete results - // However, on initial open, we need to clear the loading state + // Clear shared loading state so the next open does not inherit a spinner. setSearchLoading(false) } return () => { clearAutocompleteResults() } - // We need to update when isAskAIState changes, because we might start a session in the "Ask AI" state, and then switch to the "Search" state - // In this scenario we don't have pre-existing autocomplete results to show, so we need to fetch them - // Additionally, the query may change in the "Ask AI" state, so we need to update the results when we switch back to the "Search" state + // Refetch after Ask AI because Search may have no results and the query may have changed. }, [ searchOverlayOpen, updateAutocompleteResults, @@ -278,7 +267,7 @@ export function SearchOverlay({ aiCouldNotAnswer, ]) - // For keyboard controls, we need to use a ref for the list elements that updates when the options change + // Keyboard control refs must track the current option count. useEffect(() => { listElementsRef.current = listElementsRef.current.slice( 0, @@ -286,7 +275,7 @@ export function SearchOverlay({ ) }, [generalOptionsWithViewStatus, aiOptionsWithUserInput]) - // When loading, capture the last height of the suggestions list so we can use it for the loading div + // Estimate loading space from result counts, or reserve 150px for two suggestions. const previousSuggestionsListHeight = useMemo(() => { if (generalSearchResults.length || aiAutocompleteOptions.length) { return `${7 * (generalSearchResults.length + aiAutocompleteOptions.length)}` @@ -295,7 +284,6 @@ export function SearchOverlay({ } }, [searchLoading]) - // When the user types in the search input, update the local query and fetch autocomplete results const handleSearchQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => { event.preventDefault() const newQuery = event.target.value @@ -315,7 +303,6 @@ export function SearchOverlay({ } } - // When a general option is selected, open the article in the current window const generalSearchResultOnSelect = (selectedOption: GeneralSearchHit) => { sendEvent({ type: EventType.search, @@ -351,11 +338,11 @@ export function SearchOverlay({ onClose() } - // When an AI option is selected, set the AI query and focus the input since ask AI results replace the suggestions + // AI results replace suggestions, so keep focus in the input after selection. const aiSearchOptionOnSelect = (selectedOption: AutocompleteSearchHit) => { if (selectedOption.term) { askAIEventGroupId.current = uuidv4() - // Fire event from onSelect instead of inside the API request function (executeAISearch), because the result could be cached and not trigger an event + // Send the event here because cached results skip executeAISearch. sendEvent({ type: EventType.search, search_query: 'REDACTED', @@ -379,7 +366,6 @@ export function SearchOverlay({ onClose() } - // When a reference from an "Ask AI" result is selected, navigate to the reference const referenceOnSelect = (url: string) => { sendEvent({ type: EventType.link, @@ -404,7 +390,6 @@ export function SearchOverlay({ window.open(`${url}?${searchParams.toString()}`, '_blank') } - // Handle keyboard navigation of suggestions const handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => { const optionsLength = listElementsRef.current?.length ?? 0 if (event.key === 'ArrowDown') { @@ -415,7 +400,7 @@ export function SearchOverlay({ newIndex = 0 } else { newIndex = (selectedIndex + 1) % optionsLength - // If we go "out of bounds" (i.e. the index is less than the selected index), unselect the item + // Wraparound after the last option clears the selection. if (newIndex < selectedIndex) { newIndex = -1 } @@ -443,7 +428,7 @@ export function SearchOverlay({ newIndex = optionsLength - 1 } else { newIndex = (selectedIndex - 1 + optionsLength) % optionsLength - // If we go "out of bounds" (i.e. the index is greater than the selected index), unselect the item + // Wraparound before the first option clears the selection. if (newIndex > selectedIndex) { newIndex = -1 } @@ -469,7 +454,7 @@ export function SearchOverlay({ let pressedGroupId = searchEventGroupId let pressedOnContext = '' - // When enter is pressed and no option is manually selected (-1), perform an AI search with the user input + // Enter with no selected option asks AI with the typed query. if (selectedIndex === -1) { pressedOnContext = AI_SEARCH_CONTEXT pressedGroupKey = ASK_AI_EVENT_GROUP @@ -485,7 +470,8 @@ export function SearchOverlay({ if (!selectedItem) { return } - let action = () => {} // Execute the action after we send the event + // Send the event before running the action. + let action = () => {} if (selectedItem?.group === 'general') { if ( (selectedItem.option as GeneralSearchHitWithOptions).isViewAllResults || @@ -501,7 +487,6 @@ export function SearchOverlay({ pressedOnContext = 'ai-option' action = () => aiSearchOptionOnSelect(selectedItem.option as AutocompleteSearchHit) } else if (selectedItem?.group === 'reference') { - // On a reference select, we are in the Ask AI State / Screen pressedGroupKey = ASK_AI_EVENT_GROUP pressedGroupId = askAIEventGroupId pressedOnContext = 'reference-option' @@ -512,7 +497,7 @@ export function SearchOverlay({ } } else if (event.key === 'Escape') { event.preventDefault() - onClose() // Close the input overlay when Escape is pressed + onClose() } } @@ -526,7 +511,6 @@ export function SearchOverlay({ inputRef.current?.focus() } - // We render the AI Result in the searchGroups call, so we pass the props down via an object const askAIState = { isAskAIState, aiQuery, @@ -565,11 +549,7 @@ export function SearchOverlay({ previousSuggestionsListHeight, } - // We display different content in the overlay based: - // 1. If either search (autocomplete results or ask AI) has an error - // 2. The user has selected an AI query and we are showing the ask AI results - // 3. The search is loading - // 4. Otherwise, we show the autocomplete suggestions + // Choose error, Ask AI result, loading, or autocomplete content for the overlay body. let OverlayContents = null // We can still ask AI if there is an autocomplete search error const inErrorState = aiSearchError || (autoCompleteSearchError && !isAskAIState) @@ -594,7 +574,7 @@ export function SearchOverlay({ : `${previousSuggestionsListHeight}px`, }} > - {/* Always show the AI Search UI error message when it is needed */} + {/* Show the AI Search UI error message whenever AI search fails. */} {aiSearchError && ( <> <ActionList.Divider key="error-top-divider" /> @@ -621,7 +601,7 @@ export function SearchOverlay({ /> </div> </li> - {/* If there are general results, show bottom divider */} + {/* Show the bottom divider when general results follow the AI error. */} {generalOptionsWithViewStatus.length > 0 && ( <ActionList.Divider key="error-middle-divider" /> )} @@ -662,7 +642,7 @@ export function SearchOverlay({ onClickOutside={onClose} anchorSide="inside-center" className={cx(styles.overlayContainer, 'position-fixed')} - // We need to override the top value of the overlay when there are header notifications + // Header notifications override the overlay top offset. style={ hasOpenHeaderNotifications ? { @@ -693,8 +673,7 @@ export function SearchOverlay({ maxLength={MAX_QUERY_LENGTH} leadingVisual={<SearchIcon />} role="combobox" - // In Ask AI the input controls the results region instead of the - // suggestions list. + // In Ask AI the input controls the results region instead of the suggestions list. aria-controls={isAskAIState ? 'ask-ai-result-container' : 'search-suggestions-list'} aria-expanded={combinedOptions.length > 0} aria-label={t('search.overlay.input_aria_label')} diff --git a/src/search/components/input/variables.scss b/src/search/components/input/variables.scss index e8f10f994c2a..06252a0a60d2 100644 --- a/src/search/components/input/variables.scss +++ b/src/search/components/input/variables.scss @@ -1,10 +1,8 @@ -// Widths of the search bar button at different breakpoints $smHeaderSearchInputWidth: 100%; // Technically we don't show the search bar at this breakpoint $mdHeaderSearchInputWidth: 100%; // Technically we don't show the search bar at this breakpoint $lgHeaderSearchInputWidth: 25rem; $xlHeaderSearchInputWidth: 40rem; -// Widths of the search overlay popup at different breakpoints $smSearchOverlayWidth: 100vw; $mdSearchOverlayWidth: 100vw; $lgSearchOverlayWidth: 40rem; diff --git a/src/search/components/results/Aggregations.module.scss b/src/search/components/results/Aggregations.module.scss index 8b072bc420b8..d3871408264b 100644 --- a/src/search/components/results/Aggregations.module.scss +++ b/src/search/components/results/Aggregations.module.scss @@ -1,15 +1,9 @@ @import "@primer/react-brand/lib/design-tokens/scss/tokens/functional/size/breakpoints.scss"; -// Docs 2026 search facet rail. -// -// Below brand's `medium` breakpoint this is the body of the "Show filters" disclosure: -// it carries side and bottom borders with no top border, so it reads as one box with -// the disclosure bar above it. -// -// From `medium` up it drops its border entirely and sits flush in the rail column. The -// rail's own divider is the single border there, so the filters don't read as a box -// inside a box. It still bounds itself to the rail's height and scrolls its option list -// internally, so the heading and "Clear all" stay put and the page behind doesn't move. +// Below Brand medium this is the Show filters disclosure body. Side and bottom borders +// with no top border make it read as one box with the disclosure bar above it. +// From medium up, the rail's divider is the single border, and this panel bounds its +// height so the option list scrolls while the heading and Clear all stay pinned. .aggregations { display: flex; flex-direction: column; @@ -21,7 +15,7 @@ @media (min-width: $brand-breakpoint-medium) { border: 0; - // min-height:0 lets this shrink inside the rail's column so the list can scroll. + // min-height: 0 lets this shrink inside the rail column so the list can scroll. min-height: 0; overflow: hidden; } @@ -37,19 +31,13 @@ } .group { - // Brand's ControlGroup stacks its children with an 8px gap; the design uses 12px. - // This class lands on the same element as ControlGroup__container (the <fieldset>), - // so the override goes here directly. A descendant selector never matches. + // Brand ControlGroup stacks children with an 8px gap, but the design uses 12px. + // This class and ControlGroup__container share the fieldset, so descendant selectors miss. gap: 12px !important; - // From `medium` up the option list is the scrolling region, so the card's heading and - // "Clear all" stay put while the facets scroll independently of the page. - // - // Setting overflow-y also makes overflow-x compute to `auto`, so this box clips - // horizontally too. The checkbox sits flush against its left content edge, which left - // the focus ring and the checked state's outer edge shaved off. The negative inline - // margin pulls the clip edge outward while the padding keeps the content where it was, - // so the ring has room without the list shifting. + // From medium up, the option list scrolls while the card heading and Clear all stay pinned. + // overflow-y makes overflow-x compute to auto, clipping the checkbox focus ring. + // Negative inline margin moves the clip edge out while padding keeps content in place. padding-inline: 4px; margin-inline: -4px; @@ -61,16 +49,15 @@ } .option { - // FormControl lays a checkbox out as `auto 1fr` with an 8px gap; the design uses 12px. + // FormControl lays a checkbox out as auto 1fr with an 8px gap, but the design uses 12px. gap: 12px !important; - // Centre the box against its label. The label's line box is taller than the text + // Center the box against its label. The label's line box is taller than the text // itself, so without this the checkbox settles low and the row reads as misaligned. align-items: center !important; - // The design draws each option as a single button, so the whole row, the box - // included, should read as one target. Brand leaves the input and its wrapper on the default - // cursor, which makes the box itself look inert even though clicking it works. + // The design treats each option as one button, so the row and checkbox need pointer cursors. + // Brand leaves the input and wrapper on the default cursor even though clicking works. cursor: pointer; input, @@ -80,28 +67,25 @@ } .optionLabel { - // Figma "Action/Large": 16px Medium, line-height 16px, letter-spacing 0.16px. Brand's - // checkbox label is --brand-text-size-100 (14px) at line-height 24px / 0.21px tracking, - // so size, leading and tracking all need pinning to the design. + // Figma Action/Large: 16px Medium, line-height 16px, letter-spacing 0.16px. + // Brand checkbox labels use 14px text, 24px line-height, and 0.21px tracking. + // Size, leading, and tracking need pinning to the design. font-size: 1rem !important; line-height: 16px !important; letter-spacing: 0.16px !important; color: var(--brand-color-text-muted) !important; } -// A selected facet steps up to the default text colour, so the active filters are legible -// at a glance against the muted ones. That is the same muted/default emphasis the result -// titles use for their search match. +// Selected facets use the default text color, matching result-title search matches. .optionLabelSelected { color: var(--brand-color-text-default) !important; } .count { - // Figma: 10px Medium, line-height 1.5. In the design the count is a sibling of the - // checkbox+label group in an `items-start` row, so it rides at the top of the line - // rather than on the label's baseline. Ours is inline inside the label, for - // accessibility, so the name still reads "Account and profile (2)". It is raised - // here instead. `super` on a 10px run lifts it without growing the 16px line box. + // Figma: 10px Medium, line-height 1.5. The design count sits beside the checkbox + // and label group in an items-start row, so it rides at the top of the line. + // This markup keeps the count inline for the accessible name, like Account and profile (2). + // super on a 10px run lifts it without growing the 16px line box. margin-left: 2px; font-size: 10px; font-weight: var(--base-text-weight-medium); diff --git a/src/search/components/results/Aggregations.tsx b/src/search/components/results/Aggregations.tsx index 066eedaae400..1ce56023d81a 100644 --- a/src/search/components/results/Aggregations.tsx +++ b/src/search/components/results/Aggregations.tsx @@ -13,18 +13,17 @@ type Props = { aggregations: SearchResultAggregations } +// SearchResultsAggregations holds pending toggles so checked boxes respond before the URL updates. +// This mirrors the optimistic data-pending highlight in SidebarProduct. +// Clear all always renders as a stable footer control. The design pairs it with Apply, but filters +// apply immediately, so Apply would imply nothing happened yet. Staged filtering is separate work. +// With no selected facets, Clear all renders as a disabled button, not a link to the same URL. export function SearchResultsAggregations({ aggregations }: Props) { const { t } = useTranslation('search_results') const { query, locale, asPath, push } = useRouter() const selectedQuery = query.toplevel ? query.toplevel : [] const selected = Array.isArray(selectedQuery) ? selectedQuery : [selectedQuery] - // Checking a facet navigates, and the checkbox's state is derived from the URL, so - // without this the input snaps straight back under React and nothing moves until the - // server responds. That round trip is short, but a control that ignores the first - // click reads as a frozen page. Hold the intended state locally so the box responds - // immediately, then drop it once the URL catches up and becomes the source of truth - // again. Mirrors the optimistic `data-pending` highlight in SidebarProduct. const [pendingToggles, setPendingToggles] = useState<Record<string, boolean>>({}) useEffect(() => { setPendingToggles({}) @@ -36,10 +35,7 @@ export function SearchResultsAggregations({ aggregations }: Props) { function makeHref(toplevel: string) { const [asPathRoot, asPathQuery = ''] = asPath.split('#')[0].split('?') const params = new URLSearchParams(asPathQuery) - // Build from the optimistic state, not from `selected`. Both `asPath` and `selected` - // still describe the pre-navigation URL while a facet click is in flight, so a second - // click before the first lands would otherwise drop the first selection, leaving the - // UI with two boxes ticked and the URL carrying only one. + // Use pendingToggles because asPath and selected lag while facet navigation is in flight. const nextSelected = new Set( aggregations.toplevel.filter((agg) => isChecked(agg.key)).map((agg) => agg.key), ) @@ -52,7 +48,7 @@ export function SearchResultsAggregations({ aggregations }: Props) { for (const key of nextSelected) { params.append('toplevel', key) } - // Reset pagination when filters change to prevent showing 0 results + // Filter changes reset pagination to prevent showing 0 results. params.delete('page') return `/${locale}${asPathRoot}?${params}` } @@ -61,7 +57,7 @@ export function SearchResultsAggregations({ aggregations }: Props) { const [asPathRoot, asPathQuery = ''] = asPath.split('#')[0].split('?') const params = new URLSearchParams(asPathQuery) params.delete('toplevel') - // Reset pagination when clearing filters + // Clearing filters resets pagination. params.delete('page') return `/${locale}${asPathRoot}?${params}` } @@ -69,11 +65,7 @@ export function SearchResultsAggregations({ aggregations }: Props) { if (aggregations.toplevel && aggregations.toplevel.length > 0) { return ( <div className={styles.aggregations}> - {/* The visible heading sits outside the fieldset so it can stay pinned - while the option list scrolls beneath it. Brand renders the group's - own label as a <legend>, which is a sibling of the options and would - scroll away with them. The legend is kept, visually hidden, so the - checkbox group still has an accessible name. */} + {/* The visible heading stays pinned while the hidden legend names the group. */} <Heading as="h2" size="6" className={styles.heading}> {t('filter')} </Heading> @@ -102,15 +94,6 @@ export function SearchResultsAggregations({ aggregations }: Props) { })} </CheckboxGroup> - {/* Always rendered, so the control is a stable part of the panel rather than - appearing only once you have already filtered. The design shows it in a - persistent footer row. It pairs with an "Apply" button there, but filters - apply immediately on change today, so an Apply control would imply nothing - had happened yet. Staged filtering is Phase 2: - github/docs-engineering#6709. - - With nothing selected there is nothing to clear, so it renders as a disabled - button rather than a link to the URL it is already on. */} {selected.length > 0 ? ( <Button as={Link} diff --git a/src/search/components/results/NoQuery.module.scss b/src/search/components/results/NoQuery.module.scss index 53ae1bb6dbde..ad52f6406bb4 100644 --- a/src/search/components/results/NoQuery.module.scss +++ b/src/search/components/results/NoQuery.module.scss @@ -1,5 +1,5 @@ -// The page wrapper is full-bleed now (see SearchPage.module.scss), so this state -// supplies its own inset to line up with the hero band and result rows. +// SearchPage.module.scss makes the page full-bleed, so this state uses a fixed +// 48px horizontal inset. .heading { margin: 32px 48px 0; line-height: 1.2; diff --git a/src/search/components/results/NoQuery.tsx b/src/search/components/results/NoQuery.tsx index 82caf77cc4ed..14189f89b467 100644 --- a/src/search/components/results/NoQuery.tsx +++ b/src/search/components/results/NoQuery.tsx @@ -6,19 +6,16 @@ import { useTranslation } from '@/languages/components/useTranslation' import styles from './NoQuery.module.scss' +// NoQuery keeps the callout on Primer React because Brand lacks Flash, Banner, or Alert. +// The Docs 2026 callout system is the planned replacement. export function NoQuery() { const { t } = useTranslation('old_search') const mainContext = useMainContext() - // Use TypeScript's "not null assertion" because `context.page` should - // will present in main context if it's gotten to the stage of React - // rendering. + // Search page rendering has context.page, but the shared context type keeps page nullable. const page = mainContext.page! return ( <> - {/* Brand ships no Flash/Banner/Alert equivalent, so the callout stays on - @primer/react until the Docs 2026 callout system lands - (github/docs-engineering#6702). */} <Heading as="h1" size="3" className={styles.heading}> {page.title} </Heading> diff --git a/src/search/components/results/SearchPage.module.scss b/src/search/components/results/SearchPage.module.scss index 303dce6a0d79..1b6c883613fe 100644 --- a/src/search/components/results/SearchPage.module.scss +++ b/src/search/components/results/SearchPage.module.scss @@ -1,19 +1,8 @@ @import "@primer/react-brand/lib/design-tokens/scss/tokens/functional/size/breakpoints.scss"; -// Docs 2026 search results hero. The page previously sat inside `container-xl -// px-3 px-md-6 my-4`; that wrapper is gone so the hero band and the result rows -// run edge-to-edge in the content column, with the inset applied per-band. -// -// Deliberately no `border-top`: DocsSecondaryBar already draws a `border-bottom` -// immediately above <main>, so adding one would render a doubled 2px line. -// -// And deliberately no `background-color`: the result rows below are transparent -// over the page canvas, and brand's canvas-default is pure black in dark mode -// while the page sits on Primer's #0d1117. Painting it here put a visible seam -// between the band and the rows. -// -// The heading itself needs no breakpoints: brand's Heading size="3" resolves to -// 28/34/40px across its own scale, which is exactly what the three frames show. +// The hero band and result rows run edge-to-edge in the content column with per-band insets. +// No background-color: hero and transparent result rows share the Brand page canvas. +// Heading size 3 resolves to 28, 34, and 40px across Brand breakpoints, matching the design. .hero { padding: 24px; diff --git a/src/search/components/results/SearchResults.module.scss b/src/search/components/results/SearchResults.module.scss index 7b12e693032b..97a408b0cc5a 100644 --- a/src/search/components/results/SearchResults.module.scss +++ b/src/search/components/results/SearchResults.module.scss @@ -1,8 +1,5 @@ @import "@primer/react-brand/lib/design-tokens/scss/tokens/functional/size/breakpoints.scss"; -// The result row is a grid rather than nested flex wrappers so that the title, category -// chip, snippet and debug line stay direct children of the row. See the comment in -// SearchResults.tsx: the search rendering tests read elements by tag inside a result. .searchResult { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -41,16 +38,12 @@ line-height: 1.5; letter-spacing: 0.16px; - // The design distinguishes the search match by COLOUR, not weight: the title is a - // single 550 run, its unmatched text is muted, and the matched text is default. So the - // link is muted here and the <mark> below restores the default colour. - // - // (An earlier attempt leaned on font-weight instead, which was the wrong lever, and - // it barely rendered, since the Mona Sans instance that resolves puts 550 and 700 - // within half a pixel of each other.) + // The design distinguishes search matches by color, not weight. + // Mona Sans weights 550 and 700 differ by less than half a pixel, so weight barely changes. + // Use one 550 run with muted unmatched text and default-color matched text. mark { color: var(--brand-color-text-default); - // Override the shared `.search_result mark` bold: weight is uniform across the title. + // Override the shared search_result mark bold so title weight stays uniform. font-weight: inherit; } } @@ -67,12 +60,11 @@ .resultTopic { grid-column: 2; justify-self: end; - // Brand Token's own rules are hashed class names, so these need !important. Matching - // precedent: .cardToken in landings/components/shared/LandingArticleGridWithFilter.module.scss. - // `border-muted` is the token the design names, and it is the one that stays legible in - // both modes: it is #e4ebe6 in light (exactly the design value) and near-black in dark. - // `neutral-subtle` looks identical in light but resolves to a mid-grey in dark, which - // drops the label to a 1.6:1 contrast ratio against it. + // Brand Token uses hashed class names, so these overrides need !important. + // Precedent: .cardToken in landings/components/shared/LandingArticleGridWithFilter.module.scss. + // border-muted matches the design token and stays legible in both modes. + // It is #e4ebe6 in light, exactly the design value, and near-black in dark. + // neutral-subtle matches in light but becomes mid-gray in dark, dropping contrast to 1.6:1. background-color: var(--brand-color-border-muted) !important; border-color: transparent !important; border-radius: 4px !important; @@ -80,7 +72,7 @@ padding: 1px 7px !important; :global([class*="Token__label"]) { - // Token's label span hardcodes the normal weight, so the size/weight go here, not on the root. + // Token's label span hardcodes normal weight, so size and weight go here. font-size: 12px !important; font-weight: var(--base-text-weight-medium) !important; } diff --git a/src/search/components/results/SearchResults.tsx b/src/search/components/results/SearchResults.tsx index 3dbfa91fc4e3..ec28a82cd566 100644 --- a/src/search/components/results/SearchResults.tsx +++ b/src/search/components/results/SearchResults.tsx @@ -82,6 +82,8 @@ function NoSearchResults() { ) } +// A hit carries one toplevel, so the design's +N chip needs a real topics array indexed first. +// The chip stays a span because every anchor inside a result needs the versioned pathname. function SearchResultHit({ hit, query, @@ -107,10 +109,6 @@ function SearchResultHit({ content = hit.highlights.content[0] } - // The title, category chip, snippet and debug line are all *direct* children of the grid - // root on purpose. Wrapping the title and chip in a flex row would make that wrapper the - // first <div> in the result, and src/search/tests/rendering.ts reads a <div> inside the - // result to assert the highlighted snippet. return ( <div className={cx(styles.searchResult, styles.search_result)} data-testid="search-result"> <Heading as="h2" size="subhead-medium" className={styles.resultTitle}> @@ -134,12 +132,6 @@ function SearchResultHit({ {renderHTMLString(title, markdownComponents)} </Link> </Heading> - {/* - A hit carries exactly one `toplevel`, so this is deliberately a single chip; the "+N" - overflow chip in the design needs a real `topics` array indexed first. Rendered as a - span and never an anchor, because every <a> inside a result must carry the versioned - pathname (asserted in src/search/tests/rendering.ts). - */} {hit.toplevel && ( <Token className={styles.resultTopic} diff --git a/src/search/components/results/SidebarSearchAggregates.module.scss b/src/search/components/results/SidebarSearchAggregates.module.scss index fc754df6757e..38ec1d72f338 100644 --- a/src/search/components/results/SidebarSearchAggregates.module.scss +++ b/src/search/components/results/SidebarSearchAggregates.module.scss @@ -4,13 +4,13 @@ display: flex; flex-direction: column; - // Below `medium` the rail sits above the results in the page flow, so + // Below medium the rail sits above the results in the page flow, so // expanding the panel grows content that the browser's scroll anchoring then // compensates for by scrolling the disclosure bar off the top of the viewport // the moment you open it. Opt this subtree out so opening stays put. overflow-anchor: none; - // From `medium` up, join the rail's flex column so the card below can bound + // From medium up, join the rail's flex column so the card below can bound // itself to the viewport and scroll its own list. @media (min-width: $brand-breakpoint-medium) { flex: 1; @@ -18,9 +18,9 @@ } } -// "Show filters" / "Hide filter" disclosure. Matches the design's +// Show filters and Hide filter disclosure. Matches the design's // Nav List/2/Mobile/Collapsed: 44px tall, full width, a 12px-padded 32x32 icon -// cell, then a 14px medium label. Only exists below brand's `medium`; from there +// cell, then a 14px medium label. Only exists below Brand medium; from there // up the rail shows the panel outright and the disclosure is redundant. .toggle { display: flex; @@ -62,7 +62,7 @@ .panel { display: none; - // The panel is the rail itself from `medium` up, so it is always shown there + // The panel is the rail itself from medium up, so it is always shown there // regardless of the disclosure's state, and joins the flex column. @media (min-width: $brand-breakpoint-medium) { display: flex; @@ -80,7 +80,7 @@ display: block; // Guard against the open state leaking upward: if someone opens the drawer and - // then widens the window, `display: block` would otherwise beat the flex column + // then widens the window, display: block would otherwise beat the flex column // above and break the card's bounded height. @media (min-width: $brand-breakpoint-medium) { display: flex; diff --git a/src/search/components/results/SidebarSearchAggregates.tsx b/src/search/components/results/SidebarSearchAggregates.tsx index 8f8e88b12842..62aaa9b0c82d 100644 --- a/src/search/components/results/SidebarSearchAggregates.tsx +++ b/src/search/components/results/SidebarSearchAggregates.tsx @@ -8,15 +8,10 @@ import { SearchResultsAggregations } from './Aggregations' import styles from './SidebarSearchAggregates.module.scss' -// The facet filters, responsive per the Docs 2026 design. From brand's `medium` -// breakpoint up this is the rail card; below it the card collapses behind a -// "Show filters" disclosure, because the filters are otherwise unreachable on a -// narrow viewport. -// -// The facet markup is rendered exactly once and restyled per breakpoint, never -// a rail copy plus a drawer copy. Two copies would duplicate every checkbox id -// and make the strict-mode `getByText('Fooing (1)')` click in -// src/fixtures/tests/playwright-rendering.spec.ts ambiguous. +// The facet filters follow the Docs 2026 responsive design. From Brand medium up this +// renders as the rail card; below it, Show filters exposes filters on narrow viewports. +// The facet markup renders once and restyles per breakpoint. Two copies would make +// getByText('Fooing (1)') ambiguous in src/fixtures/tests/playwright-rendering.spec.ts. export function SidebarSearchAggregates() { const { search } = useSearchContext() const { t } = useTranslation('search_results') @@ -27,19 +22,14 @@ export function SidebarSearchAggregates() { // Skip the mount pass so we don't steal focus on first paint. const mounted = useRef(false) - // Move focus into the panel when it opens and back to the toggle when it - // closes. Above `medium` the toggle is display:none and never fires, so this - // only ever runs for the disclosure. + // Focus the panel on open and the toggle on close; above medium the hidden toggle never fires. useEffect(() => { if (!mounted.current) { mounted.current = true return } if (open) { - // preventScroll matters here: the panel is tall, and letting the browser - // scroll it into view pushes the disclosure bar and the "Filter" heading - // off the top of a phone viewport, leaving the reader mid-list with no - // visible way back out. + // preventScroll keeps the tall panel from pushing the disclosure and Filter heading away. panelRef.current?.focus({ preventScroll: true }) } else { toggleRef.current?.focus({ preventScroll: true }) @@ -47,10 +37,7 @@ export function SidebarSearchAggregates() { }, [open]) const { results } = search - // `aggregations` is truthy but empty (`{ toplevel: [] }`) for a zero-hit search, and - // SearchResultsAggregations renders nothing in that case, so checking only for the - // object left an empty bordered rail on desktop and a disclosure that opened onto an - // empty box on mobile. Check for facets to actually show. + // Zero-hit searches return { toplevel: [] }; require facets to avoid an empty rail. if (!results?.aggregations?.toplevel?.length) { return null } @@ -66,15 +53,14 @@ export function SidebarSearchAggregates() { data-testid="search-filter-toggle" onClick={() => setOpen((prev) => !prev)} > - {/* Decorative: the button is named by the visible label beside it. */} + {/* Decorative icon; the visible label names the button. */} <span className={styles.toggleIcon} aria-hidden="true"> {open ? <XIcon size={16} /> : <FilterIcon size={16} />} </span> <span className={styles.toggleLabel}>{open ? t('hide_filters') : t('show_filters')}</span> </button> - {/* Closed below `medium`, this is display:none rather than visually - hidden, so the facets leave the accessibility tree with the layout. */} + {/* Closed below medium, this uses display:none so facets leave the accessibility tree. */} <div id={panelId} ref={panelRef} diff --git a/src/search/components/results/index.tsx b/src/search/components/results/index.tsx index 05cc1266c855..af9bef4dd78b 100644 --- a/src/search/components/results/index.tsx +++ b/src/search/components/results/index.tsx @@ -22,17 +22,14 @@ export function Search() { const { query } = search.searchParams - // A reference to the `content/search/index.md` Page object. - // Not to be confused with the "page" that is for paginating - // results. + // documentPage is content/search/index.md, not the page query param for pagination. const { allVersions, page: documentPage } = useMainContext() const searchVersion = allVersions[currentVersion].versionTitle const { results, validationErrors } = search const hasQuery = Boolean((query && query.trim()) || '') - // Mostly to satisfy TypeScript because the useMainContext hook - // is run on every request and every request doesn't have a page. + // useMainContext runs on every request, including requests without a page. let pageTitle = documentPage?.fullTitle || 'Search' if (hasQuery) { pageTitle = `${t('search_results_for')} "${query.trim()}"` @@ -57,11 +54,7 @@ export function Search() { </div> )} - {/* Not having a query is actually a validation error. - But it's a bit harsh to call it an "error". - Simply going to "/en/search" shouldn't show an error message. - It should be a "no query" message, which is a bit more "gentle". - */} + {/* Empty query validates as an error, but /en/search shows the no-query state instead. */} {!hasQuery ? ( <NoQuery /> ) : validationErrors.length > 0 ? ( diff --git a/src/search/components/types.ts b/src/search/components/types.ts index be20112d1de5..ea4353f97da3 100644 --- a/src/search/components/types.ts +++ b/src/search/components/types.ts @@ -8,7 +8,6 @@ export interface SearchContextT { } } -// Parts of the search query that are set to the search context export type SearchQueryContentT = { query: string debug: boolean diff --git a/src/search/scripts/aggregate-search-index-failures.ts b/src/search/scripts/aggregate-search-index-failures.ts index a82839793859..088753ba2cf6 100644 --- a/src/search/scripts/aggregate-search-index-failures.ts +++ b/src/search/scripts/aggregate-search-index-failures.ts @@ -1,8 +1,7 @@ #!/usr/bin/env tsx -// Reads the failures-summary.json files written by the language index jobs -// that had failures, and prints a JSON AggregationResult whose `message` is a -// single report grouped by page path. index-general-search.yml posts that -// message to both a GitHub issue and Slack. +// Reads failures-summary.json files from language index jobs and prints an AggregationResult. +// The message groups failures by page path for index-general-search.yml to post to a +// GitHub issue and Slack. // // Usage: tsx aggregate-search-index-failures.ts <artifacts-dir> [--workflow-url <url>] @@ -31,22 +30,18 @@ export interface FailuresSummary { interface PageFailure { versions: Set<string> languages: Set<string> - // Full error text to the number of failures reporting it, so the report can - // lead with the dominant error rather than an alphabetically lucky one. + // Maps full error text to failure count, so the report leads with the dominant error. errors: Map<string, number> } -// A page usually fails identically across every version and language it appears -// in, so the same error repeats many times. Show a few distinct ones per page, -// keep each short, and keep the whole report inside the limits of the places it -// gets posted. A GitHub issue body is rejected outright over 65536 characters, -// which would lose the entire alert during the largest incidents. +// Pages usually fail the same way across versions and languages. Keep a few short +// errors per page and the report below post limits. GitHub rejects issue bodies over +// 65536 characters, which would lose the alert during the largest incidents. const MAX_ERRORS_PER_PAGE = 3 const MAX_ERROR_LENGTH = 200 const MAX_MESSAGE_LENGTH = 30000 -// Renders a failure as a single line of `errorType: error`, collapsing any -// whitespace so one failure can never span multiple lines of the report. +// Renders a failure as one errorType: error line, so one failure cannot span report lines. function formatError(failure: Failure): string { const normalize = (value: unknown) => typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : '' @@ -57,10 +52,8 @@ function formatError(failure: Failure): string { return errorType && detail ? `${errorType}: ${detail}` : errorType || detail } -// Escapes the characters Slack treats as control syntax, so error text lifted -// from an API response cannot inject a mention such as `<!channel>` into the -// notification. The slack-alert action escapes its own interpolated fields for -// this reason, but passes a caller-supplied message through verbatim. +// Escapes Slack control syntax, so API error text cannot inject a mention such as <!channel>. +// The slack-alert action escapes its interpolated fields, but passes caller messages verbatim. // // The same string is also posted as a GitHub issue body, where these entities // render back to the original characters. @@ -115,8 +108,7 @@ export function aggregateFailures( } } - // Count pages, not failure instances: one page fails once per version and - // language it appears in. + // Count pages, not failure instances, because one page can fail per version and language. const uniquePageCount = pageFailures.size const lines: string[] = [ @@ -133,18 +125,14 @@ export function aggregateFailures( const languages = Array.from(data.languages).sort().join(', ') const bullet = `• \`${escapeSlackControlCharacters(pagePath)}\` (versions: ${versions}, languages: ${languages})` - // Truncate before escaping so an entity is never cut in half, and so the - // limit stays a limit on the error itself rather than on its encoding. - // Merge counts after rendering: two errors that differ only past the - // truncation point would otherwise print as two identical lines. + // Truncate before escaping so entities stay whole and limits apply; merge identical lines. const renderedErrors = new Map<string, number>() for (const [error, count] of data.errors) { const rendered = escapeSlackControlCharacters(truncate(error, MAX_ERROR_LENGTH)) renderedErrors.set(rendered, (renderedErrors.get(rendered) || 0) + count) } - // Most frequent error first, breaking ties alphabetically so the report is - // stable across runs on the same input. + // Sort frequent errors first and break ties alphabetically for stable output. const errors = Array.from(renderedErrors.entries()).sort( (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]), ) @@ -163,10 +151,7 @@ export function aggregateFailures( `...and ${count} more page(s) not listed. See the workflow run for the full set.` const footerLines = workflowUrl ? ['', `Workflow: ${workflowUrl}`] : [] - // Reserve room for the footer up front, using the longest the truncation - // notice could get, so MAX_MESSAGE_LENGTH bounds the whole message rather - // than just the part written inside the loop. The one exception is the forced - // first page below, which can push the message past the limit on its own. + // Reserve longest notice and footer so the cap covers the full message; a forced page can exceed it. const footerReserve = truncatedPagesLine(sortedPages.length).length + 1 + @@ -175,9 +160,7 @@ export function aggregateFailures( let usedLength = lines.join('\n').length - // Which pages get listed is decided before any error text is added, since the - // page list is the report and the errors are the hint. Otherwise a handful of - // long errors would crowd out most of the pages. + // Choose pages before adding error text, so long errors cannot crowd pages out of the report. const shownPages: { bullet: string; errorLines: string[]; shownErrorLines: string[] }[] = [] for (const page of renderedPages) { const bulletLength = page.bullet.length + 1 diff --git a/src/search/scripts/analyze-text.ts b/src/search/scripts/analyze-text.ts index 679a0e43e6ab..7897af2fc9c0 100755 --- a/src/search/scripts/analyze-text.ts +++ b/src/search/scripts/analyze-text.ts @@ -1,9 +1,5 @@ -// See how a piece of text gets turned into tokens by the different analyzers. -// Requires that the index exists in Elasticsearch. -// -// Example: -// -// npm run analyze-text -- -V dotcom -l en "The name of the wind" +// Shows how different analyzers tokenize text. Requires an Elasticsearch index. +// Usage: npm run analyze-text -- -V dotcom -l en "The name of the wind" import { Client } from '@elastic/elasticsearch' import { Command, Option } from 'commander' @@ -15,24 +11,10 @@ import { allVersions } from '@/versions/lib/all-versions' import type { estypes } from '@elastic/elasticsearch' -// Now you can optionally have set the ELASTICSEARCH_URL in your .env file. +// Reads ELASTICSEARCH_URL from .env when the shell environment lacks it. dotenv.config() -// Create an object that maps the "short name" of a version to -// all information about it. E.g. -// -// { -// 'ghes-3.5': { -// hasNumberedReleases: true, -// currentRelease: '3.5', -// version: 'enterprise-server@3.5', -// miscBaseName: 'ghes-' -// ... -// }, -// ... -// -// We need this later to be able to map CLI arguments to what the -// records are called when found on disk. +// Collects the supported short CLI version names so Commander can validate -V input. const shortNames: Record<string, (typeof allVersions)[keyof typeof allVersions]> = Object.fromEntries( Object.values(allVersions).map((info) => { @@ -89,7 +71,7 @@ async function main(opts: Options, textArgs: string[]): Promise<void> { } let node = opts.elasticsearchUrl || process.env.ELASTICSEARCH_URL! - // Allow the user to lazily set it to `localhost:9200` for example. + // Add http:// to host:port inputs such as localhost:9200. if (!node.startsWith('http') && !node.startsWith('://') && node.split(':').length === 2) { node = `http://${node}` } @@ -104,8 +86,6 @@ async function main(opts: Options, textArgs: string[]): Promise<void> { const { verbose, language, notLanguage } = opts - // The notLanguage is useful if you want to, for example, index all languages - // *except* English. if (language && notLanguage) { throw new Error("Can't combine --language and --not-language") } @@ -116,7 +96,6 @@ async function main(opts: Options, textArgs: string[]): Promise<void> { const client = new Client({ node }) - // This will throw if it can't ping await client.ping() const versionKey = opts.version || 'dotcom' diff --git a/src/search/scripts/index-test-fixtures.sh b/src/search/scripts/index-test-fixtures.sh index d230f61b06c7..5ed2cbd167ec 100755 --- a/src/search/scripts/index-test-fixtures.sh +++ b/src/search/scripts/index-test-fixtures.sh @@ -1,14 +1,11 @@ #!/bin/bash -# This exists as a bash script because the commands are a bit too long -# and complex to express inside `package.json`. +# Package scripts would bury the long index commands. set -e -# For general site-search npm run index-general-search -- src/search/tests/fixtures/search-indexes -l en -l ja -V ghec -V fpt --index-prefix tests -# For AI search autocomplete npm run index-ai-search-autocomplete -- src/search/tests/fixtures/data -l en -v fpt -v ghec --index-prefix tests diff --git a/src/search/scripts/index/index-cli.ts b/src/search/scripts/index/index-cli.ts index ceede02d7c9b..92564524d8f9 100644 --- a/src/search/scripts/index/index-cli.ts +++ b/src/search/scripts/index/index-cli.ts @@ -12,7 +12,7 @@ import { } from '@/search/lib/elasticsearch-versions' import { indexAISearchAutocomplete } from './lib/index-ai-search-autocomplete' -// If you optionally have ELASTICSEARCH_URL set in your .env file. +// Reads ELASTICSEARCH_URL from .env when the shell environment lacks it. dotenv.config() program.name('index').description('CLI scripts for indexing Docs data into Elasticsearch') @@ -104,8 +104,7 @@ const aiSearchAutocompleteCommand = new Command('ai-search-autocomplete') .option('--index-prefix <prefix>', 'Prefix for the index names', '') .argument('<data-root>', 'path to the docs-internal-data repo') .action(async (dataRepoRoot: string, options) => { - // In the future, we may want to support multiple languages - // Currently (since this is an experiment), we only support english + // AI search autocomplete indexes English only while the experiment runs. const languages = ['en'] const indexPrefix = options.indexPrefix || '' if (!Array.isArray(options.version)) { diff --git a/src/search/scripts/index/lib/index-ai-search-autocomplete.ts b/src/search/scripts/index/lib/index-ai-search-autocomplete.ts index acb62f126962..a997fd959642 100644 --- a/src/search/scripts/index/lib/index-ai-search-autocomplete.ts +++ b/src/search/scripts/index/lib/index-ai-search-autocomplete.ts @@ -29,7 +29,7 @@ export async function indexAISearchAutocomplete(options: Options) { const client = getElasticsearchClient(undefined, options.verbose, { requestTimeout: 5 * 60 * 1000, }) - await client.ping() // Will throw if not available + await client.ping() console.log( 'Indexing AI search autocomplete for languages: %O and versions: %O', @@ -79,7 +79,7 @@ type LoadOptions = { } function loadQueriesWithPriority(options: LoadOptions): TermsWithFrequency { - // The {version} in the paths uses the version's 'plan' name, e.g. `free-pro-team` instead of `fpt` + // The {version} path segment uses the plan name, such as free-pro-team instead of fpt. const internalDataVersion = getPlanVersionFromIndexVersion(options.version) if (!internalDataVersion) { @@ -107,7 +107,7 @@ function loadQueriesWithPriority(options: LoadOptions): TermsWithFrequency { } for (const term of allQueries) { - // Don't read in the topQueries again (duplicates) + // topQueries already supplied the highest-priority entries. if (!(term in terms)) { terms[term] = popularity popularity -= 1 diff --git a/src/search/scripts/index/lib/index-general-search.ts b/src/search/scripts/index/lib/index-general-search.ts index d5ca941426a9..3b57cdf27c70 100644 --- a/src/search/scripts/index/lib/index-general-search.ts +++ b/src/search/scripts/index/lib/index-general-search.ts @@ -45,7 +45,7 @@ export async function indexGeneralSearch(sourceDirectory: string, opts: Options) const client = getElasticsearchClient(opts.elasticsearchUrl, opts.verbose, { requestTimeout: 5 * 60 * 1000, }) - await client.ping() // Will throw if not available + await client.ping() let versions: string[] | 'all' = [] if ('version' in opts) { diff --git a/src/search/scripts/index/utils/indexing-elasticsearch-utils.ts b/src/search/scripts/index/utils/indexing-elasticsearch-utils.ts index 3db2e222ac5f..4e793f803f29 100644 --- a/src/search/scripts/index/utils/indexing-elasticsearch-utils.ts +++ b/src/search/scripts/index/utils/indexing-elasticsearch-utils.ts @@ -55,11 +55,12 @@ export async function populateIndex( client.helpers.bulk({ datasource: records, onDocument: () => ({ index: { _index: indexAlias } }), - flushBytes: 4 * 1024 * 1024, // 4MB - Prevents too large of a bulk request which results in a 429 from ES + // Keep bulk requests under 4 MB, because larger requests can return 429 from Elasticsearch. + flushBytes: 4 * 1024 * 1024, concurrency: 2, refreshOnCompletion: true, timeout: '5m', - // We could use `retries` and `wait` here, but then we don't have as granular control over logging and when to retry + // Use retryOnErrorTest instead of bulk retries and wait to control timing and logging. }), { attempts, @@ -119,8 +120,7 @@ export async function updateAlias( const indices = await retryOnErrorTest( (error) => { - // 404 can happen when you're trying to get an index that - // doesn't exist. ...yet! + // A 404 can mean the index does not exist yet, so retry cat.indices. return error instanceof errors.ResponseError && error.meta.statusCode === 404 }, () => client.cat.indices({ format: 'json' }), diff --git a/src/search/scripts/index/utils/retry-on-error-test.ts b/src/search/scripts/index/utils/retry-on-error-test.ts index 4776fac68a56..c10f135a85ea 100644 --- a/src/search/scripts/index/utils/retry-on-error-test.ts +++ b/src/search/scripts/index/utils/retry-on-error-test.ts @@ -1,23 +1,7 @@ -// Return a function that you can use to run any code within and if it -// throws you get a chance to say whether to sleep + retry. -// Example: -// -// async function mainFunction() { -// if (Math.random() > 0.9) throw new Error('too large') -// return 'OK' -// } -// -// const errorTest = (err) => err instanceof Error && err.message.includes('too large') -// const config = { // all optional -// attempts: 3, -// sleepTime: 800, -// onError: (err, attempts) => console.warn(`Failed ${attempts} attempts`) -// } -// const ok = await retry(errorTest, mainFunction, config) -// -// When `exponential` is truthy the sleep time doubles on each retry, so in the -// example above it goes 800ms, 1,600ms, 3,200ms. Note that the value of -// `exponential` is only ever read as a boolean, never used as the factor. +// Runs callback until it succeeds, retries run out, or errorTest returns false. +// Matching errors wait sleepTime before each retry. When exponential is set, each wait doubles. +// exponential acts as a boolean switch, not a multiplier. +// Usage: retryOnErrorTest(errorTest, callback, { attempts, sleepTime, onError }) import { sleep } from '@/search/lib/helpers/time' @@ -45,13 +29,7 @@ export async function retryOnErrorTest<T>( if (error instanceof Error && attempts > 0 && errorTest(error)) { if (onError) onError(error, attempts, sleepTime) attempts-- - // The reason for the jitter is to avoid a thundering herd problem. - // Suppose two independent processes/threads start at the same time. - // They both fail, perhaps due to rate limiting. Now, if they both - // sleep for 30 seconds in the first retry attempt, it'll just - // clash again 30 seconds later. But if you add a bit of jitter, at - // the next attempt these independent processes/threads will now - // start at slightly different times. + // Jitter reduces synchronized retries when independent callers fail together. await sleep(addJitter(sleepTime, jitterPercent)) if (exponential) { @@ -65,9 +43,6 @@ export async function retryOnErrorTest<T>( } function addJitter(num: number, percent: number) { - // Return the number plus between 0 and $percent of that number. - // For example, for 1,000 with a 20% jitter you might get 1133.4 - // because you start with 1,000 and 13.4% is a random number between - // 0 and 20%. + // For 1,000 with 20% jitter, return at least 1,000 and less than 1,200. return num + Math.random() * percent * 0.01 * num } diff --git a/src/search/scripts/scrape/lib/build-records-from-api.ts b/src/search/scripts/scrape/lib/build-records-from-api.ts index 5ec6a4cf1b20..f9f68c2086b3 100644 --- a/src/search/scripts/scrape/lib/build-records-from-api.ts +++ b/src/search/scripts/scrape/lib/build-records-from-api.ts @@ -29,62 +29,60 @@ import type { Redirects, } from '@/search/scripts/scrape/types' -// The rehype alerts plugin only runs in the HTML pipeline, so GitHub-style -// alert markers such as `> [!NOTE]` reach the markdown-only output as literal -// text. Strip them so they stay out of search results. +// The rehype alerts plugin only runs in the HTML pipeline, so GitHub-style alert +// markers such as > [!NOTE] reach the markdown-only output as literal text. +// Strip them so they stay out of search results. const ALERT_MARKER_REGEXP = /\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\n?/gi -// Same ignored headings as the HTML scraping approach +// Match the HTML scraper's ignored navigation headings. const IGNORED_HEADING_SLUGS = new Set(['in-this-article', 'further-reading', 'prerequisites']) -// Known translations of the 3 ignored navigational headings. -// These are used as a fallback when github-slugger produces non-ASCII slugs -// that don't match the English slug set above. +// Fallback translations catch ignored headings when github-slugger emits non-ASCII slugs. const IGNORED_HEADING_TEXTS = new Set([ - // English (lowercase) + // English, lowercase 'in this article', 'further reading', 'prerequisites', - // Japanese (ja) + // Japanese, ja 'この記事の内容', '参考資料', '前提条件', - // Chinese (zh) + // Chinese, zh '本文内容', '延伸阅读', '先决条件', - // Korean (ko) + // Korean, ko '이 문서의 내용', '추가 참고 자료', '필수 조건', - // Spanish (es) + // Spanish, es 'en este artículo', 'información adicional', 'requisitos previos', - // Portuguese (pt) + // Portuguese, pt 'neste artigo', 'leitura adicional', 'pré-requisitos', - // Russian (ru) + // Russian, ru 'в этой статье', 'дополнительные материалы', 'необходимые компоненты', - // French (fr) + // French, fr 'dans cet article', 'pour aller plus loin', 'prérequis', - // German (de) + // German, de 'in diesem artikel', 'weiterführende themen', 'voraussetzungen', ]) -// Default port matches build-records.ts for consistency +// Default port matches the general-search-scrape-server package script. const DEFAULT_PORT = 4002 dotenv.config() -// These defaults are known to work fine in GitHub Actions. +// Use these request pacing defaults because they work in GitHub Actions. const MAX_CONCURRENT = parseInt(process.env.BUILD_RECORDS_MAX_CONCURRENT || '5', 10) const MIN_TIME = parseInt(process.env.BUILD_RECORDS_MIN_TIME || '200', 10) @@ -121,10 +119,8 @@ function parseMarkdown(markdown: string) { }) } -// Block container types whose children should be separated by newlines. -// These contain other block-level nodes (paragraphs, lists, etc.) and -// toString() would concatenate them without whitespace, producing tokens -// like "SSH.Make" that the ES tokenizer can't split. +// Block containers need newlines between children because toString() would otherwise +// produce tokens such as SSH.Make that the Elasticsearch tokenizer cannot split. const BLOCK_CONTAINER_TYPES = new Set([ 'root', 'blockquote', @@ -148,14 +144,13 @@ function astToPlainText(node: Node): string { return parent.children.map((child) => astToPlainText(child)).join('\n') } - // Leaf blocks (paragraph, heading, tableCell) and inline nodes: - // concatenate inline text directly. + // Leaf blocks such as paragraph, heading, and tableCell, plus inline nodes, concatenate text directly. return toString(node) } // Parses the markdown once, then extracts both headings and plain-text // content from the tree. Code blocks stay in the text so terms that only -// appear in an example, such as `ssh_url` or `ssh://`, stay searchable. +// appear in an example, such as ssh_url or ssh://, stay searchable. export function extractFromMarkdown(markdown: string): { headings: string; content: string } { const ast = parseMarkdown(markdown) @@ -170,7 +165,7 @@ export function extractFromMarkdown(markdown: string): { headings: string; conte const headingText = toString(node) const slug = slugger.slug(headingText) - // Skip navigational headings by slug or known translated text + // Skip navigational headings by slug or known translated text. if (IGNORED_HEADING_SLUGS.has(slug)) return if (IGNORED_HEADING_TEXTS.has(headingText.toLowerCase().trim())) return @@ -182,8 +177,7 @@ export function extractFromMarkdown(markdown: string): { headings: string; conte return { headings: headings.join('\n'), content } } -// Extracts h2 headings, minus the navigational ones: in-this-article, -// further-reading and prerequisites. +// Reuses extractFromMarkdown so navigational heading filters stay in one place. export function extractHeadingsFromMarkdown(markdown: string): string { return extractFromMarkdown(markdown).headings } @@ -249,7 +243,7 @@ export async function fetchArticleAsRecord( errorType = 'API Error' } } catch { - /* ignore JSON parse errors */ + // Ignore JSON parse errors so HTTP status fallback remains available. } return { record: null, @@ -281,8 +275,7 @@ export async function fetchArticleAsRecord( const errorName = error instanceof Error ? error.name : undefined const errorCode = (error as { code?: string }).code - // Prefer structured timeout indicators (name/code), with a documented - // fallback to message inspection for environments that only expose text. + // Prefer structured timeout indicators, with message text as the fallback. const isTimeout = errorName === 'AbortError' || errorCode === 'ETIMEDOUT' || @@ -305,7 +298,7 @@ export interface BuildRecordsResult { failedPages: FailedPage[] } -// A drop-in replacement for buildRecords in build-records.ts. +// Returns records and failures together so index workflows can publish partial results and alerts. export default async function buildRecordsFromApi( indexName: string, indexablePages: Page[], @@ -326,9 +319,7 @@ export default async function buildRecordsFromApi( .filter((page) => page.languageCode === languageCode) .filter((page) => page.permalinks.some((permalink) => permalink.pageVersion === pageVersion)) - // Get permalinks for this language and version, deduplicating by href. - // Cross-product children can cause the same page to appear multiple - // times in the tree under different parents. + // Deduplicate permalinks by href, because cross-product children can repeat a page. const seen = new Set<string>() const permalinks = pages .map((page) => diff --git a/src/search/scripts/scrape/lib/find-indexable-pages.ts b/src/search/scripts/scrape/lib/find-indexable-pages.ts index 3aa4f576e1cf..6b53de9976d1 100644 --- a/src/search/scripts/scrape/lib/find-indexable-pages.ts +++ b/src/search/scripts/scrape/lib/find-indexable-pages.ts @@ -6,10 +6,9 @@ export default async function findIndexablePages(match = ''): Promise<Page[]> { const allPages: Page[] = await loadPages() const indexablePages = allPages .filter((page) => !page.hidden) - // exclude pages in visible WIP products. The `|| hidden` was added in - // f4e05b189c8 to exclude hidden products too, but it keeps them instead. + // Exclude visible WIP products. Hidden WIP products still pass through this filter. .filter((page) => !page.parentProduct || !page.parentProduct.wip || page.parentProduct.hidden) - // exclude absolute home page (e.g. /en or /ja) + // Exclude absolute home pages such as /en or /ja. .filter((page) => page.relativePath !== 'index.md') .filter((page) => !match || page.relativePath.includes(match)) diff --git a/src/search/scripts/scrape/lib/popular-pages.ts b/src/search/scripts/scrape/lib/popular-pages.ts index 72dbc71dd134..1c4ca2b40436 100644 --- a/src/search/scripts/scrape/lib/popular-pages.ts +++ b/src/search/scripts/scrape/lib/popular-pages.ts @@ -24,20 +24,15 @@ export default async function getPopularPages( } const rollupRaw = await fs.readFile(filePath, 'utf-8') - // First iterate through the array of objects, not making an assumption - // that the first one is the biggest one. + // Find the biggest count after filtering because rollups are not guaranteed to be sorted. const all: { [key: string]: number } = {} for (const [path, count] of Object.entries(JSON.parse(rollupRaw))) { if (!path) { - // Can happen if the SQL query is, for some unknown reason, finding - // a path that is either `null` or an empty string. Treat it as a - // junk entry and skip it. + // Skip null or empty SQL rollup paths as junk entries. continue } if (path === 'index') { - // That's the home page which doesn't count. It doesn't count because - // people don't arrive on that for the information they seek. It's - // merely a navigation tool. + // Skip the homepage because it serves navigation rather than specific search intent. continue } if (path.startsWith('early-access/')) { @@ -50,14 +45,10 @@ export default async function getPopularPages( const biggestCount = Math.max(...Object.values(all)) const popularPages: PopularPages = {} for (const [path, count] of Object.entries(all)) { - // Don't bother writing massively long floating point numbers - // because reducing it makes the JSON records smaller and we don't - // need any more precision than 7 significant figures. + // Seven decimal places keep records smaller without useful popularity precision loss. const ratio = Number((count / biggestCount).toFixed(7)) - // The reason we're heeding redirects is because it's possible - // that the JSON file is older/"staler" than the - // content itself. + // Apply redirects because rollups can lag behind content changes. popularPages[redirects[path] || path] = ratio } diff --git a/src/search/scripts/scrape/lib/scrape-into-index-json.ts b/src/search/scripts/scrape/lib/scrape-into-index-json.ts index 5ae768e1c9d6..d9ad3f408034 100644 --- a/src/search/scripts/scrape/lib/scrape-into-index-json.ts +++ b/src/search/scripts/scrape/lib/scrape-into-index-json.ts @@ -8,8 +8,8 @@ import { getElasticSearchIndex } from '@/search/lib/elasticsearch-indexes' import type { Options, Config, Page, Redirects } from '@/search/scripts/scrape/types' -// Build a search data file for every combination of product version and -// language, e.g. `github-docs_general-search_fpt_en-records.json`. +// Builds search data files for the selected product versions and languages, such as +// github-docs_general-search_fpt_en-records.json. export default async function scrapeIntoIndexJson({ language, notLanguage, @@ -35,8 +35,7 @@ export default async function scrapeIntoIndexJson({ for (const page of indexablePages) { const href = page.relativePath.replace('index.md', '').replace('.md', '') for (let redirectFrom of page.redirect_from || []) { - // Remember that each redirect_from as a prefix / and often it ends - // with a trailing / + // redirect_from values start with / and often end with /. if (redirectFrom.startsWith('/')) redirectFrom = redirectFrom.slice(1) if (redirectFrom.endsWith('/')) redirectFrom = redirectFrom.slice(0, -1) redirects[redirectFrom] = href @@ -56,7 +55,7 @@ export default async function scrapeIntoIndexJson({ for (const indexVersion of versionsToBuild) { const { indexName } = getElasticSearchIndex('generalSearch', indexVersion, languageCode) - // The page version will be the new version, e.g., free-pro-team@latest, enterprise-server@3.7 + // The page version uses allVersions keys such as free-pro-team@latest. const { records, failedPages } = await buildRecords( indexName, indexablePages, diff --git a/src/search/scripts/scrape/scrape-cli.ts b/src/search/scripts/scrape/scrape-cli.ts index 717e5da1dd7f..9e2136cb0458 100644 --- a/src/search/scripts/scrape/scrape-cli.ts +++ b/src/search/scripts/scrape/scrape-cli.ts @@ -1,5 +1,5 @@ -// This script is run automatically via GitHub Actions on every push to `main` to generate searchable data. -// It can also be run manually. +// Indexing workflows scrape search data on schedules, dispatches, purge runs, and pull requests. +// You can also run this CLI manually. import { existsSync, statSync, readdirSync } from 'fs' import { program, Option } from 'commander' @@ -96,7 +96,6 @@ async function main(opts: ProgramOptions, args: string[]) { const { docsInternalData } = opts const { DOCS_INTERNAL_DATA } = process.env - // Taking care of legacy if (process.env.POPULAR_PAGES_JSON) { throw new Error('POPULAR_PAGES_JSON is deprecated. Use DOCS_INTERNAL_DATA instead.') } diff --git a/src/search/tests/aggregate-search-index-failures.ts b/src/search/tests/aggregate-search-index-failures.ts index 3a17988afba8..7b605fcdeec6 100644 --- a/src/search/tests/aggregate-search-index-failures.ts +++ b/src/search/tests/aggregate-search-index-failures.ts @@ -78,7 +78,7 @@ describe('aggregateFailures', () => { const result = aggregateFailures(failures) expect(result.hasFailures).toBe(true) - // Should count unique pages, not total failures + // Count unique pages, not every language and version failure. expect(result.totalCount).toBe(1) expect(result.message).toContain('1 page(s) failed') expect(result.message).toContain('versions: dotcom, ghes-3.19') @@ -261,8 +261,7 @@ describe('aggregateFailures', () => { ] const result = aggregateFailures(failures) - // Alphabetically 'aaa rare' sorts first, so ordering by count is what puts - // the common error above it. + // aaa rare sorts first alphabetically, so count order must put zzz common first. expect(result.message.indexOf('zzz common')).toBeLessThan(result.message.indexOf('aaa rare')) }) @@ -376,8 +375,7 @@ describe('aggregateFailures', () => { const workflowUrl = 'https://github.com/github/docs-internal/actions/runs/12345678901' const result = aggregateFailures(failures, workflowUrl) expect(result.totalCount).toBe(2000) - // The footer is reserved for up front, so the cap holds for the whole - // message rather than just the page list. + // Reserving the footer up front keeps the cap on the whole message, not only the page list. expect(result.message.length).toBeLessThanOrEqual(30000) expect(result.message).toContain(workflowUrl) expect(result.message).toMatch(/and \d+ more page\(s\) not listed/) @@ -407,8 +405,7 @@ describe('aggregateFailures', () => { const bullets = result.message.split('\n').filter((line) => line.startsWith('•')).length const errorLines = result.message.split('\n').filter((line) => line.includes('↳')).length - // Errors are only worth showing for the pages that fit, so the long ones - // must not push pages out of the list. + // Long errors must not push pages out of the list. expect(bullets).toBeGreaterThan(400) expect(errorLines).toBeLessThan(bullets) }) diff --git a/src/search/tests/ai-search-links-json.ts b/src/search/tests/ai-search-links-json.ts index 4bfdcbc8e469..8cc4d881be9f 100644 --- a/src/search/tests/ai-search-links-json.ts +++ b/src/search/tests/ai-search-links-json.ts @@ -53,7 +53,7 @@ describe('generateAISearchLinksJson', () => { const sources = [{ url: 'https://docs.github.com/en/billing/managing-billing' }] const aiResponse = 'Learn about [Billing](https://docs.github.com/en/billing/managing-billing).' const result = generateAISearchLinksJson(sources, aiResponse) - // Note: The inline link appears first because it's processed first + // Inline links appear first because generateAISearchLinksJson processes them first. expect(JSON.parse(result)).toEqual([ { type: 'inline', @@ -94,8 +94,10 @@ describe('generateAISearchLinksJson', () => { const aiResponse = 'Visit [GitHub](https://github.com/).' const result = generateAISearchLinksJson(sources, aiResponse) expect(JSON.parse(result)).toEqual([ - { type: 'inline', url: 'https://github.com/', product: '' }, // Non-docs inline link - { type: 'reference', url: 'https://github.com/features/actions', product: '' }, // Non-docs reference link + // Non-docs inline links have no product. + { type: 'inline', url: 'https://github.com/', product: '' }, + // Non-docs reference links have no product. + { type: 'reference', url: 'https://github.com/features/actions', product: '' }, ]) }) diff --git a/src/search/tests/ai-search-local-proxy.ts b/src/search/tests/ai-search-local-proxy.ts index f0ee6db7e7e6..1a61a1d684e4 100644 --- a/src/search/tests/ai-search-local-proxy.ts +++ b/src/search/tests/ai-search-local-proxy.ts @@ -3,10 +3,8 @@ import { expect, test, describe } from 'vitest' import { get, post } from '@/tests/helpers/e2etest' describe('AI Search Local Proxy Middleware', () => { + // Under NODE_ENV=test, frame/middleware/api.ts mounts aiSearch directly; this only proves the route answers. test('should successfully proxy to docs.github.com when CSE_COPILOT_ENDPOINT is not localhost', async () => { - // Under NODE_ENV=test, frame/middleware/api.ts mounts the real aiSearch - // middleware rather than the proxy, so nothing here reaches the proxy. This - // is a smoke test that the route exists and answers. const body = { query: 'test query', version: 'dotcom' } const response = await post('/api/ai-search/v1', { body: JSON.stringify(body), @@ -63,6 +61,7 @@ describe('AI Search Local Proxy Middleware', () => { expect([200, 500, 502, 503, 504]).toContain(response.statusCode) }) + // fetch forbids Connection, Transfer-Encoding and Upgrade, so this test cannot send them. test('should filter hop-by-hop headers correctly', async () => { const response = await post('/api/ai-search/v1', { body: JSON.stringify({ query: 'test', version: 'dotcom' }), @@ -70,9 +69,6 @@ describe('AI Search Local Proxy Middleware', () => { 'Content-Type': 'application/json', 'User-Agent': 'test-agent', 'X-Custom-Header': 'test-value', - // fetch forbids Connection, Transfer-Encoding and Upgrade, so a client - // cannot send the hop-by-hop headers the proxy filters. These are - // forwarded as-is. }, }) diff --git a/src/search/tests/apache-arrow-stub.ts b/src/search/tests/apache-arrow-stub.ts index 93f012dda12f..6c304bcdd4ff 100644 --- a/src/search/tests/apache-arrow-stub.ts +++ b/src/search/tests/apache-arrow-stub.ts @@ -2,12 +2,10 @@ import { describe, expect, it } from 'vitest' import { execFileSync } from 'child_process' describe('apache-arrow stub', () => { + // The real apache-arrow creates about 40 TypedArray subclasses via Object.setPrototypeOf. + // That triggers V8 "dependent prototype chain changed" deoptimizations, which this stub avoids. + // V8's --trace-deopt outputs to stderr. it('loading @elastic/elasticsearch does not trigger prototype chain deoptimizations', () => { - // The real apache-arrow creates ~40 TypedArray subclasses via - // Object.setPrototypeOf, which triggers V8 "dependent prototype - // chain changed" deoptimizations. The stub avoids this entirely. - // - // V8's --trace-deopt outputs to stderr. let stderr = '' try { execFileSync(process.execPath, ['--trace-deopt', '-e', "require('@elastic/elasticsearch')"], { @@ -15,8 +13,7 @@ describe('apache-arrow stub', () => { timeout: 15_000, }) } catch (error) { - // execFileSync may throw if the process exits non-zero; - // we only care about the stderr output + // execFileSync can throw on nonzero exit; only stderr matters here. stderr = (error as { stderr?: string }).stderr || '' } @@ -28,7 +25,7 @@ describe('apache-arrow stub', () => { }) it('stub exports throw clear errors if Arrow methods are called', async () => { - // Verify the stub satisfies the require but throws on use + // The stub must satisfy the require and throw only if Arrow methods run. const { Client } = await import('@elastic/elasticsearch') const client = new Client({ node: 'http://localhost:9200' }) expect(client).toBeDefined() diff --git a/src/search/tests/api-ai-search-autocomplete.ts b/src/search/tests/api-ai-search-autocomplete.ts index 239b004048a3..f5d7832568b1 100644 --- a/src/search/tests/api-ai-search-autocomplete.ts +++ b/src/search/tests/api-ai-search-autocomplete.ts @@ -1,8 +1,6 @@ -// These tests need indexed fixtures and an Elasticsearch URL for the server: -// -// ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures -// -// That writes `tests_`-prefixed indexes and leaves your regular ones alone. +// These tests need indexed fixtures and ELASTICSEARCH_URL. +// Run ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures. +// The command writes tests_-prefixed indexes and leaves regular indexes alone. import { expect, test, vi } from 'vitest' @@ -27,8 +25,7 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () => test('perform a basic ai autocomplete search', async () => { const sp = new URLSearchParams() - // To see why this will work, - // see src/search/tests/fixtures/data/ai/* + // Fixture queries under src/search/tests/fixtures/data/ai include "How do I clone a repository?". sp.set('query', 'how do I') const res = await get(getSearchEndpointWithParams(sp)) expect(res.statusCode).toBe(200) @@ -45,7 +42,7 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () => expect(hit.highlights).toBeTruthy() expect(hit.highlights[0]).toBe('<mark>How do I</mark> clone a repository?') - // Check that it can be cached at the CDN + // Search responses must be CDN-cacheable. expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -108,15 +105,14 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () => test('fuzzy autocomplete search', async () => { const sp = new URLSearchParams() - sp.set('query', 'cl') // Short for "clone" + sp.set('query', 'cl') // Matches "clone". const res = await get(getSearchEndpointWithParams(sp)) expect(res.statusCode).toBe(200) const results = JSON.parse(res.body) as AutocompleteSearchResponse - // 'cl" matches "How do I clone a repository?" + // cl matches "How do I clone a repository?". const hit = results.hits[0] expect(hit.term).toBe('How do I clone a repository?') - // Highlighting behavior will highlight the matching "term" which is an entire word - // In this case that word is "clone" when the query is "cl" + // Two-character queries use prefix matching, so cl highlights clone. expect(hit.highlights[0]).toBe('How do I <mark>clone</mark> a repository?') }) @@ -134,12 +130,12 @@ describeIfElasticsearchURL('search/ai-search-autocomplete v1 middleware', () => test('support empty query', async () => { const sp = new URLSearchParams() - // No query at all + // Omit query entirely. { const res = await get(getSearchEndpointWithParams(sp)) expect(res.statusCode).toBe(200) } - // Empty query + // Pass an empty query. { sp.set('query', '') const res = await get(getSearchEndpointWithParams(sp)) diff --git a/src/search/tests/api-ai-search.ts b/src/search/tests/api-ai-search.ts index 39ee064abeed..d25248ba5406 100644 --- a/src/search/tests/api-ai-search.ts +++ b/src/search/tests/api-ai-search.ts @@ -45,7 +45,7 @@ describe('AI Search Routes', () => { const fullResponse = chunks.join('') const chunkLines = fullResponse.split('\n').filter((line) => line.trim() !== '') - // 1. First chunk should be the SOURCES chunk + // The first chunk carries SOURCES metadata. expect(chunkLines.length).toBeGreaterThan(0) const firstChunkMatch = chunkLines[0].match(/^Chunk: (.+)$/) expect(firstChunkMatch).not.toBeNull() @@ -56,7 +56,7 @@ describe('AI Search Routes', () => { expect(Array.isArray(sourcesChunk.sources)).toBe(true) expect(sourcesChunk.sources.length).toBe(3) - // 2. Subsequent chunks should be MESSAGE_CHUNKs + // Later chunks carry MESSAGE_CHUNK text. for (let i = 1; i < chunkLines.length; i++) { const line = chunkLines[i] const messageChunk = JSON.parse(line) @@ -65,7 +65,7 @@ describe('AI Search Routes', () => { expect(typeof messageChunk.text).toBe('string') } - // 3. Verify the complete message is expected + // Concatenating MESSAGE_CHUNK text reconstructs the response. const expectedMessage = 'Creating a repository on GitHub is something you should already know how to do :shrug:' const receivedMessage = chunkLines diff --git a/src/search/tests/api-combined-search.ts b/src/search/tests/api-combined-search.ts index d320a038d89a..0a8f893e718d 100644 --- a/src/search/tests/api-combined-search.ts +++ b/src/search/tests/api-combined-search.ts @@ -1,8 +1,6 @@ -// These tests need indexed fixtures and an Elasticsearch URL for the server: -// -// ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures -// -// That writes `tests_`-prefixed indexes and leaves your regular ones alone. +// These tests need indexed fixtures and ELASTICSEARCH_URL. +// Run ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures. +// The command writes tests_-prefixed indexes and leaves regular indexes alone. import { expect, test, vi } from 'vitest' @@ -46,7 +44,7 @@ describeIfElasticsearchURL('search/combined-autocomplete v1 middleware', () => { expect(results.generalSearchResults.meta).toBeTruthy() expect(results.generalSearchResults.meta.found.value).toBe(0) - // Check that it can be cached at the CDN + // Search responses must be CDN-cacheable. expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -117,14 +115,14 @@ describeIfElasticsearchURL('search/combined-autocomplete v1 middleware', () => { test('empty query returns default results', async () => { const sp = new URLSearchParams() - // No query at all + // Omit query entirely. { const res = await get(getSearchEndpointWithParams(sp)) expect(res.statusCode).toBe(200) const results = JSON.parse(res.body) as CombinedSearchResponse expect(results).toBeTruthy() } - // Empty query + // Pass an empty query. { sp.set('query', '') const res = await get(getSearchEndpointWithParams(sp)) @@ -132,7 +130,7 @@ describeIfElasticsearchURL('search/combined-autocomplete v1 middleware', () => { const results = JSON.parse(res.body) as CombinedSearchResponse expect(results).toBeTruthy() } - // Empty when trimmed + // Pass a whitespace-only query. { sp.set('query', ' ') const res = await get(getSearchEndpointWithParams(sp)) diff --git a/src/search/tests/api-search.ts b/src/search/tests/api-search.ts index 9a6763141cc0..468d27ee7788 100644 --- a/src/search/tests/api-search.ts +++ b/src/search/tests/api-search.ts @@ -1,8 +1,6 @@ -// These tests need indexed fixtures and an Elasticsearch URL for the server: -// -// ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures -// -// That writes `tests_`-prefixed indexes and leaves your regular ones alone. +// These tests need indexed fixtures and ELASTICSEARCH_URL. +// Run ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures. +// The command writes tests_-prefixed indexes and leaves regular indexes alone. import { expect, test, vi } from 'vitest' import { describeIfElasticsearchURL } from '@/tests/helpers/conditional-runs' @@ -19,10 +17,9 @@ if (!process.env.ELASTICSEARCH_URL) { describeIfElasticsearchURL('search v1 middleware', () => { vi.setConfig({ testTimeout: 60 * 1000 }) + // src/search/tests/fixtures/search-indexes/tests_github-docs_general-search_fpt_en-records.json has title "Foo". test('basic search', async () => { const sp = new URLSearchParams() - // src/search/tests/fixtures/search-indexes/tests_github-docs_general-search_fpt_en-records.json - // has a record with the title "Foo". sp.set('query', 'foo') const res = await get(`/api/search/v1?${sp.toString()}`) expect(res.statusCode).toBe(200) @@ -36,24 +33,22 @@ describeIfElasticsearchURL('search v1 middleware', () => { expect(results.meta.took.query_msec).toBeGreaterThanOrEqual(0) expect(results.meta.took.total_msec).toBeGreaterThanOrEqual(0) - // Might be empty but at least an array + // Search hits can be empty, but the response always returns an array. expect(results.hits).toBeTruthy() - // The word 'foo' appears in more than 1 document in the fixtures. + // The word foo appears in more than one fixture document. expect(results.hits.length).toBeGreaterThanOrEqual(1) - // ...but only one has the word "foo" in its title so we can - // be certain it comes first. + // Only one fixture title includes foo, so that hit comes first. const hit: GeneralSearchHit = results.hits[0] - // This specifically checks what we expect of version v1 + // The API returns the fixture source.url unchanged. expect(hit.url).toBe('/en/foo') expect(hit.title).toBe('Foo') expect(hit.breadcrumbs).toBe('fooing') - // By default, 'title' and 'content' is included in highlights, - // but not 'headings' + // Default highlights include title and content, not headings. expect(hit.highlights.title[0]).toBe('<mark>Foo</mark>') expect(hit.highlights.content[0]).toMatch('<mark>foo</mark>') expect(hit.highlights.headings).toBeUndefined() - // Check that it can be cached at the CDN + // Search responses must be CDN-cacheable. expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -69,7 +64,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { const res = await get(`/api/search/v1?${sp.toString()}`) expect(res.statusCode).toBe(200) const results: GeneralSearchResponse = JSON.parse(res.body) - // safe because we know exactly the fixtures + // The fixture query returns a deterministic first hit. const hit: GeneralSearchHit = results.hits[0] expect(hit.popularity).toBeTruthy() expect(hit.score).toBeTruthy() @@ -77,21 +72,18 @@ describeIfElasticsearchURL('search v1 middleware', () => { }) test('search with and without autocomplete on', async () => { - // *Without* autocomplete=true + // Leave autocomplete unset to verify the stemmed term does not match. { const sp = new URLSearchParams() sp.set('query', 'sill') const res = await get(`/api/search/v1?${sp.toString()}`) expect(res.statusCode).toBe(200) const results: GeneralSearchResponse = JSON.parse(res.body) - // Fixtures contains no word called 'sill'. It does contain the term - // 'silly' which, in English, becomes 'silli` when stemmed. - // Because we don't use `&autocomplete=true` this time, we expect - // to find nothing. + // The fixture term silly stems to silli; without autocomplete, query sill does not match it. expect(results.meta.found.value).toBe(0) } - // *With* autocomplete=true + // Enable autocomplete so sill can match silly. { const sp = new URLSearchParams() sp.set('query', 'sill') @@ -133,7 +125,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { test('highlights keys matches highlights configuration', async () => { const sp = new URLSearchParams() - // This will match because it's in the 'content' but not in 'headings' + // Fact of life appears in content, not headings. sp.set('query', 'Fact of life') sp.set('highlights', 'title') const res = await get(`/api/search/v1?${sp.toString()}`) @@ -162,7 +154,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { }) test('invalid parameters', async () => { - // query is not even present + // Missing query. { const res = await get('/api/search/v1') expect(res.statusCode).toBe(400) @@ -172,7 +164,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { } expect(errorResponse.error).toBeTruthy() } - // query is just whitespace + // Whitespace-only query. { const sp = new URLSearchParams() sp.set('query', ' ') @@ -184,7 +176,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { } expect(errorResponse.error).toBeTruthy() } - // unrecognized language + // Unrecognized language. { const sp = new URLSearchParams() sp.set('query', 'test') @@ -197,7 +189,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { } expect(errorResponse.error).toMatch('language') } - // unrecognized page + // Unrecognized page. { const sp = new URLSearchParams() sp.set('query', 'test') @@ -210,7 +202,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { } expect(errorResponse.error).toMatch('page') } - // unrecognized version + // Unrecognized version. { const sp = new URLSearchParams() sp.set('query', 'test') @@ -224,7 +216,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { expect(errorResponse.error).toMatch("'xxxxx'") expect(errorResponse.field).toMatch('version') } - // unrecognized size + // Unrecognized size. { const sp = new URLSearchParams() sp.set('query', 'test') @@ -237,7 +229,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { } expect(errorResponse.error).toMatch('size') } - // unrecognized sort + // Unrecognized sort. { const sp = new URLSearchParams() sp.set('query', 'test') @@ -250,7 +242,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { } expect(errorResponse.error).toMatch('sort') } - // unrecognized highlights + // Unrecognized highlights. { const sp = new URLSearchParams() sp.set('query', 'test') @@ -263,7 +255,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { } expect(errorResponse.error).toMatch('neverheardof') } - // multiple 'query' keys + // Multiple query keys. { const sp = new URLSearchParams() sp.append('query', 'test1') @@ -284,7 +276,7 @@ describeIfElasticsearchURL('search v1 middleware', () => { const res = await get(`/api/search/v1?${sp.toString()}`) expect(res.statusCode).toBe(200) const results: GeneralSearchResponse = JSON.parse(res.body) - // safe because we know exactly the fixtures + // The fixture query returns a deterministic first hit. const hit: GeneralSearchHit = results.hits[0] expect(hit.breadcrumbs).toBe('') }) @@ -353,8 +345,7 @@ describeIfElasticsearchURL('filter by toplevel', () => { const res = await get(`/api/search/v1?${sp.toString()}`) expect(res.statusCode).toBe(200) const results: GeneralSearchResponse = JSON.parse(res.body) - // In the fixtures, there are two distinct `toplevel` that - // matches to this search. + // The fixtures include two toplevel values that match foo. const toplevels = new Set(results.hits.map((hit) => hit.toplevel)) expect(toplevels).toEqual(new Set(['Fooing', 'Baring'])) }) diff --git a/src/search/tests/build-records-from-api.ts b/src/search/tests/build-records-from-api.ts index 47c142702767..aa593a4edd03 100644 --- a/src/search/tests/build-records-from-api.ts +++ b/src/search/tests/build-records-from-api.ts @@ -113,15 +113,15 @@ Some content without sections. }) test('filters out non-English navigational headings across languages', () => { - // Chinese + // Chinese translations stay filtered. expect(extractHeadingsFromMarkdown('## 本文内容\n\n## 实际内容')).toBe('实际内容') expect(extractHeadingsFromMarkdown('## 延伸阅读\n\n## 实际内容')).toBe('实际内容') - // Korean + // Korean translations stay filtered. expect(extractHeadingsFromMarkdown('## 이 문서의 내용\n\n## 실제 내용')).toBe('실제 내용') expect(extractHeadingsFromMarkdown('## 추가 참고 자료\n\n## 실제 내용')).toBe('실제 내용') - // Spanish + // Spanish translations stay filtered. expect(extractHeadingsFromMarkdown('## En este artículo\n\n## Contenido real')).toBe( 'Contenido real', ) @@ -129,13 +129,13 @@ Some content without sections. 'Contenido real', ) - // French + // French translations stay filtered. expect(extractHeadingsFromMarkdown('## Dans cet article\n\n## Contenu réel')).toBe( 'Contenu réel', ) expect(extractHeadingsFromMarkdown('## Prérequis\n\n## Contenu réel')).toBe('Contenu réel') - // German + // German translations stay filtered. expect(extractHeadingsFromMarkdown('## Voraussetzungen\n\n## Echter Inhalt')).toBe( 'Echter Inhalt', ) @@ -191,7 +191,7 @@ More text. 2. Make a request using the CLI. ` const text = markdownToPlainText(markdown) - // "SSH." and "Make" must not merge into "SSH.Make" + // SSH. and Make must not merge into SSH.Make. expect(text).not.toMatch(/SSH\.Make/) expect(text).toMatch(/SSH\.\n/) expect(text).toContain('Make a request') @@ -203,7 +203,7 @@ More text. > Second paragraph in blockquote. ` const text = markdownToPlainText(markdown) - // Paragraphs within a blockquote should be separated + // Paragraphs within a blockquote stay separated. expect(text).not.toMatch(/blockquote\.Second/) expect(text).toContain('First paragraph in blockquote.') expect(text).toContain('Second paragraph in blockquote.') @@ -231,7 +231,7 @@ More text. expect(text).not.toContain('[!WARNING]') expect(text).not.toContain('[!IMPORTANT]') expect(text).not.toContain('[!CAUTION]') - // The alert body text should still be present + // Alert body text stays searchable. expect(text).toContain('This is a note.') expect(text).toContain('This is a tip.') expect(text).toContain('This is a warning.') @@ -280,10 +280,10 @@ More content. ` const result = extractFromMarkdown(markdown) - // Headings should exclude "Further reading" + // Further reading stays out of headings. expect(result.headings).toBe('Section One\nSection Two') - // Content should include fenced code block text + // Fenced code block text stays searchable. expect(result.content).toContain('Some content') expect(result.content).toContain('More content') expect(result.content).toContain('"key"') @@ -353,7 +353,7 @@ Here's how to begin. title: 'Archived Page', intro: 'This is archived.', product: 'Old product', - // No breadcrumbs - simulating archived page + // Archived pages can omit breadcrumbs. }, body: '# Archived Page\n\nContent here.', } @@ -378,7 +378,7 @@ Here's how to begin. const record = articleApiResponseToRecord('/en/get-started', response) - // For single breadcrumb, don't slice it off + // Single-breadcrumb product landing pages keep that breadcrumb. expect(record.breadcrumbs).toBe('Get started') expect(record.toplevel).toBe('Get started') }) @@ -413,7 +413,7 @@ Here's how to begin. const record = articleApiResponseToRecord('/en/test', response) - // Intro should appear only once + // The intro appears only once. const introCount = (record.content.match(/Same intro/g) || []).length expect(introCount).toBe(1) }) @@ -449,10 +449,10 @@ The \`name\` parameter is required. expect(record.content).toContain('Use the endpoint below') expect(record.content).toContain('parameter is required') - // Fenced code block content should be included for search + // Fenced code block content stays searchable. expect(record.content).toContain('ssh_url') expect(record.content).toContain('ssh://git@github.com') - // Inline code content should also be preserved + // Inline code content stays searchable. expect(record.content).toContain('name') }) }) diff --git a/src/search/tests/fixtures/page-with-sections.html b/src/search/tests/fixtures/page-with-sections.html index 801e8bb9270b..a1576f58af37 100644 --- a/src/search/tests/fixtures/page-with-sections.html +++ b/src/search/tests/fixtures/page-with-sections.html @@ -22,9 +22,8 @@ <h2 id="in-this-article">In this article</h2> <h2 id="first">First heading</h2> <!-- - Note that these two <p> tags have nothing between them. - But since <p> is a block-level tag, the browser would display them - on separate lines so there's a natural space between them. + These adjacent <p> tags render on separate lines because <p> is block-level, + so scraping must treat the tag boundary as whitespace. --> <p>Here's a paragraph.</p><p>And another.</p> diff --git a/src/search/tests/rendering.ts b/src/search/tests/rendering.ts index 296dd9bc6c39..318bf8fb5ef8 100644 --- a/src/search/tests/rendering.ts +++ b/src/search/tests/rendering.ts @@ -1,8 +1,6 @@ -// These tests need indexed fixtures and an Elasticsearch URL for the server: -// -// ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures -// -// That writes `tests_`-prefixed indexes and leaves your regular ones alone. +// These tests need indexed fixtures and ELASTICSEARCH_URL. +// Run ELASTICSEARCH_URL=http://localhost:9200 npm run index-test-fixtures. +// The command writes tests_-prefixed indexes and leaves regular indexes alone. import { expect, test, vi } from 'vitest' @@ -19,20 +17,17 @@ if (!process.env.ELASTICSEARCH_URL) { describeIfElasticsearchURL('search rendering page', () => { vi.setConfig({ testTimeout: 60 * 1000 }) + // src/search/tests/fixtures/search-indexes/tests_github-docs_general-search_fpt_en-records.json has title "Foo". test('happy path', async () => { - // src/search/tests/fixtures/search-indexes/tests_github-docs_general-search_fpt_en-records.json - // has a record with the title "Foo". const { $ } = await getDOM('/en/search?query=foo') expect($('h1').text()).toMatch(/\d+ Search results for "foo"/) - // Note it testid being 'search-result', not 'search-results' + // Use search-result, not search-results, for individual result rows. const results = $('[data-testid="search-result"]') expect(results.length).toBeGreaterThan(0) const result = results.first() expect($('h2', result).text()).toBe('Foo') - // The Docs 2026 result row replaced the breadcrumb line with a category chip fed by the - // hit's `toplevel`. Asserting on it also covers the `include=toplevel` plumbing in the - // search middleware. + // Result rows render the hit toplevel chip and cover include=toplevel plumbing. const toplevel = $('[data-testid="search-result-toplevel"]', result) expect(toplevel.text()).toBe('Fooing') const link = $('a', result) diff --git a/src/search/tests/search.ts b/src/search/tests/search.ts index 17529ed272e6..bddb546ae717 100644 --- a/src/search/tests/search.ts +++ b/src/search/tests/search.ts @@ -8,7 +8,7 @@ describe('search results page', () => { const { $ } = await getDOM('/en/search') const $container = $('[data-testid="search-results"]') expect($container.text()).toMatch(/Enter a search term/) - // Default is the frontmatter title of the content/search/index.md + // No-query pages use content/search/index.md's frontmatter title. expect($('title').text()).toMatch('Search - GitHub Docs') }) diff --git a/src/secret-scanning/components/SecretScanningTable.tsx b/src/secret-scanning/components/SecretScanningTable.tsx index 6173e89f4445..b1d0fbf0295d 100644 --- a/src/secret-scanning/components/SecretScanningTable.tsx +++ b/src/secret-scanning/components/SecretScanningTable.tsx @@ -14,9 +14,8 @@ const PAGE_SIZE = 25 // Identifies this table in the docs.v0.TableInteractionEvent analytics. const TABLE_INTERACTION_NAME = 'secret-scanning-patterns' -// Maps DataTable column ids to the canonical analytics field name so that a -// filter and a sort on the same column report the same -// table_interaction_field_name. Filter keys already use these canonical names. +// Canonical analytics field names keep filter and sort events for the same column +// grouped together. Filter keys already use these names. const COLUMN_FIELD_NAMES: Record<string, string> = { provider: 'provider', supportedSecret: 'secret', @@ -59,7 +58,7 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) { const [sortColumn, setSortColumn] = useState<string | undefined>(undefined) const [sortDirection, setSortDirection] = useState<'ASC' | 'DESC'>('ASC') - // Emit a TableInteractionEvent for analytics (github/docs-engineering#6593). + // TableInteractionEvent feeds search, filter, sort, and pagination analytics. const trackInteraction = useCallback( (interactionType: TableInteractionType, fieldName?: string, fieldValue?: string) => { sendEvent({ @@ -77,8 +76,7 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) { const debouncedTrackSearchRef = useRef<ReturnType<typeof debounce> | null>(null) useEffect(() => { debouncedTrackSearchRef.current = debounce((query: string) => { - // Sanitize before logging: users may paste a real secret into this - // table's search to check support, and the query is sent to analytics. + // Sanitize before analytics because users can paste real secrets into this support search. trackInteraction('search', 'search', sanitizeSearchQuery(query)) }, 500) return () => { @@ -263,8 +261,7 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) { field: 'supportedSecret', width: '280px', renderCell: (row) => { - // The middleware appends HTML for duplicates; strip it. - // Also handle </br> and <br/> separators in the raw secretType. + // Remove duplicate token-versions link; convert raw <br> separators to commas. const cleanSecretType = row.secretType .replace(/ <br\/><a href="#token-versions">Token versions<\/a>/, '') .replace(/<\/?br\s*\/?>/gi, ', ') diff --git a/src/secret-scanning/pages/api/patterns.ts b/src/secret-scanning/pages/api/patterns.ts index d8fb0c71369c..38615b48f155 100644 --- a/src/secret-scanning/pages/api/patterns.ts +++ b/src/secret-scanning/pages/api/patterns.ts @@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { getSecretScanningData } from '@/secret-scanning/lib/get-secret-scanning-data' import path from 'path' -// Returns the cached pattern data as JSON. No HTML rendering here. +// The API serves cached pattern data as JSON without HTML rendering. export default async function handler(req: NextApiRequest, res: NextApiResponse) { const version = (req.query.version as string) || 'fpt' const filepath = path.join( @@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { const data = await getSecretScanningData(filepath) - // The data only changes on deploy, so cache hard. + // The data changes only on deploy, so cache hard. res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400') res.json(data) } catch { diff --git a/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx b/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx index 7307840715cc..594e413ab6d5 100644 --- a/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx +++ b/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx @@ -43,7 +43,7 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => addUINamespaces(req, mainContext.data.ui, ['secret_scanning']) const automatedPageContext = getAutomatedPageContextFromRequest(req) - // The middleware already loads secretScanningData into req.context + // secretScanning middleware loads secretScanningData into req.context. const patterns = req.context?.secretScanningData ?? [] return { props: { diff --git a/src/secret-scanning/scripts/sync.ts b/src/secret-scanning/scripts/sync.ts index 547767aaa2c6..3f1976d014d4 100755 --- a/src/secret-scanning/scripts/sync.ts +++ b/src/secret-scanning/scripts/sync.ts @@ -1,12 +1,6 @@ -/** - * Required env variables: - * - * GITHUB_TOKEN - * - * Syncs the - * https://github.com/github/token-scanning-service/blob/main/docs/public-docs - * directory to src/secret-scanning/data/pattern-docs - */ +// Required env variable: GITHUB_TOKEN. +// Syncs https://github.com/github/token-scanning-service/blob/main/docs/public-docs into +// src/secret-scanning/data/pattern-docs. import { writeFile, mkdir } from 'fs/promises' import { load, dump } from 'js-yaml' import path from 'path' diff --git a/src/secret-scanning/tests/liquid-evaluation.ts b/src/secret-scanning/tests/liquid-evaluation.ts index 63669f985fd3..55a98e8d1fe0 100644 --- a/src/secret-scanning/tests/liquid-evaluation.ts +++ b/src/secret-scanning/tests/liquid-evaluation.ts @@ -15,8 +15,8 @@ const { targetFilename } = JSON.parse( readFileSync('src/secret-scanning/lib/config.json', 'utf8'), ) as { targetFilename: string } -// Both hasValidityCheck and hasExtendedMetadata can be emitted by token-scanning-service -// as a Liquid conditional that resolves to false on GHES and true elsewhere. +// token-scanning-service can emit hasValidityCheck and hasExtendedMetadata as Liquid +// conditionals that resolve false on GHES and true elsewhere. const ghesConditional = '{% ifversion ghes %}false{% else %}true{% endif %}' const makeEntry = (): SecretScanningData => diff --git a/src/secret-scanning/tests/rendering.ts b/src/secret-scanning/tests/rendering.ts index e169df44e0d8..2332b67337ce 100644 --- a/src/secret-scanning/tests/rendering.ts +++ b/src/secret-scanning/tests/rendering.ts @@ -20,7 +20,7 @@ describe('secret-scanning pipeline', () => { const url = '/en/enterprise-server@3.11/enterprise-cloud@latest/code-security/secret-scanning/introduction/supported-secret-scanning-patterns' const res = await get(url) - // It should probably be a 404 because the URL is invalid, but definitely not a 500 + // Invalid double-version URLs can return 404, but they must not return 500. expect(res.statusCode).not.toBe(500) }) }) diff --git a/src/shielding/middleware/handle-invalid-headers.ts b/src/shielding/middleware/handle-invalid-headers.ts index 857434e2516c..f498dcb6d277 100644 --- a/src/shielding/middleware/handle-invalid-headers.ts +++ b/src/shielding/middleware/handle-invalid-headers.ts @@ -3,11 +3,8 @@ import type { Response, NextFunction } from 'express' import { ExtendedRequest } from '@/types' const INVALID_HEADER_KEYS = [ - // Next.js will pick this up and override the status code. - // We don't want that to happen because `x-invoke-status: 203` can - // trigger the CDN to cache it. - // It can also trigger a 500 error because the header is not used - // correctly. + // Next.js treats x-invoke-status as the response status, which can make the CDN cache 203s + // or turn malformed requests into 500s. 'x-invoke-status', ] @@ -18,10 +15,7 @@ export default function handleInvalidNextPaths( ) { const header = INVALID_HEADER_KEYS.find((key) => req.headers[key]) if (header) { - // There's no point attempting to set a cache-control on this. - // The CDN will not cache if the status code is not a success - // and not a 404. - + // The CDN does not cache non-success, non-404 responses. res.status(400).type('text').send('Invalid request headers') return } diff --git a/src/shielding/middleware/handle-invalid-nextjs-paths.ts b/src/shielding/middleware/handle-invalid-nextjs-paths.ts index f2ef1f5c8de2..8141544b5d7b 100644 --- a/src/shielding/middleware/handle-invalid-nextjs-paths.ts +++ b/src/shielding/middleware/handle-invalid-nextjs-paths.ts @@ -6,16 +6,13 @@ import { ExtendedRequest } from '@/types' const STATSD_KEY = 'middleware.handle_invalid_nextjs_paths' +// Development needs /_next/static/webpack and /_next/webpack-hmr. +// Production blocks /_next/ paths unless they start with /_next/data, plus __nextFallback requests. export default function handleInvalidNextPaths( req: ExtendedRequest, res: Response, next: NextFunction, ) { - // For example, `/_next/bin/junk.css`. - // The reason for depending on checking NODE_ENV is that in development, - // the Nextjs server will send things like /_next/static/webpack/... - // or /_next/webpack-hmr. - // In local dev, we don't get these penetration-testing looking requests. if ( process.env.NODE_ENV !== 'development' && ((req.path.startsWith('/_next/') && !req.path.startsWith('/_next/data')) || diff --git a/src/shielding/middleware/handle-invalid-paths.ts b/src/shielding/middleware/handle-invalid-paths.ts index 7b259d55c941..6988c6f4adba 100644 --- a/src/shielding/middleware/handle-invalid-paths.ts +++ b/src/shielding/middleware/handle-invalid-paths.ts @@ -3,10 +3,7 @@ import type { Response, NextFunction } from 'express' import { defaultCacheControl } from '@/frame/middleware/cache-control' import { ExtendedRequest } from '@/types' -// We'll check if the current request path is one of these, or ends with -// one of these. -// These are clearly intentional "guesses" made by some sort of -// pen-testing bot. +// These path guesses come from penetration-testing bots. const JUNK_STARTS = ['///', '/\\', '/\\.'] const JUNK_ENDS = [ '/package.json', @@ -28,10 +25,9 @@ const JUNK_PATHS = new Set([ '/_next', ]) -// Basename is the last token of the path when split by `/`. -// For example `/foo/bar/baz` has a basename of `baz`. +// Basenames catch nested probes such as /en/code-security/.env. const JUNK_BASENAMES = new Set([ - // E.g. /en/code-security/.env + // Keep .env separate from .env.local matching below. '.env', ]) @@ -51,18 +47,16 @@ function isJunkPath(path: string) { } const basename = path.split('/').pop() - // E.g. `/billing/.env.local` or `/billing/.env_sample` + // Matches /billing/.env.local and /billing/.env_sample. if (basename && /^\.env(.|_)[\w.]+/.test(basename)) return true if (basename && JUNK_BASENAMES.has(basename)) return true - // Prevent various malicious injection attacks targeting Next.js + // Block malformed Next.js paths before Next.js handles them. if (path.match(/^\/_next[^/]/) || path === '/_next/data' || path === '/_next/data/') { return true } - // We currently don't use next/image for any images. - // This could change in the future but right now can just 404 on these - // so we don't have to deal with any other errors. + // Docs does not use next/image, so these paths can 404 before Next.js handles them. if (path.startsWith('/_next/image')) { return true } @@ -76,8 +70,7 @@ export default function handleInvalidPaths( next: NextFunction, ) { if (isJunkPath(req.path)) { - // We can let the CDN cache these responses because they are not going - // to suddenly work in the next deployment. + // The CDN can cache scanner responses because the paths will not work in the next deployment. defaultCacheControl(res) res.status(404).type('text').send('Not found') return diff --git a/src/shielding/middleware/handle-invalid-query-string-values.ts b/src/shielding/middleware/handle-invalid-query-string-values.ts index 324bbaaa5433..5e738bebb608 100644 --- a/src/shielding/middleware/handle-invalid-query-string-values.ts +++ b/src/shielding/middleware/handle-invalid-query-string-values.ts @@ -11,28 +11,13 @@ const logger = createLogger(import.meta.url) const STATSD_KEY = 'middleware.handle_invalid_querystring_values' -// Hi future reader! -// If there are query strings whose values are static and predictable, -// type them in here. -// It can't be something like `?query=...` because its value is highly -// dynamic. -// At the time of writing, these will match independent of location. A -// possible extension, in the future, is to make the value match -// dependent on the location. For example, you might want to express -// that the values of `?platform=...` should be none when the path is -// something like `/en/search`. +// Recognized values must be static across pages; dynamic values such as query stay out. +// Add path-aware matching if a key needs different values on pages such as /en/search. const RECOGNIZED_VALUES = { platform: allPlatforms as string[], tool: Object.keys(allTools), } -// So we can look up if a key in the object is actually present -// and not a built in. -// Otherwise... -// -// > const myObj = {foo: 'bar'} -// > 'constructor' in myObj -// true -// +// Use a Set so built-in object properties such as constructor do not count as recognized keys. const RECOGNIZED_VALUES_KEYS = new Set(Object.keys(RECOGNIZED_VALUES)) export default function handleInvalidQuerystringValues( @@ -55,8 +40,7 @@ export default function handleInvalidQuerystringValues( validValues, }) } - // Some value is not recognized. Redirect to the current URL - // but with that query string key removed. + // Redirect after removing the query key that contains an unrecognized value. const sp = new URLSearchParams(query as Record<string, string>) sp.delete(key) @@ -72,7 +56,7 @@ export default function handleInvalidQuerystringValues( } } - // For example ?foo[bar]=baz (but not ?foo=bar&foo=baz) + // Reject ?foo[bar]=baz, but not ?foo=bar&foo=baz. if (value instanceof Object && !Array.isArray(value)) { const message = 'Invalid query string' defaultCacheControl(res) diff --git a/src/shielding/middleware/handle-invalid-query-strings.ts b/src/shielding/middleware/handle-invalid-query-strings.ts index df7c81577c12..df7e85fe95b4 100644 --- a/src/shielding/middleware/handle-invalid-query-strings.ts +++ b/src/shielding/middleware/handle-invalid-query-strings.ts @@ -23,36 +23,36 @@ const RECOGNIZED_KEYS_BY_PREFIX = { } const RECOGNIZED_KEYS_BY_ANY = new Set([ - // Learning track pages + // Learning track pages add these keys. 'learn', 'learnProduct', - // Platform picker + // The platform picker adds this key. 'platform', - // Tool picker + // The tool picker adds this key. 'tool', - // When apiVersion isn't the only one. E.g. ?apiVersion=XXX&tool=vscode + // API pages can combine apiVersion with picker keys such as tool. 'apiVersion', - // Search results page + // Search results pages read this key. 'query', - // Any page, Search Overlay + // The search overlay can add these keys on any page. 'search-overlay-input', 'search-overlay-open', 'search-overlay-ask-ai', - // The drop-downs on "Webhook events and payloads" + // Webhook events pages use actionType for drop-down filtering. 'actionType', - // Landing page article grid filters + // Landing page article grids use these filter keys. 'articles-category', 'articles-filter', 'articles-page', - // Legacy domain tracking parameter (no longer processed but still recognized) + // Recognize the legacy ghdomain parameter even though Docs no longer processes it. 'ghdomain', - // UTM campaign tracking + // UTM campaign links add these keys. 'utm_source', 'utm_medium', 'utm_campaign', - // Used by experiments + // Experiments add this key. 'feature', - // Used to track API requests from external sources + // External API request links add this key. 'client_name', ]) @@ -87,8 +87,7 @@ export default function handleInvalidQuerystrings( let keys = originalKeys.filter((key) => !RECOGNIZED_KEYS_BY_ANY.has(key)) if (keys.length > 0) { - // Before we judge the number of query strings, strip out all the ones - // we're familiar with. + // Count only keys this middleware does not recognize for the current path. for (const [prefix, recognizedKeys] of Object.entries(RECOGNIZED_KEYS_BY_PREFIX)) { if (path.startsWith(prefix)) { keys = keys.filter((key) => !recognizedKeys.includes(key)) @@ -96,9 +95,7 @@ export default function handleInvalidQuerystrings( } } - // If you fill out the Survey form with all the fields and somehow - // don't attempt to make a POST request, you'll end up with a query - // string like this. + // GET and HEAD with hidden survey-token plus real survey-vote match this honeypot branch. const honeypotted = 'survey-token' in query && 'survey-vote' in query if (keys.length >= MAX_UNFAMILIAR_KEYS_BAD_REQUEST || honeypotted) { @@ -118,21 +115,12 @@ export default function handleInvalidQuerystrings( return } - // This is a pattern we've observed in production and we're shielding - // against it happening again. The root home page is hit with a - // 8 character long query string that has no value. + // Production has sent the home page 8-character valueless query strings. const rootHomePage = path.split('/').length === 2 const badKeylessQuery = rootHomePage && keys.length === 1 && keys[0].length === 8 && !query[keys[0]] - // It's still a mystery why these requests happen but we've seen large - // number of requests that have a very long URL-encoded query string - // that starts with 'tool' but doesn't have any value. - // For example - // ?tool%25252525253Dvisualstudio%252525253D%2525252526tool%25252525... - // ...3Dvscode%2525253D%25252526tool%2525253Dvscode%25253D%252526tool... - // ...%25253Dvimneovim%253D%2526tool%253Djetbrains%3D%26tool%3Djetbrains=& - // Let's shield against those by removing them. + // Strip production keys like tool%25252525253Dvisualstudio...%26tool%3Djetbrains=. const badToolsQuery = keys.some((key) => key.startsWith('tool%') && !query[key]) if (keys.length >= MAX_UNFAMILIAR_KEYS_REDIRECT || badKeylessQuery || badToolsQuery) { diff --git a/src/shielding/middleware/handle-malformed-urls.ts b/src/shielding/middleware/handle-malformed-urls.ts index a5e364b9543b..cb4ec93d66fe 100644 --- a/src/shielding/middleware/handle-malformed-urls.ts +++ b/src/shielding/middleware/handle-malformed-urls.ts @@ -3,11 +3,8 @@ import type { Response, NextFunction } from 'express' import { defaultCacheControl } from '@/frame/middleware/cache-control' import { ExtendedRequest } from '@/types' -/** - * Malformed UTF-8 in a URL, like `%FF`, makes decodeURIComponent throw. - * Express does not catch that while parsing, so without this the crash - * happens later at the router level. - */ +// Malformed UTF-8 in a URL, such as %FF, makes decodeURIComponent throw. +// Express does not catch that while parsing, so catch it before the router runs. export default function handleMalformedUrls( req: ExtendedRequest, res: Response, diff --git a/src/shielding/middleware/handle-old-next-data-paths.ts b/src/shielding/middleware/handle-old-next-data-paths.ts index ac47f1883cbe..9d276ebac1e9 100644 --- a/src/shielding/middleware/handle-old-next-data-paths.ts +++ b/src/shielding/middleware/handle-old-next-data-paths.ts @@ -1,24 +1,9 @@ -/** - * This middleware looks at the URL if it's something like: - * - * /_next/data/oOIffMZgfjR6sR9pa50O9/en/free-pro-team%40latest/pages.json?... - * - * And from that, it compares that oOIffMZgfjR6sR9pa50O9 with the content - * of the .next/BUILD_ID file. If they don't match, then it's going to 404. - * But instead of letting the nextApp.render404() handle it, we're going to - * manually handle it here. - * This makes sure the response is a short and fast plain text 404 response. - * And we can force it to be served with a cache-control which allows - * the CDN to cache it a bit. - * - * Note that when you start the local server with `npm run dev` and - * do client-side navigation in the app, NextJS will send XHR requests for... - * - * /_next/data/development/en/free-pro-team%40latest/pages.json?... - * - * Relying on that test is easier than to try to parse the - * value of `process.env.NODE_ENV`. - */ +// Next.js data routes include a build ID, for example: +// /_next/data/oOIffMZgfjR6sR9pa50O9/en/free-pro-team%40latest/pages.json +// When the build ID does not match .next/BUILD_ID, return a short cacheable 404 +// here instead of letting nextApp.render404 handle it. +// Local npm run dev uses /_next/data/development/..., so that path stays unblocked +// without depending on NODE_ENV parsing. import fs from 'fs' diff --git a/src/shielding/tests/invalid-querystring-values.ts b/src/shielding/tests/invalid-querystring-values.ts index 985c6993f546..4b5e7d5f38b9 100644 --- a/src/shielding/tests/invalid-querystring-values.ts +++ b/src/shielding/tests/invalid-querystring-values.ts @@ -8,14 +8,11 @@ describe('invalid query string values', () => { if (key === 'platform') value = 'mac' else if (key === 'tool') value = 'curl' else throw new Error('unknown key') - - // Valid value { const url = `/en/pages?${key}=${value}` const res = await get(url) expect(res.statusCode).toBe(200) } - // Invalid value { const url = `/en/pages?${key}=JUNK&other=thing` const res = await get(url) diff --git a/src/shielding/tests/invalid-querystrings.ts b/src/shielding/tests/invalid-querystrings.ts index 23871850c62e..b00a97578b33 100644 --- a/src/shielding/tests/invalid-querystrings.ts +++ b/src/shielding/tests/invalid-querystrings.ts @@ -12,8 +12,7 @@ const alphabet = alpha.map((x) => String.fromCharCode(x)) describe('invalid query strings', () => { test('400 for too many unrecognized query strings', async () => { - // This test depends on knowing exactly the number - // of unrecognized query strings that will trigger a 400. + // The exported threshold keeps this test tied to the middleware limit. const sp = new URLSearchParams() for (const letter of alphabet.slice(0, MAX_UNFAMILIAR_KEYS_BAD_REQUEST)) { sp.set(letter, '1') @@ -27,8 +26,7 @@ describe('invalid query strings', () => { }) test('302 redirect for many unrecognized query strings', async () => { - // This test depends on knowing exactly the number - // of unrecognized query strings that will trigger a redirect. + // The exported threshold keeps this test tied to the middleware limit. const sp = new URLSearchParams() for (const letter of alphabet.slice(0, MAX_UNFAMILIAR_KEYS_REDIRECT)) { sp.set(letter, '1') @@ -58,7 +56,7 @@ describe('invalid query strings', () => { const res = await get(url) expect(res.statusCode).toBe(302) expect(res.headers.location).toBe('/en') - // But note that it only applies to the home page! + // The 8-character rule applies to root-level paths. { const nestedUrl = `/en/get-started?${randomCharacters(8)}` const nestedRes = await get(nestedUrl) @@ -72,7 +70,7 @@ describe('invalid query strings', () => { expect(res.statusCode).toBe(400) expect(res.headers['content-type']).toMatch('text/plain') expect(res.body).toMatch('Invalid query string') - // Must not reflect the user-supplied key name + // Do not reflect the user-supplied key name. expect(res.body).not.toContain('(query)') }) @@ -82,7 +80,7 @@ describe('invalid query strings', () => { expect(res.statusCode).toBe(400) expect(res.headers['content-type']).toMatch('text/plain') expect(res.body).toMatch('Invalid query string') - // Must not reflect the user-supplied key name + // Do not reflect the user-supplied key name. expect(res.body).not.toContain('(constructor)') }) @@ -102,11 +100,9 @@ describe('invalid query strings', () => { expect(res.body).not.toContain('alert') }) + // Bug bounty proof of concept: enough unrecognized query keys force a redirect. + // safeRedirect normalizes // to / so Location cannot become protocol-relative. test('redirect from unrecognized query strings does not produce open redirect', async () => { - // This is the exact PoC from the bug bounty report. - // With enough unrecognized query keys, the middleware redirects - // using req.path. res.safeRedirect normalizes // to / so the - // Location header can never be a protocol-relative URL. const res = await get('//evil.com?a=1&b=2&c=3') expect(res.headers.location).not.toMatch(/^\/\//) }) diff --git a/src/shielding/tests/malformed-urls.ts b/src/shielding/tests/malformed-urls.ts index 2777e90d6c9f..6219095fdc0a 100644 --- a/src/shielding/tests/malformed-urls.ts +++ b/src/shielding/tests/malformed-urls.ts @@ -34,7 +34,8 @@ describe('malformed URLs', () => { test('allows URLs with control characters (valid UTF-8)', async () => { // %01 decodes fine, so the middleware lets it through. const res = await get('/en/test-%01-page') - expect(res.statusCode).toBe(404) // 404 because the page does not exist, not 400 + // The page does not exist, so a valid URL returns 404 instead of 400. + expect(res.statusCode).toBe(404) }) test('allows valid URLs with proper encoding', async () => { @@ -48,7 +49,7 @@ describe('malformed URLs', () => { }) test('blocks malformed query parameters', async () => { - // This is caught by checking originalUrl which contains the raw, unparsed URL + // originalUrl keeps the raw, unparsed query string. const res = await get('/en/search?q=test%FF') expect(res.statusCode).toBe(400) expect(res.headers['content-type']).toMatch('text/plain') diff --git a/src/shielding/tests/shielding.ts b/src/shielding/tests/shielding.ts index dd81e2ebce0d..43d297158c67 100644 --- a/src/shielding/tests/shielding.ts +++ b/src/shielding/tests/shielding.ts @@ -30,8 +30,7 @@ describe('junk paths', () => { test('double-slash with query params does not open redirect', async () => { const res = await get('//evil.com?a=1&b=2&c=3') - // With 3 unrecognized query keys, the query string middleware redirects - // using res.safeRedirect which normalizes // to / + // With 3 unrecognized query keys, safeRedirect normalizes // to /. expect(res.headers.location).not.toMatch(/^\/\//) }) @@ -96,7 +95,7 @@ describe('index.md and .md suffixes', () => { // .md is stripped and request flows through with Accept: text/markdown { const res = await get('/en/get-started.md') - // Should not redirect — serves markdown directly (or 404 if page doesn't exist) + // Serves markdown directly or returns 404 when the page does not exist. expect(res.statusCode).not.toBe(301) expect(res.statusCode).not.toBe(302) } diff --git a/src/tests/helpers/caching-headers.ts b/src/tests/helpers/caching-headers.ts index a8fc1bcf7fd1..4b9f201d18ff 100644 --- a/src/tests/helpers/caching-headers.ts +++ b/src/tests/helpers/caching-headers.ts @@ -22,15 +22,13 @@ export function checkCachingHeaders( } const maxAgeSeconds = parseInt(maxAgeMatch[1], 10) - // Let's not be too specific in the tests, just as long as it's testing - // that it's a reasonably large number of seconds. + // Use a lower bound so tests tolerate normal cache-window changes. expect(maxAgeSeconds).toBeGreaterThanOrEqual(minMaxAge) const surrogateKeyHeader = res.headers['surrogate-key'] as string const firstToken = surrogateKeyHeader.split(/\s/g)[0] if (defaultSurrogateKey) { - // Default cacheable responses are keyed by language for the staggered, - // per-language deploy purge: either `no-language` or `language:<code>`. + // Per-language purges need no-language or language:<code> as the first surrogate key. expect(firstToken === 'no-language' || /^language:[a-z-]+$/.test(firstToken)).toBe(true) } else { expect(firstToken).toBe(SURROGATE_ENUMS.MANUAL) diff --git a/src/tests/helpers/check-url.ts b/src/tests/helpers/check-url.ts index 6f05e4f960d3..a3a19c86a21c 100644 --- a/src/tests/helpers/check-url.ts +++ b/src/tests/helpers/check-url.ts @@ -6,15 +6,9 @@ import type { Context } from '@/types' const liquidStartRex = /^{%-?\s*ifversion .+?\s*%}/ const liquidEndRex = /{%-?\s*endif\s*-?%}$/ -// Return -// -// /foo/bar -// -// if the text input was -// -// {% ifversion ghes%}/foo/bar{%endif %} -// -// And if no liquid, just return as is. +// Frontmatter link lists sometimes wrap paths in ifversion Liquid; checks need the raw path. +// Example input: {% ifversion ghes%}/foo/bar{%endif %} +// Output: /foo/bar function stripLiquid(text: string): string { if (liquidStartRex.test(text) && liquidEndRex.test(text)) { return text.replace(liquidStartRex, '').replace(liquidEndRex, '').trim() @@ -24,27 +18,20 @@ function stripLiquid(text: string): string { return text } -// Given a URI that does not start with a specific language, -// return undefined if it can found as a known page. -// Otherwise, return an object with information that is used to -// print a useful test error message in the assertion. +// Return details for assertion errors when a language-free URI cannot resolve to a known page. export function checkURL(uri: string, index: number, redirectsContext: Context) { const url = `/en${stripLiquid(uri).split('#')[0]}` if (!redirectsContext.pages || !(url in redirectsContext.pages)) { - // Some are written without a version, but don't work with the - // default version. + // Some unversioned links resolve only after redirects add a version. let redirects = getRedirect(url, redirectsContext) - // If it does indeed redirect to a different version, - // strip that and compare again. if (redirects) { const withoutVersion = getPathWithoutVersion(redirects) if (withoutVersion === url) { - // That means, it's actually fine return null } redirects = getPathWithoutLanguage(withoutVersion) } return { uri, index, redirects } } - return null // Falsy value will be filtered out later + return null } diff --git a/src/tests/helpers/data-directory.ts b/src/tests/helpers/data-directory.ts index a61d332a6271..a39240d69afa 100644 --- a/src/tests/helpers/data-directory.ts +++ b/src/tests/helpers/data-directory.ts @@ -8,42 +8,12 @@ interface DataStructure { [key: string]: string | DataStructure } -// This helper class exists so you can create a temporary root directory -// full of data files. -// For example, if you want to unit test with files that are not part -// of the git repo but should only "temporarily" exist for the duration -// of the tests. -// This class takes an object and generates that as files on disk. E.g. -// -// const dataDirectory = new DataDirectory({ -// data: { -// reusables: { -// example: 'a rose by any other name\nwould smell as sweet', -// }, -// }, -// }) -// process.env.ROOT = dataDirectory.root -// ... -// try { -// ...unit tests here... -// } finally { -// dataDirectory.destroy() -// } -// -// Note that it's very specific about keys. For example, if the nested -// object has a key called 'ui' it doesn't create a deeper nested structure -// but takes the nested structure and writes it to a single .yml file. -// For example: -// -// const dataDirectory = new DataDirectory({ -// data: { -// ui: { -// key: "Value", -// deep: { -// er: "Stuff" -// -// will create a single <tempdir>/data/ui.yml file. -// +// DataDirectory builds a temporary data root for tests that need files outside the repository. +// It writes nested data objects to disk, then destroy removes the temp root. +// Example: new DataDirectory({ data: { reusables: { example: 'temporary text' } } }). +// Point ROOT at dataDirectory.root during the test, then call dataDirectory.destroy(). +// Special keys match production data conventions: ui writes data/ui.yml, variables write .yml +// files, and reusables write .md files. export class DataDirectory { root: string @@ -71,7 +41,7 @@ export class DataDirectory { fs.writeFileSync(path.join(here, `${key}.md`), value, 'utf-8') } else { fs.mkdirSync(path.join(here, key)) - // Using 'as' assertion because we know value must be an object when it's not a string in reusables context + // The reusables branch already ruled out strings. this.create(value as DataStructure, path.join(here, key), false, true) } } else if (isVariables) { @@ -82,7 +52,7 @@ export class DataDirectory { } else { const there = path.join(here, key) fs.mkdirSync(there) - // Using 'as' assertions because nested directory values are always objects, not strings + // Nested directory branches only handle objects. if (key === 'reusables') { this.create(value as DataStructure, there, false, true) } else if (key === 'variables') { diff --git a/src/tests/helpers/e2etest.ts b/src/tests/helpers/e2etest.ts index 7bd87f4b8bd5..8b4a8f7cd825 100644 --- a/src/tests/helpers/e2etest.ts +++ b/src/tests/helpers/e2etest.ts @@ -82,7 +82,7 @@ export async function get<T extends ResponseTypes = 'text'>( headersRecord[key] = value } - // Return response in got-compatible format + // Tests still expect got-compatible response fields. return { body: responseBody, statusCode: response.status, @@ -116,7 +116,7 @@ export async function getDOMCached( const $ = await getDOM(route, options) getDOMCache.set(key, $) } - // The non-null assertion is safe here because we've just set the key if it didn't exist + // The cache sets the key before this lookup. return getDOMCache.get(key)! } @@ -134,9 +134,9 @@ export async function getDOM(route: string, options: GetDOMOptions = {}): Promis const $ = load(res.body || '', { xmlMode: true }) const result = $ as CachedDOMResult - // Attach res to the cheerio object for backward compatibility + // Older tests read the response from the Cheerio object. result.res = res - // Attach $ to itself for destructuring compatibility + // Older tests destructure $ from the Cheerio object. result.$ = result return result diff --git a/src/tests/helpers/schemas.ts b/src/tests/helpers/schemas.ts index cb35e6c84319..e1896f82b772 100644 --- a/src/tests/helpers/schemas.ts +++ b/src/tests/helpers/schemas.ts @@ -1,35 +1,22 @@ import type { ErrorObject } from 'ajv' -// lightly format the schema errors object returned from ajv to connect the -// error message to where the problem is -- for example, if a top level 'date' -// property isn't correctly formatted as a date we return: -// -// at 'date': must match format "date" -// -// if sections > features has an array of objects that must have a 'notes' -// property and we misspell the property name in the first item: -// -// at 'sections > features > item 0': must have required property 'notes' +// Format AJV errors with the path first, so data-file failures point at the bad value. +// Example: at 'sections > features > item 0': must have required property 'notes' export const formatAjvErrors = (errors: ErrorObject[] = []): string => { return errors .map((errorObj) => { - // ajv instancePath tells us in the data we're checking where there was a - // schema error -- for release notes looks like this for example - // `/sections/features/0` if the error is in the first feature under sections. + // instancePath points to the failing data path, such as /sections/features/0. const split = errorObj.instancePath.split('/') split.shift() - // handle additional properties error specifically since we can call out - // which property shouldn't be there + // Call out the unexpected property name for additionalProperties errors. let additionalProperties = '' if (errorObj.keyword === 'additionalProperties') { additionalProperties = `: additional property is '${errorObj.params.additionalProperty}'` } - // ajv's enum message is "must be equal to one of the allowed values" but - // does not name them, which is not actionable when the schema lives in a - // different file than the data being validated + // AJV omits enum values from its message, but schema-file lookups need actionable values. let allowedValues = '' if (errorObj.keyword === 'enum' && Array.isArray(errorObj.params.allowedValues)) { diff --git a/src/tests/helpers/schemas/products-schema.ts b/src/tests/helpers/schemas/products-schema.ts index 3da38b902727..4b0fb77b1fee 100644 --- a/src/tests/helpers/schemas/products-schema.ts +++ b/src/tests/helpers/schemas/products-schema.ts @@ -15,19 +15,19 @@ export default { href: { description: 'the href to the product landing page', type: 'string', - pattern: '^(/|http)', // if internal, must start with a slash; if external, must start with http + pattern: '^(/|http)', // Internal hrefs start with /; external hrefs start with http. }, dir: { description: 'the local relative path to the product directory', type: 'string', - pattern: '^content/.*?[^/]$', // must start with content, can't end with a slash + pattern: '^content/.*?[^/]$', // Product directories start with content and omit a trailing slash. }, toc: { description: 'the local relative path to the product toc page', type: 'string', - pattern: '^content/.*?index.md$', // must start with content and end with index.md + pattern: '^content/.*?index.md$', // TOC files start with content and end with index.md. }, hasEnterpriseUserVersions: { diff --git a/src/tests/helpers/schemas/versions-schema.ts b/src/tests/helpers/schemas/versions-schema.ts index 44b84bf50ce6..e76431d37d86 100644 --- a/src/tests/helpers/schemas/versions-schema.ts +++ b/src/tests/helpers/schemas/versions-schema.ts @@ -1,5 +1,4 @@ -// match plan@release -// e.g., free-pro-team@latest, enterprise-server@3.0 +// Version strings match plan@release, for example free-pro-team@latest or enterprise-server@3.XX. const planPattern = '^[a-z-]+' const releasePattern = '[a-z0-9-.]+' const delimiter = '@' @@ -63,7 +62,7 @@ const schema: VersionSchema = { pattern: planPattern, }, planTitle: { - description: 'the plan title', // this is the same as the version title, sans numbered release + description: 'the plan title', // planTitle matches versionTitle without the numbered release. type: 'string', }, shortName: { @@ -90,7 +89,8 @@ const schema: VersionSchema = { type: 'boolean', }, nonEnterpriseDefault: { - description: 'boolean indicating whether the plan is the default non-Enterprise version', // helper if the plan name changes + // Identifies the default non-Enterprise version without hard-coding a plan name. + description: 'boolean indicating whether the plan is the default non-Enterprise version', type: 'boolean', }, openApiBaseName: { diff --git a/src/tests/lib/validate-json-schema.ts b/src/tests/lib/validate-json-schema.ts index b20418628e60..76c8efa913ef 100644 --- a/src/tests/lib/validate-json-schema.ts +++ b/src/tests/lib/validate-json-schema.ts @@ -10,12 +10,8 @@ addErrors(ajv) ajv.addKeyword({ keyword: 'translatable', }) -// Schemas can contain the custom keyword `lintable` to define -// a property as a Markdown string that can be linted by the -// content linter. -// The custom keyword does not define a custom validator function. -// This allows the custom keyword to be present in a schema but -// doesn't perform additional validation other than type checking. +// The lintable keyword marks Markdown strings that the content linter can check. +// AJV still validates only the string type. ajv.addKeyword({ keyword: 'lintable', type: 'string', @@ -25,21 +21,13 @@ ajv.addFormat('semver', { validate: (x: string): boolean => semver.validRange(x) !== null, }) -// The ajv.validate function is supposed to cache -// the compiled schema, but the documentation says -// that the best performance is achieved by calling -// the compile function and then calling validate. -// So when the same schema is validated multiple times, -// this is the best function to use. If the schema -// changes from one call to the next, then the validateJson -// function makes more sense to use. +// Reuse compiled validators when one schema validates multiple payloads. +// Use validateJson when each call may receive a different schema. export function getJsonValidator(schema: SchemaObject): ValidateFunction { return ajv.compile(schema) } -// The next call to ajv.validate will overwrite -// the ajv.errors property, so returning it here -// ensures that it remains accessible. +// Clone AJV errors before the next validate call overwrites ajv.errors. export function validateJson( schema: SchemaObject, data: unknown, diff --git a/src/tests/mocks/cse-copilot-mock.ts b/src/tests/mocks/cse-copilot-mock.ts index 6e1d0e8b6094..dbfd0d51fb1a 100644 --- a/src/tests/mocks/cse-copilot-mock.ts +++ b/src/tests/mocks/cse-copilot-mock.ts @@ -1,6 +1,6 @@ import { Request, Response } from 'express' -// Prefix used for mocking. This can be any value +// Tests use this prefix only to mount the mock route; any stable value works. export const CSE_COPILOT_PREFIX = 'cse-copilot' export function cseCopilotPostAnswersMock(req: Request, res: Response) { diff --git a/src/tests/mocks/start-mock-server.ts b/src/tests/mocks/start-mock-server.ts index 9236b73a60e6..b3a82d05c9e0 100644 --- a/src/tests/mocks/start-mock-server.ts +++ b/src/tests/mocks/start-mock-server.ts @@ -1,24 +1,7 @@ -/* When testing API routes via an integration test, e.g. - -const res = await post('/api/<some-route>', { - body: JSON.stringify(api_body), - headers: { 'Content-Type': 'application/json' }, -}) - -expect(res.status).toBe(200) - -The `api/<route>` may call an external URL. - -We are unable to use `nock` in this circumstance since we run the server in a separate instance. - -Instead, we can use the `startMockServer` helper to start a mock server that will intercept the request and return a canned response. - -In order for this to work you MUST use a process.env variable for the URL you are calling, - -e.g. `process.env.CSE_COPILOT_ENDPOINT` - -You should override the variable in the overrideEnvForTesting function in this file. -*/ +// Integration tests cannot use nock when API routes call external URLs from a separate server. +// Example: post to /api/<some-route>, and let that route call the mock server. +// Point the route at this mock server through an env var such as CSE_COPILOT_ENDPOINT. +// Set that env var in overrideEnvForTesting. import express from 'express' import type { Server } from 'http' diff --git a/src/tests/scripts/copy-fixture-data.ts b/src/tests/scripts/copy-fixture-data.ts index cc2ad6437747..dc8fc04092b3 100755 --- a/src/tests/scripts/copy-fixture-data.ts +++ b/src/tests/scripts/copy-fixture-data.ts @@ -1,14 +1,5 @@ -// [start-readme] -// -// There are certain files that have to be manually copied from the -// real data into the test fixture data. -// -// This script copies the files from `data/` into `tests/fitures/data/...` -// that are files that are both needed for fixture testing yet can't -// live with the code. For example, `data/ui.yml` is part of the rendering -// code, but it lives in `data/` so it can be translated. -// -// [end-readme] +// Some fixture tests need translated data files that live under data, outside test code. +// This script copies those required data files into src/fixtures/fixtures/data. import fs from 'fs' import path from 'path' @@ -16,8 +7,7 @@ import path from 'path' import { program } from 'commander' import chalk from 'chalk' -// Here, write down all the files that are actually part of the rendering -// functionality yet live in data. +// Keep files that rendering needs but stores under data. const MANDATORY_FILES = [ 'data/ui.yml', 'data/reusables/enterprise_deprecation/deprecation_details.md', diff --git a/src/tests/scripts/copy-to-test-repo.sh b/src/tests/scripts/copy-to-test-repo.sh index f82f54f0349f..03f8ce9df386 100755 --- a/src/tests/scripts/copy-to-test-repo.sh +++ b/src/tests/scripts/copy-to-test-repo.sh @@ -1,7 +1,7 @@ #!/bin/bash -# Copies certain directories over to docs-internal-test and pushes. Useful for debugging actions -# Doesn't copy over content/ and data/ directories +# Copies selected files to docs-internal-test and pushes them for GitHub Actions debugging. +# Excludes content, data, node_modules, Git metadata, .gitattributes, and .github/CODEOWNERS. echo "Make sure to run this script in the root path of docs-internal!" @@ -39,5 +39,3 @@ fi; exit - - diff --git a/src/tools/components/Fields.tsx b/src/tools/components/Fields.tsx index 635446920d10..7e0fcf9d9593 100644 --- a/src/tools/components/Fields.tsx +++ b/src/tools/components/Fields.tsx @@ -30,11 +30,7 @@ export const Fields = (fieldProps: { if (onSelect) onSelect(item) setOpen(!open) }} - // These extra links in the pickers are not a part of the selection variant - // in that they are generally external links, so we want to remove the selection - // variant span box in front of it. To date there isn't a possibility to have - // an ActionMenu in Primer that allow non-selection variant items with selection - // variant items + // Primer ActionMenu cannot mix selection items with external links, so extras hide the indicator. className={cx( (item.extra?.arrow || item.extra?.info) && styles.extrasDisplay, styles.linkItem, diff --git a/src/tools/components/InArticlePicker.module.scss b/src/tools/components/InArticlePicker.module.scss index 47edd48cfd44..072817233a7b 100644 --- a/src/tools/components/InArticlePicker.module.scss +++ b/src/tools/components/InArticlePicker.module.scss @@ -1,11 +1,9 @@ .container { - // Override Primer's selected tab indicator color to meet WCAG 1.4.11 - // non-text contrast minimum of 3:1. The default --color-primer-border-active - // (#FD8C73) has only 2.3:1 contrast. Using a Primer theme variable - // that meets contrast in both light and dark color modes. + // Primer's --color-primer-border-active has 2.3:1 contrast and fails WCAG 1.4.11. + // --color-severe-emphasis meets the 3:1 minimum in light and dark modes. --underlineNav-borderColor-active: var(--color-severe-emphasis); - // target the ActionList dropdown that appears when UnderlineNav overflows + // Primer does not expose a stable class for the UnderlineNav overflow ActionList. ul[class*="prc-ActionList-ActionList"] { background-color: var( --overlay-bgColor, diff --git a/src/tools/components/InArticlePicker.tsx b/src/tools/components/InArticlePicker.tsx index f8c9dbe52973..0b726836f1c9 100644 --- a/src/tools/components/InArticlePicker.tsx +++ b/src/tools/components/InArticlePicker.tsx @@ -14,9 +14,9 @@ type Option = { label: string } type Props = { - // Use this if not specified on the query string + // Used when the query string does not specify a valid value. defaultValue?: string - // Use this if not specified on the query string or no cookie + // Used when the query string is invalid, defaultValue is unset, and the cookie is invalid. fallbackValue: string cookieKey: string queryStringKey: string @@ -39,12 +39,9 @@ export const InArticlePicker = ({ const { query, locale } = router const [currentValue, setCurrentValue] = useState('') - // Tracks whether the last currentValue change was triggered by a user click - // (as opposed to initial mount or external navigation). When true, we move - // focus to the newly-selected tab so keyboard users don't lose their place. + // True after user clicks, so focus moves only for direct tab selection. const focusAfterNavRef = useRef(false) - // Run on mount for client-side only features useEffect(() => { const raw = query[queryStringKey] let value = '' @@ -52,8 +49,7 @@ export const InArticlePicker = ({ if (Array.isArray(raw)) value = raw[0] else value = raw } - // Only pick it up from the possible query string if its value - // is a valid option. + // Ignore query string values outside this picker's options. const possibleValues = options.map((option) => option.value) if (!value || !possibleValues.includes(value)) { const cookieValue = Cookies.get(cookieKey) @@ -70,49 +66,24 @@ export const InArticlePicker = ({ const [asPathRoot, asPathQuery = ''] = router.asPath.split('#')[0].split('?') - // Use a layout effect so the DOM mutation (hiding non-matching .ghd-tool - // content) happens before the browser paints. With React 19's stricter - // effect timing, a regular useEffect could leave non-matching content - // visible on initial page load until after first paint. + // Apply the selection before paint so non-matching .ghd-tool content does not flash. useIsomorphicLayoutEffect(() => { - // This will make the hook run this callback on mount and on change. - // That's important because even though the user hasn't interacted - // and made an overriding choice, we still want to run this callback - // because the page might need to be corrected based on *a* choice - // independent of whether it's a change. + // Initial values still need to update the page before the user interacts. if (currentValue) { onValue(currentValue) } }, [ currentValue, - // This is important because we can't otherwise rely on the firing - // of this effect on initial mount. It also needs to fire when the - // URL (i.e. route) changes. - // Don't use `router.asPath` because that contains the query string - // which we handle in the other useEffect above. + // Query string changes are handled separately, so depend on the route path only. asPathRoot, ]) - // This is exclusively for local development. - // If you're in local development, you have the <ClientSideRefresh> - // causing a XHR refresh of the content triggered by the Page Visibility - // API (implemented in the uswSWR hook). That means that on the pages that - // contain these `.ghd-tool` classes, any DOM changes we might - // have previously made are lost and started over. + // Local ClientSideRefresh replaces article HTML on visibility changes, so reapply selection. useEffect(() => { let mounted = true const toggleVisibility = () => { if (document.visibilityState === 'visible') { - // We don't need to track this timer, and possibly cancel it on - // dismount, because within the callback we use the `mounted` - // boolean which means we can know to do nothing if the parent - // component has been dismounted. - // The reason this is wrapped in a short timeout is because the - // React rendering might not actually have fully updated the DOM - // (from the XHR HTML it receives) so allow the DOM to refresh - // first before asking it to change. The number can be quite low - // (which is sufficient for human eyes) but must be at least - // in the lower hundreds of milliseconds. + // Keep at least a 100 ms delay so refreshed HTML reaches the DOM before selection changes it. setTimeout(() => { if (mounted) { onValue(currentValue) @@ -148,10 +119,7 @@ export const InArticlePicker = ({ Cookies.set(cookieKey, value) } - // After a user clicks a tab, the shallow route change updates `currentValue`. - // Once the DOM reflects the new selection (aria-current="page" is on the new - // tab), move keyboard focus there so the user's context is preserved. - // WCAG 2.4.3 Focus Order: focus must land on the triggered control. + // WCAG 2.4.3 requires focus to remain on the triggered tab after shallow routing. useEffect(() => { if (!focusAfterNavRef.current || !currentValue) return focusAfterNavRef.current = false @@ -171,7 +139,7 @@ export const InArticlePicker = ({ return ( <div data-testid={`${queryStringKey}-picker`} className={styles.container}> - {/* The key attribute is required for a bug in UnderlineNav that doesn't render the component when there are changes to the items. */} + {/* UnderlineNav can miss item changes without a changing key. */} <UnderlineNav key={router.asPath} {...sharedContainerProps}> {options.map((option) => { params.set(queryStringKey, option.value) diff --git a/src/tools/components/PlatformPicker.tsx b/src/tools/components/PlatformPicker.tsx index 7d78b2e48e6c..e113fdd96003 100644 --- a/src/tools/components/PlatformPicker.tsx +++ b/src/tools/components/PlatformPicker.tsx @@ -13,7 +13,7 @@ const platforms = [ { value: 'linux', label: 'Linux' }, ] -// Note: platform === os +// Content calls this preference platform, but the stored preference name is os. export const PlatformPicker = () => { const { defaultPlatform, detectedPlatforms } = useArticleContext() @@ -28,9 +28,7 @@ export const PlatformPicker = () => { setDefaultUA(userAgent) }, []) - // Defensively, just in case some article happens to have an array - // but for some reasons, it might be empty, let's not have a picker - // at all. + // Articles with no detected platforms do not need a picker. if (!detectedPlatforms.length) return null const options = platforms.filter((platform) => detectedPlatforms.includes(platform.value)) @@ -46,9 +44,7 @@ export const PlatformPicker = () => { cookieKey={OS_PREFERRED_COOKIE_NAME} queryStringKey={platformQueryKey} onValue={(value: string) => { - // Visibility is driven by React state via ToggleableContent/MiniTocs - // (#6619); the article body is React-owned on both the hast and string - // paths, so no imperative DOM mutation is needed. + // React state drives visibility because the article body is React-owned. setPlatform(value) }} preferenceName="os" diff --git a/src/tools/components/SelectionContext.tsx b/src/tools/components/SelectionContext.tsx index 0c09457855b6..5032ab8d1477 100644 --- a/src/tools/components/SelectionContext.tsx +++ b/src/tools/components/SelectionContext.tsx @@ -4,17 +4,10 @@ import type { ReactNode } from 'react' import { allPlatforms } from '@/tools/lib/all-platforms' import { allTools } from '@/tools/lib/all-tools' -// React-native replacement for the imperative platform/tool visibility toggling -// that PlatformPicker/ToolPicker used to do by walking the DOM and setting -// `style.display` on `.ghd-tool`/`.platform-*`/`.tool-*` elements (#6619). The -// selected platform + tool live in this context; the article body (rendered from -// hast) maps the relevant elements to <ToggleableContent>, which reads the -// selection and hides non-matching content instead of mutating React-owned nodes. -// -// Selections start empty so that the server render and the first client render -// both show ALL variants (matching the pre-JS markup), which keeps hydration -// stable. The pickers set the real selection in an effect after hydration, the -// same moment the old imperative code used to run. +// PlatformPicker and ToolPicker store selection here so ToggleableContent can +// hide React-owned article elements without imperative style.display mutations. +// Empty initial selections keep server and first client renders showing all variants, +// which matches pre-JS markup and keeps hydration stable. export type SelectionContextT = { platform: string @@ -62,14 +55,10 @@ function toClassList(className: unknown): string[] { return [] } -// Determine whether an element is platform/tool-scoped and which value gates it. -// `.ghd-tool <value>` is a block (the {% mac %}/{% webui %} liquid tags); the -// extra class is the platform or tool value. `platform-<value>`/`tool-<value>` -// are author-written inline spans. We classify strictly against the canonical -// platform/tool vocabularies and return null on anything outside them, so an -// unrecognized class never makes content disappear. When several recognized -// markers are present the first match wins; in practice an element carries -// exactly one platform/tool marker. +// .ghd-tool uses a separate class for the platform or tool value. +// platform-<value> and tool-<value> are author-written inline spans. +// Strict vocabulary checks prevent unknown classes from hiding content. +// .ghd-tool prefers a recognized platform; inline spans use the first recognized marker. export function classifyToggleClass(className: unknown): ToggleClassification | null { const classes = toClassList(className) if (!classes.length) return null @@ -100,8 +89,7 @@ export function isToggleClass(className: unknown): boolean { return classifyToggleClass(className) !== null } -// Visible when no selection has been made yet (initial render shows everything, -// matching the pre-JS markup) or when the element's value is the selected one. +// Empty initial selections show everything, matching the pre-JS markup. export function isContentVisible( classification: ToggleClassification, selection: { platform: string; tool: string }, diff --git a/src/tools/components/ToggleableContent.tsx b/src/tools/components/ToggleableContent.tsx index c1e114897032..0659ace2a6a6 100644 --- a/src/tools/components/ToggleableContent.tsx +++ b/src/tools/components/ToggleableContent.tsx @@ -7,12 +7,8 @@ import { useSelection, } from '@/tools/components/SelectionContext' -// Wraps a platform/tool-scoped element from the article body hast and toggles -// its visibility from SelectionContext instead of the old imperative -// `style.display` mutation (#6619). Renders the same element/props/children, but -// sets `hidden` when the current platform/tool selection doesn't match. We keep -// the node in the DOM (hidden) rather than returning null so anchors, IDs, and -// screen-reader traversal behave like the previous `display:none` approach. +// ToggleableContent keeps hidden article nodes in the DOM so anchors, IDs, and +// screen-reader traversal match the previous display:none behavior. type ToggleableContentProps = { tag: 'div' | 'span' className?: string diff --git a/src/tools/components/ToolPicker.tsx b/src/tools/components/ToolPicker.tsx index 94748f6b1523..e0565ff98f0b 100644 --- a/src/tools/components/ToolPicker.tsx +++ b/src/tools/components/ToolPicker.tsx @@ -3,18 +3,16 @@ import { InArticlePicker } from './InArticlePicker' import { useSelection } from './SelectionContext' import { TOOL_PREFERRED_COOKIE_NAME } from '@/frame/lib/constants' -// Example page with a tool picker: -// http://localhost:4000/en/codespaces/developing-in-codespaces/creating-a-codespace - -// Note: tool === application, and picker === switcher +// Example tool picker page: /en/codespaces/developing-in-codespaces/creating-a-codespace +// Content calls this preference tool, but the stored preference name is application. function getDefaultTool(defaultTool: string | undefined, detectedTools: Array<string>): string { if (defaultTool && detectedTools.includes(defaultTool)) return defaultTool - // Default to webui if present (this is generally the case where we show UI/CLI/Desktop info) + // UI, CLI, and Desktop articles default to webui. if (detectedTools.includes('webui')) return 'webui' - // Default to cli if present (this is generally the case where we show curl/CLI info) + // Curl and CLI articles default to cli. if (detectedTools.includes('cli')) return 'cli' return detectedTools[0] @@ -37,9 +35,7 @@ export const ToolPicker = () => { cookieKey={TOOL_PREFERRED_COOKIE_NAME} queryStringKey={toolQueryKey} onValue={(value: string) => { - // Visibility is driven by React state via ToggleableContent/MiniTocs - // (#6619); the article body is React-owned on both the hast and string - // paths, so no imperative DOM mutation is needed. + // React state drives visibility because the article body is React-owned. setTool(value) }} preferenceName="application" diff --git a/src/tools/lib/all-platforms.ts b/src/tools/lib/all-platforms.ts index 13845752f6a4..62636fde0b0f 100644 --- a/src/tools/lib/all-platforms.ts +++ b/src/tools/lib/all-platforms.ts @@ -1,4 +1,3 @@ -// All platforms available for the platform picker. export type Platform = 'mac' | 'windows' | 'linux' export const allPlatforms: Platform[] = ['mac', 'windows', 'linux'] diff --git a/src/tools/lib/all-tools.ts b/src/tools/lib/all-tools.ts index db932c91334c..e6dc50beebea 100644 --- a/src/tools/lib/all-tools.ts +++ b/src/tools/lib/all-tools.ts @@ -1,26 +1,16 @@ -// Maps each tool identifier to its display name. export interface ToolsMapping { [key: string]: string } -/* - All the tools available for the Tool Picker - - Ordered by usage analytics to prioritize most-used tools in the tool switcher. - This ensures popular tools appear before the "More" menu in the UnderlineNav component. - - Analytics Query (KQL): - ``` - docs_v0_preference_event - | where timestamp between (ago(180d) .. now()) - | where context.hostname == 'docs.github.com' - | where abs(totimespan(context.created - timestamp)) < 1h // bot filter - | summarize Count=count() by Name=preference_name, Value=preference_value - | order by Count desc - ``` - - Data as of 2025-11-04 (180-day window) -*/ +// Tools are ordered by usage analytics so common options appear before the More menu. +// Retune with this Kusto Query Language (KQL): +// docs_v0_preference_event +// | where timestamp between (ago(180d) .. now()) +// | where context.hostname == 'docs.github.com' +// | where abs(totimespan(context.created - timestamp)) < 1h // bot filter +// | summarize Count=count() by Name=preference_name, Value=preference_value +// | order by Count desc +// The trailing comments show counts from a 180-day window ending 2025-11-04. export const allTools: ToolsMapping = { vscode: 'Visual Studio Code', // 310,824 jetbrains: 'JetBrains IDEs', // 306,982 diff --git a/src/tools/scripts/liquid-markdown-tables/convert.ts b/src/tools/scripts/liquid-markdown-tables/convert.ts index eaf95e25050e..5bc6a2373a32 100644 --- a/src/tools/scripts/liquid-markdown-tables/convert.ts +++ b/src/tools/scripts/liquid-markdown-tables/convert.ts @@ -1,4 +1,4 @@ -// See the comment at the top of index.ts for how to use this script. +// Run with npm run liquid-markdown-tables -- convert content/path/to/article.md. import fs from 'fs' import chalk from 'chalk' diff --git a/src/tools/scripts/liquid-markdown-tables/index.ts b/src/tools/scripts/liquid-markdown-tables/index.ts index 04b093a4125b..7c4883a45953 100644 --- a/src/tools/scripts/liquid-markdown-tables/index.ts +++ b/src/tools/scripts/liquid-markdown-tables/index.ts @@ -1,47 +1,13 @@ -/** - * This script helps you rewrite Markdown files that might contain - * tables with Liquid `ifversion` tags the old/wrong way. - * For example: - * - * | Header | Header 2 | - * |--------|----------| - * | bla | bla |{% ifversion dependency-review-action-licenses %} - * | foo | foo |{% endif %}{% ifversion dependency-review-action-fail-on-scopes %} - * | bar | bar |{% endif %} - * | baz | baz | - * {%- ifversion dependency-review-action-licenses %} - * | qux | qux |{% endif %} - * - * Will become: - * - * | Header | Header 2 | - * |--------|----------| - * | bla | bla | - * | {% ifversion dependency-review-action-licenses %} | - * | foo | foo | - * | {% endif %} | - * | {% ifversion dependency-review-action-fail-on-scopes %} | - * | bar | bar | - * | {% endif %} | - * | baz | baz | - * | {% ifversion dependency-review-action-licenses %} | - * | qux | qux | - * | {% endif %} | - * - * Run the script like this: - * - * npm run liquid-markdown-tables -- convert content/path/to/article.md - * git diff - * - * To *find* files that you *can* convert, use: - * - * npm run liquid-markdown-tables -- find - * # or - * npm run liquid-markdown-tables -- find --filter content/mydocset - * - * This will print out paths to files that most likely contain the old/wrong Liquid `ifversion` tags. - * - */ +// Finds and converts Markdown tables that place Liquid ifversion tags inside table rows. +// Example input: | foo | bar |{% ifversion dependency-review-action-licenses %} +// Example output: +// | foo | bar | +// | {% ifversion dependency-review-action-licenses %} | +// Run convert: npm run liquid-markdown-tables -- convert content/path/to/article.md +// Then run git diff to inspect changes. +// Run find: npm run liquid-markdown-tables -- find +// Run filtered find: npm run liquid-markdown-tables -- find --filter content/mydocset +// Find prints paths that likely contain misplaced Liquid ifversion tags. import { program } from 'commander' diff --git a/src/tools/scripts/liquid-markdown-tables/lib.ts b/src/tools/scripts/liquid-markdown-tables/lib.ts index ec15b03812d0..d98ace258ef6 100644 --- a/src/tools/scripts/liquid-markdown-tables/lib.ts +++ b/src/tools/scripts/liquid-markdown-tables/lib.ts @@ -1,7 +1,7 @@ -// E.g. `{%- ifversion dependency-review-action-licenses %}\n` +// Matches a standalone ifversion line, such as {%- ifversion dependency-review-action-licenses %}. const ifVersionRegex = /^{%-?\s*ifversion\s+([\w- ]+)\s*-?%}\n/ const ifVersionEndRegex = /\|({%-?\s*ifversion\s+([\w- ]+)\s*-?%})\n/ -// E.g. `... |{% endif %}{% ifversion dependency-review-action-fail-on-scopes %}\n` +// Matches a row ending in endif plus ifversion, such as |{% endif %}{% ifversion foo %}. const endifIfVersionRegex = /\|({%-?\s*endif\s*%})({%-?\sifversion\s+([\w- ]+)\s*-?%})\n/ const endifRegex = /\|({%-?\s*endif\s*%})\n/ const endifAloneRegex = /^({%-?\s*endif\s*%})\n/ @@ -40,7 +40,7 @@ export async function processFile(content: string) { inTable = false } if (inTable) { - // E.g. `{%- ifversion dependency-review-action-licenses %}\n` + // Standalone ifversion tags become their own table rows. if (ifVersionRegex.test(line)) { const better = line.replace('{%-', '{%').replace('-%}', '%}').trim() line = `| ${better} |\n` diff --git a/src/types/eslint-plugins.d.ts b/src/types/eslint-plugins.d.ts index 9c948f7ae537..9b6879007196 100644 --- a/src/types/eslint-plugins.d.ts +++ b/src/types/eslint-plugins.d.ts @@ -1,4 +1,4 @@ -// Type declarations for ESLint plugins without official TypeScript definitions +// These ESLint plugins do not ship TypeScript definitions. declare module 'eslint-plugin-github' { import type { ESLint, Linter } from 'eslint' diff --git a/src/types/index.ts b/src/types/index.ts index ad447b150f48..742f7ff3ffa2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,2 @@ -// Kept so existing `@/types` imports keep working. +// Keep this barrel so existing @/types imports keep working. export * from './types' diff --git a/src/types/primer__octicons.d.ts b/src/types/primer__octicons.d.ts index d5f2a11788a9..a829595b712e 100644 --- a/src/types/primer__octicons.d.ts +++ b/src/types/primer__octicons.d.ts @@ -28,7 +28,7 @@ declare module '@primer/octicons' { const octicons: { [iconName: string]: Octicon - // Common icons (non-exhaustive list for better autocomplete) + // This non-exhaustive list improves autocomplete for common icons. alert: Octicon check: Octicon 'check-circle': Octicon diff --git a/src/types/types.ts b/src/types/types.ts index f7553da5a49e..0b1ff39d964d 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -16,8 +16,7 @@ export interface ResolvedArticle { category: string[] } -// Middleware attaches things to the Request, like `req.context`. -// This type collects everything we add. +// ExtendedRequest collects properties middleware attaches to Request, including req.context. export type ExtendedRequest = Request & { pagePath?: string context?: Context @@ -27,9 +26,9 @@ export type ExtendedRequest = Request & { FailBot?: Failbot } -// Hand-maintained to match `schema` in frame/lib/frontmatter.ts. -// Generating it from the AJV schema would need extra build tooling, -// and the schema is built dynamically with version-specific properties. +// Hand-maintained to match schema in frame/lib/frontmatter.ts. +// Generating it from the AJV schema would need extra build tooling because +// the schema is built dynamically with version-specific properties. export type PageFrontmatter = { title: string versions: FrontmatterVersions @@ -112,7 +111,7 @@ type Redirects = { } export type Context = { - // Allows dynamic properties like features & version shortnames as keys + // Context allows dynamic keys for features and version short names. [key: string]: unknown currentCategory?: string currentJourneyTrack?: JourneyContext | null @@ -428,8 +427,8 @@ export type AllVersions = { [name: string]: Version } -// Cast `req.query` to this when building URLSearchParams. -// Without it TypeScript reports an error that can't actually happen at runtime. +// Cast req.query to this type when building URLSearchParams. +// TypeScript rejects values that are safe at runtime. export type URLSearchParamsTypes = string | string[][] | Record<string, string> | URLSearchParams export type FeatureData = { @@ -439,7 +438,7 @@ export type Versions = { versions: FrontmatterVersions } -// For parsing .md frontmatter. Not the full set the schema allows. +// Fields parsed from .md frontmatter; the full schema allows more fields. export type MarkdownFrontmatter = { title: string shortTitle?: string diff --git a/src/versions/components/DeprecationBanner.tsx b/src/versions/components/DeprecationBanner.tsx index db25ee8a732e..6062966c591d 100644 --- a/src/versions/components/DeprecationBanner.tsx +++ b/src/versions/components/DeprecationBanner.tsx @@ -16,10 +16,7 @@ export const DeprecationBanner = () => { return null } - // Have to "trick" TypeScript here because by default, this is an - // optional key. But because we're confident with the JS business - // logic in MainContext.tsx, we can safely assume that this key - // is present. + // MainContext supplies enterprise_deprecation before React renders this banner. const enterpriseDeprecation = data.reusables.enterprise_deprecation as EnterpriseDeprecation const message = enterpriseServerReleases.isOldestReleaseDeprecated ? enterpriseDeprecation.version_was_deprecated diff --git a/src/versions/components/VersionPicker.module.scss b/src/versions/components/VersionPicker.module.scss index f2c2e2c6f441..8bda6b9abb02 100644 --- a/src/versions/components/VersionPicker.module.scss +++ b/src/versions/components/VersionPicker.module.scss @@ -1,8 +1,5 @@ -/* - * The header variant's styling is shared with the language picker and lives in - * @/frame/components/page-header/HeaderPicker.module.scss. Only the default - * (non-header) variant is styled here. - */ +// The header variant shares HeaderPicker.module.scss with the language picker. +// This file styles the default variant. .itemsWidth { width: 14rem; diff --git a/src/versions/components/VersionPicker.tsx b/src/versions/components/VersionPicker.tsx index aab00700fc06..3d5dc7e98d6d 100644 --- a/src/versions/components/VersionPicker.tsx +++ b/src/versions/components/VersionPicker.tsx @@ -14,8 +14,8 @@ import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion' import { useTranslation } from '@/languages/components/useTranslation' import styles from './VersionPicker.module.scss' -// The header variant's trigger, menu surface and rows are shared with the language -// picker so the two dropdowns cannot drift apart. +// The header variant shares HeaderPicker.module.scss with the language picker, +// so the two dropdowns stay in sync. import headerStyles from '@/frame/components/page-header/HeaderPicker.module.scss' type Props = { @@ -27,8 +27,8 @@ type VersionPickerLink = { text: string selected: boolean href: string - // Brand's ActionMenu identifies the chosen row by string value, so every row needs - // one. Versions use their own version name; the two extra rows use sentinels. + // Brand's ActionMenu identifies rows by string value. Versions use their version + // name; extra rows use sentinels. value: string extra: { arrow: boolean @@ -41,31 +41,25 @@ type VersionPickerLink = { const ALL_RELEASES_VALUE = 'all-enterprise-releases' const ABOUT_VERSIONS_VALUE = 'about-versions' -// Brand clones ActionMenu.Button with its own ref, so the trigger cannot be reached -// through a React ref. A stable test id keeps both the Escape handler and the tests -// off Brand's hashed CSS class names. +// Brand clones ActionMenu.Button with its own ref, so React refs cannot reach the +// trigger. A stable test id keeps the Escape handler and tests off hashed CSS classes. const HEADER_TRIGGER_TESTID = 'version-picker-button' type PlanMenuItemProps = { item: VersionPickerLink - // Injected by ActionMenu.Overlay, which clones each of its direct children with the - // select handler and the selection type derived from `selectionVariant`. + // ActionMenu.Overlay injects handler and type into each direct child. handler?: (value: string) => void type?: 'none' | 'single' | 'link' } +// Extra rows opt out of Brand selection semantics because axe rejects aria-checked +// on menuitem, and Brand derives both role and aria-checked from type. const PlanMenuItem = ({ item, handler, type }: PlanMenuItemProps) => { const isExtra = Boolean(item.extra.arrow || item.extra.info) return ( <BrandActionMenu.Item handler={handler} - // Brand derives both `role` and `aria-checked` from `type`. The two extra rows - // navigate elsewhere instead of choosing a version, so under the injected - // 'single' they would render role="menuitem" *plus* aria-checked — which axe - // rejects, since aria-checked is not an allowed attribute on menuitem. Overlay - // injects `type` into its direct children only, so this wrapper is the seam - // where a single row can opt out of selection semantics. type={isExtra ? 'none' : type} value={item.value} selected={item.selected} @@ -73,8 +67,7 @@ const PlanMenuItem = ({ item, handler, type }: PlanMenuItemProps) => { headerStyles.headerMenuItem, item.selected && headerStyles.headerMenuItemSelected, )} - // Only spread `role` for the extras: passing `role={undefined}` would override - // the role Brand computes and leave the version rows with no role at all. + // Only spread role for extras; role undefined overrides Brand's computed role. {...(isExtra ? { role: 'menuitem' } : {})} > <span data-testid="version-picker-item" className={headerStyles.headerMenuItemLabel}> @@ -82,21 +75,23 @@ const PlanMenuItem = ({ item, handler, type }: PlanMenuItemProps) => { {item.extra.arrow && <ArrowRightIcon verticalAlign="middle" size={15} className="ml-1" />} {item.extra.info && <InfoIcon verticalAlign="middle" size={15} className="ml-1" />} </span> - {/* The design marks the current plan with a trailing green dot instead of - Brand's leading check icon, which the stylesheet hides. */} + {/* HeaderPicker.module.scss hides Brand's leading check icon; design uses a trailing green dot. */} {item.selected && <DotFillIcon size={16} className={headerStyles.headerMenuItemDot} />} </BrandActionMenu.Item> ) } -// The rule between the version rows and the two navigation rows. Brand has no divider -// child, and ActionMenu.Overlay clones every direct child with `handler` and `type`, -// so this wrapper takes no props at all: the injected ones are swallowed here instead -// of landing on the DOM node. The <li> carries no tabIndex and no `data-value`, so -// Brand's focus zone and its Enter handler both skip it — and it is never the menu's -// first or last <li>, which are the two rows Brand wires its arrow-key wrap-around to. +// Brand lacks a divider child, and ActionMenu.Overlay injects handler and type into +// every direct child. This wrapper swallows those props so they do not reach the li. +// Without tabIndex or data-value, Brand's focus zone and Enter handler skip the +// separator. The caller keeps it away from the first and last li, which Brand uses +// for arrow-key wrap-around. const PlanMenuSeparator = () => <li role="separator" className={headerStyles.headerMenuSeparator} /> +// VersionPicker uses startsWith to identify Enterprise Server because VersionItem +// omits hasNumberedReleases. The label says "version" for Enterprise Server because +// versionTitle includes the numbered release; a "plan" label would make screen +// readers announce "Select your plan: Enterprise Server 3.19". export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { const router = useRouter() const { currentVersion } = useVersion() @@ -104,19 +99,11 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { const [open, setOpen] = useState(false) const pickerId = useId() const isHeader = variant === 'header' - // Use TypeScript's "not null assertion" because mainContext.page should - // be present in mainContext if it's gotten to the stage of React - // rendering. + // React rendering only starts after MainContext adds page. const page = mainContext.page! const { allVersions, enterpriseServerVersions } = mainContext const { t } = useTranslation(['pages', 'picker']) - // The same control chooses a plan on dotcom and Enterprise Cloud but a numbered - // release on Enterprise Server, where `versionTitle` is `${planTitle} ${release}`. - // A single "Select your plan:" would announce "Select your plan: Enterprise - // Server 3.19" to screen readers. Uses the same `startsWith` predicate as - // `hasEnterpriseVersions` below: `hasNumberedReleases` is set on the runtime - // version object but is not declared on the `VersionItem` type. const pickerLabel = currentVersion.startsWith('enterprise-server') ? t('version_picker_label') : t('plan_picker_label') @@ -195,7 +182,7 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { const selectedOption = allLinks.find((item) => item.selected) const handleVersionSelect = (item: VersionPickerLink) => { - // Save the user's version preference when they actively select one + // Navigation rows leave the existing version preference alone. if (item.extra?.version) { try { Cookies.set(USER_VERSION_COOKIE_NAME, item.extra.version) @@ -204,7 +191,7 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { } } setOpen(false) - // Navigate after setting cookie + // Set the cookie before navigation so the next page can read the preference. if (item.href) { onNavigate?.() router.push(item.href) @@ -212,18 +199,12 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { } if (isHeader) { - // The Figma dropdown node draws no divider, but the rule that separated the - // versions from the two navigation rows is kept from the @primer/react menu this - // replaced. The filter keeps it from ever becoming the menu's first or last row: - // Brand focuses the first <li> and binds its arrow-key wrap-around to the first - // and the last, and neither should land on a separator. + // Keep the separator from the default picker, but not where Brand focuses or wraps rows. const headerLinks = allLinks.filter( (item, index) => !item.divider || (index > 0 && index < allLinks.length - 1), ) - // Brand reports the chosen row by value. Routing every row — the two extras - // included — back through handleVersionSelect keeps navigation client-side - // instead of letting the extras become anchors that reload the page. + // Route extra rows through handleVersionSelect so they stay client-side. const handleHeaderSelect = (value: string) => { const item = headerLinks.find((link) => link.value === value) if (item) { @@ -239,15 +220,10 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { ) if (trigger?.getAttribute('aria-expanded') !== 'true') return - // Brand's ActionMenu and SubdomainNavBar both listen for Escape on `document` - // and neither honours defaultPrevented, so a single Escape would close this - // picker *and* the surrounding narrow menu. Stopping the event here — while it - // is still in its capture phase, before it reaches either listener — leaves the - // outer menu open. Brand has no controlled `open` prop, so the picker is closed - // through its own trigger: focus it first so focus stays put, then click it to - // let ActionMenu toggle itself shut. + // Stop Escape in capture so SubdomainNavBar's document listener leaves the narrow menu open. event.preventDefault() event.stopPropagation() + // Brand has no controlled open prop, so click its focused trigger to close it. trigger.focus() trigger.click() } diff --git a/src/versions/lib/all-versions.ts b/src/versions/lib/all-versions.ts index b502f0d7932a..39007a55dda4 100644 --- a/src/versions/lib/all-versions.ts +++ b/src/versions/lib/all-versions.ts @@ -2,9 +2,7 @@ import fs from 'fs' import type { AllVersions, Version } from '@/types' import enterpriseServerReleases from './enterprise-server-releases' -// version = "plan"@"release" -// example: enterprise-server@2.21 -// where "enterprise-server" is the plan and "2.21" is the release +// Version keys combine plan and release, for example enterprise-server@2.21. const versionDelimiter = '@' const latestNonNumberedRelease = 'latest' const REST_DATA_META_FILE = 'src/rest/lib/config.json' @@ -27,26 +25,23 @@ interface RestApiConfig { } } -// !Explanation of versionless redirect fallbacks! -// This array is **in order** of the versions the site should try to fall back to if -// no version is provided in a URL. For example, if /foo refers to a page that is available -// in all versions, we should not redirect it (because /foo is the correct FPT versioned URL). -// But if /foo refers to a page that is only available in GHEC and GHES, we should redirect it -// to /enterprise-cloud@latest/foo (since GHEC comes first in the hierarchy of version fallbacks). -// The implementation lives in lib/redirects/permalinks.ts. +// Versionless redirects try these plans in order. If /foo supports every plan, it +// stays the Free, Pro, and Team URL. If it supports only Enterprise Cloud and +// Enterprise Server, src/redirects/lib/permalinks.ts redirects it to +// /enterprise-cloud@latest/foo. const plans: PlanConfig[] = [ { - // free-pro-team is **not** a user-facing version and is stripped from URLs. - // See lib/remove-fpt-from-path.ts for details. + // free-pro-team is not user-facing. + // src/versions/lib/remove-fpt-from-path.ts strips it from URLs. plan: 'free-pro-team', planTitle: 'Free, Pro, & Team', shortName: 'fpt', releases: [latestNonNumberedRelease], latestRelease: latestNonNumberedRelease, - nonEnterpriseDefault: true, // permanent way to refer to this plan if the name changes + nonEnterpriseDefault: true, // Marks the non-enterprise default independently of the plan name. hasNumberedReleases: false, - openApiBaseName: 'fpt', // used for REST - miscBaseName: 'dotcom', // used for GraphQL and webhooks + openApiBaseName: 'fpt', // REST base name. + miscBaseName: 'dotcom', // Search index version map base name. }, { plan: 'enterprise-cloud', @@ -72,8 +67,6 @@ const plans: PlanConfig[] = [ const allVersions: AllVersions = {} -// combine the plans and releases to get allVersions object -// e.g. free-pro-team@latest, enterprise-server@2.21, enterprise-server@2.20, etc. for (const planObj of plans) { for (const release of planObj.releases) { const version = `${planObj.plan}${versionDelimiter}${release}` @@ -91,8 +84,10 @@ for (const planObj of plans) { miscVersionName: planObj.hasNumberedReleases ? `${planObj.miscBaseName}${release}` : planObj.miscBaseName, - apiVersions: [], // REST Calendar Date Versions, this may be empty for non calendar date versioned products - latestApiVersion: '', // Latest REST Calendar Date Version, this may be empty for non calendar date versioned products + // REST calendar date versions; empty for products without calendar date API versions. + apiVersions: [], + // Latest REST calendar date version; empty for products without calendar date API versions. + latestApiVersion: '', plan: planObj.plan, planTitle: planObj.planTitle, shortName: planObj.shortName, @@ -108,7 +103,7 @@ for (const planObj of plans) { } } -// Adds the calendar date (or api versions) to the allVersions object +// REST config adds calendar date API versions after the version objects exist. const apiVersions: RestApiConfig['api-versions'] = JSON.parse( fs.readFileSync(REST_DATA_META_FILE, 'utf8'), )['api-versions'] @@ -116,7 +111,7 @@ const apiVersions: RestApiConfig['api-versions'] = JSON.parse( for (const key of Object.keys(apiVersions)) { const docsVersion = getDocsVersion(key) allVersions[docsVersion].apiVersions.push(...apiVersions[key].sort().reverse()) - // Create a copy of the array to avoid mutating the original when using pop() + // Copy before pop so latestApiVersion does not remove a version from apiVersions. const sortedVersions = [...apiVersions[key].sort()] allVersions[docsVersion].latestApiVersion = sortedVersions.pop() || '' } @@ -130,9 +125,7 @@ export function isApiVersioned(version: string): boolean { return allVersions[version] && allVersions[version].apiVersions.length > 0 } -// Currently the versions from the OpenAPI do not match the versions on Docs. -// There is a mapping between the version names. This gets the Docs version from -// the OpenAPI version name. +// OpenAPI names do not match Docs version names, so this maps one to its Docs version. export function getDocsVersion(openApiVersion: string): string { const matchingVersion = Object.values(allVersions).find((version) => openApiVersion.startsWith(version.openApiVersionName), diff --git a/src/versions/lib/enterprise-server-releases.d.ts b/src/versions/lib/enterprise-server-releases.d.ts index 85f2391e2272..86d4910323f9 100644 --- a/src/versions/lib/enterprise-server-releases.d.ts +++ b/src/versions/lib/enterprise-server-releases.d.ts @@ -1,11 +1,13 @@ type Dates = { [key: string]: { - releaseDate: string // For backward compatibility - will be RC date initially, then GA date once available + // Templates read releaseDate as the display date: RC date until the GA date exists. + releaseDate: string deprecationDate: string - releaseCandidateDate?: string // Release Candidate date - generalAvailabilityDate?: string // General Availability date - displayCandidateDate?: string | null // Computed: RC date if in past, null if future - displayReleaseDate?: string | null // Computed: GA date if in past, null if future + releaseCandidateDate?: string + generalAvailabilityDate?: string + // Templates hide release dates until each date has passed. + displayCandidateDate?: string | null + displayReleaseDate?: string | null } } diff --git a/src/versions/lib/enterprise-server-releases.ts b/src/versions/lib/enterprise-server-releases.ts index 49511d28cc48..224e728ad796 100644 --- a/src/versions/lib/enterprise-server-releases.ts +++ b/src/versions/lib/enterprise-server-releases.ts @@ -24,18 +24,18 @@ const rawDates: RawDatesData = JSON.parse( fs.readFileSync('src/ghes-releases/lib/enterprise-dates.json', 'utf8'), ) -// Upcoming GHES release numbers (used in frontmatter and release planning) +// Frontmatter and release planning use the next two GHES release numbers. export const next = '3.23' export const nextNext = '3.24' -// Currently supported GHES versions (in descending order, latest first) +// Keep supported GHES versions in descending order, latest first. export const supported = ['3.22', '3.21', '3.20', '3.19', '3.18', '3.17'] -// Set to version number when in RC phase, null when no RC is active +// Use the release number during an active RC; use null outside RC. export const releaseCandidate = null -// Deprecated versions with functional redirect handling (3.0+) -// When archiving a new version, add it here and update the archival process +// Deprecated releases from 3.0 onward use functional redirects. +// Add a newly archived release here and update the archival process. export const deprecatedWithFunctionalRedirects = [ '3.16', '3.15', @@ -56,7 +56,7 @@ export const deprecatedWithFunctionalRedirects = [ '3.0', ] -// All deprecated versions (combines functional + legacy redirect handling) +// The deprecated list combines functional redirects with legacy redirect handling. export const deprecated = [ ...deprecatedWithFunctionalRedirects, '2.22', @@ -85,13 +85,13 @@ export const deprecated = [ '11.10.340', ] -// Versions with legacy asset handling (stored in separate repos before blob storage) +// Legacy asset releases store assets in separate repos instead of blob storage. export const legacyAssetVersions = ['3.0', '2.22', '2.21'] export const firstReleaseStoredInBlobStorage = '3.2' export const firstVersionDeprecatedOnNewSite = '2.13' export const lastVersionWithoutArchivedRedirectsFile = '2.17' -export const lastReleaseWithLegacyFormat = '2.18' // Last to use /enterprise/<release>/... paths +export const lastReleaseWithLegacyFormat = '2.18' // Last release with /enterprise/<release>/... paths. export const firstReleaseNote = '2.20' export const firstRestoredAdminGuides = '2.21' @@ -101,7 +101,7 @@ export const latest = supported[0] export const latestStable = releaseCandidate ? supported[1] : latest export const oldestSupported = supported[supported.length - 1] -// Enhanced dates object with computed display values for templates +// Templates read these computed display dates to hide future release dates. export const dates: Record<string, EnhancedVersionDateData> = Object.fromEntries( Object.entries(rawDates).map(([version, versionData]) => [ version, @@ -118,8 +118,7 @@ export const isOldestReleaseDeprecated = nextDeprecationDate ? new Date() > new Date(nextDeprecationDate) : false -// Find any other releases that may share the oldest deprecation date -// We'll want to display the deprecation banner on all of these releases (not just oldest) +// Show the deprecation banner on every release that shares the oldest deprecation date. export const releasesWithOldestDeprecationDate = Object.entries(dates) .filter(([, versionData]) => versionData.deprecationDate === nextDeprecationDate) .map(([version]) => version) @@ -140,8 +139,8 @@ export const deprecatedReleasesOnDeveloperSite = deprecated.filter((version) => versionSatisfiesRange(version, '<=2.16'), ) -// Returns the date only once it has passed, so we never advertise a future -// release date. An unparseable date gives NaN, which also returns null. +// Return a date only after it has passed, so templates never advertise future releases. +// Unparseable dates produce NaN, which also returns null. function processDateForDisplay(date: string | undefined): string | null { if (!date) return null const currentTimestamp = Math.floor(Date.now() / 1000) diff --git a/src/versions/lib/get-applicable-versions.ts b/src/versions/lib/get-applicable-versions.ts index cba545b0cf0b..ec6541833b2b 100644 --- a/src/versions/lib/get-applicable-versions.ts +++ b/src/versions/lib/get-applicable-versions.ts @@ -20,12 +20,14 @@ interface FeatureData { } } -// Feature data is dynamically loaded from YAML files +// Feature data loads lazily from YAML on first use. let featureData: FeatureData | null = null const allVersionKeys = Object.keys(allVersions) -// return an array of versions that an article's product versions encompasses +// Feature frontmatter can name one feature, feature: foo, or many, feature: [foo, bar]. +// Merge each feature's version rules before evaluation. Example: fpt: * with +// feature: foo can add ghes: >=2.23 to the versions object. function getApplicableVersions( versionsObj: VersionsObject | string | undefined, filepath?: string, @@ -35,7 +37,7 @@ function getApplicableVersions( throw new Error(`No \`versions\` frontmatter found in ${filepath || 'undefined'}`) } - // Catch an old frontmatter value that was used to indicate an article was available in all versions. + // versions: * is invalid legacy frontmatter; use plan keys or feature-based frontmatter. if (versionsObj === '*') { throw new Error( `${filepath || 'undefined'} contains the invalid versions frontmatter: *. Please explicitly list out all the versions that apply to this article.`, @@ -46,16 +48,6 @@ function getApplicableVersions( featureData = getDeepDataByLanguage('features', 'en') as FeatureData } - // Check for frontmatter that includes a feature name, like: - // fpt: '*' - // feature: 'foo' - // or multiple feature names, like: - // fpt: '*' - // feature: ['foo', 'bar'] - // and add the versions affiliated with the feature (e.g., foo) to the frontmatter versions object: - // fpt: '*' - // ghes: '>=2.23' - // where the feature is bringing the ghes versions into the mix. const featureVersionsObj: VersionsObject = typeof versionsObj === 'string' ? {} @@ -90,7 +82,7 @@ function getApplicableVersions( ) } - // Sort them by the order in lib/all-versions. + // Return versions in the same order as src/versions/lib/all-versions.ts. let sortedVersions = sortBy(applicableVersions, (v) => { return allVersionKeys.indexOf(v) }) @@ -104,36 +96,27 @@ function getApplicableVersions( return sortedVersions } +// evaluateVersions accepts short names such as ghes: >=2.19 and expands them to full version keys. function evaluateVersions(versionsObj: VersionsObject): string[] { - // get an array like: [ 'free-pro-team@latest', 'enterprise-server@2.21', 'enterprise-cloud@latest' ] const versions: string[] = [] - // where versions obj is something like: - // fpt: '*' - // ghes: '>=2.19' - // ghec: '*' - // ^ where each key corresponds to a plan's short name (defined in lib/all-versions.ts) for (const [plan, planValue] of Object.entries(versionsObj)) { if (typeof planValue !== 'string') continue - // For each available plan (e.g., `ghes`), get the matching versions from allVersions. - // This will be an array of one or more version objects. + // Short and full plan names both match version objects. const matchingVersionObjs: Version[] = Object.values(allVersions).filter( (relevantVersionObj: Version) => relevantVersionObj.plan === plan || relevantVersionObj.shortName === plan, ) - // For each matching version found above, compare it to the provided planValue. - // E.g., compare `enterprise-server@2.19` to `ghes: >=2.19`. for (const relevantVersionObj of matchingVersionObjs) { - // If the version doesn't require any semantic comparison, we can assume it applies. + // Non-numbered plans always match because only numbered releases use ranges. if (!relevantVersionObj.hasNumberedReleases) { versions.push(relevantVersionObj.version) continue } - // Special handling for a plan value that evaluates to the next GHES release number or a hardcoded `next`. - // Note these will not be included in the final array unless the `includeNextVersion` option is provided. + // Include future GHES releases only when includeNextVersion keeps them in the returned array. if (versionSatisfiesRange(next, planValue) || planValue === 'next') { versions.push(`${relevantVersionObj.plan}@${next}`) } @@ -141,7 +124,6 @@ function evaluateVersions(versionsObj: VersionsObject): string[] { versions.push(`${relevantVersionObj.plan}@${nextNext}`) } - // Determine which release to use for semantic comparison. const releaseToCompare: string = relevantVersionObj.currentRelease if (releaseToCompare && versionSatisfiesRange(releaseToCompare, planValue)) { diff --git a/src/versions/lib/remove-fpt-from-path.ts b/src/versions/lib/remove-fpt-from-path.ts index 32ee9ded6ff7..04f3e8f6de68 100644 --- a/src/versions/lib/remove-fpt-from-path.ts +++ b/src/versions/lib/remove-fpt-from-path.ts @@ -1,9 +1,8 @@ import slash from 'slash' import nonEnterpriseDefaultVersion from './non-enterprise-default-version' -// This is a convenience function to remove free-pro-team@latest from all -// **user-facing** aspects of the site (particularly URLs) while continuing to support -// free-pro-team@latest as a version both in the codebase and in content/data files. +// Strip free-pro-team@latest from user-facing paths while retaining it as a code and +// content version. export default function removeFPTFromPath(path: string): string { return slash(path.replace(`/${nonEnterpriseDefaultVersion}`, '')) } diff --git a/src/versions/lib/version-satisfies-range.ts b/src/versions/lib/version-satisfies-range.ts index b4abc8089e92..fe1852b822be 100644 --- a/src/versions/lib/version-satisfies-range.ts +++ b/src/versions/lib/version-satisfies-range.ts @@ -1,21 +1,15 @@ import semver from 'semver' -// Where "release" is a release number, like `3.1` for Enterprise Server, -// and "range" is a semver range operator with another number, like `<=3.2`. +// Release is a GHES release such as 3.1; range is a semver range such as <=3.2. export default function versionSatisfiesRange(release: string | undefined, range: string): boolean { - // Handle undefined release if (!release) { return false } - // workaround for Enterprise Server 11.10.340 because we can't use semver to - // compare it to 2.x like we can with 2.0+ + // Enterprise Server 11.10.340 predates semver-compatible 2.x, so only less-than ranges match. if (release === '11.10.340') return range.startsWith('<') - // If the release is '*', we want it to evaluate to false against the range 'next' - // but to true against itself ('*'). Unfortunately by default it will evaluate to - // true against 'next'. So we have to do a hack here and replace it with a - // dummy value of '1.0', which will get the results we want. + // Treat wildcard as 1.0 so it matches wildcard ranges and not next. if (release === '*') { release = '1.0' } diff --git a/src/versions/middleware/features.ts b/src/versions/middleware/features.ts index 5870e701477e..198d4d577173 100644 --- a/src/versions/middleware/features.ts +++ b/src/versions/middleware/features.ts @@ -30,16 +30,13 @@ const cache = new Map<string, Record<string, boolean>>() export function getFeaturesByVersion(currentVersion: string): Record<string, boolean> { if (!cache.has(currentVersion)) { if (!allFeatures) { - // As of Oct 2022, the `data/features/**` reading is *not* JIT. - // The `data/features` is deliberately not ignored in nodemon.json. - // See internal issue #2389 + // data/features loads outside JIT, so nodemon watches it instead of ignoring it. allFeatures = getDeepDataByLanguage('features', 'en') as Record<string, FeatureVersions> } const featureFlags: { [feature: string]: boolean } = {} - // Determine whether the currentVersion belongs to the list of versions the feature is available in. for (const [featureName, feature] of Object.entries(allFeatures)) { const { versions } = feature const applicableVersions = getApplicableVersions( @@ -47,8 +44,7 @@ export function getFeaturesByVersion(currentVersion: string): Record<string, boo path.join(ROOT, `data/features/${featureName}.yml`), ) - // Adding the resulting boolean to the context object gives us the ability to use - // `{% if featureName ... %}` conditionals in content files. + // Context booleans let content use Liquid conditionals such as {% if featureName ... %}. const isFeatureAvailableInCurrentVersion = applicableVersions.includes(currentVersion) featureFlags[featureName] = isFeatureAvailableInCurrentVersion } diff --git a/src/versions/middleware/short-versions.ts b/src/versions/middleware/short-versions.ts index 86ec086133cc..76556683ee5c 100644 --- a/src/versions/middleware/short-versions.ts +++ b/src/versions/middleware/short-versions.ts @@ -1,11 +1,5 @@ -// This module creates shortcuts for version comparisons in Liquid conditional strings. -// -// Supported: -// {% if fpt %} -// {% if ghec %} -// {% if ghes %} -// -// For the custom operator handling in statements like {% if ghes > 3.0 %}, see `lib/liquid-tags/if-ver.ts`. +// Liquid conditionals use these shortcuts: {% if fpt %}, {% if ghec %}, and {% if ghes %}. +// Release comparisons use the custom ifversion tag, such as {% ifversion ghes > 3.XX %}. import type { ExtendedRequest } from '@/types' import type { Response, NextFunction } from 'express' @@ -20,10 +14,8 @@ export default async function shortVersions( return next() } - // Add the short name to context. req.context[currentVersionObj.shortName] = true - // Add convenience props. if (currentVersion) { req.context.currentRelease = currentVersion.split('@')[1] req.context.currentVersionShortName = currentVersionObj.shortName diff --git a/src/versions/scripts/update-versioning-in-files.ts b/src/versions/scripts/update-versioning-in-files.ts index 1edcfcc5e319..324ec77cdaf1 100755 --- a/src/versions/scripts/update-versioning-in-files.ts +++ b/src/versions/scripts/update-versioning-in-files.ts @@ -17,7 +17,6 @@ const dataFiles = walk(dataPath, { includeBasePath: true, directories: false }) for (const file of dataFiles) { const content = fs.readFileSync(file, 'utf8') - // Update Liquid in data files const newContent = updateLiquid(content) fs.writeFileSync(file, newContent) @@ -26,15 +25,12 @@ for (const file of dataFiles) { for (const file of contentFiles) { const { data, content } = frontmatter(fs.readFileSync(file, 'utf8')) - // Update Liquid in content files const newContent = content ? updateLiquid(content) : '' - // Update versions frontmatter if (data) { if (!data.versions && data.productVersions) { data.versions = data.productVersions for (const version of Object.keys(data.versions)) { - // update dotcom, actions, rest, etc. if (version !== 'enterprise') { data.versions['free-pro-team'] = data.versions[version] delete data.versions[version] @@ -47,9 +43,8 @@ for (const file of contentFiles) { delete data.productVersions - // Update Liquid in frontmatter props const frontmatterKeys = Object.keys(data) - // Only process a subset of props + // Rewrite Liquid only in title, intro, and product frontmatter. .filter((xkey) => xkey === 'title' || xkey === 'intro' || xkey === 'product') for (const key of frontmatterKeys) { data[key] = updateLiquid(data[key]) diff --git a/src/versions/scripts/use-short-versions.ts b/src/versions/scripts/use-short-versions.ts index f0847142db9b..36c78e35ebdf 100755 --- a/src/versions/scripts/use-short-versions.ts +++ b/src/versions/scripts/use-short-versions.ts @@ -37,47 +37,40 @@ interface OperatorsMap { } const operatorsMap: OperatorsMap = { - // old: new '==': '=', ver_gt: '>', ver_lt: '<', - '!=': '!=', // noop + '!=': '!=', // Already matches ifversion syntax. } -// [start-readme] -// -// Run this script to convert long form Liquid conditionals (e.g., {% if currentVersion == "free-pro-team" %}) to -// the new custom tag (e.g., {% ifversion fpt %}) and also use the short names in versions frontmatter. -// -// [end-readme] +// Converts long-form Liquid conditionals to ifversion tags and short version names +// in versions frontmatter. async function main() { if (dryRun) console.log('This is a dry run! The script will not write any files. Use for debugging.\n') - // 1. UPDATE MARKDOWN FILES (CONTENT AND REUSABLES) + // Markdown files need both Liquid conditionals and versions frontmatter converted. console.log('Updating Liquid conditionals and versions frontmatter in Markdown files...\n') for (const file of markdownFiles) { - // A. UPDATE LIQUID CONDITIONALS IN CONTENT - // Create an { old: new } conditionals object so we can get the replacements and - // make the replacements separately and not do both in nested loops. + // Collect replacements before editing so nested loops do not rewrite generated conditionals. const content = fs.readFileSync(file, 'utf8') const contentReplacements = getLiquidReplacements(content, file) const newContent = makeLiquidReplacements(contentReplacements, content) - // B. UPDATE FRONTMATTER VERSIONS PROPERTY + // Frontmatter versions need short plan names in addition to Liquid updates. const { data } = frontmatter(newContent) as { data: VersionData } if (data.versions && typeof data.versions !== 'string') { const versions = data.versions as Record<string, string> for (const [plan, value] of Object.entries(versions)) { - // Update legacy versioning while we're here + // Normalize legacy versions before writing short plan names. const valueToUse = value .replace('2.23', '3.0') .replace(`>=${oldestSupported}`, '*') .replace(/>=?2\.20/, '*') .replace(/>=?2\.19/, '*') - // Find the relevant version from the master list so we can access the short name. + // Find the version config before replacing the plan with its short name. const versionObj = allVersionKeys.find( (version) => version.plan === plan || version.shortName === plan, ) @@ -98,19 +91,19 @@ async function main() { frontmatter.stringify( newContent, data, - // lineWidth is a js-yaml option passed through gray-matter, not in gray-matter's type definitions + // lineWidth is a js-yaml option passed through gray-matter, not in its types. { lineWidth: 10000 } as unknown as Parameters<typeof frontmatter.stringify>[2], ), ) } } - // 2. UPDATE LIQUID CONDITIONALS IN DATA YAML FILES + // YAML data files need Liquid conditional and versions-key rewrites. console.log('Updating Liquid conditionals in YAML files...\n') for (const file of yamlFiles) { const yamlContent = fs.readFileSync(file, 'utf8') const yamlReplacements = getLiquidReplacements(yamlContent, file) - // Update any `versions` properties in the YAML as well + // YAML versions keys use short plan names too. const newYamlContent = makeLiquidReplacements(yamlReplacements, yamlContent) .replace(/("|')?free-pro-team("|')?:/g, 'fpt:') .replace(/("|')?enterprise-server("|')?:/g, 'ghes:') @@ -131,7 +124,7 @@ try { process.exit(1) } -// Remove verbose input properties for readability in debugging output +// Remove verbose input properties for readable debugging output. function removeInputProps(arrayOfObjects: TopLevelToken[]): TopLevelToken[] { return arrayOfObjects.map((obj) => { const record = obj as unknown as Record<string, unknown> @@ -143,29 +136,28 @@ function removeInputProps(arrayOfObjects: TopLevelToken[]): TopLevelToken[] { }) } +// makeLiquidReplacements also collapses "ghes and ghes" from old deprecation-script +// guards. Example: enterpriseServerVersions contains currentVersion plus +// currentVersion ver_gt enterprise-server@3.XX becomes ghes > 3.XX. function makeLiquidReplacements(replacementsObj: ReplacementsMap, text: string): string { let newText = text for (const [oldCond, newCond] of Object.entries(replacementsObj)) { const oldCondRegex = new RegExp(`({%-?)\\s*?${RegExp.escape(oldCond)}\\s*?(-?%})`, 'g') newText = newText .replace(oldCondRegex, `$1 ${newCond} $2`) - // Content files use an old-school hack to ensure our old regex deprecation script DTRT, for example: - // `if enterpriseServerVersions contains currentVersion and currentVersion ver_gt "enterprise-server@2.21"` - // This script will change the above to `if ghes and ghes > 2.21`. - // But we don't need the hack for the new deprecation script, because it will change `if ghes > 2.21` to `if ghes`. - // So we can update this to the simpler `{% if ghes > 2.21 %}`. + // Collapse duplicated GHES guards from old deprecation-script conditionals. .replace(/ghes and ghes/g, 'ghes') } return newText } -// Versions map: -// if currentVersion == "myVersion@myRelease" -> ifversion myVersionShort OR ifversion myVersionShort = @myRelease -// if currentVersion != "myVersion@myRelease" -> ifversion not myVersionShort OR ifversion myVersionShort != @myRelease -// if currentVersion ver_gt "myVersion@myRelease -> ifversion myVersionShort > myRelease -// if currentVersion ver_lt "myVersion@myRelease -> ifversion myVersionShort < myRelease -// if enterpriseServerVersions contains currentVersion -> ifversion ghes +// getLiquidReplacements maps long currentVersion conditionals to ifversion conditionals: +// currentVersion == enterprise-server@3.XX -> ifversion ghes = 3.XX +// currentVersion != free-pro-team@latest -> ifversion not fpt +// currentVersion ver_gt enterprise-server@3.XX -> ifversion ghes > 3.XX +// currentVersion ver_lt enterprise-server@3.XX -> ifversion ghes < 3.XX +// enterpriseServerVersions contains currentVersion -> ifversion ghes function getLiquidReplacements(content: string, file: string): ReplacementsMap { const replacements: ReplacementsMap = {} @@ -191,22 +183,18 @@ function getLiquidReplacements(content: string, file: string): ReplacementsMap { .map((xtoken) => xtoken.content) for (const token of conditionalTokens) { const newToken = token.startsWith('if') ? ['ifversion'] : ['elsif'] - // Everything from here on pushes to the `newToken` array to construct the new conditional. for (const op of token.replace(/(if|elsif) /, '').split(/ (or|and) /)) { if (op === 'or' || op === 'and') { newToken.push(op) continue } - // This string will always resolve to `ifversion ghes`. + // enterpriseServerVersions contains currentVersion maps to ifversion ghes. if (op.includes('enterpriseServerVersions contains currentVersion')) { newToken.push('ghes') continue } - // For the rest, we need to check the release string. - - // E.g., [ 'currentVersion', '==', '"enterprise-server@3.0"']. const opParts = op.split(' ') if (!(opParts.length === 3 && opParts[0] === 'currentVersion')) { @@ -215,10 +203,8 @@ function getLiquidReplacements(content: string, file: string): ReplacementsMap { } const operator = opParts[1] - // Remove quotes around the version and then split it on the at sign. const [plan, release] = opParts[2].slice(1, -1).split('@') - // Find the relevant version from the master list so we can access the short name. const versionObj = allVersionKeys.find((version) => version.plan === plan) if (!versionObj) { @@ -226,7 +212,6 @@ function getLiquidReplacements(content: string, file: string): ReplacementsMap { process.exit(1) } - // Handle numbered releases! if (versionObj.hasNumberedReleases) { const newOperator: string | undefined = operatorsMap[operator] if (!newOperator) { @@ -236,60 +221,54 @@ function getLiquidReplacements(content: string, file: string): ReplacementsMap { process.exit(1) } - // Account for this one weird version included in a couple content files + // Some content still references 1.19, so treat it as deprecated for this conversion. deprecated.push('1.19') - // E.g., ghes > 2.20 const availableInAllGhes = deprecated.includes(release) && newOperator === '>' - // We can change > deprecated releases, like ghes > 2.19, to just ghes. - // These are now available for all ghes releases. + // A greater-than check against a deprecated release matches every supported GHES release. if (availableInAllGhes) { newToken.push(versionObj.shortName) continue } - // E.g., ghes < 2.20 const lessThanDeprecated = deprecated.includes(release) && newOperator === '<' - // E.g., ghes < 2.21 const lessThanOldestSupported = release === oldestSupported && newOperator === '<' - // E.g., ghes = 2.20 const equalsDeprecated = deprecated.includes(release) && newOperator === '=' const hasDeprecatedContent = lessThanDeprecated || lessThanOldestSupported || equalsDeprecated - // Remove these by hand. + // Deprecated-only content needs manual removal instead of conversion. if (hasDeprecatedContent) { console.error(`Found content that needs to be removed! See "${token} in "${file}`) process.exit(1) } - // Override for legacy 2.23, which should be 3.0 + // Legacy 2.23 conditionals map to the first 3.0 release. const releaseToUse = release === '2.23' ? '3.0' : release newToken.push(`${versionObj.shortName} ${newOperator} ${releaseToUse}`) continue } - // Turn != into nots, now that we can assume this is not a numbered release. + // Non-numbered inequality maps to ifversion not. if (operator === '!=') { newToken.push(`not ${versionObj.shortName}`) continue } - // We should only have equality conditionals left. + // Non-numbered releases only support equality after inequality handling. if (operator !== '==') { console.error(`Expected == but found ${operator} in "${op}" in ${token}`) process.exit(1) } - // Handle `latest`! if (release === 'latest') { newToken.push(versionObj.shortName) continue } - // Handle all other non-standard releases, like github-ae@next and github-ae@issue-12345 + // Keep non-standard non-numbered releases in the condition name, such as github-ae@next. newToken.push(`${versionObj.shortName}-${release}`) } diff --git a/src/versions/tests/get-applicable-versions.ts b/src/versions/tests/get-applicable-versions.ts index ba7bba65a3bb..7ea317ce383f 100644 --- a/src/versions/tests/get-applicable-versions.ts +++ b/src/versions/tests/get-applicable-versions.ts @@ -43,7 +43,7 @@ describe('Versions frontmatter', () => { describe('general cases', () => { test('wildcard * is no longer used', () => { - // docs engineering 3110 + // versions: * shorthand is invalid; plan keys and feature-based frontmatter are explicit. expect.assertions(2) try { getApplicableVersions('*') @@ -65,7 +65,7 @@ describe('general cases', () => { const applicableVersions = getApplicableVersions(versions) expect(applicableVersions.every((v) => Object.keys(allVersions).includes(v))) } - // Same thing but as an array each time + // Feature arrays follow the same rules as a single feature name. for (const possibleFeature of possibleFeatures) { const versions: Versions = { feature: [possibleFeature] } const applicableVersions = getApplicableVersions(versions) diff --git a/src/versions/tests/version-cookie.ts b/src/versions/tests/version-cookie.ts index a691d3092f40..195f3a9bd8c1 100644 --- a/src/versions/tests/version-cookie.ts +++ b/src/versions/tests/version-cookie.ts @@ -58,8 +58,8 @@ describe('version cookie redirects', () => { }) }) -// See github/technical-content#7227. Before this, the cookie was only ever consulted on the bare -// homepage, so every deep link served Free/Pro/Team no matter what the reader preferred. +// Version preference applies to article URLs, not only the homepage. The cookie is +// the default, and an explicit path segment wins. describe('version cookie on article URLs', () => { // Exists in Free/Pro/Team and in Enterprise Cloud. const versioned = '/en/get-started/start-your-journey/what-is-github' @@ -76,8 +76,7 @@ describe('version cookie on article URLs', () => { '/en/enterprise-cloud@latest/get-started/start-your-journey/what-is-github', ) expect(res.headers.vary).toContain('x-user-version') - // Listed once, not twice. The manual append is skipped on the redirect path because - // `languageAndVersionCacheControl` already names it. + // Manual append is skipped on redirects because languageAndVersionCacheControl names it. expect(res.headers.vary!.match(/x-user-version/g)).toHaveLength(1) }) @@ -101,9 +100,8 @@ describe('version cookie on article URLs', () => { expect(res.statusCode).toBe(200) }) - // The escape hatch. `getRedirect` strips the `/free-pro-team@latest` prefix, so without - // reading the request path we would bounce this reader straight back to Enterprise Cloud - // and they could never look at the Free/Pro/Team article on purpose. + // An explicit free-pro-team URL must beat the cookie. getRedirect strips the prefix; + // without the request path, the reader would bounce back to Enterprise Cloud. test('an explicit free-pro-team URL beats the cookie', async () => { const res = await get( '/en/free-pro-team@latest/get-started/start-your-journey/what-is-github', @@ -134,9 +132,8 @@ describe('version cookie on article URLs', () => { expect(res.statusCode).toBe(200) }) - // Varying only for cookie holders would let this cached response be handed to a reader - // who should have been redirected. test('varies on the cookie even for readers who have not set one', async () => { + // Unversioned articles with alternate versions vary on x-user-version so caches keep redirects. const res = await get(versioned, { followRedirects: false }) expect(res.statusCode).toBe(200) expect(res.headers.vary).toContain('x-user-version') @@ -153,7 +150,6 @@ describe('version cookie on article URLs', () => { ) }) - // Staying in the reader's language is covered by the unit tests in - // src/redirects/tests/version-preference.ts. It cannot be covered here because this - // suite runs against real content, and only English is loaded. + // Unit tests in src/redirects/tests/version-preference.ts cover staying in the + // reader's language. This suite runs against real content, and only English is loaded. }) diff --git a/src/webhooks/components/Webhook.tsx b/src/webhooks/components/Webhook.tsx index 2f4949fdb3d0..c90cd7d83d94 100644 --- a/src/webhooks/components/Webhook.tsx +++ b/src/webhooks/components/Webhook.tsx @@ -19,7 +19,6 @@ type Props = { webhook: WebhookAction } -// fetcher passed to useSWR() to get webhook data using the given URL async function webhookFetcher(url: string) { const response = await fetch(url) if (!response.ok) { @@ -30,25 +29,16 @@ async function webhookFetcher(url: string) { } export function Webhook({ webhook }: Props) { - // Get version for requests to switch webhook action type const version = useVersion() const { t, tObject } = useTranslation('webhooks') - // Get more user friendly language for the different availability options in - // the webhook schema (we can't change it directly in the schema). Note that - // we specifically don't want to translate these strings with useTranslation() - // like we usually do with strings from data/ui.yml. + // Map schema availability values to UI copy instead of translating source values directly. const rephraseAvailability = tObject('rephrase_availability') - // The param that was clicked so we can expand its property <details> element const [clickedBodyParameterName, setClickedBodyParameterName] = useState<undefined | string>('') - // The selected webhook action type the user selects via a dropdown const [selectedWebhookActionType, setSelectedWebhookActionType] = useState('') - // The index of the selected action type so we can highlight which one is selected - // in the action type dropdown const [selectedActionTypeIndex, setSelectedActionTypeIndex] = useState(0) - // Tracks whether we need to announce once data loads (first interaction only, - // before SWR cache is populated). + // Tracks the first uncached interaction so data-load effects can announce it once. const [pendingAnnouncement, setPendingAnnouncement] = useState('') const webhookSlug = slug(webhook.data.category) @@ -57,10 +47,7 @@ export function Webhook({ webhook }: Props) { version: version.currentVersion, })}` - // fires when the webhook action type changes or someone clicks on a nested - // body param for the first time. In either case, we now have all the data - // for a webhook (i.e. all the data for each action type and all of their - // nested parameters) + // Fetch full webhook data after the action type changes or a user expands nested parameters. const { data, error } = useSWR<WebhookData, Error>( clickedBodyParameterName || selectedWebhookActionType ? webhookFetchUrl : null, webhookFetcher, @@ -69,13 +56,7 @@ export function Webhook({ webhook }: Props) { }, ) - // When you load the page we want to support linking to a specific webhook type - // so this effect sets the webhook type if it's provided in the URL e.g.: - // - // webhook-events-and-payloads?actionType=published#package - // - // where the webhook is set in the hash (which is equal to webhookSlug) and - // the webhook action type is passed in the actionType parameter. + // Example: webhook-events-and-payloads?actionType=published#package opens the published package payload. useEffect(() => { const url = new URL(location.href) const actionType = url.searchParams.get('actionType') @@ -86,7 +67,6 @@ export function Webhook({ webhook }: Props) { } }, []) - // Build a plain-text announcement from the webhook action data. const buildAnnouncement = useCallback( (type: string, actionData: { descriptionHtml: string }) => { const tempEl = document.createElement('div') @@ -100,26 +80,15 @@ export function Webhook({ webhook }: Props) { [t], ) - // callback for the action type dropdown -- sets the action type to the given - // type, index is the index of the selected type so we can highlight it as - // selected. - // - // Besides setting the action type state, we also want to: - // - // * clear the clicked body param so that no properties are expanded when we - // re-render the webhook - // * update the URL so people can link to a specific webhook action type + // Reset nested parameters, announce the selected action type, and keep the URL linkable. function handleActionTypeChange(type: string, index: number) { setClickedBodyParameterName('') setSelectedWebhookActionType(type) setSelectedActionTypeIndex(index) - // If SWR data is already cached, announce immediately. Otherwise, flag - // the type so the effect can announce once data arrives. + // Cached data can announce now; uncached data announces after SWR loads. if (data && data[type]) { - // Use setTimeout so the announcement fires after the ActionMenu closes - // and VoiceOver finishes reading the button. Compute message eagerly to - // avoid stale closures if data changes before the timeout fires. + // Compute the message eagerly to avoid stale data, then delay until VoiceOver finishes the menu. const message = buildAnnouncement(type, data[type]) setTimeout(() => { announce(message, { politeness: 'assertive' }) @@ -128,15 +97,13 @@ export function Webhook({ webhook }: Props) { setPendingAnnouncement(type) } - // Update the URL without triggering Next.js router navigation, which causes - // VoiceOver to re-read the page title and swallow live-region announcements. + // Replace history directly so Next.js navigation does not make VoiceOver re-read the page title. const url = new URL(location.href) url.searchParams.set('actionType', type) url.hash = webhookSlug window.history.replaceState(window.history.state, '', url.toString()) } - // callback to trigger useSWR() hook after a nested property is clicked function handleBodyParamExpansion(target: HTMLDetailsElement) { setClickedBodyParameterName(target.closest('details')?.dataset.nestedParamId) } @@ -144,8 +111,7 @@ export function Webhook({ webhook }: Props) { const currentWebhookActionType = selectedWebhookActionType || webhook.data.action const currentWebhookAction = (data && data[currentWebhookActionType]) || webhook.data - // Announce content changes when data arrives for the first time (before SWR - // cache is populated). Subsequent changes are announced directly in the handler. + // Announce the first uncached selection after SWR loads; cached selections announce in the handler. useEffect(() => { if (!pendingAnnouncement || !data || !data[pendingAnnouncement]) return const type = pendingAnnouncement diff --git a/src/webhooks/lib/index.ts b/src/webhooks/lib/index.ts index 0756bfa27417..111fa4b69fbc 100644 --- a/src/webhooks/lib/index.ts +++ b/src/webhooks/lib/index.ts @@ -33,23 +33,19 @@ interface WebhookActionData { type WebhookCategory = Record<string, WebhookActionData> type WebhookData = Record<string, WebhookCategory> -// Two-tier cache: fpt and ghec are pinned in a plain Map (never evicted) because -// they account for the vast majority of traffic. All other versions (ghes) go -// into a bounded LRU cache to prevent unbounded memory growth. +// Pin fpt and ghec because they receive most traffic. +// Bound all GHES versions with LRU so schema cache memory cannot grow without limit. const PINNED_OPEN_API_VERSIONS = new Set(['fpt', 'ghec']) const pinnedCache = new Map<string, WebhookCategory>() const LRU_MAX_SIZE = Math.max(1, parseInt(process.env.WEBHOOK_SCHEMA_LRU_SIZE ?? '', 10) || 96) const lruCache = new QuickLRU<string, WebhookCategory>({ maxSize: LRU_MAX_SIZE }) -// In-flight deduplication: concurrent cache misses for the same key share one -// file read instead of each triggering their own. +// Concurrent cache misses for the same key share one file read. const inflight = new Map<string, Promise<WebhookCategory>>() const brotliDecompressAsync = promisify(brotliDecompress) -// cache for webhook data for when you first visit the webhooks page where we -// show all webhooks for the current version but only 1 action type per webhook -// and also no nested parameters +// Landing-page data has every webhook for a version, one action type each, and no nested params. const initialWebhooksCache = new Map<string, InitialWebhook[]>() interface InitialWebhook { @@ -58,7 +54,6 @@ interface InitialWebhook { data: WebhookActionData } -// Returns the data described above for initialWebhooksCache. export async function getInitialPageWebhooks(version: string): Promise<InitialWebhook[]> { if (initialWebhooksCache.has(version)) { return initialWebhooksCache.get(version) || [] @@ -66,9 +61,7 @@ export async function getInitialPageWebhooks(version: string): Promise<InitialWe const allWebhooks = await getWebhooks(version) const initialWebhooks: InitialWebhook[] = [] - // The webhooks page shows all webhooks but for each webhook only a single - // webhook action type at a time. We pick the first webhook type from each - // webhook's set of action types to show. + // Show one action type per webhook on the landing page; the full payload loads on drill-down. for (const [key, webhook] of Object.entries(allWebhooks)) { const actionTypes = Object.keys(webhook) const defaultAction = actionTypes.length > 0 ? actionTypes[0] : '' @@ -79,25 +72,19 @@ export async function getInitialPageWebhooks(version: string): Promise<InitialWe data: defaultAction ? webhook[defaultAction] : {}, } - // Sync writes the base category files with childParamsGroups already empty - // and puts the real values in separate .child-params.json files, so there is - // nothing to strip here. + // Sync stores childParamsGroups in sidecar files, so base category files need no stripping. initialWebhooks.push(initialWebhook) } initialWebhooksCache.set(version, initialWebhooks) return initialWebhooks } -// Allowlist pattern for webhook category names: only lowercase letters, digits, -// and underscores. This prevents path traversal (e.g. "../secret") when the -// category comes from user-supplied query parameters. +// Allow only lowercase letters, digits, and underscores in webhook category names. +// This blocks path traversal such as ../secret in user-supplied query parameters. const SAFE_CATEGORY_RE = /^[a-z0-9_]+$/ -// returns the webhook data for the given version and webhook category (e.g. -// `check_run`) -- this includes all the data per webhook action type and all -// nested parameters. Loads only the requested category file on demand. -// When `includeChildParams` is true (default), also loads and merges the -// separate child-params file so drill-down pages get full nested parameter data. +// Loads one webhook category, such as check_run, on demand. Drill-down requests +// also merge the child-params sidecar so nested parameter data stays off the landing page. export async function getWebhook( version: string, webhookCategory: string, @@ -107,10 +94,7 @@ export async function getWebhook( const openApiVersion = getOpenApiVersion(version) - // Resolve the requested category against the on-disk category list so every - // file path below is built from a filesystem-derived name rather than the - // raw request value. This both 404s unknown categories and severs the - // user-input taint chain (defeats path traversal / CodeQL path-injection). + // Use a filesystem-derived category name so unknown categories 404 and requests cannot traverse paths. const safeCategory = getWebhookCategories(version).find((name) => name === webhookCategory) if (!safeCategory) return undefined @@ -131,8 +115,7 @@ export async function getWebhook( const slimData = cache.get(cacheKey) if (!slimData || !includeChildParams) return slimData - // Merge childParamsGroups from the separate file for drill-down requests. - // This data is not cached because it is large and only needed per request. + // Drill-down childParamsGroups stay uncached because they are large and needed per request. const childParamsPath = path.join( WEBHOOK_DATA_DIR, openApiVersion, @@ -144,9 +127,7 @@ export async function getWebhook( return mergeChildParams(slimData, childParams) } -// returns all the webhook data for the given version by loading each category -// file in parallel. Does NOT include childParamsGroups, because this feeds the -// landing page. Use getWebhook() for drill-down with full nested params. +// Loads landing-page data for every category in parallel, without childParamsGroups. export async function getWebhooks(version: string): Promise<WebhookData> { const categories = getWebhookCategories(version) const entries = await Promise.all( @@ -158,11 +139,8 @@ export async function getWebhooks(version: string): Promise<WebhookData> { return Object.fromEntries(entries) } -// returns the list of webhook category names available for the given version -// by reading the data directory. Mirrors getRestCategories() in src/rest/lib/index.ts. -// Memoized per openApiVersion: the data directory is static at runtime, and -// getWebhook() consults this on every call as a path-injection allowlist, so we -// must not pay a readdirSync on each lookup. +// Mirrors getRestCategories in src/rest/lib/index.ts. +// Cache the static data-directory listing because getWebhook uses it as a path-injection allowlist. const categoriesCache = new Map<string, string[]>() export function getWebhookCategories(version: string): string[] { const openApiVersion = getOpenApiVersion(version) @@ -179,21 +157,15 @@ export function getWebhookCategories(version: string): string[] { type ChildParamsData = Record<string, Record<string, unknown[]>> -// Load the child-params file for a webhook category. Returns null if the file -// does not exist (some webhooks have no childParamsGroups). -// The filePath is built from a filesystem-derived category name (validated -// against getWebhookCategories in getWebhook), never directly from request input. +// Child-params sidecars are optional; categories without nested groups return null. +// getWebhook passes a filesystem-derived path, never raw request input. async function loadChildParamsFile(filePath: string): Promise<ChildParamsData | null> { try { const compressed = await fsPromises.readFile(`${filePath}.br`) const decompressed = await brotliDecompressAsync(compressed) return JSON.parse(decompressed.toString()) as ChildParamsData } catch { - // The brotli variant is optional; fall back to plain JSON. A genuine - // missing-file (ENOENT) means this category simply has no childParamsGroups, - // so we return null. Any other error (malformed JSON, truncated/corrupt - // read, permission denied) is a real failure that would silently drop - // nested params on drill-down, so we log it loudly before returning null. + // Missing sidecars return null; corrupt or unreadable plain JSON logs before dropping nested params. try { const raw = await fsPromises.readFile(filePath, 'utf-8') return JSON.parse(raw) as ChildParamsData @@ -206,7 +178,6 @@ async function loadChildParamsFile(filePath: string): Promise<ChildParamsData | } } -// Merge childParamsGroups back into slim webhook data for drill-down responses. function mergeChildParams( slimData: WebhookCategory, childParams: ChildParamsData, @@ -232,17 +203,16 @@ function mergeChildParams( return merged } -// Read asynchronously to avoid blocking the event loop on a cache miss. -// Try the brotli-compressed variant first (used in staging), then plain JSON. -// basePath is built from a filesystem-derived category name (validated against -// getWebhookCategories in getWebhook), never directly from request input. +// Read category files asynchronously so cache misses do not block the event loop. +// Staging can serve Brotli files; plain JSON remains the fallback. +// getWebhook passes a filesystem-derived basePath, never raw request input. async function loadWebhookFile(basePath: string): Promise<WebhookCategory> { try { const compressed = await fsPromises.readFile(`${basePath}.br`) const decompressed = await brotliDecompressAsync(compressed) return JSON.parse(decompressed.toString()) as WebhookCategory } catch { - // .br missing or unreadable, so fall back to plain JSON. + // Missing or unreadable Brotli files fall back to plain JSON. const raw = await fsPromises.readFile(basePath, 'utf-8') return JSON.parse(raw) as WebhookCategory } diff --git a/src/webhooks/lib/tests/index.ts b/src/webhooks/lib/tests/index.ts index 4abdbe58b328..e31ef29f0f90 100644 --- a/src/webhooks/lib/tests/index.ts +++ b/src/webhooks/lib/tests/index.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { getInitialPageWebhooks, getWebhook, getWebhooks } from '../index' -// Use a version that's guaranteed to exist in the data directory. +// free-pro-team@latest always exists in the data directory. const VERSION = 'free-pro-team@latest' // Pick a webhook category that has a .child-params.json sidecar, so getWebhook @@ -23,7 +23,6 @@ describe('getInitialPageWebhooks does not corrupt the getWebhook cache', () => { }) it('preserves childParamsGroups in the getWebhook cache after getInitialPageWebhooks runs', async () => { - // Seed the cache and record original childParamsGroups lengths. const before = await getWebhook(VERSION, CATEGORY) expect(before).toBeDefined() @@ -37,11 +36,9 @@ describe('getInitialPageWebhooks does not corrupt the getWebhook cache', () => { } expect(Object.keys(originalLengths).length).toBeGreaterThan(0) - // The initial-page data has empty childParamsGroups. It must not reach back - // into the objects getWebhook already cached. + // Initial-page data must not mutate childParamsGroups already cached by getWebhook. await getInitialPageWebhooks(VERSION) - // getWebhook returns cached data, which must NOT have been mutated. const after = await getWebhook(VERSION, CATEGORY) expect(after).toBeDefined() @@ -61,7 +58,6 @@ describe('getInitialPageWebhooks does not corrupt the getWebhook cache', () => { describe('childParamsGroups deferred loading', () => { it('getWebhooks() returns data without childParamsGroups (slim)', async () => { const allWebhooks = await getWebhooks(VERSION) - // Check a category known to have child params const webhook = allWebhooks[CATEGORY] expect(webhook).toBeDefined() diff --git a/src/webhooks/middleware/webhooks.ts b/src/webhooks/middleware/webhooks.ts index 86063188feda..cc59b7f62e51 100644 --- a/src/webhooks/middleware/webhooks.ts +++ b/src/webhooks/middleware/webhooks.ts @@ -5,11 +5,7 @@ import { defaultCacheControl } from '@/frame/middleware/cache-control' const router = express.Router() -// Returns a webhook for the given category and version -// -// Example request: -// -// /api/webhooks/v1?category=check_run&version=free-pro-team%40latest +// Example: /api/webhooks/v1?category=check_run&version=free-pro-team%40latest router.get('/v1', async function webhooks(req, res) { if (!req.query.category) { res.status(400).json({ error: "Missing 'category' in query string" }) diff --git a/src/webhooks/pages/webhook-events-and-payloads.tsx b/src/webhooks/pages/webhook-events-and-payloads.tsx index d3ad7860187e..ee7f2fa4cc08 100644 --- a/src/webhooks/pages/webhook-events-and-payloads.tsx +++ b/src/webhooks/pages/webhook-events-and-payloads.tsx @@ -40,16 +40,12 @@ export default function WebhooksEventsAndPayloads({ ) }) - // When someone clicks on a minitoc hash anchor link on this page, we want to - // remove the type query parameter from the URL because the type won't make - // sense anymore (e.g. ?actionType=closed#issues and you click on the fork minitoc - // we don't want the URL to be ?actionType=closed#fork). + // Drop actionType on hash navigation, such as ?actionType=closed#issues to #fork; it no longer applies. useEffect(() => { const hashChangeHandler = () => { const { pathname, hash, search } = window.location - // carry over any other query parameters besides `actionType` for the webhook - // action type + // Preserve unrelated query parameters when removing actionType. const params = new URLSearchParams(search) params.delete('actionType') @@ -87,13 +83,10 @@ export const getServerSideProps: GetServerSideProps<Props> = async (context) => addUINamespaces(req, mainContext.data.ui, ['parameter_table', 'webhooks']) const { miniTocItems } = getAutomatedPageContextFromRequest(req) - // Get data for initial webhooks page (i.e. only 1 action type per webhook and - // no nested parameters) + // Landing-page webhooks include one action type per webhook and no nested parameters. const webhooks = (await getInitialPageWebhooks(currentVersion)) as unknown as WebhookAction[] - // Build the minitocs for the webhooks page which is based on the webhook - // categories in addition to the Markdown in the webhook-events-and-payloads.md - // content file + // Add webhook categories to the mini table of contents from webhook-events-and-payloads.md. const webhooksMiniTocs = await getAutomatedPageMiniTocItems( webhooks.map((webhook) => webhook.data.category), context, diff --git a/src/webhooks/scripts/sync.ts b/src/webhooks/scripts/sync.ts index 2a909e72635d..1ff0b958accc 100644 --- a/src/webhooks/scripts/sync.ts +++ b/src/webhooks/scripts/sync.ts @@ -22,10 +22,7 @@ export async function syncWebhookData( webhookSchemas.map(async (schemaName) => { const file = path.join(sourceDirectory, schemaName) const schema: WebhookFile = JSON.parse(await readFile(file, 'utf-8')) - // In OpenAPI version 3.1, the schema data is under the `webhooks` - // key, but in 3.0 the schema data was in `x-webhooks`. - // We just fallback to `x-webhooks` for now since there's - // currently no difference in the schema data between versions. + // OpenAPI 3.1 stores webhook data under webhooks and 3.0 under x-webhooks; both use the same shape. const webhookSchemaData = schema.webhooks ?? schema['x-webhooks'] if (!webhookSchemaData) { console.log( @@ -51,12 +48,7 @@ export async function syncWebhookData( await mkdir(targetDirectory, { recursive: true }) } - // Write one JSON file per webhook category (e.g. check_run.json) instead - // of a single monolithic schema.json. This allows the server to load only - // the requested webhook on demand rather than the entire version schema. - // - // childParamsGroups are split into a separate file ({category}.child-params.json) - // so the landing page never loads them. They are fetched on drill-down only. + // Split categories, such as check_run.json, from childParamsGroups sidecars. await Promise.all( Object.entries(data).map(async ([category, categoryData]) => { const childParams: Record<string, Record<string, unknown[]>> = {} @@ -97,10 +89,7 @@ export async function syncWebhookData( await writeFile(childParamsPath, JSON.stringify(childParams, null, 2)) console.log(`✅ Wrote ${childParamsPath}`) } else { - // Remove any stale sidecar from a previous sync where this category - // had child params but no longer does. getWebhook() probes for this - // file unconditionally, so leaving it would reintroduce removed - // nested params on drill-down. + // Remove stale child-param sidecars so drill-down pages do not show removed nested params. for (const stalePath of [childParamsPath, `${childParamsPath}.br`]) { if (existsSync(stalePath)) { await unlink(stalePath) @@ -126,10 +115,8 @@ async function processWebhookSchema(webhooks: Webhook[]): Promise<void> { } } -// Create an object with all webhooks where the key is the webhook name. -// Webhooks typically have a property called `action` that describes the -// events that trigger the webhook. Some webhooks (like `ping`) don't have -// action types -- in that case we set the value of action to 'default'. +// Groups webhooks by category and action type. +// Webhooks without action types, such as ping, use default. async function formatWebhookData( webhooks: Webhook[], ): Promise<Record<string, Record<string, Webhook>>> { diff --git a/src/webhooks/scripts/webhook-schema.ts b/src/webhooks/scripts/webhook-schema.ts index be1053124491..f624ad607255 100644 --- a/src/webhooks/scripts/webhook-schema.ts +++ b/src/webhooks/scripts/webhook-schema.ts @@ -1,10 +1,8 @@ -// This schema is used to validate each generated webhook object at build time - +// Defines the build-time schema for every generated webhook object. export default { type: 'object', required: ['availability', 'bodyParameters', 'category', 'descriptionHtml', 'summaryHtml'], properties: { - // Properties from the source OpenAPI schema that this module depends on action: { description: 'The webhook action type', type: ['string', 'null'], diff --git a/src/webhooks/scripts/webhook.ts b/src/webhooks/scripts/webhook.ts index a081fbc6f67a..8d0b88a0f0a9 100644 --- a/src/webhooks/scripts/webhook.ts +++ b/src/webhooks/scripts/webhook.ts @@ -63,9 +63,7 @@ export default class Webhook implements WebhookInterface { null, ) - // for some webhook action types (like some pull-request webhook types) the - // schema properties are under a oneOf so we try and take the action from - // the first one (the action will be the same across oneOf items) + // Some pull-request webhooks put the same action enum under the first oneOf schema. if (!this.action) { this.action = get( webhook, @@ -74,9 +72,7 @@ export default class Webhook implements WebhookInterface { ) } - // The OpenAPI uses hyphens for the webhook names, but the webhooks - // are sent using underscores (e.g. `branch_protection_rule` instead - // of `branch-protection-rule`) + // OpenAPI uses branch-protection-rule, but delivered webhooks use branch_protection_rule. this.category = webhook['x-github'].subcategory.replace(/-/g, '_') } @@ -101,7 +97,7 @@ export default class Webhook implements WebhookInterface { const schema = get(this.#webhook, `requestBody.content['application/json'].schema`, {}) this.bodyParameters = isPlainObject(schema) ? await getBodyParams(schema, true) : [] - // Removes the children of the common properties + // Common top-level properties do not need expanded child parameter groups. for (const param of this.bodyParameters) { if (NO_CHILD_PROPERTIES.includes(param.name)) { param.childParamsGroups = [] diff --git a/src/webhooks/tests/api.ts b/src/webhooks/tests/api.ts index 5092943e10a0..1cb9b68b91e1 100644 --- a/src/webhooks/tests/api.ts +++ b/src/webhooks/tests/api.ts @@ -6,9 +6,7 @@ import { makeLanguageSurrogateKey } from '@/frame/middleware/set-fastly-surrogat describe('webhooks v1 middleware', () => { test('basic get webhook', async () => { const sp = new URLSearchParams() - // Based on live data which isn't ideal but it should rarely change at least. - // Just check that we find the webhook and that the result has the `category` - // field which all webhook types should have. + // Live data can change, so assert only the stable category field on an existing webhook. sp.set('category', 'branch_protection_rule') sp.set('version', 'free-pro-team@latest') const res = await get(`/api/webhooks/v1?${sp}`) @@ -18,7 +16,6 @@ describe('webhooks v1 middleware', () => { expect(actionTypes.length).toBeGreaterThan(2) expect(Object.keys(results[actionTypes[0]]).includes('category')).toBeTruthy() - // Check that it can be cached at the CDN expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -64,7 +61,7 @@ describe('webhooks v1 middleware', () => { test('drill-down endpoint returns childParamsGroups', async () => { const sp = new URLSearchParams() - // projects_v2_item is known to have non-empty childParamsGroups + // projects_v2_item is known to have non-empty childParamsGroups. sp.set('category', 'projects_v2_item') sp.set('version', 'free-pro-team@latest') const res = await get(`/api/webhooks/v1?${sp}`) diff --git a/src/webhooks/tests/oneof-handling.ts b/src/webhooks/tests/oneof-handling.ts index 174814e73e86..cff3971b363f 100644 --- a/src/webhooks/tests/oneof-handling.ts +++ b/src/webhooks/tests/oneof-handling.ts @@ -7,8 +7,7 @@ import { describe('oneOf handling in webhook parameters', () => { test('should handle oneOf fields correctly for secret_scanning_alert_location details', async () => { - // Mirrors the secret_scanning_alert_location details field in the real - // OpenAPI schema. + // The mock mirrors the real secret_scanning_alert_location details field. const mockSchema = { type: 'object', properties: { @@ -215,7 +214,7 @@ describe('oneOf handling in webhook parameters', () => { expect(detailsParam).toBeDefined() expect(detailsParam?.childParamsGroups?.length).toBe(2) - // When titles are missing, the name should be undefined or handled gracefully + // Untitled oneOf options still render object variants without names. if (detailsParam?.childParamsGroups) { for (const param of detailsParam.childParamsGroups) { expect(param.type).toBe('object') diff --git a/src/webhooks/tests/rendering.ts b/src/webhooks/tests/rendering.ts index 3240d6b5d75a..f35df5099e4b 100644 --- a/src/webhooks/tests/rendering.ts +++ b/src/webhooks/tests/rendering.ts @@ -7,8 +7,7 @@ import { getWebhooks } from '../lib/index' describe('webhooks events and payloads', () => { vi.setConfig({ testTimeout: 3 * 60 * 1000 }) - // This test ensures that the page component and the Markdown file are - // in sync. It also checks that all expected items are present. + // Keeps the page component, Markdown, and generated webhook data in sync. test('loads webhook schema data for all versions', async () => { for (const version in allVersions) { const webhooks = await getWebhooks(version) @@ -27,8 +26,6 @@ describe('webhooks events and payloads', () => { }) test('Non-GHES versions do not load GHES only webhook', async () => { - // available since 3.4, only in GHES (technically also GHAE which is based - // off of GHES) const ghesOnlyWebhook = 'cache_sync' for (const version in allVersions) { @@ -52,8 +49,7 @@ describe('webhooks events and payloads', () => { const $root = $(rootSelector) expect($root.length).toBe(1) - // on the webhooks page the lead is separate from the article body (unlike - // the REST pages for example) + // Webhooks pages render the lead outside the article body for search extraction. const leadSelector = '[data-search=lead] p' const $lead = $(leadSelector) expect($lead.length).toBe(1) @@ -64,12 +60,7 @@ describe('webhooks events and payloads', () => { test('every webhook event has at least one payload example', async () => { const versions = Object.values(allVersions).map((value) => value.version) - // For all versions, check that the webhook events and payloads page - // has at least one payload example for each event. Payload examples - // start with the id `webhook-payload-example` and have a sibling div - // with the class `height-constrained-code-block`. The sibling is - // usually but not always the next sibling element, which is why - // `nextUntil` is used. + // nextUntil finds payload code blocks in later siblings, not only the next one. for (const version of versions) { const page = `/${version}/webhooks-and-events/webhooks/webhook-events-and-payloads` const $ = await getDOM(page) diff --git a/src/webhooks/tests/webhook-generation-oneof.ts b/src/webhooks/tests/webhook-generation-oneof.ts index b7ff4534c208..82451ba9740e 100644 --- a/src/webhooks/tests/webhook-generation-oneof.ts +++ b/src/webhooks/tests/webhook-generation-oneof.ts @@ -3,8 +3,7 @@ import Webhook from '../scripts/webhook' describe('webhook generation with oneOf fields', () => { test('should properly generate webhook documentation for secret_scanning_alert_location with oneOf details', async () => { - // Mock OpenAPI schema that represents the actual structure from github/rest-api-description - // This simulates the secret_scanning_alert_location webhook with oneOf details field + // Mirrors secret_scanning_alert_location's oneOf details shape in github/rest-api-description. const mockWebhookSchema = { summary: 'This event occurs when there is activity relating to the locations of a secret in a secret scanning alert.', @@ -267,34 +266,28 @@ describe('webhook generation with oneOf fields', () => { }, } - // Create webhook instance and process it const webhook = new Webhook(mockWebhookSchema) await webhook.process() - // Verify basic webhook properties expect(webhook.category).toBe('secret_scanning_alert_location') expect(webhook.action).toBe('created') expect(webhook.availability).toEqual(['repository', 'organization', 'app']) expect(webhook.bodyParameters).toBeDefined() expect(webhook.bodyParameters.length).toBeGreaterThan(0) - // Find the location parameter const locationParam = webhook.bodyParameters.find((param) => param.name === 'location') expect(locationParam).toBeDefined() expect(locationParam?.type).toBe('object') expect(locationParam?.childParamsGroups).toBeDefined() - // Find the details parameter within location const detailsParam = locationParam?.childParamsGroups?.find((param) => param.name === 'details') expect(detailsParam).toBeDefined() expect(detailsParam?.type).toBe('object') - // Verify that oneOf handling worked correctly expect(detailsParam?.oneOfObject).toBe(true) expect(detailsParam?.childParamsGroups).toBeDefined() expect(detailsParam?.childParamsGroups?.length).toBeGreaterThan(1) - // Check that all expected oneOf variants are present const childParams = detailsParam?.childParamsGroups || [] const variantNames = childParams.map((param) => param.name) @@ -311,13 +304,11 @@ describe('webhook generation with oneOf fields', () => { expect(variantNames).toContain('pull_request_review') expect(variantNames).toContain('pull_request_review_comment') - // Verify specific variant details const commitVariant = childParams.find((param) => param.name === 'commit') expect(commitVariant).toBeDefined() expect(commitVariant?.description).toContain("commit' secret scanning location type") expect(commitVariant?.childParamsGroups?.length).toBeGreaterThan(0) - // Check commit variant has expected properties const commitProperties = commitVariant?.childParamsGroups?.map((param) => param.name) || [] expect(commitProperties).toContain('path') expect(commitProperties).toContain('start_line') @@ -334,13 +325,11 @@ describe('webhook generation with oneOf fields', () => { expect(issueUrlParam?.name).toBe('issue_title_url') expect(issueUrlParam?.description).toContain('API URL to get the associated issue resource') - // Verify that descriptions are properly rendered expect(commitVariant?.description).toContain('<p>') expect(issueTitleVariant?.description).toContain('<p>') }) test('should handle mixed oneOf types correctly', async () => { - // Test case where oneOf contains both objects and non-objects const mockMixedOneOfSchema = { summary: 'Test webhook with mixed oneOf types', description: 'A webhook for testing mixed oneOf handling', @@ -386,7 +375,6 @@ describe('webhook generation with oneOf fields', () => { const mixedParam = webhook.bodyParameters.find((param) => param.name === 'mixed_field') expect(mixedParam).toBeDefined() - // For mixed types, it should use the fallback behavior (not oneOfObject) expect(mixedParam?.oneOfObject).toBeFalsy() expect(mixedParam?.type).toContain('string') expect(mixedParam?.type).toContain('object') @@ -418,7 +406,6 @@ describe('webhook generation with oneOf fields', () => { const webhook = new Webhook(mockEmptyOneOfSchema) - // Should not throw an error await expect(webhook.process()).resolves.not.toThrow() const emptyParam = webhook.bodyParameters.find((param) => param.name === 'empty_oneof') diff --git a/src/workflows/sync-sdk-docs/convert-mermaid.ts b/src/workflows/sync-sdk-docs/convert-mermaid.ts index dbcd214ae81d..d171e66edb4d 100644 --- a/src/workflows/sync-sdk-docs/convert-mermaid.ts +++ b/src/workflows/sync-sdk-docs/convert-mermaid.ts @@ -1,9 +1,8 @@ #!/usr/bin/env node -// Renders each ```mermaid block in the SDK docs to a PNG with +// Renders each mermaid code block in the SDK docs to a PNG with // @mermaid-js/mermaid-cli (mmdc), saves it under the assets directory, and -// replaces the code block with an image reference. A block whose render fails -// is left as it is. +// replaces the source block with an image reference. Failed renders stay as source. // // Filenames come from the source file path and the block index, so re-running // produces stable results. @@ -36,7 +35,7 @@ if (!fs.existsSync(SDK_DOCS_DIR)) { process.exit(1) } -// Find the mmdc binary: global PATH first, then local node_modules. +// Prefer mmdc from PATH, then fall back to the repo dependency. let MMDC_BIN: string try { MMDC_BIN = execSync('which mmdc', { encoding: 'utf8' }).trim() @@ -50,7 +49,6 @@ try { } } -// Recursively collect all .md files. function getAllMarkdownFiles(dir: string): string[] { const results: string[] = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -64,15 +62,13 @@ function getAllMarkdownFiles(dir: string): string[] { return results } -// Generates a filename from the source file's relative path and the block -// index, so it is stable across runs. +// Deterministic filenames come from the source path and mermaid block index. function generateImageName(filePath: string, blockIndex: number): string { const rel = path.relative(SDK_DOCS_DIR, filePath).replace(/\.md$/, '').replace(/\//g, '-') return `${rel}-diagram-${blockIndex}.png` } -// Builds generic alt text from the diagram type named on the first line. The -// contents of the diagram are not used. +// Generic alt text avoids inventing semantics from diagram source. function generateAltText(mermaidSource: string): string { const lines = mermaidSource.trim().split('\n') const firstLine = lines[0].trim() @@ -106,7 +102,6 @@ function generateAltText(mermaidSource: string): string { return 'Diagram illustrating the described process.' } -// Converts the mermaid blocks in one file and returns how many succeeded. function processFile(filePath: string, assetsUrlPath: string): number { const raw = fs.readFileSync(filePath, 'utf8') @@ -118,7 +113,7 @@ function processFile(filePath: string, assetsUrlPath: string): number { let converted = 0 let result = raw - // Process matches in reverse order to preserve string indices + // Process matches in reverse order to preserve string indices. for (let i = matches.length - 1; i >= 0; i--) { const match = matches[i] const mermaidSource = match[1] @@ -169,9 +164,8 @@ console.log('--- Converting Mermaid diagrams to PNG ---\n') fs.mkdirSync(ASSETS_DIR, { recursive: true }) -// Compute the URL path for image references -// The assets dir relative to the docs-internal root gives us the URL path -// e.g. assets/images/help/copilot/sdk-docs → /assets/images/help/copilot/sdk-docs +// Image references need the assets directory relative to the docs-internal root. +// Example: assets/images/help/copilot/sdk-docs becomes /assets/images/help/copilot/sdk-docs. const assetsUrlPath = `/${path.relative(REPO_ROOT, ASSETS_DIR)}` const files = getAllMarkdownFiles(SDK_DOCS_DIR) diff --git a/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts b/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts index d7cd5bf4b26c..d45efd1cf395 100644 --- a/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts +++ b/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts @@ -1,11 +1,8 @@ #!/usr/bin/env node -// Normalizes Copilot SDK docs for publishing on docs.github.com. The steps are -// called at the bottom of this file, roughly but not exactly in numeric order: -// Step 0a runs before Step 0, and Step 1b after Step 1. Where the ordering -// matters, the step's own comment says why. -// -// Adapted from the spike normalization script in docs-internal#60525. +// Normalizes Copilot SDK docs for docs.github.com, including README landing +// pages, frontmatter, links, code fences, ordered lists, hidden validation +// samples, codetabs, and SDK-specific markdownlint suppressions. // // Usage: // npx tsx src/workflows/sync-sdk-docs/normalize-sdk-docs.ts --content-dir <path> \ @@ -28,33 +25,24 @@ const { values: args } = parseArgs({ const CONTENT_DIR = path.resolve(args['content-dir'] as string) const SDK_DOCS_DIR = path.resolve(args['sdk-docs-dir'] as string) -/** - * Pages that have been relocated OUT of the synced SDK docs tree into - * hand-authored content elsewhere in docs-internal. - * - * Keys are paths relative to the SDK docs root, exactly as they appear upstream - * in github/copilot-sdk's `docs/` directory. Values are the docs.github.com URL - * the page now lives at. - * - * Each entry does two inseparable things on every sync: - * 1. Deletes the upstream copy after it is rsynced in (Step 0a), so the page - * is not republished at its old URL. That URL is now a `redirect_from` on - * the hand-authored page and must stay vacant. - * 2. Teaches the internal-link rewriter (Step 3) to point inbound relative - * links at the new URL, instead of logging "target missing" and leaving a - * raw `../getting-started.md` link in published content. - * - * Both halves must stay together, which is why this lives here rather than as an - * rsync `--exclude` in .github/workflows/sync-sdk-docs.yml: excluding the file - * at copy time without remapping its links would ship ~17 broken links. - * - * Destinations are validated on every run; see validateRelocatedDestinations(). - */ +// These paths moved out of the synced SDK docs tree into hand-authored content. +// Keys match github/copilot-sdk docs paths relative to the SDK docs root. +// Values are their docs.github.com destinations. +// +// Each entry deletes the upstream copy after rsync, so the old URL stays vacant +// for redirect_from, and it remaps inbound links to the new URL. +// +// Keep the delete and remap together here. An rsync exclude in +// .github/workflows/sync-sdk-docs.yml would ship about 17 broken links because +// the internal-link rewriter would still point at raw relative paths such as +// ../getting-started.md. +// +// validateRelocatedDestinations() checks these destinations on every run. const RELOCATED_PAGES: Record<string, string> = { 'getting-started.md': '/copilot/get-started/sdk-quickstart', } -// Relocated pages whose upstream source file was not found during this sync. +// Track missing relocated sources because an upstream rename can republish at a new URL. const missingRelocatedSources: string[] = [] if (!fs.existsSync(CONTENT_DIR)) { @@ -66,7 +54,6 @@ if (!fs.existsSync(SDK_DOCS_DIR)) { process.exit(1) } -// Recursively collect all .md files in a directory. function getAllMarkdownFiles(dir: string): string[] { const results: string[] = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -80,19 +67,12 @@ function getAllMarkdownFiles(dir: string): string[] { return results } -/** - * Step 0: Rename `README.md` files to `index.md`. - * - * The copilot-sdk repo uses `README.md` as the landing page for each docs - * directory (the GitHub convention). docs-internal instead requires `index.md` - * for directory pages, referenced by the parent's `children` frontmatter. - * - * This step: - * - Renames every `README.md` to `index.md` (skipping any directory that - * already has an `index.md`, to avoid clobbering). - * - Rewrites relative Markdown links that point at `README.md` so they target - * `index.md`, keeping later link-rewriting steps able to resolve them. - */ +// copilot-sdk uses README.md as directory landing pages. docs-internal requires +// index.md pages referenced by the parent's children frontmatter. +// +// Rewrite in-tree README.md links to index.md here, so later link rewriting can +// resolve them to directory URLs. Directories that already have index.md keep +// their README.md to avoid clobbering content. function convertReadmesToIndex(): void { const renamedDirs = new Set<string>() @@ -118,11 +98,7 @@ function convertReadmesToIndex(): void { if (renamedDirs.size === 0) return - // Rewrite relative links that target a README.md *inside the docs tree* to - // point at index.md, so the internal-link rewriter (Step 3) resolves them to - // the directory URL. Links to README.md files *outside* the docs tree (e.g. - // sibling language-SDK dirs like ../nodejs/README.md) are left untouched so - // Step 3b can link them to the real README on GitHub. + // Rewrite only in-tree README.md links, so SDK repo links still point at GitHub. const readmeLinkRegex = /\[([^\]]+)\]\(((?:\.{1,2}\/)[^)]*README\.md(?:#[^)]*)?)\)/g for (const file of getAllMarkdownFiles(SDK_DOCS_DIR)) { const raw = fs.readFileSync(file, 'utf8') @@ -135,7 +111,7 @@ function convertReadmesToIndex(): void { const resolved = path.resolve(dir, rawPath) const renamed = resolved.replace(/README\.md$/, 'index.md') - // Only rewrite when the target now exists as an index.md inside the docs tree. + // Rewrite only when an in-tree index.md target exists. if ( !renamed.startsWith(SDK_DOCS_DIR + path.sep) && renamed !== path.join(SDK_DOCS_DIR, 'index.md') @@ -156,32 +132,21 @@ function convertReadmesToIndex(): void { } } -// Returns the new URL for a relocated page, or undefined for a page that has -// not been relocated. function relocatedUrlFor(absPath: string): string | undefined { return RELOCATED_PAGES[path.relative(SDK_DOCS_DIR, absPath)] } -/** - * Step 0a: Delete pages that have been relocated out of the synced tree. - * - * The sync `rm -rf`s and re-rsyncs this whole directory every run, so a page - * moved into hand-authored content elsewhere in docs-internal would otherwise - * reappear at its old URL on the next sync and collide with the `redirect_from` - * that now claims it. (Redirect compilation resolves that collision by dropping - * the redirect, so the deletion is a hard invariant, not a tidiness measure.) - * - * This runs before every other step, so keys stay expressed in upstream terms: - * before Step 0 renames `README.md` to `index.md`, and before Step 1 so that - * `getChildren()` never sees the file and the parent index.md's `children` - * array is free of dangling entries. - * - * A missing source is reported rather than ignored: it usually means upstream - * renamed the file, in which case the page silently republishes under a new URL - * and the vacated URL may be reclaimed. It does not fail the sync, because - * github/copilot-sdk is a separate repo that may legitimately delete the page - * once docs-internal is canonical. - */ +// The sync rebuilds this directory on every run, so relocated upstream pages +// would otherwise reappear at their old URLs and collide with redirect_from on +// hand-authored pages. Redirect compilation drops the redirect on collision. +// +// Delete relocated pages before README.md becomes index.md and before getChildren() +// reads parents, so RELOCATED_PAGES stays in upstream terms and children arrays +// do not point at removed pages. +// +// Report missing sources because upstream may republish the page at a new URL +// and leave the previous URL open for reuse. Do not fail, because +// github/copilot-sdk may delete a page once docs-internal owns it. function removeRelocatedPages(): void { for (const [relPath, newUrl] of Object.entries(RELOCATED_PAGES)) { const absPath = path.join(SDK_DOCS_DIR, relPath) @@ -195,14 +160,7 @@ function removeRelocatedPages(): void { } } -/** - * Validate that every relocated page's destination actually exists in the - * hand-authored content tree. A typo or an unrelated rename would otherwise - * silently repoint every inbound link at a 404. - * - * Unlike a missing upstream source, this is entirely within docs-internal's - * control, so it fails the sync. It runs before anything mutates the tree. - */ +// Fail before mutating files if a relocated destination would send inbound links to a 404. function validateRelocatedDestinations(): void { const broken: string[] = [] @@ -221,11 +179,7 @@ function validateRelocatedDestinations(): void { process.exit(1) } -/** - * Report relocated pages whose upstream source vanished, to the Actions job - * summary linked from the generated PR. Mirrors reportUnbalancedMarkers(): the - * run log alone is not something a PR reviewer will see. - */ +// Put missing relocated sources in the generated PR's Actions summary, not only the run log. function reportMissingRelocatedSources(): void { const summaryPath = process.env.GITHUB_STEP_SUMMARY if (missingRelocatedSources.length === 0 || !summaryPath) return @@ -246,7 +200,6 @@ function reportMissingRelocatedSources(): void { fs.appendFileSync(summaryPath, lines.join('\n')) } -// Convert a filename slug to a title-case short title. function slugToTitle(slug: string): string { const ACRONYMS: Record<string, string> = { cli: 'CLI', @@ -265,7 +218,6 @@ function slugToTitle(slug: string): string { .join(' ') } -// Return the children entries for an index.md file. function getChildren(indexPath: string): string[] { const dir = path.dirname(indexPath) const entries = fs.readdirSync(dir, { withFileTypes: true }) @@ -288,7 +240,7 @@ function getChildren(indexPath: string): string[] { return children.sort() } -// Converts an absolute file path to a docs URL path, so +// The docs URL drops the content root, .md extension, and trailing index. // <repo>/content/copilot/sdk-docs/setup/local-cli.md becomes // /copilot/sdk-docs/setup/local-cli. function filePathToUrlPath(absPath: string): string { @@ -298,8 +250,7 @@ function filePathToUrlPath(absPath: string): string { return `/${rel}` } -// Step 1: Add frontmatter, taking the title from the first H1 and the intro -// from the first paragraph. +// SDK source files lack docs-internal frontmatter. function addFrontmatter(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') @@ -341,7 +292,7 @@ function addFrontmatter(filePath: string): void { intro = paraLines.join(' ') } - // shortTitle comes from the filename so the slugified-title test passes. + // Derive shortTitle from the filename so its slug passes the slugified-title test. const basename = path.basename(filePath, '.md') const shortTitle = basename === 'index' ? undefined : slugToTitle(basename) @@ -366,8 +317,7 @@ function addFrontmatter(filePath: string): void { } } - // For index.md files, strip all body content (docs-internal convention: - // index pages are frontmatter-only, navigation is generated from children) + // docs-internal index pages are frontmatter-only; children generates navigation. const body = isIndex ? '' : bodyLines.join('\n') const output = matter.stringify(body, frontmatterData) @@ -375,18 +325,16 @@ function addFrontmatter(filePath: string): void { console.log(` OK: ${path.relative(SDK_DOCS_DIR, filePath)}`) } -// Step 3: Rewrite internal relative .md links to [AUTOTITLE](/url-path). function rewriteInternalLinks(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') const dir = path.dirname(filePath) - // Match any relative Markdown link whose target ends in .md, including bare - // same-directory links written without a leading "./" (e.g. `[Hooks](hooks.md)`). + // Also match bare same-directory links such as [Hooks](hooks.md). const linkRegex = /\[([^\]]+)\]\(([^)]+\.md(?:#[^)]*)?)\)/g let changed = false const updated = raw.replace(linkRegex, (_match: string, _text: string, href: string) => { - // Only handle relative links: skip absolute paths, anchors, and external URLs. + // Skip absolute paths, anchors, and external URLs. if (href.startsWith('/') || href.startsWith('#') || /^[a-z][a-z0-9+.-]*:\/\//i.test(href)) { return _match } @@ -396,9 +344,7 @@ function rewriteInternalLinks(filePath: string): void { if (!resolved.startsWith(CONTENT_DIR)) return _match - // Pages relocated out of the synced tree no longer exist on disk, so the - // existence check below would leave a raw relative link. Repoint them at - // their new home instead. + // Relocated pages no longer exist on disk, so point them at their new home. const relocatedUrl = relocatedUrlFor(resolved) if (relocatedUrl) { changed = true @@ -422,9 +368,8 @@ function rewriteInternalLinks(filePath: string): void { } } -// Step 3b: Rewrite the ./ and ../ .md links Step 3 could not resolve into -// links to the SDK repo on GitHub. Mostly these point outside the docs tree, -// such as ../nodejs/README.md, but a missing in-tree target lands here too. +// Missing ./ and ../ Markdown targets can point outside the docs tree, such as +// ../nodejs/README.md, so rewrite them to the SDK repo on GitHub. function rewriteRepoRelativeLinks(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') const dir = path.dirname(filePath) @@ -439,16 +384,10 @@ function rewriteRepoRelativeLinks(filePath: string): void { if (fs.existsSync(resolved)) return _match - // content/copilot/sdk-docs/ maps to copilot-sdk/docs/, so a link from - // content/copilot/sdk-docs/getting-started.md to ../nodejs/README.md - // resolves to content/copilot/nodejs/README.md, which in the SDK repo is - // nodejs/README.md. + // content/copilot/sdk-docs maps ../nodejs/README.md to nodejs/README.md in the SDK repo. const relFromSdkDocs = path.relative(SDK_DOCS_DIR, resolved) - // One leading ../ reaches the repo root, so relFromSdkDocs looks like - // "../nodejs/README.md". Strip the leading ../ segments. A target more than - // one level above SDK_DOCS_DIR is outside the repo entirely and still gets - // a plausible-looking repo URL. + // Strip leading ../ segments; higher targets still get a plausible SDK repo URL. const parts = relFromSdkDocs.split(path.sep) let upCount = 0 for (const part of parts) { @@ -468,8 +407,7 @@ function rewriteRepoRelativeLinks(filePath: string): void { } } -// Step 4: Strip the docs.github.com domain from markdown links. A target found -// in CONTENT_DIR also gets its link text replaced with AUTOTITLE. +// docs.github.com Markdown links publish as root-relative links. function rewriteDocsGitHubLinks(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') @@ -488,8 +426,7 @@ function rewriteDocsGitHubLinks(filePath: string): void { console.log( ` STRIP-DOMAIN (target not in content tree): ${urlPath} in ${path.relative(SDK_DOCS_DIR, filePath)}`, ) - // Strip the docs.github.com domain even if the target doesn't exist - // locally. The path may be valid at runtime (e.g. versioned pages). + // Keep runtime-only paths such as versioned pages. const anchorSuffix = anchor ? `#${anchor}` : '' changed = true return `[${_text}](${urlPath}${anchorSuffix})` @@ -507,7 +444,6 @@ function rewriteDocsGitHubLinks(filePath: string): void { } } -// Step 5: Create missing index.md files for subdirectories. function createMissingIndexFiles(): string[] { const created: string[] = [] @@ -546,7 +482,7 @@ function createMissingIndexFiles(): string[] { return created } -// Step 6: Replace ```go with ```golang and ```ts with ```typescript. +// Markdownlint allows golang and typescript, not go and ts. function fixCodeFenceLanguages(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') @@ -573,7 +509,7 @@ function fixCodeFenceLanguages(filePath: string): void { } } -// Step 7: Renumber ordered lists so every item uses "1.". +// Markdownlint expects ordered-list items to use 1. for every item. function normalizeOrderedLists(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') const lines = raw.split('\n') @@ -601,7 +537,7 @@ function normalizeOrderedLists(filePath: string): void { } } -// Step 8: MD040 wants a language on every fence, so label a bare one ```text. +// MD040 requires a language on opening fences; closing fences stay unchanged. function fixBareCodeFences(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') const lines = raw.split('\n') @@ -613,10 +549,10 @@ function fixBareCodeFences(filePath: string): void { const isBare = /^\s*```\s*$/.test(lines[i]) if (isBare) { if (inCodeBlock) { - // Closing fence, leave as-is. + // Leave closing fences unchanged. inCodeBlock = false } else { - // Opening fence with no language, so add 'text'. + // Label bare opening fences as text. lines[i] = lines[i].replace(/```/, '```text') inCodeBlock = true changed = true @@ -632,7 +568,7 @@ function fixBareCodeFences(filePath: string): void { } } -// Step 9: MD031 wants a blank line before and after every fenced code block. +// MD031 requires blank lines around fenced code blocks. function fixBlanksAroundFences(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') const lines = raw.split('\n') @@ -646,8 +582,7 @@ function fixBlanksAroundFences(filePath: string): void { if (isFence) { if (!inCodeBlock) { - // Opening fence, so add a blank line before it unless this is the start - // of the file or the previous line is already blank. + // Add a blank line before opening fences when content precedes them. if (result.length > 0 && result[result.length - 1].trim() !== '') { result.push('') changed = true @@ -655,7 +590,7 @@ function fixBlanksAroundFences(filePath: string): void { result.push(line) inCodeBlock = true } else { - // Closing fence, so push it and then add a blank line after. + // Add a blank line after closing fences when content follows them. result.push(line) inCodeBlock = false if (i + 1 < lines.length && lines[i + 1].trim() !== '') { @@ -674,19 +609,12 @@ function fixBlanksAroundFences(filePath: string): void { } } -/** - * Step 1b: Remove `docs-validate: hidden` ranges. - * These wrap validation-only code samples that the SDK's docs-validate workflow - * compiles in place of the reader-facing fragment that follows them. The markers - * are HTML comments with no rendering semantics, so without this step the - * validation sample publishes alongside the real one and readers see the same - * example twice. Runs before the codetabs conversion so the ranges are gone - * before any <details> group is rewritten. - * - * An unbalanced marker is left in place rather than swallowing the rest of the - * file. Because this workflow opens its PR automatically, those warnings are - * also written to the job summary so they survive outside the run log. - */ +// docs-validate: hidden ranges wrap validation-only samples that compile in +// place of the reader-facing fragment. Remove them before codetabs conversion, +// so validation samples do not publish beside the real examples. +// +// Leave unbalanced markers in place rather than swallowing the rest of the file, +// and write warnings to the job summary because this workflow opens its PR. const unbalancedMarkerWarnings: string[] = [] function stripHiddenValidationBlocks(filePath: string): void { @@ -706,11 +634,7 @@ function stripHiddenValidationBlocks(filePath: string): void { } } -/** - * Write unbalanced-marker warnings to the Actions job summary, which is linked - * from the generated PR. Without this the only record is the run log, which a - * PR reviewer will not see. - */ +// Put unbalanced-marker warnings in the generated PR's Actions summary, not only the run log. function reportUnbalancedMarkers(): void { const summaryPath = process.env.GITHUB_STEP_SUMMARY if (unbalancedMarkerWarnings.length === 0 || !summaryPath) return @@ -729,12 +653,12 @@ function reportUnbalancedMarkers(): void { fs.appendFileSync(summaryPath, lines.join('\n'), 'utf8') } -// Step 2: SDK source docs use <details><summary><strong>Language</strong> -// </summary> blocks for multi-language examples. Convert a group of two or -// more consecutive ones to {% codetabs %}/{% codetab %} Liquid syntax. A block -// whose label has no codetab key is warned about and dropped from the output. +// SDK source docs use consecutive details blocks for multi-language examples. +// Convert groups with at least two supported labels to codetabs. Keep original +// details blocks when fewer than two labels are supported. Otherwise warn and +// drop unsupported labels to avoid mixed rendering patterns. -// Maps <summary> label text to codetab language keys +// These labels come from summary text in upstream SDK docs. const LABEL_TO_CODETAB_KEY: Record<string, string> = { 'Node.js / TypeScript': 'typescript', 'Node.js / TypeScript (standalone SDK)': 'typescript', @@ -770,10 +694,7 @@ function convertDetailsToCodetabs(filePath: string): void { while (i < lines.length) { const line = lines[i] - // A bare toggle counts any ``` line as a delimiter, so a fenced content - // line such as ```<details> flips the state mid-block. That used to - // self-correct only because a stalled cursor re-toggled the same line. - // Now that every line is visited once, track fences the CommonMark way. + // CommonMark fence tracking keeps a ```<details> content line from toggling the state. openFence = nextFenceState(line, openFence) if (openFence || !/<details[\s>]/.test(line)) { @@ -782,7 +703,7 @@ function convertDetailsToCodetabs(filePath: string): void { continue } - // A <details> tag outside a code block, so try to collect a group. + // Outside code fences, <details> can start a convertible codetabs group. const group: DetailsBlock[] = [] const groupStartLine = i @@ -792,14 +713,12 @@ function convertDetailsToCodetabs(filePath: string): void { group.push(block) i = block.endLine + 1 - // Skip blank lines between consecutive details blocks, - // but remember where we started in case the next line isn't <details> + // Skip blank lines between consecutive details blocks. const blankStart = i while (i < lines.length && lines[i].trim() === '') { i++ } - // If the next non-blank line isn't <details>, restore index to after - // the </details> so the blank lines are preserved for later output + // Restore trailing blanks when the next block does not continue the group. if (i >= lines.length || !/<details[\s>]/.test(lines[i])) { i = blankStart break @@ -807,9 +726,7 @@ function convertDetailsToCodetabs(filePath: string): void { } if (group.length < 2) { - // When the first block fails to parse, `i` never moved — which happens - // for an inline `<details>` mention in prose, since fence tracking does - // not cover code spans. Step over the line so the loop can't stall. + // Advance i after an inline <details> parse fails, or the outer loop stalls. if (i === groupStartLine) { result.push(lines[i]) i++ @@ -830,10 +747,10 @@ function convertDetailsToCodetabs(filePath: string): void { } } - // Unsupported blocks are dropped, not passed through. + // Drop unsupported blocks only after at least two supported tabs can render. const convertible = group.filter((b) => b.codetabKey) if (convertible.length < 2) { - // Not enough convertible tabs, so emit the original lines. + // Emit originals when fewer than two tabs can render. for (let j = groupStartLine; j < i; j++) { result.push(lines[j]) } @@ -860,8 +777,6 @@ function convertDetailsToCodetabs(filePath: string): void { } } -// Parses one <details> block starting at line index `start`, returning null -// when the block does not match the expected structure. function parseDetailsBlock(lines: string[], start: number): DetailsBlock | null { if (!/<details[\s>]/.test(lines[start])) return null @@ -875,7 +790,7 @@ function parseDetailsBlock(lines: string[], start: number): DetailsBlock | null i++ break } - // If we hit </details> or another <details> before finding summary, bail + // Bail if the block ends or another <details> starts before its summary. if (/<\/details>/.test(lines[i]) || /<details[\s>]/.test(lines[i])) { return null } @@ -893,12 +808,11 @@ function parseDetailsBlock(lines: string[], start: number): DetailsBlock | null i++ } - if (i >= lines.length) return null // No closing </details> found + if (i >= lines.length) return null - const endLine = i // The </details> line + const endLine = i - // Step 1b already removed the balanced hidden ranges. An unbalanced one is - // left in place deliberately, so only blank-line trimming is needed here. + // Balanced hidden ranges are already gone; trim blanks without touching unbalanced ranges. const cleaned = [...innerLines] while (cleaned.length > 0 && cleaned[0].trim() === '') cleaned.shift() @@ -915,8 +829,7 @@ function parseDetailsBlock(lines: string[], start: number): DetailsBlock | null } } -// Step 10: Rewrite the raw docs.github.com URLs left over from Step 4, which -// are the ones not inside markdown link syntax. +// Raw docs.github.com URLs outside Markdown link syntax also publish as root-relative links. function rewriteBareDocsUrls(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') @@ -924,7 +837,7 @@ function rewriteBareDocsUrls(filePath: string): void { const updated = raw.replace( /(?<!\()(https:\/\/docs\.github\.com\/(?:en\/)?[^\s)>\]]+)/g, (match: string, _p1: string, offset: number) => { - // Skip if this URL is inside a markdown link (preceded by `](`) + // Markdown links were already rewritten. if (offset > 1 && raw.substring(offset - 2, offset) === '](') return match changed = true @@ -940,8 +853,8 @@ function rewriteBareDocsUrls(filePath: string): void { } } -// Step 11: Add a markdownlint-disable comment after the frontmatter for the -// rules that don't apply to SDK docs, per the docs pipeline proposal. +// SDK docs keep source release terminology and hardcoded data-variable text. +// GHD046 and GHD005 reject those respectively. function suppressSdkLintRules(filePath: string): void { const raw = fs.readFileSync(filePath, 'utf8') const SUPPRESS_COMMENT = @@ -953,7 +866,8 @@ function suppressSdkLintRules(filePath: string): void { const fmEnd = raw.indexOf('---', raw.indexOf('---') + 3) if (fmEnd === -1) return - const insertPos = fmEnd + 4 // After --- and newline + // Add 4 for the closing frontmatter delimiter's three dashes and newline. + const insertPos = fmEnd + 4 const updated = `${raw.slice(0, insertPos)}\n${SUPPRESS_COMMENT}\n${raw.slice(insertPos)}` fs.writeFileSync(filePath, updated, 'utf8') @@ -963,16 +877,13 @@ function suppressSdkLintRules(filePath: string): void { console.log(`Normalizing SDK docs in: ${SDK_DOCS_DIR}`) console.log(`Content directory: ${CONTENT_DIR}\n`) -// Step 0a: Remove pages relocated out of the synced tree (see RELOCATED_PAGES). -// Runs first so keys stay expressed in upstream terms (before README->index -// renaming) and so getChildren() never lists a relocated page. +// Remove relocated pages before README.md renaming and children generation. validateRelocatedDestinations() console.log('--- Removing relocated pages ---\n') removeRelocatedPages() reportMissingRelocatedSources() -// Step 0: Rename README.md files to index.md (copilot-sdk uses README.md as -// directory landing pages; docs-internal requires index.md). +// Rename README.md files to the docs-internal index.md convention. console.log('\n--- Renaming README.md files to index.md ---\n') convertReadmesToIndex() @@ -983,8 +894,7 @@ for (const file of files) { addFrontmatter(file) } -// Step 1b: Remove docs-validate: hidden ranges before the codetabs conversion -// rewrites the <details> groups that contain them. +// Hidden validation ranges must be gone before converting details groups to codetabs. console.log('\n--- Removing docs-validate: hidden blocks ---\n') for (const file of files) { stripHiddenValidationBlocks(file) diff --git a/src/workflows/sync-sdk-docs/preserve-redirects.ts b/src/workflows/sync-sdk-docs/preserve-redirects.ts index 1bf56ad9e983..52f8e1070eeb 100644 --- a/src/workflows/sync-sdk-docs/preserve-redirects.ts +++ b/src/workflows/sync-sdk-docs/preserve-redirects.ts @@ -1,28 +1,20 @@ #!/usr/bin/env node -/** - * Preserves and generates `redirect_from` frontmatter for synced Copilot SDK docs. - * - * The sync workflow deletes the SDK content directory and rebuilds it from the - * upstream repo on every run. Upstream markdown has no `redirect_from`, and the - * normalizer builds frontmatter from scratch, so every redirect previously added - * in docs-internal is silently dropped. Each sync since the May 2026 restructure - * has needed a manual "restore redirects" commit to avoid shipping live 404s. - * - * This script runs after normalization and reconciles the rebuilt tree against - * the pre-sync state recorded in git: - * - * - Preserve: redirects on a page that still exists are merged back in. - * - Generate: when a page disappears (renamed or moved upstream), its URL — - * plus any redirects it had accumulated — are transferred to its successor, - * so redirect chains are never broken. - * - * The script only ever adds redirects. It never removes one, so a redirect added - * by hand in docs-internal survives indefinitely. - * - * Usage: - * npx tsx preserve-redirects.ts --sdk-docs-dir <path> [--git-ref HEAD] [--fail-on-unresolved] - */ +// Preserves and generates redirect_from frontmatter for synced Copilot SDK docs. +// The sync deletes the SDK content directory and rebuilds it from upstream +// Markdown that has no redirect_from, while the normalizer rebuilds frontmatter +// from scratch. +// +// Run this after normalization to reconcile the rebuilt tree with pre-sync git +// state. Surviving pages recover redirects. Reshaped pages keep redirects by URL +// identity. Pages that lose their URL need a human decision. +// +// This script normalizes and deduplicates redirects before writing. A redirect +// added by hand in docs-internal survives future syncs unless it duplicates +// another entry or redirects the page to itself. +// +// Usage: +// npx tsx preserve-redirects.ts --sdk-docs-dir <path> [--git-ref HEAD] [--fail-on-unresolved] import fs from 'node:fs' import path from 'node:path' @@ -30,12 +22,11 @@ import { execFileSync } from 'node:child_process' import { parseArgs } from 'node:util' import matter from '@gr2m/gray-matter' -/** - * Convert a repo-relative content path to the URL docs.github.com serves it at. - * - * `content/copilot/how-tos/copilot-sdk/features/mcp.md` -> `/copilot/how-tos/copilot-sdk/features/mcp` - * `content/copilot/how-tos/copilot-sdk/auth/index.md` -> `/copilot/how-tos/copilot-sdk/auth` - */ +// Repo-relative content paths become docs.github.com URLs. +// content/copilot/how-tos/copilot-sdk/features/mcp.md becomes +// /copilot/how-tos/copilot-sdk/features/mcp. +// content/copilot/how-tos/copilot-sdk/auth/index.md becomes +// /copilot/how-tos/copilot-sdk/auth. export function contentPathToUrl(repoRelativePath: string): string { const withoutPrefix = repoRelativePath .replace(/\\/g, '/') @@ -45,7 +36,7 @@ export function contentPathToUrl(repoRelativePath: string): string { return `/${withoutIndex}`.replace(/\/$/, '') || '/' } -/** Read `redirect_from` from a frontmatter blob, tolerating string or array form. */ +// Existing frontmatter can store redirect_from as a string or an array. export function readRedirects(data: Record<string, unknown>): string[] { const raw = data.redirect_from if (!raw) return [] @@ -53,10 +44,7 @@ export function readRedirects(data: Record<string, unknown>): string[] { return list.filter((entry): entry is string => typeof entry === 'string') } -/** - * Merge redirect lists, preserving first-seen order and dropping duplicates and - * trailing slashes. `redirect-orphans` fails the build on a trailing slash. - */ +// redirect-orphans fails on trailing slashes, so normalize while preserving order. export function mergeRedirects(...lists: string[][]): string[] { const seen = new Set<string>() const merged: string[] = [] @@ -69,13 +57,7 @@ export function mergeRedirects(...lists: string[][]): string[] { return merged } -/** - * The key a page is matched on when looking for its successor. - * - * An `index.md` identifies a directory rather than a page, so matching it on - * its basename would pair unrelated directories. Those match on the parent - * directory name instead. - */ +// index.md identifies a directory, so match it by parent directory instead of basename. export function successorKey(repoPath: string): { key: string; reason: string } { const basename = path.basename(repoPath) return basename === 'index.md' @@ -83,18 +65,9 @@ export function successorKey(repoPath: string): { key: string; reason: string } : { key: `file:${basename}`, reason: 'file name' } } -/** - * Suggest a candidate successor for a page that no longer exists. - * - * Upstream restructures move files between directories but rarely rename the - * file itself, so an unambiguous name match is a useful hint. It is only a - * hint: matching names are not evidence that one page replaced another, so the - * result is reported for a human to confirm and is never written automatically. - * - * The key must identify exactly one page on *both* sides. Requiring uniqueness - * among `removedPaths` as well as `currentPaths` stops two removed pages that - * share a basename from both being pointed at the same survivor. - */ +// A same-name successor is only a hint for a human to confirm, so never write it +// automatically. Require one match on both sides so two removed pages that share +// a basename cannot point at the same survivor. export function findSuccessor( removedPath: string, currentPaths: string[], @@ -102,23 +75,16 @@ export function findSuccessor( ): { path: string; reason: string } | null { const { key, reason } = successorKey(removedPath) - // Ambiguous on the removed side: several pages disappeared under this name, - // so no single one of them can claim the survivor. + // Several removed pages with this key cannot claim one survivor. if (removedPaths.filter((p) => successorKey(p).key === key).length !== 1) return null const matches = currentPaths.filter((p) => successorKey(p).key === key) return matches.length === 1 ? { path: matches[0], reason } : null } -/** - * List the .md files present under a directory at a given git ref. - * - * `git ls-tree` exits 0 with no output when the ref is valid but the path is - * absent, so an empty list genuinely means "nothing there yet" (the first sync). - * A throw therefore means the ref itself could not be read, which must fail the - * run rather than be mistaken for a first sync — silently treating a broken - * baseline as empty would drop every redirect in the tree. - */ +// git ls-tree exits 0 with no output when a valid ref lacks the path, so an +// empty list means the first sync. A thrown error means the ref is unreadable; +// treating that as empty would drop every redirect in the tree. function listFilesAtRef(repoRoot: string, ref: string, dirRelativeToRoot: string): string[] { let out: string try { @@ -139,12 +105,7 @@ function listFilesAtRef(repoRoot: string, ref: string, dirRelativeToRoot: string .filter((line) => line.endsWith('.md')) } -/** - * Read a file's contents at a given git ref. - * - * Callers only ask for paths that `listFilesAtRef` just reported at this same - * ref, so a failure here is a real error, not a missing file. - */ +// Paths from listFilesAtRef at the same ref must be readable. function readFileAtRef(repoRoot: string, ref: string, repoRelativePath: string): string { try { return execFileSync('git', ['show', `${ref}:${repoRelativePath}`], { @@ -161,7 +122,6 @@ function readFileAtRef(repoRoot: string, ref: string, repoRelativePath: string): } } -/** Recursively collect .md files from the working tree. */ function getAllMarkdownFiles(dir: string): string[] { const results: string[] = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -180,17 +140,10 @@ type PreSyncPage = { redirects: string[] } -/** - * Insert or replace the `redirect_from` block in a raw frontmatter string. - * - * The block is edited as text rather than re-serialized from a parsed object. - * Round-tripping through YAML rewraps long values — the `intro` field in - * particular — which would bury the redirect change in unrelated reflow noise - * on every sync. Editing the lines directly leaves every other byte untouched. - * - * The block is placed just before `contentType` to match how these files are - * already written, falling back to the end of the frontmatter. - */ +// Edit redirect_from as text because YAML round-trips rewrap long values such +// as intro and bury redirect changes in unrelated reflow noise. Place the block +// before contentType to match existing SDK docs frontmatter, or append it when +// contentType is absent. export function upsertRedirectBlock(rawFrontmatter: string, redirects: string[]): string { const lines = rawFrontmatter.split('\n') const isListItem = (line: string | undefined) => line !== undefined && /^\s+-\s/.test(line) @@ -201,9 +154,7 @@ export function upsertRedirectBlock(rawFrontmatter: string, redirects: string[]) const line = lines[i] if (/^redirect_from:\s*$/.test(line)) { - // Consume the indented list that follows. A blank line is only part of - // the block if another list item comes after it; otherwise it belongs to - // whatever follows and must be preserved. + // Preserve blank lines that belong to whatever follows the redirect_from block. let j = i + 1 while (j < lines.length) { if (isListItem(lines[j])) { @@ -238,9 +189,6 @@ export function upsertRedirectBlock(rawFrontmatter: string, redirects: string[]) return kept.join('\n') } -/** - * Rewrite a file's `redirect_from` in place. Returns true if the file changed. - */ function writeRedirects(absolutePath: string, redirects: string[]): boolean { const raw = fs.readFileSync(absolutePath, 'utf8') const match = raw.match(/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n)([\s\S]*)$/) @@ -283,10 +231,7 @@ function main() { const gitRef = args['git-ref'] as string const failOnUnresolved = args['fail-on-unresolved'] as boolean - // Resolve the root from the docs directory so the script works against any - // checkout, not just the process's current working directory. Both sides are - // canonicalized so a symlinked path (macOS /var -> /private/var) still yields - // a correct relative path. + // Resolve from the docs directory and canonicalize symlinks such as macOS /var. const repoRoot = fs.realpathSync( path.resolve( execFileSync('git', ['rev-parse', '--show-toplevel'], { @@ -298,7 +243,7 @@ function main() { const sdkDirRelative = path.relative(repoRoot, sdkDocsDir).replace(/\\/g, '/') - // 1. Record the pre-sync state from git. + // Read the pre-sync state from git before looking at the rebuilt tree. const preSyncPaths = listFilesAtRef(repoRoot, gitRef, sdkDirRelative) const preSyncPages = new Map<string, PreSyncPage>() for (const repoPath of preSyncPaths) { @@ -324,28 +269,27 @@ function main() { return } - // 2. Read the post-sync working tree. + // Read the rebuilt working tree. const currentRepoPaths = getAllMarkdownFiles(sdkDocsDir).map((p) => path.relative(repoRoot, p).replace(/\\/g, '/'), ) const currentRepoPathSet = new Set(currentRepoPaths) const currentUrls = new Set(currentRepoPaths.map(contentPathToUrl)) - // Several files can resolve to one URL (`guide.md` and `guide/index.md` both - // serve `.../guide`), so the reverse mapping is one-to-many. + // guide.md and guide/index.md both serve .../guide, so map URLs to many paths. const currentPathsByUrl = new Map<string, string[]>() for (const repoPath of currentRepoPaths) { const url = contentPathToUrl(repoPath) currentPathsByUrl.set(url, [...(currentPathsByUrl.get(url) ?? []), repoPath]) } - // Redirects to add, keyed by the repo-relative path of the page receiving them. + // Key additions by the repo-relative path of the page receiving them. const additions = new Map<string, string[]>() const addFor = (repoPath: string, urls: string[]) => { additions.set(repoPath, mergeRedirects(additions.get(repoPath) ?? [], urls)) } - // 3. Preserve redirects for pages that survived the sync at the same path. + // Preserve redirects for pages that survived the sync at the same path. let preservedPages = 0 for (const repoPath of currentRepoPaths) { const before = preSyncPages.get(repoPath) @@ -356,11 +300,7 @@ function main() { const allRemoved = [...preSyncPages.keys()].filter((p) => !currentRepoPathSet.has(p)) - // 4. A page can lose its file while keeping its URL, because `guide.md` and - // `guide/index.md` serve the same URL. The URL itself stays live, so nothing - // 404s and no successor guess is needed — but the redirects it inherited are - // still stranded, since the file now serving that URL has never carried them. - // Transfer those by URL identity rather than by inference. + // Transfer reshaped-page redirects by URL identity instead of guessing a successor. const needSuccessor: string[] = [] let reshaped = 0 for (const removedPath of allRemoved) { @@ -370,8 +310,7 @@ function main() { needSuccessor.push(removedPath) continue } - // `before.url` is deliberately not carried over: it is the URL these files - // already serve, so adding it would create a self-redirect. + // Do not carry before.url over, because the serving file already owns that URL. if (before.redirects.length === 0) continue if (servingPaths.length > 1) { throw new Error( @@ -385,27 +324,20 @@ function main() { console.log(` RESHAPED: ${before.url} still served by ${servingPaths[0]}, redirects moved`) } - // 5. Pages that lost their URL outright need a human decision. - // - // A same-named page elsewhere in the tree is reported as a candidate but is - // never written. Matching names is not evidence of succession, and a redirect - // aimed at the wrong live page is worse than a 404 because nothing catches - // it: `render-changed-and-deleted-files` asserts the old URL resolves, but - // never checks where it lands. + // URL loss needs human review because render-changed-and-deleted-files only checks resolution. const unresolved: { repoPath: string; urls: string[]; candidate: string | null }[] = [] for (const removedPath of needSuccessor) { const before = preSyncPages.get(removedPath)! const successor = findSuccessor(removedPath, currentRepoPaths, needSuccessor) unresolved.push({ repoPath: removedPath, - // Every URL here 404s, not just the page's own: the redirects it carried - // have no other home either. + // Every carried redirect 404s too, because no other page owns it. urls: [before.url, ...before.redirects], candidate: successor ? contentPathToUrl(successor.path) : null, }) } - // 6. Write the merged frontmatter back. + // Write the merged frontmatter back. let written = 0 let addedEntries = 0 for (const [repoPath, incoming] of additions) { @@ -424,9 +356,7 @@ function main() { const merged = mergeRedirects(existing, incoming).filter((url) => { // A page must never redirect to itself. if (url === selfUrl) return false - // `redirect-orphans` fails if a live page's URL is another page's - // redirect_from. Keep entries we already had so this stays additive, and - // let that test flag any pre-existing conflict. + // redirect-orphans rejects live-page shadows; keep existing conflicts additive. if (currentUrls.has(url) && !existing.includes(url)) { console.log(` SKIP (live page): ${url} would shadow an existing page`) return false @@ -465,8 +395,7 @@ function main() { ' is a same-name match only and has not been verified.', ) - // Surface this in the Actions run summary. Buried log output is how the - // earlier 404s went unnoticed until they reached production. + // Put unresolved redirects in the Actions summary because log output is easy to miss. if (process.env.GITHUB_STEP_SUMMARY) { const summary = [ `### Copilot SDK docs sync: ${lostUrlCount} URLs need a redirect decision`, @@ -502,13 +431,12 @@ function main() { } } -// Only run when executed directly, so the helpers above stay unit-testable. +// Keep helper exports unit-testable by running main only for direct execution. if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) { try { main() } catch (error) { - // Every throw in this script marks a case where continuing would silently - // drop redirects, so failing the sync is the intended outcome. + // Every thrown error marks a case where continuing would silently drop redirects. console.error(`\nRedirect preservation failed.\n\n${(error as Error).message}\n`) process.exit(1) } diff --git a/src/workflows/sync-sdk-docs/strip-hidden-blocks.ts b/src/workflows/sync-sdk-docs/strip-hidden-blocks.ts index b467fc2393fe..ac6aa035b33a 100644 --- a/src/workflows/sync-sdk-docs/strip-hidden-blocks.ts +++ b/src/workflows/sync-sdk-docs/strip-hidden-blocks.ts @@ -1,41 +1,35 @@ -/** - * Removes `docs-validate: hidden` ranges from Copilot SDK docs. - * - * The copilot-sdk repo wraps validation-only code samples in a marker pair: - * - * <!-- docs-validate: hidden --> - * ```go - * package main - * - * func main() { ... } - * ``` - * <!-- /docs-validate: hidden --> - * - * ```go - * client := copilot.NewClient(nil) - * ``` - * - * The first sample is a complete, compilable program that exists so the SDK's - * `docs-validate` workflow has something a compiler can accept. The second is - * the trimmed fragment intended for readers. The SDK's extractor treats the - * closing marker as "validate the hidden block instead of the next one", so the - * contract is: compile the hidden sample, publish the visible one. - * - * Nothing enforced the publishing half of that contract. The markers are plain - * HTML comments, and a Markdown parser treats each as a self-contained - * single-line HTML block. The fence between them is a sibling node, not a - * child, so it renders like any other code block. Without this step both - * samples ship and readers see the same example twice. - */ - -// Markers are our own directive syntax, so match them permissively: a marker we -// fail to recognize silently reintroduces the duplicate-sample bug. Trailing -// content after `-->` is tolerated for the same reason. +// Removes docs-validate: hidden ranges from Copilot SDK docs. +// +// The copilot-sdk repo wraps validation-only samples in a marker pair: +// +// <!-- docs-validate: hidden --> +// ```go +// package main +// +// func main() { ... } +// ``` +// <!-- /docs-validate: hidden --> +// +// ```go +// client := copilot.NewClient(nil) +// ``` +// +// The first sample gives the SDK docs-validate workflow a complete program the +// compiler accepts. The second sample is the fragment readers see. The +// SDK extractor treats the closing marker as "validate the hidden block instead +// of the next one", so the contract is: compile the hidden sample, publish the +// visible one. +// +// The markers are plain HTML comments. A Markdown parser treats each as a +// self-contained single-line HTML block, and the fence between them renders as +// any other code block. Removing the range keeps the hidden sample from publishing. + +// Match markers permissively so unexpected spacing cannot republish duplicate samples. +// Tolerate trailing content after --> for the same reason. const HIDDEN_OPEN = /^\s*<!--\s*docs-validate:\s*hidden\s*-->/i const HIDDEN_CLOSE = /^\s*<!--\s*\/\s*docs-validate:\s*hidden\s*-->/i -// Fences are CommonMark structure, so match them exactly: an opener may be -// indented at most 3 spaces, and the run of backticks or tildes may exceed 3. +// CommonMark fences can indent at most 3 spaces and can use more than 3 markers. const FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/ export interface OpenFence { @@ -43,13 +37,9 @@ export interface OpenFence { length: number } -/** - * Apply a line to the fence state machine and return the new state. - * - * A closing fence must use the same character as its opener, be at least as - * long, and carry no info string. Tracking the length matters because a - * four-backtick fence can legally contain a three-backtick line as content. - */ +// Track fence length because a four-backtick fence can contain a three-backtick line. +// A closing fence must use the opener's character, be at least as long, and +// carry no info string. export function nextFenceState(line: string, open: OpenFence | null): OpenFence | null { const match = FENCE.exec(line) if (!match) return open @@ -59,7 +49,7 @@ export function nextFenceState(line: string, open: OpenFence | null): OpenFence const length = marker.length if (open === null) { - // An info string on a backtick fence may not itself contain a backtick. + // Backtick fence info strings cannot contain backticks. if (char === '`' && info.includes('`')) return null return { char, length } } @@ -70,17 +60,14 @@ export function nextFenceState(line: string, open: OpenFence | null): OpenFence export interface StripHiddenBlocksResult { content: string - /** Number of complete marker ranges removed. */ + // Complete marker ranges removed. removed: number - /** Number of opening markers with no matching close. */ + // Opening markers with no matching close. unbalanced: number } -/** - * Find the closing marker for an opener, ignoring markers inside code fences. - * Returns -1 when the range is malformed, which includes a second opener - * appearing before any close. - */ +// Ignore markers inside code fences. Return -1 for malformed ranges, including +// a second opener before any close. function findClosingMarker(lines: string[], start: number): number { // The opener is only matched outside a fence, so the inner scan starts closed. let fence: OpenFence | null = null @@ -102,14 +89,8 @@ function findClosingMarker(lines: string[], start: number): number { return -1 } -/** - * Strip every `docs-validate: hidden` range, markers included. - * - * Markers inside a fenced code block are sample text rather than directives and - * are left alone. An opener with no matching close is also left alone: dropping - * to the end of the file would silently destroy content, so the caller is - * warned instead. - */ +// Markers inside fenced code are sample text, not directives. Leave unmatched +// openers in place because dropping to the end of the file would destroy content. export function stripHiddenBlocks(content: string): StripHiddenBlocksResult { const lines = content.split('\n') const result: string[] = [] @@ -136,17 +117,15 @@ export function stripHiddenBlocks(content: string): StripHiddenBlocksResult { const previous = result[result.length - 1] const next = lines[i] - // Treat the start and end of the file as blank so the range never leaves - // a stray blank line at either edge. + // Treat file edges as blank so the removed range leaves no stray edge blank. const previousIsBlank = previous === undefined || previous.trim() === '' const nextIsBlank = next === undefined || next.trim() === '' if (previousIsBlank && nextIsBlank) { - // Both sides were blank and are now adjacent, so keep only one. + // Keep one blank when removal makes two blanks adjacent. i++ } else if (!previousIsBlank && !nextIsBlank) { - // The range was the only thing separating two blocks. Without a blank - // line between them they would merge into a single paragraph. + // Preserve a paragraph boundary when the removed range separated text. result.push('') } continue diff --git a/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts b/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts index c2fb5b0ad9f4..7e8f69ef5feb 100644 --- a/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts +++ b/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts @@ -3,7 +3,7 @@ import os from 'node:os' import path from 'node:path' import { execFileSync } from 'node:child_process' -import { describe, expect, test, beforeAll, afterAll } from 'vitest' +import { describe, expect, test, vi, beforeAll, afterAll } from 'vitest' import { contentPathToUrl, @@ -258,6 +258,8 @@ describe('upsertRedirectBlock', () => { */ describe('preserve-redirects end to end', () => { let repo: string + // Each test spawns npx tsx, and a cold start on a busy CI runner can exceed the 5s default. + vi.setConfig({ testTimeout: 30 * 1000 }) const git = (...args: string[]) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }) diff --git a/src/workflows/unallowed-contribution-filters.yml b/src/workflows/unallowed-contribution-filters.yml index 9930c22b66c3..209f066a8d21 100644 --- a/src/workflows/unallowed-contribution-filters.yml +++ b/src/workflows/unallowed-contribution-filters.yml @@ -9,6 +9,7 @@ notAllowed: - 'src/**' - 'patches/**' - 'content/actions/how-tos/secure-your-work/security-harden-deployments/**' + - 'content/README.md' contentTypes: - 'content/**' # allows getting a list of just added files from the dorny/paths-filter action