From 5ce6953fb298e3e66e2d6614e0b2fd93fadc03fe Mon Sep 17 00:00:00 2001 From: subatoi <32935794+subatoi@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:35:18 +0000 Subject: [PATCH 01/27] Do not allow content/README (OS) contributions (#63548) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/workflows/unallowed-contribution-filters.yml | 1 + 1 file changed, 1 insertion(+) 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 From 7e48247b0207ce29938ac0f1a380930f5f287105 Mon Sep 17 00:00:00 2001 From: subatoi <32935794+subatoi@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:35:22 +0000 Subject: [PATCH 02/27] Add a 'skip-unallowed-check' label (#63546) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/triage-unallowed-contributions.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/triage-unallowed-contributions.yml b/.github/workflows/triage-unallowed-contributions.yml index 13a2309ae208..0905051a8397 100644 --- a/.github/workflows/triage-unallowed-contributions.yml +++ b/.github/workflows/triage-unallowed-contributions.yml @@ -19,6 +19,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: From c7fb42f841e495d631b7ee99cb887ceab9e03d7a Mon Sep 17 00:00:00 2001 From: subatoi <32935794+subatoi@users.noreply.github.com> Date: Mon, 28 Sep 2026 14:56:06 +0000 Subject: [PATCH 03/27] Add some extra checks to stop non-main OS PRs (#63549) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/check-for-spammy-prs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 339429df5ad02314aa9976e27dde0c17b3b910f6 Mon Sep 17 00:00:00 2001 From: docs-bot <77750099+docs-bot@users.noreply.github.com> Date: Mon, 28 Sep 2026 15:27:18 +0000 Subject: [PATCH 04/27] docs: update copilot-cli content from source docs (#63375) Co-authored-by: github-actions[bot] Co-authored-by: hubwriter Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: hubwriter <54933897+hubwriter@users.noreply.github.com> --- .../cli-command-reference.md | 60 ++++++++++++++++--- .../cli-config-dir-reference.md | 16 +++-- .../cli-plugin-reference.md | 20 +++---- content/copilot/reference/hooks-reference.md | 26 ++++++++ src/content-pipelines/state/copilot-cli.sha | 2 +- 5 files changed, 100 insertions(+), 24 deletions(-) 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/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 From 64cc99ba878acf21fd87bfc839dbe2ff7bdc14d5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 15:34:14 +0000 Subject: [PATCH 05/27] Tighten code comments in content linter lib, scripts, and style (#63438) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/content-linter/lib/diff-files.ts | 19 +- .../lib/helpers/get-lintable-yml.ts | 51 +----- .../lib/helpers/liquid-utils.ts | 54 ++---- .../lib/helpers/print-annotations.ts | 14 +- src/content-linter/lib/helpers/rule-utils.ts | 5 +- src/content-linter/lib/helpers/utils.ts | 24 +-- src/content-linter/scripts/disable-rules.ts | 10 +- .../scripts/find-unsed-variables.ts | 26 +-- src/content-linter/scripts/generate-docs.ts | 1 - src/content-linter/scripts/lint-content.ts | 164 +++++------------- src/content-linter/scripts/lint-report.ts | 22 +-- .../scripts/pretty-print-results.ts | 5 +- src/content-linter/style/github-docs.ts | 32 ++-- src/content-linter/types.ts | 7 +- 14 files changed, 107 insertions(+), 327 deletions(-) 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/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 } From 206993f01d1976d99240faae044ecc47b933a87f Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 15:34:17 +0000 Subject: [PATCH 06/27] Tighten code comments in src/content-render/scripts (#63440) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .../scripts/add-content-type.ts | 30 +++---- .../scripts/all-documents/cli.ts | 53 +++-------- src/content-render/scripts/cta-builder.ts | 43 ++++----- src/content-render/scripts/liquid-tags.ts | 58 +++++------- .../scripts/move-by-content-type.ts | 47 ++++------ src/content-render/scripts/move-content.ts | 88 +++++-------------- src/content-render/scripts/reusables-cli.ts | 6 +- .../reusables-cli/find/potential-uses.ts | 2 +- .../scripts/reusables-cli/find/unused.ts | 2 +- .../scripts/reusables-cli/find/used.ts | 2 +- .../scripts/reusables-cli/ignore-reusables.ts | 5 +- .../scripts/reusables-cli/shared.ts | 7 +- .../scripts/update-filepaths.ts | 52 ++++------- 13 files changed, 127 insertions(+), 268 deletions(-) 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 } From 34eb5d5ceac36dc6f9adebf276a72e6903140719 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 15:34:20 +0000 Subject: [PATCH 07/27] Tighten code comments in src/content-render/liquid and tests (#63441) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/content-render/liquid/data.ts | 22 ++---- src/content-render/liquid/engine.ts | 21 +---- src/content-render/liquid/error-handling.ts | 4 +- src/content-render/liquid/ifversion.ts | 46 +++++------ .../liquid/indented-data-reference.ts | 15 +--- src/content-render/liquid/octicon.ts | 23 ++---- src/content-render/liquid/post.ts | 10 +-- src/content-render/liquid/prompt.ts | 6 +- src/content-render/liquid/tool.ts | 48 ++--------- src/content-render/tests/annotate.ts | 3 +- src/content-render/tests/collect-mini-toc.ts | 2 +- src/content-render/tests/data.ts | 4 +- .../tests/link-error-line-numbers.ts | 3 - src/content-render/tests/liquid-tags.ts | 3 +- src/content-render/tests/liquid.ts | 18 ++--- src/content-render/tests/prompt-id.ts | 14 ++-- .../tests/render-changed-and-deleted-files.ts | 79 +++++-------------- src/content-render/tests/render-content.ts | 5 +- src/content-render/tests/render-to-hast.ts | 7 +- .../tests/table-accessibility-labels.ts | 5 +- 20 files changed, 96 insertions(+), 242 deletions(-) diff --git a/src/content-render/liquid/data.ts b/src/content-render/liquid/data.ts index 44c0151cd40b..42f738203e96 100644 --- a/src/content-render/liquid/data.ts +++ b/src/content-render/liquid/data.ts @@ -10,7 +10,7 @@ const logger = createLogger(import.meta.url) const Syntax = /([a-z0-9/\\_.\-[\]]+)/i const SyntaxHelp = "Syntax Error in 'data' - Valid syntax: data [path]" -// Using unknown for scope because it has custom environments property not in Liquid's Scope type +// Custom environments are not exposed in Liquid's Scope type. interface CustomScope { environments: { currentLanguage?: string @@ -60,24 +60,14 @@ export default { }, } as DataTag +// Multiline data output keeps the tag's indentation so Markdown blocks, such as lists, stay intact. +// Example: three spaces before {% data variables.foo.bar %} are kept on each output line. function handleIndent(tagToken: TagToken, text: string): string { - // Any time what we're about to replace in here has more than one line, - // if the use of `{% data ... %}` was itself indented, from the left, - // keep *that* indentation, in replaced output, for every line. - // - // For example: - // - // 1. Bullet point - // {% data variables.foo.bar %} - // - // In this example, the `{% data ...` starts with 3 whitespaces - // (based on the `1. Bull...` in the example). So put 3 whitespaces - // in front every line of the output. if (text.split('\n').length === 0) return text const { input, begin } = tagToken let i = 1 while (input.charAt(begin - i) === ' ') { - i++ // this goes one character "to the left" + i++ } const goBack = input.slice(begin - i, begin) if (goBack.charAt(0) === '\n' && goBack.length > 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/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()
   })
 

From ec3e631850b22596f787f8fe78e5aed0c73e5c1d Mon Sep 17 00:00:00 2001
From: Kevin Heis 
Date: Mon, 28 Sep 2026 15:34:25 +0000
Subject: [PATCH 08/27] Tighten code comments in src/content-linter/tests
 (#63442)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571
---
 src/content-linter/tests/category-pages.ts    |  24 +--
 .../tests/integration/lint-cli.ts             |  14 +-
 src/content-linter/tests/lint-files.ts        | 153 ++++--------------
 .../tests/lint-frontmatter-links.ts           |  18 +--
 .../tests/site-data-references.ts             |  18 +--
 .../unit/code-annotation-comment-spacing.ts   |   8 -
 src/content-linter/tests/unit/ctas-schema.ts  |  38 ++---
 .../tests/unit/frontmatter-children.ts        |   2 +-
 .../tests/unit/frontmatter-content-type.ts    |  23 +--
 .../tests/unit/frontmatter-hero-image.ts      |   1 -
 .../unit/frontmatter-landing-carousels.ts     |  13 +-
 .../tests/unit/frontmatter-schema.ts          |   2 +-
 .../tests/unit/frontmatter-search-replace.ts  |  18 +--
 .../unit/frontmatter-versions-whitespace.ts   |   2 +-
 .../unit/image-alt-text-end-punctuation.ts    |   4 +-
 .../image-alt-text-exclude-start-words.ts     |   4 +-
 .../tests/unit/image-alt-text-length.ts       |   3 +-
 .../tests/unit/internal-links-no-lang.ts      |   5 +-
 .../tests/unit/internal-links-old-version.ts  |   4 +-
 .../tests/unit/internal-links-slash.ts        |   4 +-
 .../tests/unit/journey-tracks.ts              |   6 +-
 .../tests/unit/link-punctuation.ts            |   3 +-
 .../tests/unit/lint-report-exclusions.ts      |  27 +---
 .../tests/unit/liquid-data-tags.ts            |   2 +-
 .../tests/unit/liquid-ifversion-versions.ts   |   8 +-
 .../unit/liquid-quoted-conditional-args.ts    |   1 -
 .../tests/unit/liquid-syntax.ts               |   4 +-
 .../tests/unit/liquid-versioning.ts           |   8 +-
 .../tests/unit/rai-app-card-structure.ts      |   5 -
 .../tests/unit/search-replace.ts              |  43 ++---
 .../unit/table-column-integrity-simple.ts     |   4 +-
 .../unit/third-party-actions-reusable.ts      |   2 +-
 32 files changed, 141 insertions(+), 330 deletions(-)

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(' - '), () => {

From 8c46ca03100afc63a66754e4d5ce7b93f38dfd78 Mon Sep 17 00:00:00 2001
From: Kevin Heis 
Date: Mon, 28 Sep 2026 15:34:28 +0000
Subject: [PATCH 09/27] Tighten code comments in src/links scripts and
 stylesheets (#63443)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571
---
 src/links/scripts/action-injections.ts        |   8 +-
 .../scripts/check-github-github-links.ts      |  48 ++----
 src/links/scripts/check-links-external.ts     |  75 +++------
 src/links/scripts/check-links-internal.ts     | 155 +++++-------------
 src/links/scripts/check-links-pr.ts           |  97 ++++-------
 src/links/scripts/combine-link-reports.ts     |  11 +-
 src/links/scripts/update-internal-links.ts    |  39 ++---
 src/links/scripts/upload-artifact.ts          |   3 +-
 .../generate-new-json.ts                      |   6 +-
 .../post-pr-comment.ts                        |  37 ++---
 .../validate.ts                               |   2 +-
 11 files changed, 139 insertions(+), 342 deletions(-)

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//, 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// 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()
   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()
   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 {
     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()
 
 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()
 
-  // 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()
   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>()
   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 }
@@ -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 = ''
   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()
   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>()
 
@@ -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, obj2: Record) {
   if (!equalSet(new Set(Object.keys(obj1)), new Set(Object.keys(obj2)))) {
     return false
@@ -287,7 +272,7 @@ function equalObject(obj1: Record, obj2: Record 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(``)
 
     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 {

From 3cf1c16c41624b7b1fe614f99b825d928a4c2070 Mon Sep 17 00:00:00 2001
From: Kevin Heis 
Date: Mon, 28 Sep 2026 15:34:32 +0000
Subject: [PATCH 10/27] Tighten code comments in src/workflows/sync-sdk-docs
 (#63445)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571
---
 .../sync-sdk-docs/convert-mermaid.ts          |  22 +-
 .../sync-sdk-docs/normalize-sdk-docs.ts       | 272 ++++++------------
 .../sync-sdk-docs/preserve-redirects.ts       | 174 ++++-------
 .../sync-sdk-docs/strip-hidden-blocks.ts      | 105 +++----
 4 files changed, 192 insertions(+), 381 deletions(-)

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  \
@@ -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 = {
   '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()
 
@@ -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 = {
     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.
 // /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 
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
Language -// 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 label text to codetab language keys +// These labels come from summary text in upstream SDK docs. const LABEL_TO_CODETAB_KEY: Record = { '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 ```
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 ```
content line from toggling the state. openFence = nextFenceState(line, openFence) if (openFence || !/]/.test(line)) { @@ -782,7 +703,7 @@ function convertDetailsToCodetabs(filePath: string): void { continue } - // A
tag outside a code block, so try to collect a group. + // Outside code fences,
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
+ // 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
, restore index to after - // the
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 || !/]/.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 `
` 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
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
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 (!/]/.test(lines[start])) return null @@ -875,7 +790,7 @@ function parseDetailsBlock(lines: string[], start: number): DetailsBlock | null i++ break } - // If we hit
or another
before finding summary, bail + // Bail if the block ends or another
starts before its summary. if (/<\/details>/.test(lines[i]) || /]/.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
found + if (i >= lines.length) return null - const endLine = i // The
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( /(?\]]+)/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
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 [--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 [--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[] { const raw = data.redirect_from if (!raw) return [] @@ -53,10 +44,7 @@ export function readRedirects(data: Record): 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() 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() 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() 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() 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: - * - * - * ```go - * package main - * - * func main() { ... } - * ``` - * - * - * ```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: +// +// +// ```go +// package main +// +// func main() { ... } +// ``` +// +// +// ```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*/i const HIDDEN_CLOSE = /^\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 From ef7c8e7c3f00c9bbf7bee9a3db1828d531445c5d Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 15:58:49 +0000 Subject: [PATCH 11/27] Tighten code comments in the header Playwright spec (#63446) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/fixtures/tests/playwright-header.spec.ts | 315 ++++++------------- 1 file changed, 104 insertions(+), 211 deletions(-) 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}`) From 957348f6d1372ac7f4dc99379e3bf5e312e5fc1c Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 15:58:53 +0000 Subject: [PATCH 12/27] Tighten code comments in src/search scripts and tests (#63448) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .../aggregate-search-index-failures.ts | 47 +++++--------- src/search/scripts/analyze-text.ts | 31 ++-------- src/search/scripts/index-test-fixtures.sh | 5 +- src/search/scripts/index/index-cli.ts | 5 +- .../index/lib/index-ai-search-autocomplete.ts | 6 +- .../scripts/index/lib/index-general-search.ts | 2 +- .../utils/indexing-elasticsearch-utils.ts | 8 +-- .../index/utils/retry-on-error-test.ts | 37 ++--------- .../scrape/lib/build-records-from-api.ts | 61 ++++++++----------- .../scrape/lib/find-indexable-pages.ts | 5 +- .../scripts/scrape/lib/popular-pages.ts | 19 ++---- .../scrape/lib/scrape-into-index-json.ts | 9 ++- src/search/scripts/scrape/scrape-cli.ts | 5 +- .../tests/aggregate-search-index-failures.ts | 11 ++-- src/search/tests/ai-search-links-json.ts | 8 ++- src/search/tests/ai-search-local-proxy.ts | 8 +-- src/search/tests/apache-arrow-stub.ts | 13 ++-- .../tests/api-ai-search-autocomplete.ts | 24 +++----- src/search/tests/api-ai-search.ts | 6 +- src/search/tests/api-combined-search.ts | 16 +++-- src/search/tests/api-search.ts | 61 ++++++++----------- src/search/tests/build-records-from-api.ts | 30 ++++----- .../tests/fixtures/page-with-sections.html | 5 +- src/search/tests/rendering.ts | 17 ++---- src/search/tests/search.ts | 2 +- 25 files changed, 162 insertions(+), 279 deletions(-) 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 [--workflow-url ] @@ -31,22 +30,18 @@ export interface FailuresSummary { interface PageFailure { versions: Set languages: Set - // 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 } -// 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 `` 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 . +// 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() 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 = Object.fromEntries( Object.values(allVersions).map((info) => { @@ -89,7 +71,7 @@ async function main(opts: Options, textArgs: string[]): Promise { } 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 { 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 { 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 for the index names', '') .argument('', '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( 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( } 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() 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 { 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('How do I 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 clone 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('Foo') expect(hit.highlights.content[0]).toMatch('foo') 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 @@

    In this article

    First heading

    Here's a paragraph.

    And another.

    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') }) From e01a0365c1ab36f34605234aa75ed3ef85cc4e39 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 15:58:58 +0000 Subject: [PATCH 13/27] Tighten code comments in src/search/components (#63449) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .../helpers/ai-search-links-json.ts | 15 ++-- .../helpers/execute-search-actions.ts | 10 +-- .../helpers/fix-incomplete-markdown.ts | 3 +- .../hooks/useAISearchAutocomplete.ts | 10 +-- .../hooks/useAISearchLocalStorageCache.ts | 12 +-- .../components/hooks/useMultiQueryParams.ts | 23 ++---- src/search/components/hooks/useQuery.ts | 3 +- src/search/components/input/AskAIResults.tsx | 27 +++---- src/search/components/input/SearchGroups.tsx | 14 ++-- .../input/SearchOverlay.module.scss | 8 +- src/search/components/input/SearchOverlay.tsx | 75 +++++++------------ src/search/components/input/variables.scss | 2 - .../results/Aggregations.module.scss | 60 ++++++--------- .../components/results/Aggregations.tsx | 35 +++------ .../components/results/NoQuery.module.scss | 4 +- src/search/components/results/NoQuery.tsx | 9 +-- .../components/results/SearchPage.module.scss | 17 +---- .../results/SearchResults.module.scss | 28 +++---- .../components/results/SearchResults.tsx | 12 +-- .../SidebarSearchAggregates.module.scss | 12 +-- .../results/SidebarSearchAggregates.tsx | 32 +++----- src/search/components/results/index.tsx | 13 +--- src/search/components/types.ts | 1 - 23 files changed, 144 insertions(+), 281 deletions(-) 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( @@ -24,12 +23,13 @@ export function useAISearchLocalStorageCache( 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( 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 | 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(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, 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 | 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 | 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('') const disclaimerRef = useRef(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 (
    , ) - // 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(null) const suggestionsListHeightRef = useRef(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>([]) const [selectedIndex, setSelectedIndex] = useState(-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('') const overlayRef = useRef(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('') - // 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 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) => { 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) => { 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 && ( <> @@ -621,7 +601,7 @@ export function SearchOverlay({ />
  • - {/* If there are general results, show bottom divider */} + {/* Show the bottom divider when general results follow the AI error. */} {generalOptionsWithViewStatus.length > 0 && ( )} @@ -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={} 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
    ), - // 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>({}) 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 (
    - {/* 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 , 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. */} {t('filter')} @@ -102,15 +94,6 @@ export function SearchResultsAggregations({ aggregations }: Props) { })} - {/* 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 ? ( - {/* 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. */}
    )} - {/* 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 ? ( ) : 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 From 5587af29ae1fe91f9b9fdcc5248984207b729812 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 15:59:04 +0000 Subject: [PATCH 14/27] Tighten code comments in src/fixtures helpers and tests (#63450) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/fixtures/helpers/color-contrast.ts | 4 +- src/fixtures/helpers/turn-off-experiments.ts | 7 +- src/fixtures/playwright.config.ts | 68 ++----------- src/fixtures/tests/annotations.ts | 9 +- src/fixtures/tests/api-article-body.ts | 14 +-- src/fixtures/tests/breadcrumbs.ts | 8 +- .../tests/categories-and-subcategory.ts | 6 +- src/fixtures/tests/footer.ts | 2 +- src/fixtures/tests/glossary.ts | 4 +- src/fixtures/tests/head.ts | 4 +- src/fixtures/tests/homepage.ts | 3 +- src/fixtures/tests/images.ts | 28 +++--- src/fixtures/tests/internal-links.ts | 13 ++- src/fixtures/tests/liquid.ts | 97 ++++--------------- src/fixtures/tests/markdown.ts | 3 +- src/fixtures/tests/permissions-callout.ts | 9 +- src/fixtures/tests/playwright-a11y.spec.ts | 23 ++--- .../tests/playwright-secret-scanning.spec.ts | 24 ++--- src/fixtures/tests/sidebar.ts | 21 +--- src/fixtures/tests/spotlight-processing.ts | 2 - src/fixtures/tests/translations.ts | 41 ++------ src/fixtures/tests/versioning.ts | 19 ++-- 22 files changed, 100 insertions(+), 309 deletions(-) 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-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) }) From 612a246300c0baf555b1ceb21d9b3dbbf5b4a5c2 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 15:59:09 +0000 Subject: [PATCH 15/27] Tighten code comments in src/article-api/middleware (#63452) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/article-api/liquid-renderers/index.ts | 2 - src/article-api/liquid-renderers/rest-tags.ts | 11 ++-- src/article-api/middleware/article-body.ts | 19 +++--- .../middleware/article-pageinfo.ts | 63 ++++++------------- src/article-api/middleware/article.ts | 27 +++----- src/article-api/middleware/pagelist.ts | 13 ++-- src/article-api/middleware/validation.ts | 35 ++++------- 7 files changed, 57 insertions(+), 113 deletions(-) diff --git a/src/article-api/liquid-renderers/index.ts b/src/article-api/liquid-renderers/index.ts index 7b2739dec3ea..16fcfd074da7 100644 --- a/src/article-api/liquid-renderers/index.ts +++ b/src/article-api/liquid-renderers/index.ts @@ -1,5 +1,3 @@ -// Custom Liquid tags used by article-api transformers. - import { restTags } from './rest-tags' export const apiTransformerTags = { diff --git a/src/article-api/liquid-renderers/rest-tags.ts b/src/article-api/liquid-renderers/rest-tags.ts index e8121e40c9c2..b7fc29a222c2 100644 --- a/src/article-api/liquid-renderers/rest-tags.ts +++ b/src/article-api/liquid-renderers/rest-tags.ts @@ -7,7 +7,7 @@ import { createLogger } from '@/observability/logger' const logger = createLogger('article-api/liquid-renderers/rest-tags') -// Usage: {% rest_parameter param %} +// Templates render REST parameters with {% rest_parameter param %}. export class RestParameter { private paramName: string @@ -17,7 +17,6 @@ export class RestParameter { liquid: Liquid, private liquidContext?: LiquidContext, ) { - // The tag receives the parameter object from the template context this.paramName = token.args.trim() } @@ -53,7 +52,7 @@ export class RestParameter { } } -// Usage: {% rest_body_parameter param indent %} +// Templates render REST body parameters with {% rest_body_parameter param indent %}. export class RestBodyParameter { constructor( token: TagToken, @@ -109,7 +108,7 @@ export class RestBodyParameter { } } -// Usage: {% rest_status_code statusCode %} +// Templates render REST status codes with {% rest_status_code statusCode %}. export class RestStatusCode { private statusCodeName: string @@ -190,10 +189,10 @@ async function htmlToMarkdown(html: string, context: Context): Promise { } catch (error) { logger.error('Failed to render HTML content to markdown in REST tag', { error, - html: html.substring(0, 100), // First 100 chars for context + html: html.substring(0, 100), // Log 100 characters for rendering context. contextInfo: context && context.page ? { page: context.page.relativePath } : undefined, }) - // In non-production, re-throw to aid debugging + // Re-throw outside production to expose rendering failures during development. if (process.env.NODE_ENV !== 'production') { throw error } diff --git a/src/article-api/middleware/article-body.ts b/src/article-api/middleware/article-body.ts index ce0c6717c341..1dc44229defb 100644 --- a/src/article-api/middleware/article-body.ts +++ b/src/article-api/middleware/article-body.ts @@ -11,7 +11,7 @@ import { normalizeRenderedMarkdown } from '@/article-api/lib/normalize-markdown' import { allVersions } from '@/versions/lib/all-versions' import type { Page } from '@/types' -// Creates a mocked rendering request, contextualized for rendering a page as markdown. +// Article Markdown rendering needs a mocked request with page context. async function createContextualizedRenderingRequest(pathname: string, page: Page) { const mockedContext: Context = {} const renderingReq = { @@ -25,25 +25,24 @@ async function createContextualizedRenderingRequest(pathname: string, page: Page }, } - // contextualize the request to get proper version info + // Context middleware sets currentVersion for API version fallback. await contextualize(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {}) renderingReq.context.page = page - // Load feature flags into context (needed for {% ifversion %} tags) + // ifversion Liquid tags read feature flags from context. features(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {}) - // Run page-specific contextualizers (e.g., glossaries middleware) + // Glossary pages read rendered terms from context instead of the Markdown body. await glossaries(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {}) - // Load data-driven table content (needed for {% for entry in tables.* %} Liquid loops) + // Data table Liquid loops read tables from context. await dataTables(renderingReq as ExtendedRequestWithPageInfo, {} as Response, () => {}) return renderingReq } +// pathValidationMiddleware and pageValidationMiddleware fill req.pageinfo before getArticleBody. export async function getArticleBody(req: ExtendedRequestWithPageInfo) { - // req.pageinfo is set from pageValidationMiddleware and pathValidationMiddleware - // and is in the ExtendedRequestWithPageInfo const { page, pathname, archived } = req.pageinfo if (archived?.isArchived) @@ -51,15 +50,13 @@ export async function getArticleBody(req: ExtendedRequestWithPageInfo) { const apiVersion = req.query.apiVersion as string | undefined - // With the catch-all ArticleTransformer registered last, - // findTransformer always returns a transformer. + // The catch-all ArticleTransformer makes a missing transformer a registry bug. const transformer = transformerRegistry.findTransformer(page) if (!transformer) throw new Error(`No transformer found for page: ${pathname}`) const renderingReq = await createContextualizedRenderingRequest(pathname, page) - // Determine the API version to use (provided or latest) - // Validation is handled by apiVersionValidationMiddleware + // apiVersionValidationMiddleware rejects invalid explicit API versions before body rendering. const currentVersion = renderingReq.context.currentVersion let effectiveApiVersion = apiVersion diff --git a/src/article-api/middleware/article-pageinfo.ts b/src/article-api/middleware/article-pageinfo.ts index c80f8b172ea1..edd700ff940f 100644 --- a/src/article-api/middleware/article-pageinfo.ts +++ b/src/article-api/middleware/article-pageinfo.ts @@ -9,17 +9,10 @@ import breadcrumbs from '@/frame/middleware/context/breadcrumbs' import currentProductTree from '@/frame/middleware/context/current-product-tree' import { readCompressedJsonFile } from '@/frame/lib/read-json-file' -// If you have pre-computed page info into a JSON file on disk, this is -// where it would be expected to be found. -// Note that if the file does not exist, it will be ignored and -// every pageinfo is computed every time. -// Note! The only reason this variable is exported is so that -// it can be imported by the script scripts/precompute-pageinfo.ts +// scripts/precompute-pageinfo.ts imports this path; missing files fall back to live computation. export const CACHE_FILE_PATH = '.pageinfo-cache.json.br' -// Build a mocked request/context for `page` at `pathname`, running the -// minimum middleware chain needed for downstream renderProp calls and -// breadcrumb computation. +// Metadata rendering and breadcrumbs need this minimal middleware chain. async function makeRenderingReq(page: Page, pathname: string) { const mockedContext: Context = {} const renderingReq = { @@ -64,40 +57,32 @@ async function computeCacheableFromReq(renderingReq: RenderingReq, page: Page) { return { title, intro, product } } +// For hidden non-early-access pages and unset-page requests, breadcrumbs middleware leaves +// context.breadcrumbs unset so JSON responses omit breadcrumbs instead of serializing []. async function computeBreadcrumbsFromReq(renderingReq: RenderingReq) { const next = () => {} const res = {} await currentProductTree(renderingReq as ExtendedRequest, res as Response, next) breadcrumbs(renderingReq as ExtendedRequest, res as Response, next) - // Return as-is. Note that for hidden non-early-access pages and - // unset-page requests, the breadcrumbs middleware does not assign - // `context.breadcrumbs` at all, so this can be `undefined`. Callers - // rely on that to keep the existing JSON response shape (the field - // is omitted from the response rather than serialized as `[]`). return renderingReq.context.breadcrumbs as Breadcrumb[] | undefined } -// Compute the cacheable portion of a page's metadata: title, intro, product. -// Breadcrumbs are intentionally excluded so they can be computed lazily on -// cache hits, keeping the on-disk cache and in-memory dictionary smaller. +// Cache only title, intro, and product. +// Breadcrumbs compute cheaply on cache hits and would bloat the cache file and dictionary. export async function getCacheablePageInfo(page: Page, pathname: string) { const renderingReq = await makeRenderingReq(page, pathname) return computeCacheableFromReq(renderingReq, page) } -// Compute breadcrumbs for `page` at `pathname`. Cheap relative to title/intro -// rendering, so it's safe to run on every cache hit instead of bloating the -// precomputed cache file. +// Breadcrumbs cost less than title and intro rendering, so cache hits compute them. export async function getBreadcrumbsForPage(page: Page, pathname: string) { const renderingReq = await makeRenderingReq(page, pathname) return computeBreadcrumbsFromReq(renderingReq) } +// getPageInfo reuses one rendering request on cache misses. +// contextualize, shortVersions, and features run once for metadata and breadcrumbs. export async function getPageInfo(page: Page, pathname: string) { - // Cache-miss path: build the rendering req once and reuse it for both the - // cacheable fields and breadcrumbs, instead of letting the two helpers - // each call makeRenderingReq() (which would run - // contextualize/shortVersions/features twice). const renderingReq = await makeRenderingReq(page, pathname) const base = await computeCacheableFromReq(renderingReq, page) const pageBreadcrumbs = await computeBreadcrumbsFromReq(renderingReq) @@ -107,9 +92,7 @@ export async function getPageInfo(page: Page, pathname: string) { const _productPageCache: { [key: string]: string } = {} -// The title of the product is much easier to cache because it's often -// repeated. What determines the title of the product is the language -// and the version. A lot of pages have the same title for the product. +// Product page titles repeat across articles, so cache them by page, version, and language. async function getProductPageInfo(page: Page, context: Context) { const cacheKey = `${page.relativePath}:${context.currentVersion}:${context.currentLanguage}` if (!(cacheKey in _productPageCache)) { @@ -142,6 +125,10 @@ type PageInfoWithBreadcrumbs = CachedPageInfoEntry & { cacheInfo?: string } +// getPageInfoFromCache does not fill the in-memory cache on misses. +// Production sees each HTTP GET once per deploy because the CDN caches it until purge. +// Local review does not need this cache path for performance. +// CI warms the precomputed cache with npm run precompute-pageinfo before vitest. let _cache: CachedPageInfo | null = null export async function getPageInfoFromCache( page: Page, @@ -168,34 +155,20 @@ export async function getPageInfoFromCache( let meta: PageInfoWithBreadcrumbs if (cached) { - // Breadcrumbs are not stored in the precomputed cache (they are large - // and cheap to compute). Compute on demand on every cache hit. + // The precomputed cache omits breadcrumbs because cache hits can compute them cheaply. const pageBreadcrumbs = await getBreadcrumbsForPage(page, pathname) meta = { ...cached, breadcrumbs: pageBreadcrumbs } } else { - // Cache miss path: compute the full thing inline. - // You might wonder; why do we not store this compute information - // into the `_cache` from here? - // The short answer is; it won't be used again. - // In production, which is the only place where performance matters, - // a HTTP GET request will only happen once per deployment. That's - // because the CDN will cache it until the next deployment (which is - // followed by a CDN purge). - // In development (local review), the performance doesn't really matter. - // In CI, we use the caching because the CI runs - // `npm run precompute-pageinfo` right before it runs vitest tests. meta = await getPageInfo(page, pathname) } meta.cacheInfo = cacheInfo return meta } +// pageValidationMiddleware follows redirects before getMetadata. +// For pages, pathname matches a valid permalink before metadata renders. +// For example, /en/articles/foo resolves to that page's valid permalink. export async function getMetadata(req: ExtendedRequestWithPageInfo) { - // Remember, the `validationMiddleware` will use redirects if the - // `pathname` used is a redirect (e.g. /en/articles/foo or - // /articles or '/en/enterprise-server@latest/foo/bar) - // So by the time we get here, the pathname should be one of the - // page's valid permalinks. const { page, pathname, archived, redirectedFrom } = req.pageinfo const documentType = page?.documentType ?? null diff --git a/src/article-api/middleware/article.ts b/src/article-api/middleware/article.ts index 84b2ff61fab6..f011aaa98fc8 100644 --- a/src/article-api/middleware/article.ts +++ b/src/article-api/middleware/article.ts @@ -19,10 +19,8 @@ import statsd from '@/observability/lib/statsd' const router = express.Router() -// For all these routes in `/api/article`: -// - pathValidationMiddleware ensures the path is properly structured and handles errors when it's not -// - pageValidationMiddleware fetches the page from the pagelist, returns 404 to the user if not found - +// All /api/article routes validate pathname structure before page lookup. +// pageValidationMiddleware returns 404 when the pagelist cannot resolve the path. /** * Get article metadata and content in a single object. Equivalent to calling `/article/meta` concatenated with `/article/body`. * @route GET /api/article @@ -108,6 +106,8 @@ router.get( }), ) +// /api/article/meta sets a language surrogate key because /api URLs lack a language segment. +// Fastly needs the key for staggered language purges. /** * Get metadata about an article. * @route GET /api/article/meta @@ -148,13 +148,6 @@ router.get( incrementArticleLookup(req, 'meta', cacheInfo) defaultCacheControl(res) - // This is necessary so that the `Surrogate-Key` header is set with - // the correct language surrogate key bit. By default, it's set - // from the pathname but `/api/**` URLs don't have a language - // (other than the default 'en'). - // We do this so that all of these URLs are cached in Fastly by language - // which we need for the staggered purge. - setFastlySurrogateKey( res, makeLanguageSurrogateKey(req.pageinfo?.page?.languageCode || 'en'), @@ -164,7 +157,9 @@ router.get( }), ) -// this helps us standardize calls to our datadog agent for article api purposes +// Keep Datadog metric tags consistent across Article API endpoints. +// Datadog tags max at 200 characters, so path and source tags are truncated. +// See https://docs.datadoghq.com/getting_started/tagging/#define-tags function incrementArticleLookup( req: ExtendedRequestWithPageInfo, type: 'full' | 'body' | 'meta', @@ -173,8 +168,7 @@ function incrementArticleLookup( const pathname = req.pageinfo.pathname const language = req.pageinfo.page?.languageCode || 'en' - // logs the source of the request, if it's for hovercards it'll have the header X-Request-Source. - // see src/links/components/LinkPreviewPopover.tsx + // Hovercards set X-Request-Source; src/links/components/LinkPreviewPopover.tsx sends the header. let source = req.get('X-Request-Source') if (!source) { const referer = req.get('Referer') @@ -190,16 +184,13 @@ function incrementArticleLookup( } const tags = [ - // According to https://docs.datadoghq.com/getting_started/tagging/#define-tags - // the max length of a tag is 200 characters. Most of ours are less than - // that but we truncate just to be safe. `pathname:${pathname}`.slice(0, 200), `language:${language}`, `type:${type}`, `source:${source}`.slice(0, 200), ] - // the /article/meta endpoint uses a cache + // Full and metadata lookups include page-info cache status. if (cacheInfo) tags.push(`cache:${cacheInfo}`) statsd.increment('api.article.lookup', 1, tags) diff --git a/src/article-api/middleware/pagelist.ts b/src/article-api/middleware/pagelist.ts index a80978c481c4..19c43d42e72a 100644 --- a/src/article-api/middleware/pagelist.ts +++ b/src/article-api/middleware/pagelist.ts @@ -14,8 +14,6 @@ import { languages, languageKeys } from '@/languages/lib/languages' const router = express.Router() -// pagelistValidationMiddleware is used for every route to normalize the lang and version from the path - /** * Get all available product versions for the docs site. * @route GET /api/pagelist/versions @@ -64,7 +62,7 @@ router.get( catchMiddlewareError(async function (req: ExtendedRequest, res: Response) { defaultCacheControl(res) - // Remove redirectPatterns from output as they are RegExp objects and not JSON serializable + // JSON serializes RegExp redirectPatterns as empty objects, so omit them. const sanitizedLanguages = Object.fromEntries( Object.entries(languages).map(([code, lang]) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -82,7 +80,7 @@ router.get( }) as RequestHandler, ) -// If no version or lang is provided we'll assume english and fpt and redirect there +// Bare pagelist requests redirect to English free-pro-team@latest. router.get( '/', pagelistValidationMiddleware as RequestHandler, @@ -97,7 +95,7 @@ router.get( }), ) -// handles paths with fragments that could be the language or the version +// One-segment pagelist requests redirect after validation finds the language or version. router.get( '/:someParam', pagelistValidationMiddleware as RequestHandler, @@ -143,8 +141,7 @@ router.get( versionMatcher(key, req.context!.currentVersion!, req.context!.currentLanguage!), ) - // if we've filtered it out of existence, there's no articles to return so we must've - // gotten a bad language or version + // An empty filtered pagelist means the language or version failed validation. if (!filteredPermalinks.length) { const { lang, productVersion } = req.params @@ -164,7 +161,7 @@ router.get( incrementPagelistLookup(req.context!.currentVersion!, req.context!.currentLanguage!) defaultCacheControl(res) - // new line added at the end so `wc` works as expected with `-l` and `-w`. + // Keep a trailing newline so wc -l counts the last path. res.type('text').send(filteredPermalinks.join('\n').concat('\n')) }), ) diff --git a/src/article-api/middleware/validation.ts b/src/article-api/middleware/validation.ts index eff2c9c842da..cfad75632cbe 100644 --- a/src/article-api/middleware/validation.ts +++ b/src/article-api/middleware/validation.ts @@ -8,9 +8,8 @@ import { getVersionStringFromPath, getLangFromPath } from '@/frame/lib/path-util import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-version' import { allVersions } from '@/versions/lib/all-versions' -// validates the path for pagelist endpoint -// specifically, defaults to `/en/free-pro-team@latest` when those values are missing -// when they're provided, checks and cleans them up so we don't just lookup bad lang codes or versions +// Pagelist paths use /en/free-pro-team@latest when language or version is missing. +// The pagelist lookup rejects unknown normalized language or version fragments. export const pagelistValidationMiddleware = ( req: ExtendedRequest, res: Response, @@ -56,38 +55,33 @@ export const pathValidationMiddleware = ( return res.status(400).json({ error: `'pathname' cannot contain whitespace` }) } - // req.pageinfo.page will be defined later or it will throw + // pageValidationMiddleware replaces the placeholder page or returns an error. req.pageinfo = { pathname, page: {} as Page } return next() } +// pageValidationMiddleware calls getRedirect directly because findPage hides pathname changes. +// Redirected pathnames must match page.permalinks for translated-page fallback. +// Archived enterprise paths skip redirect lookup, even if they also match a redirect. +// This matches the site middleware order. export const pageValidationMiddleware = ( req: ExtendedRequestWithPageInfo, res: Response, next: NextFunction, ) => { let { pathname } = req.pageinfo - // We can't use the `findPage` middleware utility function because we - // need to know when the pathname is a redirect. - // This is important so that the final `pathname` value - // matches the page's permalinks. - // This is important when rendering a page because of translations, - // if it needs to do a fallback, it needs to know the correct - // equivalent English page. if (!req.context || !req.context.pages || !req.context.redirects) throw new Error('request not yet contextualized') const redirectsContext = { pages: req.context.pages, redirects: req.context.redirects } - // Similar to how the `handle-redirects.ts` middleware works, let's first - // check if the URL is just having a trailing slash. + // Strip trailing slashes before redirect lookup, as the site's trailingSlashes middleware does. while (pathname.endsWith('/') && pathname.length > 1) { pathname = pathname.slice(0, -1) } - // E.g. a request for `/` is handled as a redirect outside the - // getRedirect() function. + // getRedirect does not handle /, so use the current language root. if (pathname === '/') { pathname = `/${req.context.currentLanguage}` } @@ -96,11 +90,6 @@ export const pageValidationMiddleware = ( req.pageinfo.archived = { isArchived: false } if (!(pathname in req.context.pages)) { - // If a pathname is not a known page, it might *either* be a redirect, - // or an archived enterprise version, or both. - // That's why it's import to not bother looking at the redirects - // if the pathname is an archived enterprise version. - // This mimics how our middleware work and their order. req.pageinfo.archived = isArchivedVersionByPath(pathname) if (!req.pageinfo.archived.isArchived) { const redirect = getRedirect(pathname, redirectsContext) @@ -111,12 +100,12 @@ export const pageValidationMiddleware = ( } } - // Remember this might yield undefined if the pathname is not a page + // Archived paths can leave page undefined. req.pageinfo.page = req.context.pages[pathname] if (!req.pageinfo.page && !req.pageinfo.archived.isArchived) { return res.status(404).json({ error: `No page found for '${pathname}'` }) } - // The pathname might have changed if it was a redirect + // Store the normalized or redirected pathname for rendering, metadata, and metrics. req.pageinfo.pathname = pathname return next() @@ -139,7 +128,7 @@ export const apiVersionValidationMiddleware = ( const pathname = req.pageinfo?.pathname || (req.query.pathname as string) if (!pathname) { - // This should not happen as pathValidationMiddleware runs first + // pathValidationMiddleware runs before API version validation. throw new Error('pathname not available for apiVersion validation') } From b53264098bece4e877767a348f5496f723e25066 Mon Sep 17 00:00:00 2001 From: subatoi <32935794+subatoi@users.noreply.github.com> Date: Mon, 28 Sep 2026 16:17:53 +0000 Subject: [PATCH 16/27] Add check for non-English issue/PR titles in OS repo (#63550) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/workflows/check-for-non-english.yml | 87 +++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .github/workflows/check-for-non-english.yml 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, + }) + } From a4c090dbd11c2a3c531f32a0147ac283e1b89af5 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 16:20:37 +0000 Subject: [PATCH 17/27] Tighten code comments in src/article-api/transformers (#63454) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .../transformers/article-transformer.ts | 5 +- .../transformers/audit-logs-transformer.ts | 12 ++--- .../bespoke-landing-transformer.ts | 12 +---- .../category-landing-transformer.ts | 9 +--- .../transformers/codeql-cli-transformer.ts | 6 +-- .../discovery-landing-transformer.ts | 15 +----- .../transformers/github-apps-transformer.ts | 20 ++----- .../graphql-breaking-changes-transformer.ts | 3 -- .../graphql-changelog-transformer.ts | 5 +- .../transformers/graphql-index-transformer.ts | 3 -- .../graphql-reference-transformer.ts | 53 +++---------------- src/article-api/transformers/index.ts | 3 +- .../journey-landing-transformer.ts | 6 +-- .../transformers/release-notes-transformer.ts | 18 ++----- .../transformers/rest-transformer.ts | 23 +++----- .../transformers/search-page-transformer.ts | 5 +- .../secret-scanning-transformer.ts | 25 +++------ .../transformers/toc-transformer.ts | 6 +-- src/article-api/transformers/types.ts | 24 ++------- .../transformers/webhooks-transformer.ts | 11 ++-- 20 files changed, 58 insertions(+), 206 deletions(-) diff --git a/src/article-api/transformers/article-transformer.ts b/src/article-api/transformers/article-transformer.ts index 94a61e52eab4..d1f9c91bd00e 100644 --- a/src/article-api/transformers/article-transformer.ts +++ b/src/article-api/transformers/article-transformer.ts @@ -1,10 +1,7 @@ import type { Context, Page } from '@/types' import type { PageTransformer } from './types' -/** - * Catch-all transformer, registered last. Renders the page body as markdown - * and prepends the title and intro. - */ +// Register this catch-all after specific transformers. export class ArticleTransformer implements PageTransformer { canTransform(page: Page): boolean { return page != null diff --git a/src/article-api/transformers/audit-logs-transformer.ts b/src/article-api/transformers/audit-logs-transformer.ts index fd3ff4c6aa7d..92190f56b8ac 100644 --- a/src/article-api/transformers/audit-logs-transformer.ts +++ b/src/article-api/transformers/audit-logs-transformer.ts @@ -6,9 +6,6 @@ import { renderContent } from '@/content-render/index' import { loadTemplate } from '@/article-api/lib/load-template' import matter from '@gr2m/gray-matter' -/** - * Converts audit log events and their data into markdown using a Liquid template. - */ export class AuditLogsTransformer implements PageTransformer { templateName = 'audit-logs-page.template.md' @@ -17,7 +14,7 @@ export class AuditLogsTransformer implements PageTransformer { } async transform(page: Page, pathname: string, context: Context): Promise { - // Import audit log lib dynamically to avoid circular dependencies + // Dynamic import avoids circular dependencies. const { getCategorizedAuditLogEvents, getCategoryNotes, resolveReferenceLinksToMarkdown } = await import('@/audit-logs/lib/index') @@ -92,13 +89,12 @@ export class AuditLogsTransformer implements PageTransformer { ): Promise> { const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - // Sort categories and events, and compute fields shared by most (≥80%) events const allFieldSets: string[][] = [] const sortedCategorizedEvents: CategorizedEvents = {} const sortedCategories = Object.keys(categorizedEvents).sort((a, b) => a.localeCompare(b)) for (const category of sortedCategories) { - // Create a copy of the events array to avoid mutating the cache + // Copy cached array before sorting; clone events before resolving links or trimming fields. const events = [...categorizedEvents[category]].sort((a, b) => a.action.localeCompare(b.action), ) @@ -119,7 +115,7 @@ export class AuditLogsTransformer implements PageTransformer { ) } - // Compute base fields that appear in ≥80% of events + // Base fields appear in at least 80 percent of events. const fieldCounts = new Map() for (const fields of allFieldSets) { for (const f of fields) { @@ -132,7 +128,7 @@ export class AuditLogsTransformer implements PageTransformer { .map(([field]) => field) .sort() - // Remove base fields from each event's field list + // Event rows omit base fields because the template lists them once. const baseFieldSet = new Set(baseFields) for (const category of Object.keys(sortedCategorizedEvents)) { for (const event of sortedCategorizedEvents[category]) { diff --git a/src/article-api/transformers/bespoke-landing-transformer.ts b/src/article-api/transformers/bespoke-landing-transformer.ts index 490b2cc1094d..747a275079fb 100644 --- a/src/article-api/transformers/bespoke-landing-transformer.ts +++ b/src/article-api/transformers/bespoke-landing-transformer.ts @@ -12,11 +12,7 @@ interface BespokeLandingPage extends Omit { includedCategories?: string[] } -/** - * Transforms bespoke-landing pages into markdown. - * Unlike discovery-landing, this shows every article regardless of - * includedCategories, which only filters discovery-landing pages. - */ +// Bespoke landing pages ignore includedCategories; only discovery landing pages apply it. export class BespokeLandingTransformer implements PageTransformer { templateName = 'landing-page.template.md' @@ -46,7 +42,6 @@ export class BespokeLandingTransformer implements PageTransformer { const bespokePage = page as BespokeLandingPage const sections: Section[] = [] - // Process carousels (each carousel becomes a section) const carousels = bespokePage.carousels ?? bespokePage.rawCarousels if (carousels && typeof carousels === 'object') { const { default: getPageLinkData } = await import('@/frame/lib/get-link-data') @@ -56,14 +51,12 @@ export class BespokeLandingTransformer implements PageTransformer { let links: LinkData[] if (typeof articles[0] === 'object' && 'title' in articles[0]) { - // Already resolved articles links = articles.map((item) => ({ href: typeof item === 'string' ? item : item.href, title: (typeof item === 'object' && item.title) || '', intro: (typeof item === 'object' && item.intro) || '', })) } else { - // Raw paths that need resolution const linkData = await getPageLinkData(articles as string[], context, { title: true, intro: true, @@ -88,8 +81,7 @@ export class BespokeLandingTransformer implements PageTransformer { } } - // Recursively gather every descendant article, matching the site's - // genericTocFlat/genericTocNested behaviour. + // getAllTocItems matches the site's genericTocFlat and genericTocNested behavior. if (bespokePage.children && bespokePage.children.length > 0) { const tocItems = await getAllTocItems(page, context) diff --git a/src/article-api/transformers/category-landing-transformer.ts b/src/article-api/transformers/category-landing-transformer.ts index f75cdf08579f..d267baf94c69 100644 --- a/src/article-api/transformers/category-landing-transformer.ts +++ b/src/article-api/transformers/category-landing-transformer.ts @@ -10,10 +10,6 @@ interface CategoryPage extends Page { children?: string[] } -/** - * Transforms category-landing pages into markdown format. - * Handles spotlight sections and recursively collects all descendant articles. - */ export class CategoryLandingTransformer implements PageTransformer { templateName = 'landing-page.template.md' @@ -32,8 +28,7 @@ export class CategoryLandingTransformer implements PageTransformer { }) } - // Walks the page tree from the given parent hrefs, collecting every non-index - // descendant. The visited set guards against circular references. + // Collect every non-index descendant; the visited set prevents circular references. private async getAllDescendantArticles( parentHrefs: string[], languageCode: string, @@ -61,7 +56,7 @@ export class CategoryLandingTransformer implements PageTransformer { const children = parentPage.children if (children && Array.isArray(children) && children.length > 0) { - // Get the parent's permalink to use as the base path for resolving children + // Resolve child hrefs relative to the parent page, not the original landing page. const parentPermalink = parentPage.permalinks.find( (p) => p.languageCode === languageCode && p.pageVersion === context.currentVersion, ) diff --git a/src/article-api/transformers/codeql-cli-transformer.ts b/src/article-api/transformers/codeql-cli-transformer.ts index 2aa07e4a187b..1421cfb4f551 100644 --- a/src/article-api/transformers/codeql-cli-transformer.ts +++ b/src/article-api/transformers/codeql-cli-transformer.ts @@ -4,9 +4,6 @@ import { renderContent } from '@/content-render/index' import { loadTemplate } from '@/article-api/lib/load-template' import { stripHtmlCommentsAndNormalizeWhitespace } from '@/article-api/lib/strip-html-comments' -/** - * Renders autogenerated CodeQL CLI reference pages as markdown using a Liquid template. - */ export class CodeQLCliTransformer implements PageTransformer { templateName = 'codeql-cli-page.template.md' @@ -15,8 +12,7 @@ export class CodeQLCliTransformer implements PageTransformer { } async transform(page: Page, _pathname: string, context: Context): Promise { - // CodeQL CLI pages are fully generated markdown files in the repo. - // We render them with markdownRequested=true to get the markdown output. + // Fully generated CodeQL CLI pages need markdownRequested=true to render markdown output. context.markdownRequested = true const content = await page.render(context) diff --git a/src/article-api/transformers/discovery-landing-transformer.ts b/src/article-api/transformers/discovery-landing-transformer.ts index bcf16217b4ac..babbbf9f5021 100644 --- a/src/article-api/transformers/discovery-landing-transformer.ts +++ b/src/article-api/transformers/discovery-landing-transformer.ts @@ -13,11 +13,6 @@ interface DiscoveryPage extends Page { children?: string[] } -/** - * Transforms discovery-landing pages into markdown format. - * Handles recommended carousel, intro links, article grids with - * category filtering, and children listings. - */ export class DiscoveryLandingTransformer implements PageTransformer { templateName = 'landing-page.template.md' @@ -47,7 +42,6 @@ export class DiscoveryLandingTransformer implements PageTransformer { const discoveryPage = page as DiscoveryPage const sections: Section[] = [] - // Process carousels (each carousel becomes a section) const carousels = discoveryPage.carousels ?? discoveryPage.rawCarousels if (carousels && typeof carousels === 'object') { const { default: getPageLinkData } = await import('@/frame/lib/get-link-data') @@ -57,14 +51,12 @@ export class DiscoveryLandingTransformer implements PageTransformer { let links: LinkData[] if (typeof articles[0] === 'object' && 'title' in articles[0]) { - // Already resolved articles links = articles.map((item) => ({ href: typeof item === 'string' ? item : item.href, title: (typeof item === 'object' && item.title) || '', intro: (typeof item === 'object' && item.intro) || '', })) } else { - // Raw paths that need resolution const linkData = await getPageLinkData(articles as string[], context, { title: true, intro: true, @@ -125,10 +117,7 @@ export class DiscoveryLandingTransformer implements PageTransformer { } } - // Articles section: recursively gather descendant articles within - // this product section. The basePath guard prevents cross-product - // recursion (e.g. /rest listing /enterprise-admin children that - // point outside the /rest hierarchy). + // getAllTocItems sets basePath to keep /rest from recursing into /enterprise-admin children. if (discoveryPage.children && discoveryPage.children.length > 0) { const tocItems = await getAllTocItems(page, context) @@ -138,7 +127,7 @@ export class DiscoveryLandingTransformer implements PageTransformer { if (discoveryPage.includedCategories && discoveryPage.includedCategories.length > 0) { const includedCategories = discoveryPage.includedCategories.map((c) => c.toLowerCase()) - // Build a map of href → category from the full tree + // includedCategories filters by category metadata from the full TOC tree. const categoryMap = new Map() interface TocNode { href: string diff --git a/src/article-api/transformers/github-apps-transformer.ts b/src/article-api/transformers/github-apps-transformer.ts index c93fa67efab0..5b7072dc5ac9 100644 --- a/src/article-api/transformers/github-apps-transformer.ts +++ b/src/article-api/transformers/github-apps-transformer.ts @@ -59,7 +59,7 @@ interface PreparedPermissionItem { permissions: PreparedPermissionOperation[] } -// Map content files to their page types from config.json +// Keep page type values in sync with config.json. const PAGE_TYPE_MAP: Record = { 'endpoints-available-for-github-app-installation-access-tokens.md': 'server-to-server-rest', 'endpoints-available-for-github-app-user-access-tokens.md': 'user-to-server-rest', @@ -68,7 +68,6 @@ const PAGE_TYPE_MAP: Record = { 'permissions-required-for-fine-grained-personal-access-tokens.md': 'fine-grained-pat-permissions', } -// Page types that display as bullet lists vs tables const LIST_PAGE_TYPES = new Set([ 'server-to-server-rest', 'user-to-server-rest', @@ -80,12 +79,7 @@ const PERMISSIONS_PAGE_TYPES = new Set([ 'fine-grained-pat-permissions', ]) -/** - * Transformer for GitHub Apps pages. - * Converts GitHub Apps autogenerated data into markdown format. - * Uses a Liquid template for list pages, but builds markdown tables programmatically - * in TypeScript for permissions pages to avoid Liquid escaping issues. - */ +// Permission pages build markdown tables in TypeScript because Liquid escapes table cells. export class GithubAppsTransformer implements PageTransformer { templateName = 'github-apps-page.template.md' @@ -102,7 +96,7 @@ export class GithubAppsTransformer implements PageTransformer { const startTime = DEBUG ? Date.now() : 0 if (DEBUG) console.log(`[DEBUG] GitHubAppsTransformer: ${pathname}`) - // Import getAppsData dynamically to avoid circular dependencies + // Dynamic import avoids circular dependencies. const { getAppsData } = await import('@/github-apps/lib/index') const currentVersion = context.currentVersion! @@ -127,7 +121,7 @@ export class GithubAppsTransformer implements PageTransformer { let manualContent = '' if (page.markdown) { const { content } = matter(page.markdown) - // GitHub Apps pages don't have the automated marker, so we render all markdown content + // GitHub Apps pages lack the automated marker, so render all markdown content. if (content.trim()) { manualContent = await renderContent(content, { ...context, @@ -152,7 +146,6 @@ export class GithubAppsTransformer implements PageTransformer { const templateContent = loadTemplate(this.templateName) - // For permissions pages, we need to construct the tables manually to avoid Liquid escaping let finalContent: string if (isPermissionsPage) { let introMarkdown = `# ${templateData.page.title}\n\n` @@ -163,7 +156,7 @@ export class GithubAppsTransformer implements PageTransformer { introMarkdown += `${templateData.manualContent}\n\n` } - // Add token type legend (only for GitHub App permissions, not fine-grained PAT) + // Only GitHub App permissions need a token type legend; fine-grained PAT pages do not. if (pageType === 'server-to-server-permissions') { introMarkdown += `**Token types:** UAT = user access token, IAT = installation access token\n\n` } @@ -198,7 +191,6 @@ export class GithubAppsTransformer implements PageTransformer { finalContent = introMarkdown + tablesMarkdown } else { - // For list pages, Liquid template works fine finalContent = await renderContent(templateContent, { ...context, pageData: templateData.page, @@ -237,7 +229,6 @@ export class GithubAppsTransformer implements PageTransformer { let preparedItems: PreparedListItem[] | PreparedPermissionItem[] = [] if (isListPage) { - // For list pages, data is organized by category -> array of operations preparedItems = Object.entries(appsData as GitHubAppsListData).map( ([category, operations]) => ({ category, @@ -247,7 +238,6 @@ export class GithubAppsTransformer implements PageTransformer { }), ) } else if (isPermissionsPage) { - // For permissions pages, data is organized by permission name -> permission object preparedItems = Object.entries(appsData as GitHubAppsPermissionsData).map( ([permissionName, permissionObject]) => { const { displayTitle, permissions } = permissionObject diff --git a/src/article-api/transformers/graphql-breaking-changes-transformer.ts b/src/article-api/transformers/graphql-breaking-changes-transformer.ts index 6a1d6fb62ea6..caa36e456f02 100644 --- a/src/article-api/transformers/graphql-breaking-changes-transformer.ts +++ b/src/article-api/transformers/graphql-breaking-changes-transformer.ts @@ -7,9 +7,6 @@ import { fastTextOnly } from '@/content-render/unified/text-only' import { extractManualContent } from '@/article-api/lib/graphql-helpers' import GithubSlugger from 'github-slugger' -/** - * Renders the GraphQL breaking changes page, organized by date. - */ export class GraphQLBreakingChangesTransformer implements PageTransformer { templateName = 'graphql-breaking-changes.template.md' diff --git a/src/article-api/transformers/graphql-changelog-transformer.ts b/src/article-api/transformers/graphql-changelog-transformer.ts index 513d66f6d73f..e6758add7424 100644 --- a/src/article-api/transformers/graphql-changelog-transformer.ts +++ b/src/article-api/transformers/graphql-changelog-transformer.ts @@ -6,9 +6,6 @@ import { loadTemplate } from '@/article-api/lib/load-template' import { fastTextOnly } from '@/content-render/unified/text-only' import { extractManualContent } from '@/article-api/lib/graphql-helpers' -/** - * Renders the GraphQL changelog: schema changes, preview changes, and upcoming changes. - */ export class GraphQLChangelogTransformer implements PageTransformer { templateName = 'graphql-changelog.template.md' @@ -32,7 +29,7 @@ export class GraphQLChangelogTransformer implements PageTransformer { if (year) { schema = getGraphqlChangelogByYear(currentVersion, year) as ChangelogItemT[] } else { - // Index page: show only the latest year + // The index page shows only the latest changelog year. const latestYear = years[0] schema = getGraphqlChangelogByYear(currentVersion, latestYear) as ChangelogItemT[] } diff --git a/src/article-api/transformers/graphql-index-transformer.ts b/src/article-api/transformers/graphql-index-transformer.ts index dd400a8173c7..4854eece5f9a 100644 --- a/src/article-api/transformers/graphql-index-transformer.ts +++ b/src/article-api/transformers/graphql-index-transformer.ts @@ -4,9 +4,6 @@ import { renderContent } from '@/content-render/index' import { loadTemplate } from '@/article-api/lib/load-template' import { extractManualContent } from '@/article-api/lib/graphql-helpers' -/** - * Renders the GraphQL reference index page with links to its child pages. - */ export class GraphQLIndexTransformer implements PageTransformer { templateName = 'graphql-index.template.md' diff --git a/src/article-api/transformers/graphql-reference-transformer.ts b/src/article-api/transformers/graphql-reference-transformer.ts index 73989be04107..5425f179e345 100644 --- a/src/article-api/transformers/graphql-reference-transformer.ts +++ b/src/article-api/transformers/graphql-reference-transformer.ts @@ -16,9 +16,6 @@ import { loadTemplate } from '@/article-api/lib/load-template' import { fastTextOnly } from '@/content-render/unified/text-only' import { extractManualContent } from '@/article-api/lib/graphql-helpers' -/** - * Renders GraphQL reference pages: schema items with their fields and arguments. - */ export class GraphQLReferenceTransformer implements PageTransformer { templateName = 'graphql-reference.template.md' @@ -31,25 +28,22 @@ export class GraphQLReferenceTransformer implements PageTransformer { return isReference && isNotIndex } + // React category pages render kinds in a fixed order. + // Their mini-TOCs and anchors need kind-disambiguated slugs. + // They prevent collisions between names such as Repository object and repository query. async transform(page: Page, pathname: string, context: Context): Promise { const currentVersion = context.currentVersion! const pathParts = pathname.split('/').filter(Boolean) const graphqlIndex = pathParts.indexOf('graphql') - const pageType = pathParts[graphqlIndex + 2] // category slug like 'repos', 'issues', etc. + const pageType = pathParts[graphqlIndex + 2] const { getGraphqlSchema, getAllGraphqlObjects } = await import('@/graphql/lib/index') const { isValidCategory, ALL_KIND_KEYS, KIND_SLUG_PREFIX } = await import('@/graphql/lib/categories') - // Category pages render every kind in a fixed section order. The mini-toc - // and in-page anchors use kind-disambiguated slugs so items sharing a - // case-insensitive name across kinds (e.g. `Repository` object vs - // `repository` query) don't collide. if (!isValidCategory(pageType)) { - // Defensive: legacy kind URLs redirect to /graphql/reference, so this - // transformer should never see them. Returning empty content lets the - // 404 path handle anything unexpected. + // Kind URLs redirect to /graphql/reference; empty content lets the 404 path handle misses. return '' } @@ -59,9 +53,7 @@ export class GraphQLReferenceTransformer implements PageTransformer { const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' const manualContent = await extractManualContent(page, context) - // Flatten every kind into a single alphabetical list. Each prepared item - // carries its kind label so the template can render a "name - kind" - // disambiguator next to the heading without grouping by kind sections. + // Flatten kinds into one alphabetical list; keep labels to disambiguate same-name items. const KIND_DISPLAY: Record = { queries: 'query', mutations: 'mutation', @@ -116,8 +108,7 @@ export class GraphQLReferenceTransformer implements PageTransformer { }) } - // Dispatch an item to the appropriate prepare* helper based on kind. The - // resulting slug is kind-disambiguated to match the in-page anchors. + // prepareByKind assigns kind-disambiguated slugs that match the in-page anchors. private async prepareByKind( kind: string, item: { name: string; [k: string]: unknown }, @@ -161,14 +152,11 @@ export class GraphQLReferenceTransformer implements PageTransformer { default: prepared = {} } - // Override `slug` with the kind-disambiguated form used by the React page. + // Match the React page's kind-disambiguated slug format. prepared.slug = `${slugPrefix}-${(prepared.slug as string | undefined) ?? item.name.toLowerCase()}` return prepared } - /** - * Prepare a query item for rendering - */ private async prepareQuery(query: QueryT): Promise> { return { name: query.name, @@ -189,9 +177,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { } } - /** - * Prepare a mutation item for rendering - */ private async prepareMutation(mutation: MutationT): Promise> { return { name: mutation.name, @@ -206,9 +191,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { } } - /** - * Prepare an object item for rendering - */ private async prepareObject(object: ObjectT): Promise> { return { name: object.name, @@ -223,9 +205,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { } } - /** - * Prepare an interface item for rendering - */ private async prepareInterface(item: InterfaceT): Promise> { return { name: item.name, @@ -237,9 +216,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { } } - /** - * Prepare an enum item for rendering - */ private async prepareEnum(item: EnumT): Promise> { return { name: item.name, @@ -254,9 +230,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { } } - /** - * Prepare a union item for rendering - */ private async prepareUnion(item: UnionT): Promise> { return { name: item.name, @@ -268,9 +241,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { } } - /** - * Prepare an input object item for rendering - */ private async prepareInputObject(item: InputObjectT): Promise> { return { name: item.name, @@ -282,9 +252,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { } } - /** - * Prepare a scalar item for rendering - */ private async prepareScalar(item: ScalarT): Promise> { return { name: item.name, @@ -297,7 +264,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { private static STANDARD_PAGINATION_ARGS = new Set(['after', 'before', 'first', 'last']) - // True when the field's only arguments are after, before, first and last. private hasOnlyStandardPaginationArgs(field: FieldT): boolean { if (!field.arguments || field.arguments.length !== 4) return false return field.arguments.every((arg) => @@ -305,9 +271,6 @@ export class GraphQLReferenceTransformer implements PageTransformer { ) } - /** - * Prepare fields for rendering - */ private async prepareFields(fields: FieldT[]): Promise>> { return fields.map((field) => { const hasPaginationOnly = this.hasOnlyStandardPaginationArgs(field) diff --git a/src/article-api/transformers/index.ts b/src/article-api/transformers/index.ts index a6fc2d634860..908a7c21e7c1 100644 --- a/src/article-api/transformers/index.ts +++ b/src/article-api/transformers/index.ts @@ -18,7 +18,6 @@ import { SearchPageTransformer } from './search-page-transformer' import { ReleaseNotesTransformer } from './release-notes-transformer' import { ArticleTransformer } from './article-transformer' -// Every page-to-markdown transformer is registered here. export const transformerRegistry = new TransformerRegistry() transformerRegistry.register(new RestTransformer()) @@ -38,7 +37,7 @@ transformerRegistry.register(new CategoryLandingTransformer()) transformerRegistry.register(new DiscoveryLandingTransformer()) transformerRegistry.register(new SearchPageTransformer()) transformerRegistry.register(new ReleaseNotesTransformer()) -// ArticleTransformer is the catch-all, so it must be registered last. +// Register ArticleTransformer last because it catches every non-null page. transformerRegistry.register(new ArticleTransformer()) export { TransformerRegistry } from './types' diff --git a/src/article-api/transformers/journey-landing-transformer.ts b/src/article-api/transformers/journey-landing-transformer.ts index e966d2b58a7f..b0440765e158 100644 --- a/src/article-api/transformers/journey-landing-transformer.ts +++ b/src/article-api/transformers/journey-landing-transformer.ts @@ -22,11 +22,7 @@ interface JourneyPage extends Page { children?: string[] } -/** - * Transforms journey-landing pages into markdown. Renders journey tracks - * (grouped learning paths), falling back to a children listing when no track - * produces a renderable link. - */ +// Journey pages fall back to a children listing when no track produces a renderable link. export class JourneyLandingTransformer implements PageTransformer { templateName = 'landing-page.template.md' diff --git a/src/article-api/transformers/release-notes-transformer.ts b/src/article-api/transformers/release-notes-transformer.ts index 804b26351ad8..fca2dc76d612 100644 --- a/src/article-api/transformers/release-notes-transformer.ts +++ b/src/article-api/transformers/release-notes-transformer.ts @@ -4,13 +4,7 @@ import { getReleaseNotes } from '@/release-notes/middleware/get-release-notes' import { formatReleases } from '@/release-notes/lib/release-notes-utils' import { renderContent } from '@/content-render/index' -/** - * Transformer for GHES enterprise-server release notes pages. - * - * The release notes content comes from YAML data files (not the markdown body), - * so the generic ArticleTransformer would return an empty body. This transformer - * fetches the release notes data directly and renders it as markdown. - */ +// GHES release notes come from YAML; ArticleTransformer would return an empty markdown body. export class ReleaseNotesTransformer implements PageTransformer { canTransform(page: Page): boolean { return page.layout === 'release-notes' @@ -42,8 +36,7 @@ export class ReleaseNotesTransformer implements PageTransformer { } } -// Matches the labels used by the web renderer; keep in sync with -// src/release-notes/components/PatchNotes.tsx. +// Match the web renderer labels in src/release-notes/components/PatchNotes.tsx. const SECTION_LABELS: Record = { features: 'Features', bugs: 'Bug fixes', @@ -61,10 +54,9 @@ async function renderNoteMarkdown(raw: string, context: Context): Promise[1]) } @@ -51,14 +50,11 @@ type PreparedTemplateData = { apiVersion?: string } -/** - * Converts REST operations and their data into markdown using a Liquid template. - */ export class RestTransformer implements PageTransformer { templateName = 'rest-page.template.md' canTransform(page: Page): boolean { - // Landing pages like /en/rest are handled by a different transformer. + // A different transformer handles landing pages like /en/rest. return page.autogenerated === 'rest' && !page.relativePath.endsWith('index.md') } @@ -71,7 +67,7 @@ export class RestTransformer implements PageTransformer { const startTime = DEBUG ? Date.now() : 0 if (DEBUG) console.log(`[DEBUG] RestTransformer: ${pathname}`) - // Import getRest dynamically to avoid circular dependencies + // Dynamic import avoids circular dependencies. const { default: getRest } = await import('@/rest/lib/index') const currentVersion = context.currentVersion! @@ -82,7 +78,7 @@ export class RestTransformer implements PageTransformer { ? context.currentVersionObj.latestApiVersion : undefined) - // e.g. /en/rest/actions/artifacts -> category: actions, subcategory: artifacts + // /en/rest/actions/artifacts resolves to category actions and subcategory artifacts. const pathParts = pathname.split('/').filter(Boolean) const restIndex = pathParts.indexOf('rest') @@ -91,7 +87,7 @@ export class RestTransformer implements PageTransformer { } const category = pathParts[restIndex + 1] - const subcategory = pathParts[restIndex + 2] // May be undefined for category-only pages + const subcategory = pathParts[restIndex + 2] // Undefined for category-only pages. const categoryData = await getRest(currentVersion, effectiveApiVersion, category) @@ -136,9 +132,7 @@ export class RestTransformer implements PageTransformer { const templateContent = loadTemplate(this.templateName) - // Render the template with Liquid. templateData intentionally replaces - // context.page with a simplified, text-rendered {title, intro} for the - // template, so the merged object is not a strict Context. + // templateData replaces context.page, so the merged object is not a strict Context. const rendered = await renderContent(templateContent, { ...context, ...templateData, @@ -162,8 +156,7 @@ export class RestTransformer implements PageTransformer { operations.map(async (operation) => await this.prepareOperation(operation)), ) - // Deduplicate identical response schemas across operations on the same page. - // When multiple endpoints share the same schema, render it once and reference it. + // Identical response schemas render once per page; later endpoints link to the first operation. const slugger = new GithubSlugger() const titleToSlug = new Map() for (const op of preparedOperations) { diff --git a/src/article-api/transformers/search-page-transformer.ts b/src/article-api/transformers/search-page-transformer.ts index ed5b7e7a8871..d489a2ebcf78 100644 --- a/src/article-api/transformers/search-page-transformer.ts +++ b/src/article-api/transformers/search-page-transformer.ts @@ -1,10 +1,7 @@ import type { Context, Page } from '@/types' import type { PageTransformer } from './types' -/** - * /en/search is a UI-only page with no markdown content, so this returns the - * title plus a pointer to the Search API. - */ +// /en/search has no markdown content, so return the title and a pointer to the Search API. export class SearchPageTransformer implements PageTransformer { templateName = '' diff --git a/src/article-api/transformers/secret-scanning-transformer.ts b/src/article-api/transformers/secret-scanning-transformer.ts index 2b1d57525720..3375c68096c5 100644 --- a/src/article-api/transformers/secret-scanning-transformer.ts +++ b/src/article-api/transformers/secret-scanning-transformer.ts @@ -8,9 +8,6 @@ import { loadTemplate } from '@/article-api/lib/load-template' import { stripHtmlComments } from '@/article-api/lib/strip-html-comments' import { getSecretScanningData } from '@/secret-scanning/lib/get-secret-scanning-data' -/** - * Loads secret scanning pattern data and converts it into markdown. - */ export class SecretScanningTransformer implements PageTransformer { templateName = 'secret-scanning-page.template.md' @@ -18,6 +15,8 @@ export class SecretScanningTransformer implements PageTransformer { return page.autogenerated === 'secret-scanning' } + // Secret scanning output skips the final remark pass because page.render rewrites markdown links. + // Later cleanup only creates fragment links such as #token-versions, which do not need rewriting. async transform(page: Page, _pathname: string, context: Context): Promise { if (!context.secretScanningData) { const currentVersion = context.currentVersion @@ -38,15 +37,14 @@ export class SecretScanningTransformer implements PageTransformer { const data = await getSecretScanningData(filepath) for (const entry of data) { - // Process Liquid for the hasValidityCheck field, as in the middleware + // Resolve Liquid before YAML parses hasValidityCheck to match middleware behavior. if (typeof entry.hasValidityCheck === 'string' && entry.hasValidityCheck.includes('{%')) { - // Render Liquid and parse as YAML to get correct boolean type entry.hasValidityCheck = load( await liquid.parseAndRender(entry.hasValidityCheck, context), ) as boolean } - // Process Liquid for the hasExtendedMetadata field, as in the middleware + // Resolve Liquid before YAML parses hasExtendedMetadata to match middleware behavior. if ( typeof entry.hasExtendedMetadata === 'string' && entry.hasExtendedMetadata.includes('{%') @@ -74,14 +72,11 @@ export class SecretScanningTransformer implements PageTransformer { context.markdownRequested = true let content = await page.render(context) - // Inject the full patterns table for agent/crawler access - // (The React DataTable is not rendered in markdown mode) + // Agent and crawler requests need the full table; React DataTable is absent in markdown mode. if (context.secretScanningData && context.secretScanningData.length > 0) { const bool = (v: unknown) => (v ? '✓' : '✗') const escape = (s: string) => s.replace(/\\/g, '\\\\').replace(/\|/g, '\\|') - // Strip HTML from secretType before inserting into markdown table rows. - // The isduplicate logic above appends
    HTML which would break - // single-line markdown table rows once
    is later converted to \n. + // Convert isduplicate's
    HTML before later br cleanup splits secretType table cells. const cleanSecretType = (s: string) => s .replace( @@ -108,9 +103,7 @@ export class SecretScanningTransformer implements PageTransformer { content = content.replace(/]*aria-label="Unsupported"[^>]*>[^<]*<\/span>/g, '✗') content = content.replace(//gi, '\n') content = content.replace(/]*>([^<]*)<\/a>/gi, '[$2]($1)') - // Strip any remaining HTML tags. Loop until stable to handle nested or - // malformed tags (e.g. "ipt>"). Limit iterations to prevent - // infinite loops on pathological input. + // Repeat stripping handles nested tags like ipt>; the limit stops loops. let previous = '' let iterations = 0 const MAX_STRIP_ITERATIONS = 10 @@ -124,10 +117,6 @@ export class SecretScanningTransformer implements PageTransformer { const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : '' - // Render the template with Liquid only. page.render() already ran - // rewriteLocalLinks on all markdown links, and the regex cleanup above - // only creates fragment links (e.g. #token-versions) which don't need - // link rewriting. So we skip the expensive remark re-parse. const templateContent = loadTemplate(this.templateName) return await liquid.parseAndRender(templateContent, { ...context, diff --git a/src/article-api/transformers/toc-transformer.ts b/src/article-api/transformers/toc-transformer.ts index cbe51d4204ba..a53a866d7c6b 100644 --- a/src/article-api/transformers/toc-transformer.ts +++ b/src/article-api/transformers/toc-transformer.ts @@ -9,11 +9,7 @@ interface CategoryPage extends Page { children?: string[] } -/** - * Transformer for pages that have children but no specific layout: category, - * subcategory, product and homepage. Lists each child with its title and intro. - * Corresponds to the TocLanding component in the web UI. - */ +// This mirrors the TocLanding web UI for child-listing pages without a specific layout. export class TocTransformer implements PageTransformer { templateName = 'landing-page.template.md' diff --git a/src/article-api/transformers/types.ts b/src/article-api/transformers/types.ts index cc43d421b52d..43ae86efaf35 100644 --- a/src/article-api/transformers/types.ts +++ b/src/article-api/transformers/types.ts @@ -1,8 +1,5 @@ import type { Context, Page } from '@/types' -/** - * Link data for landing page sections - */ export interface LinkData { href: string title: string @@ -19,36 +16,24 @@ export interface Section { groups: LinkGroup[] } -/** - * Template data structure for landing pages - */ export interface TemplateData { title: string intro: string sections: Section[] } -/** - * Converts a page into markdown. - */ export interface PageTransformer { - /** Template file to render with, e.g. 'landing-page.template.md'. */ templateName?: string canTransform(page: Page): boolean - /** `apiVersion` is the REST calendar version, e.g. '2022-11-28'. */ + // apiVersion selects the REST calendar version, such as 2022-11-28. transform(page: Page, pathname: string, context: Context, apiVersion?: string): Promise } -/** - * Transformers are evaluated in registration order, and the first one whose - * `canTransform()` returns true wins. Register the specific ones before the - * general ones. - * - * This class is not thread-safe, so register everything during initialization - * rather than while handling requests. - */ +// Transformers run in registration order, and the first matching canTransform wins. +// Register specific transformers before general transformers. +// Register all transformers during initialization; this class is not thread-safe. export class TransformerRegistry { private transformers: PageTransformer[] = [] @@ -56,7 +41,6 @@ export class TransformerRegistry { this.transformers.push(transformer) } - /** Returns null when `page` is nullish or nothing can handle it. */ findTransformer(page: Page): PageTransformer | null { if (page == null) { return null diff --git a/src/article-api/transformers/webhooks-transformer.ts b/src/article-api/transformers/webhooks-transformer.ts index d780665852fb..a47cef345dff 100644 --- a/src/article-api/transformers/webhooks-transformer.ts +++ b/src/article-api/transformers/webhooks-transformer.ts @@ -5,9 +5,6 @@ import { fastTextOnly } from '@/content-render/unified/text-only' import { loadTemplate } from '@/article-api/lib/load-template' import matter from '@gr2m/gray-matter' -/** - * Converts webhook events and payloads into markdown using a Liquid template. - */ export class WebhooksTransformer implements PageTransformer { templateName = 'webhooks-page.template.md' @@ -16,7 +13,7 @@ export class WebhooksTransformer implements PageTransformer { } async transform(page: Page, pathname: string, context: Context): Promise { - // Import getInitialPageWebhooks dynamically to avoid circular dependencies + // Dynamic import avoids circular dependencies. const { getInitialPageWebhooks } = await import('@/webhooks/lib/index') const currentVersion = context.currentVersion! @@ -43,7 +40,7 @@ export class WebhooksTransformer implements PageTransformer { } } - // Prepare webhooks data for template (payload examples are omitted to save space) + // Omit payload examples so the generated webhook page stays compact. const preparedWebhooks = webhooksData.map((webhook) => ({ name: webhook.name, actionTypes: webhook.actionTypes, @@ -60,7 +57,7 @@ export class WebhooksTransformer implements PageTransformer { })), })) - // Identify common body parameters that appear in most webhooks + // Common body parameters appear in at least 60 percent of webhooks. const paramCounts = new Map() for (const webhook of preparedWebhooks) { for (const param of webhook.bodyParameters) { @@ -72,7 +69,7 @@ export class WebhooksTransformer implements PageTransformer { [...paramCounts.entries()].filter(([, count]) => count >= threshold).map(([name]) => name), ) - // Remove common params from each webhook and collect them for summary + // Omit high-frequency body parameters from rows; summarize matches from the first webhook. const commonParams = preparedWebhooks[0]?.bodyParameters.filter((p: { name: string }) => commonParamNames.has(p.name), From 678a0467f908fb86d0ab80986a168596eb78d634 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 16:22:40 +0000 Subject: [PATCH 18/27] Tighten code comments in src/article-api lib, scripts, and tests (#63453) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/article-api/lib/get-all-toc-items.ts | 37 +++----------- src/article-api/lib/get-link-data.ts | 8 --- src/article-api/lib/graphql-helpers.ts | 1 - src/article-api/lib/load-template.ts | 2 - src/article-api/lib/normalize-markdown.ts | 21 ++------ src/article-api/lib/resolve-path.ts | 16 +----- src/article-api/lib/strip-html-comments.ts | 12 ++--- src/article-api/lib/summarize-schema.ts | 26 ++-------- src/article-api/scripts/generate-api-docs.ts | 19 ++++--- .../scripts/precompute-pageinfo.ts | 37 +++----------- src/article-api/tests/article-body.ts | 11 +--- .../tests/audit-logs-transformer.ts | 5 +- .../tests/bespoke-landing-transformer.ts | 1 - .../tests/category-landing-transformer.ts | 5 +- .../tests/codeql-cli-transformer.ts | 1 - .../tests/discovery-landing-transformer.ts | 8 +-- .../tests/github-apps-transformer.ts | 6 --- src/article-api/tests/graphql-transformer.ts | 18 +------ .../tests/journey-landing-transformer.ts | 1 - src/article-api/tests/normalize-markdown.ts | 5 +- src/article-api/tests/pageinfo.ts | 51 +++++-------------- src/article-api/tests/pagelist.ts | 12 ++--- .../tests/release-notes-transformer.ts | 9 +--- src/article-api/tests/resolve-path.ts | 2 - src/article-api/tests/rest-transformer.ts | 32 ++---------- .../tests/secret-scanning-transformer.ts | 11 +--- .../tests/summarize-schema.test.ts | 17 +------ src/article-api/tests/toc-transformer.ts | 3 -- src/article-api/tests/webhooks-transformer.ts | 17 ++----- 29 files changed, 72 insertions(+), 322 deletions(-) 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