diff --git a/.github/workflows/check-for-non-english.yml b/.github/workflows/check-for-non-english.yml new file mode 100644 index 000000000000..a80541642904 --- /dev/null +++ b/.github/workflows/check-for-non-english.yml @@ -0,0 +1,87 @@ +name: Check for non-English titles + +# **What it does**: Closes issues/PRs whose title contains non-Latin-script +# characters, which is a common pattern for spam/scam submissions (e.g. +# fake certificate/badge issues written in Arabic, Cyrillic, CJK, etc.) +# **Why we have it**: We get spam in the open-source repo with titles in +# scripts our triage team can't read, making it hard to evaluate intent. +# **Who does it impact**: Open-source contributors. + +on: + issues: + types: [opened] + pull_request_target: + types: [opened] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + non-english-title-check: + name: Flag and close non-English titles + if: github.repository == 'github/docs' + runs-on: ubuntu-latest + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 + with: + github-token: ${{ secrets.DOCS_BOT_PAT_BASE }} + script: | + const isIssue = !!context.payload.issue + const item = context.payload.issue || context.payload.pull_request + const owner = 'github' + const repo = 'docs' + const title = item.title || '' + + // Allow: basic Latin letters/digits/punctuation, common symbols, + // and emoji (surrogate pairs), so normal English titles pass. + // Flag titles containing letters from non-Latin scripts + // (Arabic, CJK, Cyrillic, Devanagari, Hebrew, Thai, etc.) + const nonLatinScriptRegex = /(?!\p{Script=Latin})\p{Letter}/u + + if (!nonLatinScriptRegex.test(title)) { + return + } + + try { + await github.rest.teams.getMembershipForUserInOrg({ + org: 'github', + team_slug: 'employees', + username: context.payload.sender.login, + }) + // Don't action GitHub employees + return + } catch (err) { + // Not an employee — continue + } + + const commentBody = + `Thanks for your contribution! It looks like the title of this ${isIssue ? 'issue' : 'pull request'} contains non-English characters. ` + + `To help our team triage effectively, please resubmit with an English-language title describing the change or problem. ` + + `I'm closing this for now, but feel free to open a new one!` + + if (isIssue) { + await github.rest.issues.update({ + owner, repo, + issue_number: item.number, + labels: ['invalid'], + state: 'closed', + }) + await github.rest.issues.createComment({ + owner, repo, + issue_number: item.number, + body: commentBody, + }) + } else { + await github.rest.pulls.update({ + owner, repo, + pull_number: item.number, + state: 'closed', + }) + await github.rest.issues.createComment({ + owner, repo, + issue_number: item.number, + body: commentBody, + }) + } diff --git a/.github/workflows/check-for-spammy-prs.yml b/.github/workflows/check-for-spammy-prs.yml index ac862691065a..6568410ab995 100644 --- a/.github/workflows/check-for-spammy-prs.yml +++ b/.github/workflows/check-for-spammy-prs.yml @@ -6,7 +6,7 @@ name: Check for Spammy PRs on: pull_request_target: - types: [opened] + types: [opened, edited, reopened, synchronize] permissions: contents: read diff --git a/.github/workflows/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: diff --git a/content/account-and-profile/how-tos/account-management/creating-an-account-on-github.md b/content/account-and-profile/how-tos/account-management/creating-an-account-on-github.md index 2eba44c1a468..11d4483b7f43 100644 --- a/content/account-and-profile/how-tos/account-management/creating-an-account-on-github.md +++ b/content/account-and-profile/how-tos/account-management/creating-an-account-on-github.md @@ -23,7 +23,7 @@ contentType: how-tos ## About your personal account on {% data variables.product.github %} -To get started with {% data variables.product.prodname_dotcom_the_website %}, you need to a personal account and a verified email address. +To get started with {% data variables.product.prodname_dotcom_the_website %}, you need to have a personal account and a verified email address. When creating a free account on {% data variables.product.prodname_dotcom_the_website %}, you can also authenticate with Google or Apple - which are the supported social login providers. For iOS users, even if you have enabled the setting "Hide My Email addresses" for your Apple account, using social login will result in creating a new {% data variables.product.github %} account. diff --git a/content/actions/reference/workflows-and-actions/workflow-syntax.md b/content/actions/reference/workflows-and-actions/workflow-syntax.md index 22fd3cf022c0..5f916e52265e 100644 --- a/content/actions/reference/workflows-and-actions/workflow-syntax.md +++ b/content/actions/reference/workflows-and-actions/workflow-syntax.md @@ -684,7 +684,7 @@ We strongly recommend that you include the version of the action you are using b Some actions require inputs that you must set using the [`with`](#jobsjob_idstepswith) keyword. Review the action's README file to determine the inputs required. -Actions are either JavaScript files or Docker containers. If the action you're using is a Docker container you must run the job in a Linux environment. For more details, see [`runs-on`](#jobsjob_idruns-on). +Actions can be JavaScript, composite, or Docker container actions. If the action you're using is a Docker container you must run the job in a Linux environment. For more details, see [`runs-on`](#jobsjob_idruns-on). ### Example: Using versioned actions diff --git a/content/code-security/reference/code-scanning/sarif-files/sarif-support.md b/content/code-security/reference/code-scanning/sarif-files/sarif-support.md index e79f8a3ff0dc..4955da703c87 100644 --- a/content/code-security/reference/code-scanning/sarif-files/sarif-support.md +++ b/content/code-security/reference/code-scanning/sarif-files/sarif-support.md @@ -248,14 +248,13 @@ This SARIF output file has example values to show the minimum required propertie "name": "Tool Name", "rules": [ { - "id": "R01" - ... + "id": "R01", "properties" : { "id" : "java/unsafe-deserialization", "kind" : "path-problem", "name" : "...", "problem.severity" : "error", - "security-severity" : "9.8", + "security-severity" : "9.8" } } ] @@ -309,14 +308,13 @@ This SARIF output file has example of values for the field `originalUriBaseIds`, "name": "Tool Name", "rules": [ { - "id": "R01" - ... + "id": "R01", "properties" : { "id" : "java/unsafe-deserialization", "kind" : "path-problem", "name" : "...", "problem.severity" : "error", - "security-severity" : "9.8", + "security-severity" : "9.8" } } ] diff --git a/content/copilot/how-tos/administer-copilot/manage-for-enterprise/use-managed-settings/get-started.md b/content/copilot/how-tos/administer-copilot/manage-for-enterprise/use-managed-settings/get-started.md index 4f0c25fc288d..6c5849d6753b 100644 --- a/content/copilot/how-tos/administer-copilot/manage-for-enterprise/use-managed-settings/get-started.md +++ b/content/copilot/how-tos/administer-copilot/manage-for-enterprise/use-managed-settings/get-started.md @@ -121,7 +121,7 @@ To review configuration issues: {% data reusables.enterprise-accounts.access-enterprise %} {% data reusables.enterprise-accounts.ai-controls-tab %} 1. On the **Agents** tab, find the "Copilot settings validation" section. -1. Review the errors and warnings. Each issue identifies the affected file and and JSON path. +1. Review the errors and warnings. Each issue identifies the affected file and JSON path. 1. To fix an issue, update the affected file and commit the change to the default branch of the `.github-private` repository. Then reload the **Agents** page to check the updated configuration. If the validator finds no issues, the "Copilot settings validation" section isn't displayed. If validation is temporarily unavailable, your existing settings continue to apply. Reload the **Agents** page later to check again. 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/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 / comments - // without specifying rule names, which re-enables ALL markdownlint rules - // including ones we don't use (like line-length/MD013). + // Bare markdownlint-enable comments can re-enable unconfigured rules, such as MD013. if (!ruleConfig) { return null } @@ -588,7 +535,6 @@ function formatResult(object: LintError, isInPrecommitMode: boolean): FormattedR }, formattedResult) } -// Get a list of changed and staged files in the local git repo function getChangedFiles() { const changedFiles = execSync(`git diff --diff-filter=d --name-only`) .toString() @@ -603,8 +549,7 @@ function getChangedFiles() { return [...changedFiles, ...stagedFiles] } -// Summarizes the list of rules we have available to run with their -// short name, long name, and description. +// listRules prints short names, long names, and descriptions for CLI help. function listRules() { let ruleList = '' for (const rule of allRules) { @@ -614,9 +559,7 @@ function listRules() { return ruleList } -// Some rules can't be run on data files, since those Markdown files are -// partials included in full Markdown files. Those rules have the property -// `partial-markdown-files` set to false. +// Data Markdown files are partials, so rules with partial-markdown-files false skip them. function getMarkdownLintConfig( filterErrorsOnly: boolean, runRules: string[] | undefined, @@ -638,8 +581,7 @@ function getMarkdownLintConfig( const customRule = (customConfig as Record)[ruleName] ? (getCustomRule(ruleName) as MarkdownlintRule) : undefined - // search-replace is handled differently than other rules because - // it has nested metadata and rules. + // search-replace has nested metadata and pseudo-rules. if ( filterErrorsOnly && getSeverity(ruleConfig, isPrecommit) !== 'error' && @@ -650,7 +592,6 @@ function getMarkdownLintConfig( if (runRules && !shouldIncludeRule(ruleName, runRules)) continue - // There are a subset of rules run on just the frontmatter in files if ((githubDocsFrontmatterConfig as Record)[ruleName]) { config.frontMatter[ruleName] = ruleConfig if (customRule) configuredRules.frontMatter.push(customRule) @@ -665,9 +606,7 @@ function getMarkdownLintConfig( for (const searchRule of ruleConfig.rules) { const searchRuleSeverity = getSeverity(searchRule, isPrecommit) if (filterErrorsOnly && searchRuleSeverity !== 'error') continue - // The frontmatter pass lints the whole file, so a rule with - // applyToFrontmatter must run there and nowhere else, or every match - // gets reported twice. + // applyToFrontmatter runs only in the frontmatter pass, or every match reports twice. if (searchRule.applyToFrontmatter) { frontmatterSearchReplaceRules.push(searchRule) } else { @@ -714,17 +653,13 @@ function getMarkdownLintConfig( return { config, configuredRules } } -// Return the severity value of a rule but keep in mind it could be -// running as a precommit hook, which means the severity could be -// deliberately different. +// Precommit can lower or raise a rule's normal severity. function getSeverity(ruleConfig: Config, isInPrecommitMode: boolean): string { return isInPrecommitMode ? ruleConfig.precommitSeverity || ruleConfig.severity : ruleConfig.severity } -// Gets a custom rule function from the name of the rule -// in the configuration file function getCustomRule(ruleName: string): Rule | MarkdownlintRule { const rule = customRules.find((r) => r.names.includes(ruleName)) if (!rule) @@ -734,20 +669,17 @@ function getCustomRule(ruleName: string): Rule | MarkdownlintRule { return rule } -// Check if a rule should be included based on user-specified rules -// Handles both short names (e.g., GHD047, MD001) and long names (e.g., table-column-integrity, heading-increment) +// Accept both short rule IDs and long rule names. export function shouldIncludeRule(ruleName: string, runRules: string[]) { if (runRules.includes(ruleName)) { return true } - // For custom rules, check if any of the rule's names (short or long) are in the runRules list const customRule = customRules.find((rule) => rule.names.includes(ruleName)) if (customRule) { return customRule.names.some((name) => runRules.includes(name)) } - // For built-in markdownlint rules, check if any of the rule's names are in the runRules list const builtinRule = allRules.find((rule) => rule.names.includes(ruleName)) if (builtinRule) { return builtinRule.names.some((name: string) => runRules.includes(name)) @@ -756,24 +688,8 @@ export function shouldIncludeRule(ruleName: string, runRules: string[]) { return false } -/* - The severity of the search-replace custom rule is embedded in - each individual search rule. This function returns the severity - of the individual search rule. The name we define for each search - rule shows up in the errorDetail property of the error object. - The error object returned from Markdownlint has the following structure: - - { - lineNumber: 266, - ruleNames: [ 'search-replace' ], - ruleDescription: 'Custom rule', - ruleInformation: 'https://github.com/OnkarRuikar/markdownlint-rule-search-replace', - errorDetail: 'docs-domain: Catch occurrences of docs.github.com domain.', - errorContext: "column: 21 text:'docs.github.com'", - errorRange: [ 21, 15 ], - fixInfo: null - } -*/ +// markdownlint-rule-search-replace stores the pseudo-rule name before the colon in +// errorDetail, for example "docs-domain: Catch occurrences of docs.github.com domain." function getSearchReplaceRuleSeverity( ruleName: string, object: LintError, @@ -782,26 +698,25 @@ function getSearchReplaceRuleSeverity( const pluginRuleName = object.errorDetail?.split(':')[0].trim() const ruleConfig = allConfig[ruleName] as Config const rule = ruleConfig.rules?.find((r) => r.name === pluginRuleName) - if (!rule) return 'error' // Default to error if rule not found + if (!rule) return 'error' // Unknown search-replace sub-rules default to error severity. return isInPrecommitMode ? rule.precommitSeverity || rule.severity : rule.severity } function isOptionsValid() { - // paths should only contain existing files and directories const optionPaths = program.opts().paths || [] const validPaths = [] for (const filePath of optionPaths) { try { fs.statSync(filePath) - validPaths.push(filePath) // Keep track of valid paths + validPaths.push(filePath) } catch { if ('paths'.includes(filePath)) { console.warn('warning: did you mean --paths') } else { console.warn(`warning: the value '${filePath}' was not found. Skipping this path.`) } - // Keep going: one bad path should not abandon the rest. + // Keep going so one bad path does not abandon the rest. } } @@ -809,7 +724,6 @@ function isOptionsValid() { program.setOptionValue('paths', validPaths) } - // rules should only contain existing, correctly spelled rules const allRulesList = [...allRules.map((rule) => rule.names).flat(), ...Object.keys(allConfig)] const optionRules = program.opts().rules || [] for (const ruleName of optionRules) { @@ -825,7 +739,7 @@ function isOptionsValid() { } } - // Only return false if paths were specified but none are valid + // Bad paths fail only when none of the requested paths exist. return optionPaths.length === 0 || validPaths.length > 0 } diff --git a/src/content-linter/scripts/lint-report.ts b/src/content-linter/scripts/lint-report.ts index f77bde4d3df0..ada3d2079a16 100644 --- a/src/content-linter/scripts/lint-report.ts +++ b/src/content-linter/scripts/lint-report.ts @@ -7,7 +7,7 @@ import { getEnvInputs } from '@/workflows/get-env-inputs' import { createReportIssue, linkReports } from '@/workflows/issue-report' import { getAllRuleNames } from '@/content-linter/lib/helpers/rule-utils' -// GitHub issue body size limit is ~65k characters, so we'll use 60k as a safe limit +// GitHub issue bodies max out near 65k characters, so reports stop at 60k. const MAX_ISSUE_BODY_SIZE = 60000 // If the number of warnings exceeds this number, print a warning so we can give them attention @@ -33,7 +33,6 @@ function shouldIncludeInReport(flaw: LintFlaw): boolean { return true } - // Check if any rule name is in the include list that overrides severity const hasIncludedRule = allRuleNames.some((ruleName: string) => reportingConfig.includeRules.includes(ruleName), ) @@ -44,19 +43,8 @@ function shouldIncludeInReport(flaw: LintFlaw): boolean { return false } -// [start-readme] -// -// This script runs once a week via a scheduled GitHub Action to lint -// the entire content and data directories based on our -// markdownlint.js rules. -// -// If errors or warnings are found, it will open up a new issue in the -// docs-content repo with the label "broken content markdown report". -// -// The Content FR will go through the issue and update the content and -// data files accordingly. -// -// [end-readme] +// The weekly report turns content and data lint results into a docs-content issue for +// Content FR. program .description( @@ -77,15 +65,13 @@ async function main() { const { REPORT_REPOSITORY, REPORT_AUTHOR, REPORT_LABEL } = process.env const octokit = github() - // `GITHUB_TOKEN` is optional. If you need the token to post a comment - // or open an issue report, you might get cryptic error messages from Octokit. + // Validate GITHUB_TOKEN early because Octokit auth errors are cryptic. getEnvInputs(['GITHUB_TOKEN']) core.info(`Creating issue for configured lint rules...`) const parsedResults = JSON.parse(lintResults) - // Keep track of warnings so we can print an alert when they exceed a manageable number let totalWarnings = 0 const filteredResults: Record = {} diff --git a/src/content-linter/scripts/pretty-print-results.ts b/src/content-linter/scripts/pretty-print-results.ts index 1bc20181b91f..5e319f14fbf3 100644 --- a/src/content-linter/scripts/pretty-print-results.ts +++ b/src/content-linter/scripts/pretty-print-results.ts @@ -39,8 +39,7 @@ export function prettyPrintResults( console.log(chalk.bold(file)) console.log('') - // It's very possible that the same file has multiple flaws of the - // same rule but on different line numbers. + // Keep repeated rule failures together without losing line-number order within each group. const sorted = [...flaws] .sort((a, b) => a.lineNumber - b.lineNumber) .sort((a, b) => a.ruleDescription.localeCompare(b.ruleDescription)) @@ -160,7 +159,7 @@ function chalkFunColors(text: string): string { function indentWrappedString(str: string, startingIndent: number): string { const NEW_LINE_PADDING = ' '.repeat(16) - const width = process.stdout.columns || 80 // Use terminal width, default to 80 if not available + const width = process.stdout.columns || 80 // Default to 80 columns when stdout is not a TTY. let indentedString = '' let currentLine = '' let isFirstLine = true diff --git a/src/content-linter/style/github-docs.ts b/src/content-linter/style/github-docs.ts index 2c6a8e89809b..9f1fe8edb959 100644 --- a/src/content-linter/style/github-docs.ts +++ b/src/content-linter/style/github-docs.ts @@ -162,7 +162,7 @@ const githubDocsConfig = { 'partial-markdown-files': true, 'yml-files': true, }, - // GHD044 removed - octicon aria-labels are now auto-generated + // GHD044 stays unused because octicon aria-labels are auto-generated. 'code-annotation-comment-spacing': { // GHD045 severity: 'error', @@ -315,8 +315,7 @@ export const githubDocsFrontmatterConfig = { }, } -// Configures rules from the `github/markdownlint-github` repo -// created by the accessibility team. +// Rules from github/markdownlint-github come from the accessibility team. const githubMarkdownlintConfig = { 'no-default-alt-text': { severity: 'error', @@ -330,8 +329,7 @@ const githubMarkdownlintConfig = { }, } -// Configures rules from the open-source Markdownlint extension -// search-replace: +// search-replace rule docs: // https://www.npmjs.com/package/markdownlint-rule-search-replace export const searchReplaceConfig = { 'search-replace': { @@ -345,7 +343,7 @@ export const searchReplaceConfig = { precommitSeverity: 'warning', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Critical for content quality - prevents placeholders in titles, intros, etc. + applyToFrontmatter: true, // Catch placeholders in titles, intros, and similar metadata. }, { name: 'docs-domain', @@ -355,7 +353,7 @@ export const searchReplaceConfig = { severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Should not appear in frontmatter + applyToFrontmatter: true, // Catch this domain in frontmatter. }, { name: 'help-domain', @@ -365,25 +363,21 @@ export const searchReplaceConfig = { severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Should not appear in frontmatter + applyToFrontmatter: true, // Catch this domain in frontmatter. }, { name: 'developer-domain', message: 'Catch occurrences of developer.github.com domain.', - // Do not match developer.github.com/changes or - // developer.github.com/enterprise/[0-9] or - // developer.github.com/enterprise/{{something}} (e.g. liquid). - // There are occurrences that will likely always remain in the content. + // Allow /changes, /enterprise/3.17, and /enterprise/{{ currentVersion }} paths. searchPattern: '/developer\\.github\\.com(?!\\/(changes|enterprise\\/([0-9]|{))).*/g', searchScope: 'all', severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Should not appear in frontmatter + applyToFrontmatter: true, // Catch this domain in frontmatter. }, { - // Catches usage of old liquid data reusable syntax. For example: - // {{ site.data.variables.product_releases }} + // Catches deprecated site.data syntax, such as {{ site.data.variables.product_releases }}. name: 'deprecated liquid syntax: site.data', message: 'Catch occurrences of deprecated liquid data syntax.', searchPattern: '/{{\\s*?site\\.data\\.([a-zA-Z0-9-_]+(?:\\.[a-zA-Z0-9-_]+)+)\\s*?}}/g', @@ -391,12 +385,10 @@ export const searchReplaceConfig = { severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Can appear in frontmatter strings + applyToFrontmatter: true, // Can appear in frontmatter strings. }, { - // Catches usage of old octicon variable syntax. For example: - // - {{ octicon-plus }} - // - {{ octicon-plus An example label }} + // Catches octicon- syntax, such as {{ octicon-plus An example label }}. name: 'deprecated liquid syntax: octicon-', message: 'The octicon liquid syntax used is deprecated. Use this format instead `octicon "" aria-label=""`', @@ -404,7 +396,7 @@ export const searchReplaceConfig = { severity: 'error', 'partial-markdown-files': true, 'yml-files': true, - applyToFrontmatter: true, // Can appear in frontmatter strings + applyToFrontmatter: true, // Can appear in frontmatter strings. }, ], }, diff --git a/src/content-linter/tests/category-pages.ts b/src/content-linter/tests/category-pages.ts index e12311db0328..7a81b6e21783 100644 --- a/src/content-linter/tests/category-pages.ts +++ b/src/content-linter/tests/category-pages.ts @@ -43,35 +43,28 @@ describe.skip('category pages', () => { const productIndices = walk(contentDir, walkOptions) const productNames = productIndices.map((index) => path.basename(path.dirname(index))) - // Combine those to fit vitest's `.each` usage const productTuples = zip(productNames, productIndices) as [string, string][] - // Use a regular for...of loop to generate the `describe(...)` blocks - // otherwise, if one of them has no categories, the tests will fail. + // describe.each fails when a product has no categories, so generate describes imperatively. for (const tuple of productTuples) { const [, productIndex] = tuple const productDir = path.dirname(productIndex) - // Get links included in product index page. - // Each link corresponds to a product subdirectory (category). - // Example: "getting-started-with-github" - // Note: We need to read this synchronously here because vitest's describe.each - // can't asynchronously define tests + // Vitest must define describe.each cases synchronously. + // Children include category slugs such as getting-started-with-github. const contents = fs.readFileSync(productIndex, 'utf8') const data = getFrontmatterData(contents) const children: string[] = data.children const categoryLinks = children - // Only include category directories, not standalone category files like content/actions/quickstart.md + // Skip standalone category files such as content/actions/quickstart.md. .filter((link) => fs.existsSync(getPath(productDir, link, 'index'))) const categoryPaths = categoryLinks.map((link) => getPath(productDir, link, 'index')) - // Make them relative for nicer display in test names const categoryRelativePaths = categoryPaths.map((p) => path.relative(contentDir, p)) - // Combine those to fit vitest's `.each` usage const categoryTuples = zip(categoryRelativePaths, categoryPaths, categoryLinks) as [ string, string, @@ -95,7 +88,6 @@ describe.skip('category pages', () => { beforeAll(async () => { const categoryDir = path.dirname(indexAbsPath) - // Get child article links included in each subdir's index page const indexContents = await fs.promises.readFile(indexAbsPath, 'utf8') const parsed = matter(indexContents) if (!parsed.data) throw new Error('No frontmatter') @@ -123,7 +115,6 @@ describe.skip('category pages', () => { const productIndexContents = await fs.promises.readFile(productIndex, 'utf8') const productIndexData = getFrontmatterData(productIndexContents) - // Save the index title for later testing indexTitle = productIndexData.title.includes('{') ? await renderContent(productIndexData.title, req.context, { textOnly: true }) : productIndexData.title @@ -143,7 +134,7 @@ describe.skip('category pages', () => { const articleContents = await fs.promises.readFile(articlePath, 'utf8') const articleData = getFrontmatterData(articleContents) - // Do not include subcategories nor hidden pages in list of published articles + // Published article lists omit subcategories and hidden pages. if (articleData.subcategory || articleData.hidden) return null // ".../content/github/{category}/{article}.md" => "/{article}" @@ -164,7 +155,7 @@ describe.skip('category pages', () => { const articleContents = await fs.promises.readFile(articlePath, 'utf8') const availableArticleData = getFrontmatterData(articleContents) - // Do not include subcategories nor hidden pages in list of available articles + // Available article lists omit subcategories and hidden pages. if (availableArticleData.subcategory || availableArticleData.hidden) return null // ".../content/github/{category}/{article}.md" => "/{article}" @@ -234,8 +225,7 @@ describe.skip('category pages', () => { }) function getPath(productDir: string, link: string, filename: string) { - // Handle absolute /content/ paths for cross-product children - // The link parameter contains the child path from frontmatter + // Absolute /content/ links resolve from contentDir instead of productDir. if (link.startsWith('/content/')) { const absolutePath = link.slice('/content/'.length) if (filename === 'index') { diff --git a/src/content-linter/tests/integration/lint-cli.ts b/src/content-linter/tests/integration/lint-cli.ts index bbc7a701c9c5..b92fe79d9efb 100644 --- a/src/content-linter/tests/integration/lint-cli.ts +++ b/src/content-linter/tests/integration/lint-cli.ts @@ -1,9 +1,6 @@ -// End-to-end tests for the lint-content script, run via npm and checked by -// their output. They cover argument parsing, file discovery, rule filtering, -// and exit codes. -// -// Test files are written to content/test-integration/ because the linter only -// processes files under content/ or data/. +// End-to-end lint-content tests run through npm, so they cover argument parsing, +// file discovery, rule filtering, and exit codes. +// Test files live under content/test-integration/ so these cases exercise content-root inputs. import { execSync } from 'child_process' import { beforeEach, afterEach, describe, test, expect } from 'vitest' @@ -61,7 +58,7 @@ TODOCS This placeholder should definitely be detected. const { output, exitCode } = await runLinter(`--paths "${testFile}" --rules search-replace`) - // This MUST work - if it doesn't, the linter is completely broken + // This failure means lint-content did not detect the fixture error. expect(exitCode).toBe(1) expect(output).toContain('todocs-placeholder') expect(output).toContain('ERROR') @@ -70,8 +67,7 @@ TODOCS This placeholder should definitely be detected. describe('Default linter behavior', () => { test('should verify default rule execution behavior', async () => { - // This test verifies that all rules run by default when no --rules are specified - // It serves as regression protection against the TODOCS bug where no rules would run + // Guards against the TODOCS regression where default runs skipped all rules. const testFile = path.join(testContentDir, 'default-behavior-test.md') const testContent = `--- title: Test Article diff --git a/src/content-linter/tests/lint-files.ts b/src/content-linter/tests/lint-files.ts index dd6a6f916721..0aab67ad7d02 100755 --- a/src/content-linter/tests/lint-files.ts +++ b/src/content-linter/tests/lint-files.ts @@ -20,50 +20,19 @@ const fbvDir = path.join(rootDir, 'data/features') const languageCodes = Object.keys(languages) -// This is a string that contributors can use in markdown and yaml files as a placeholder. -// If any placeholders slip through, this test will flag them. +// Contributors use TODOCS as a placeholder; this test catches leftovers in Markdown and YAML. const placeholder = 'TODOCS' const placeholderRegex = new RegExp(`\\b${placeholder}\\b`, 'gi') -// WARNING: Complicated RegExp below! -// -// Things matched by this RegExp: -// - [link text](link-url) -// - [link text] (link-url) -// - [link-definition-ref]: link-url -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [link text](#link-url) -// - [link text] (#link-url) -// - [link-definition-ref]: #link-url -// - [link text](/link-url) -// - [link-definition-ref]: /link-url -// - [link text](https://link-url) -// - [link-definition-ref]: https://link-url -// - [link text](mailto:mail-url) -// - [link-definition-ref]: mailto:mail-url -// - [link text](tel:phone-url) -// - [link-definition-ref]: tel:phone-url -// - [link text]({{ site.data.variables.product_url }}) -// - [link-definition-ref]: {{ site.data.variables.product_url }} -// - [link text][link-definition-ref]: other text -// - [link text][link-definition-ref] (other text) -// - etc. -// +// Matches relative Markdown link targets, including definitions and space-before-target links. +// Examples: "[Billing](billing/usage)" and "[Billing]: billing/usage". +// Excludes anchors, root-relative paths, external URLs, tel/mailto URLs, and Liquid targets. +// Examples: "[Email](mailto:docs@example.com)" and "[Phone](tel:555-0100)". const relativeArticleLinkRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?!\/|#|https?:\/\/|tel:|mailto:|\{[%{]\s*)[^)\s]+(?:(?:\s*[%}]\})?\)|\s+|$)/gm -// Things matched by this RegExp: -// - [link text](/en/github/blah) -// - [link text] (https://docs.github.com/ja/github/blah) -// - [link-definition-ref]: http://help.github.com/es/github/blah -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [Node.js](https://nodejs.org/en/) -// - etc. -// +// Matches docs URLs with hard-coded language prefixes such as /en/github/overview. +// Excludes external non-docs URLs such as https://nodejs.org/en/. const languageLinkRegex = new RegExp( `(?=^|[^\\]]\\s*)\\[[^\\]]+\\](?::\\n?[ \\t]+|\\s*\\()(?:(?:https?://(?:help|docs|developer)\\.github\\.com)?/(?:${languageCodes.join( '|', @@ -71,79 +40,36 @@ const languageLinkRegex = new RegExp( 'gm', ) -// Things matched by this RegExp: -// - [link text](/enterprise/2.19/admin/blah) -// - [link text] (https://docs.github.com/enterprise/11.10.340/admin/blah) -// - [link-definition-ref]: http://help.github.com/enterprise/2.8/admin/blah -// -// Things intentionally NOT matched by this RegExp: -// - [link text](https://someservice.com/enterprise/1.0/blah) -// - [link text](/github/site-policy/enterprise/2.2/admin/blah) +// Matches docs URLs with hard-coded Enterprise Server versions such as /enterprise/2.19/admin. +// Excludes non-docs external URLs and current versioning paths under /github/site-policy/enterprise/. const versionLinkRegEx = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:(?:https?:\/\/(?:help|docs|developer)\.github\.com)?\/enterprise\/\d+(\.\d+)+(?:\/[^)\s]*)?)(?:\)|\s+|$)/gm -// Things matched by this RegExp: -// - [link text](/early-access/github/blah) -// - [link text] (https://docs.github.com/early-access/github/blah) -// - [link-definition-ref]: http://help.github.com/early-access/github/blah -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [Node.js](https://nodejs.org/early-access/) -// - etc. -// +// Matches docs URLs that leak Early Access paths such as /early-access/github/overview. +// Excludes external non-docs URLs such as https://nodejs.org/early-access/. const earlyAccessLinkRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:(?:https?:\/\/(?:help|docs|developer)\.github\.com)?\/early-access(?:\/[^)\s]*)?)(?:\)|\s+|$)/gm -// - [link text](https://docs.github.com/github/blah) -// - [link text] (https://help.github.com/github/blah) -// - [link-definition-ref]: http://developer.github.com/v3/ -// - [link text](//docs.github.com) -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [link text](/github/blah) -// - [link text[(https://developer.github.com/changes/2018-02-22-protected-branches-required-signatures/) -// - etc. -// +// Matches hard-coded docs domains such as docs.github.com, help.github.com, +// and developer.github.com. +// Excludes root-relative links and developer.github.com/changes URLs. const domainLinkRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:https?:)?\/\/(?:help|docs|developer)\.github\.com(?!\/changes\/)[^)\s]*(?:\)|\s+|$)/gm -// Things matched by this RegExp: -// - ![image text](/assets/images/early-access/github/blah.gif) -// - ![image text] (https://docs.github.com/assets/images/early-access/github/blah.gif) -// - [image-definition-ref]: http://help.github.com/assets/images/early-access/github/blah.gif -// - [link text](/assets/images/early-access/github/blah.gif) -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [Node.js](https://nodejs.org/assets/images/early-access/blah.gif) -// - etc. -// +// Matches docs image links under /assets/images/early-access. +// Excludes external non-docs URLs such as https://nodejs.org/assets/images/early-access/. const earlyAccessImageRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:(?:https?:\/\/(?:help|docs|developer)\.github\.com)?\/assets\/images\/early-access(?:\/[^)\s]*)?)(?:\)|\s+|$)/gm -// Things matched by this RegExp: -// - ![image text](/assets/early-access/images/github/blah.gif) -// - ![image text] (https://docs.github.com/images/early-access/github/blah.gif) -// - [image-definition-ref]: http://help.github.com/assets/early-access/github/blah.gif -// - [link text](/early-access/assets/images/github/blah.gif) -// - [link text](/early-access/images/github/blah.gif) -// - etc. -// -// Things intentionally NOT matched by this RegExp: -// - [Node.js](https://nodejs.org/assets/early-access/images/blah.gif) -// - etc. -// +// Matches misplaced Early Access image paths, including /assets/early-access/images. +// Excludes external non-docs URLs such as https://nodejs.org/assets/early-access/images/. const badEarlyAccessImageRegex = /(?=^|[^\]]\s*)\[[^\]]+\](?::\n?[ \t]+|\s*\()(?:(?:https?:\/\/(?:help|docs|developer)\.github\.com)?\/(?:(?:assets|images)\/early-access|early-access\/(?:assets|images))(?:\/[^)\s]*)?)(?:\)|\s+|$)/gm -// {{ site.data.example.pizza }} +// Matches old site.data Liquid variables such as {{ site.data.example.pizza }}. const oldVariableRegex = /{{\s*?site\.data\..*?}}/g -// - {{ octicon-plus }} -// - {{ octicon-plus An example label }} -// +// Matches old octicon Liquid variables such as {{ octicon-plus An example label }}. const oldOcticonRegex = /{{\s*?octicon-([a-z-]+)(\s[\w\s\d-]+)?\s*?}}/g const relativeArticleLinkErrorText = 'Found unexpected relative article links:' const languageLinkErrorText = 'Found article links with hard-coded language codes:' @@ -158,8 +84,6 @@ const oldVariableErrorText = const oldOcticonErrorText = 'Found octicon variables with the old {{ octicon-name }} syntax. Use {% octicon "name" %} instead!' -// Also test the "data/variables/" YAML files - const yamlWalkOptions = { globs: ['**/*.yml'], directories: false, @@ -168,26 +92,20 @@ const yamlWalkOptions = { let ymlToLint -// compile lists of all the files we want to lint - -// data/variables const variableYamlAbsPaths = walk(variablesDir, yamlWalkOptions).sort() const variableYamlRelPaths = variableYamlAbsPaths.map((p) => slash(path.relative(rootDir, p))) const variableYamlTuples = zip(variableYamlRelPaths, variableYamlAbsPaths) -// data/glossaries const glossariesYamlAbsPaths = walk(glossariesDir, yamlWalkOptions).sort() const glossariesYamlRelPaths = glossariesYamlAbsPaths.map((p) => slash(path.relative(rootDir, p))) const glossariesYamlTuples = zip(glossariesYamlRelPaths, glossariesYamlAbsPaths) -// data/features (feature-based versioning) const FbvYamlAbsPaths = walk(fbvDir, yamlWalkOptions).sort() const FbvYamlRelPaths = FbvYamlAbsPaths.map((p) => slash(path.relative(rootDir, p))) const fbvTuples = zip(FbvYamlRelPaths, FbvYamlAbsPaths) -// Put all the yaml files together ymlToLint = ([] as Array<[string | undefined, string | undefined]>).concat( - variableYamlTuples, // These "tuples" not tested independently; they are only tested as part of ymlToLint. + variableYamlTuples, glossariesYamlTuples, fbvTuples, ) @@ -196,8 +114,7 @@ function formatLinkError(message: string, links: string[]) { return `${message}\n - ${links.join('\n - ')}` } -// Returns `content` if its a string, or `content.description` if it can. -// Used for getting the nested `description` key in glossary files. +// Glossary YAML stores text directly or under a description key. function getContent(content: unknown) { if (typeof content === 'string') return content if ( @@ -212,15 +129,11 @@ function getContent(content: unknown) { const diffFiles = getDiffFiles() -// If it is present and not empty, use it. In most cases it is empty. +// DIFF_FILES or DIFF_FILE narrows YAML linting to the listed files. if (diffFiles.length > 0) { - // It's faster to do this once and then re-use over and over in the - // .filter() later on. + // Reuse a Set because every YAML tuple checks both relative and absolute paths. const only = new Set( - // If the environment variable encodes all the names - // with quotation marks, strip them. - // E.g. Turn `"foo" "bar"` into ['foo', 'bar'] - // Note, this assumes no possible file contains a space. + // Strip quotes from CI tokens such as "foo" "bar"; filenames with spaces are unsupported. diffFiles.map((name) => { if (/^['"]/.test(name) && /['"]$/.test(name)) { return name.slice(1, -1) @@ -237,7 +150,7 @@ if (diffFiles.length > 0) { } if (ymlToLint.length === 0) { - // This is to make sure the file has at least once `describe`. + // Keep Vitest happy when diff filtering leaves no YAML files. describe('deliberately do nothing', () => { test('void', () => {}) }) @@ -247,12 +160,11 @@ if (ymlToLint.length === 0) { describe.each(ymlToLint)( '%s', (yamlRelPath: string | undefined, yamlAbsPath: string | undefined) => { - let dictionary: unknown // YAML structure varies by file type (variables, glossaries, features) + // YAML structure varies by variables, glossaries, and features files. + let dictionary: unknown let isEarlyAccess: boolean let fileContents: string - // This variable is used to determine if the file was parsed successfully. - // When `load()` fails to parse the file, it is overwritten with the error message. - // `false` is intentionally chosen since `null` and `undefined` are valid return values. + // Use false as the parse sentinel because null and undefined are valid YAML values. let dictionaryError: unknown = false beforeAll(async () => { @@ -295,7 +207,7 @@ if (ymlToLint.length === 0) { }) test('must not leak Early Access doc URLs', async () => { - // Only execute for docs that are NOT Early Access + // Early Access docs can link to Early Access docs. if (!isEarlyAccess) { const matches = [] @@ -314,7 +226,7 @@ if (ymlToLint.length === 0) { }) test('must not leak Early Access image URLs', async () => { - // Only execute for docs that are NOT Early Access + // Early Access docs can link to Early Access images. if (!isEarlyAccess) { const matches = [] @@ -333,8 +245,7 @@ if (ymlToLint.length === 0) { }) test('must have correctly formatted Early Access image URLs', async () => { - // Execute for ALL docs (not just Early Access) to ensure non-EA docs - // are not leaking incorrectly formatted EA image URLs + // Check all YAML files because non-Early-Access docs can leak bad image paths. const matches = [] for (const [key, content] of Object.entries(dictionary as Record)) { diff --git a/src/content-linter/tests/lint-frontmatter-links.ts b/src/content-linter/tests/lint-frontmatter-links.ts index f13643ecad07..29bd45eef0b7 100644 --- a/src/content-linter/tests/lint-frontmatter-links.ts +++ b/src/content-linter/tests/lint-frontmatter-links.ts @@ -41,8 +41,6 @@ describe('front matter', () => { return customErrorMessage } - // Test content with .featuredLinks front matter - const pagesWithFeaturedLinks = pageList.filter((page) => page.featuredLinks) test.each(pagesWithFeaturedLinks)( '$relativePath .featuredLinks have pristine links', @@ -51,8 +49,7 @@ describe('front matter', () => { const trouble = [] for (const links of Object.values(page.featuredLinks!)) { - // Some thing in `.featuredLinks` are not arrays. - // For example `popularHeading`. So just skip them. + // .featuredLinks includes scalars such as popularHeading, so only check arrays. if (!Array.isArray(links)) continue trouble.push( @@ -68,8 +65,9 @@ describe('front matter', () => { }, ) - // Test content with .introLinks front matter - + // Intro links can include conditional absolute CTA URLs such as try_ghec_for_free: + // https://github.com/account/enterprises/new on /en/enterprise-cloud@latest/admin. + // checkURL only handles docs-relative URLs. const pagesWithIntroLinks = pageList.filter((page) => page.introLinks) test.each(pagesWithIntroLinks)('$relativePath .introLinks have pristine links', async (page) => { const redirectsContext = { redirects, pages } @@ -79,14 +77,8 @@ describe('front matter', () => { const links = Array.isArray(linksRaw) ? linksRaw : [linksRaw] trouble.push( ...links - // At the present, we're not able to check when the URI - // contains an `elsif` Liquid tag. So just skip them. + // Skip URIs with elsif Liquid because checkURL cannot resolve conditional targets. .filter((uri) => !containsLiquidElseIf(uri)) - // On /en/enterprise-cloud@latest/admin we have, - // - // try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/new{% endif %}' - // - // Ignore those too. .filter((uri) => !uri.includes('https://')) .map((uri, i) => checkURL(uri, i, redirectsContext)) .filter((item): item is NonNullable => Boolean(item)), diff --git a/src/content-linter/tests/site-data-references.ts b/src/content-linter/tests/site-data-references.ts index c0828043b187..37b73929eb5f 100644 --- a/src/content-linter/tests/site-data-references.ts +++ b/src/content-linter/tests/site-data-references.ts @@ -5,25 +5,17 @@ import { describe, expect, test, vi } from 'vitest' import patterns from '@/frame/lib/patterns' import { getDataByLanguage, getDeepDataByLanguage } from '@/data-directory/lib/get-data' -// Given syntax like {% data foo.bar %} or {% indented_data_reference foo.bar spaces=3 %}, -// the following regex returns just the dotted path: foo.bar +// Extracts dotted data paths from data and indented_data_reference Liquid tags. -// Note this regex allows nonstandard whitespace between terms; it does not enforce a single space. -// In other words, it will allow {%data foo.bar %} or {% data foo.bar %}. -// We should enforce a single space someday, but the content will need a lot of cleanup first, and -// we should have a more purpose-driven validation test for that instead of enforcing it here. +// Content cleanup needs a purpose-built test before this rejects nonstandard Liquid spacing. const getDataPathRegex = /{%\s*?(?:data|indented_data_reference)\s+?(\S+?)\s*?(?:spaces=\d\d?\s*?)?%}/ const rawLiquidPattern = /{%\s*raw\s*%}.*?{%\s*endraw\s*%}/gs +// Strip raw Liquid blocks so examples inside {% raw %} do not count as real references. +// Example: "{% raw %}{% data reusables.foo %}{% endraw %}" returns no references. const getDataReferences = (content: string): string[] => { - // When looking for things like `{% data reusables.foo %}` in the - // content, we first have to exclude any Liquid that isn't real. - // E.g. - // {% raw %} - // Here's an example: {% data reusables.foo.bar %} - // {% endraw %} const withoutRawLiquidBlocks = content.replace(rawLiquidPattern, '') const refs = withoutRawLiquidBlocks.match(patterns.dataReference) || [] return refs.map((ref: string) => ref.replace(getDataPathRegex, '$1')) @@ -59,7 +51,7 @@ describe('data references', () => { }) }) -// object is the allVariables object with dynamic keys, value is the nested object we're searching for +// Search allVariables by object identity because getDataByLanguage returns the nested value only. function getFilenameByValue(object: Record, value: unknown): string | undefined { return Object.keys(object).find((key) => object[key] === value) } diff --git a/src/content-linter/tests/unit/code-annotation-comment-spacing.ts b/src/content-linter/tests/unit/code-annotation-comment-spacing.ts index 37f76e5fe8fc..0d19056b3d45 100644 --- a/src/content-linter/tests/unit/code-annotation-comment-spacing.ts +++ b/src/content-linter/tests/unit/code-annotation-comment-spacing.ts @@ -50,7 +50,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(3) - // Check first error (JavaScript comment) expect(errors[0].lineNumber).toBe(5) expect(errors[0].errorDetail).toContain("Comment must have exactly one space after '//'") expect(errors[0].fixInfo).toEqual({ @@ -60,7 +59,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '// This should fail the content linter', }) - // Check second error (Python/Shell comment) expect(errors[1].lineNumber).toBe(8) expect(errors[1].errorDetail).toContain("Comment must have exactly one space after '#'") expect(errors[1].fixInfo).toEqual({ @@ -70,7 +68,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '# This should also fail', }) - // Check third error (SQL comment) expect(errors[2].lineNumber).toBe(11) expect(errors[2].errorDetail).toContain("Comment must have exactly one space after '--'") expect(errors[2].fixInfo).toEqual({ @@ -102,7 +99,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(3) - // Check first error (JavaScript comment) expect(errors[0].lineNumber).toBe(5) expect(errors[0].errorDetail).toContain( "Comment must have exactly one space after '//', found multiple spaces", @@ -114,7 +110,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '// This has too many spaces', }) - // Check second error (Python/Shell comment) expect(errors[1].lineNumber).toBe(8) expect(errors[1].errorDetail).toContain( "Comment must have exactly one space after '#', found multiple spaces", @@ -126,7 +121,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: '# This also has too many', }) - // Check third error (SQL comment) expect(errors[2].lineNumber).toBe(11) expect(errors[2].errorDetail).toContain( "Comment must have exactly one space after '--', found multiple spaces", @@ -159,7 +153,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { const errors = result.markdown expect(errors.length).toBe(2) - // Check first error (indented JavaScript comment without space) expect(errors[0].lineNumber).toBe(6) expect(errors[0].errorDetail).toContain("Comment must have exactly one space after '//'") expect(errors[0].fixInfo).toEqual({ @@ -169,7 +162,6 @@ describe(codeAnnotationCommentSpacing.names.join(' - '), () => { insertText: ' // Missing space in indented comment', }) - // Check second error (indented comment with multiple spaces) expect(errors[1].lineNumber).toBe(9) expect(errors[1].errorDetail).toContain( "Comment must have exactly one space after '#', found multiple spaces", diff --git a/src/content-linter/tests/unit/ctas-schema.ts b/src/content-linter/tests/unit/ctas-schema.ts index 9fa8139057b7..686fcff65475 100644 --- a/src/content-linter/tests/unit/ctas-schema.ts +++ b/src/content-linter/tests/unit/ctas-schema.ts @@ -59,9 +59,8 @@ describe(ctasSchema.names.join(' - '), () => { ` const result = await runRule(ctasSchema, { strings: { markdown } }) const errors = result.markdown - expect(errors.length).toBe(2) // Should have errors for 'Trial' and 'Button' + expect(errors.length).toBe(2) - // Check that both expected errors are present (order may vary) const errorMessages = errors.map((error) => error.errorDetail) expect(errorMessages.some((msg) => msg.includes('Invalid value for ref_type: "Trial"'))).toBe( true, @@ -79,15 +78,14 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n ` const result = await runRule(ctasSchema, { strings: { markdown } }) const errors = result.markdown - expect(errors.length).toBe(1) // Should detect and try to convert the old CTA format + expect(errors.length).toBe(1) expect(errors[0].fixInfo).toBeDefined() - // The extracted URL should not include the curly brace from the Liquid tag. const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() expect(fixedUrl).not.toContain('{') expect(fixedUrl).not.toContain('}') - expect(fixedUrl).toContain('ref_product=ghec') // Should have converted old format correctly + expect(fixedUrl).toContain('ref_product=ghec') }) test('old CTA format autofix preserves original URL structure', async () => { @@ -99,11 +97,10 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n expect(errors.length).toBe(1) expect(errors[0].fixInfo).toBeDefined() - // The fixed URL should not introduce extra slashes const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() - expect(fixedUrl).toMatch(/^https:\/\/github\.com\?ref_product=/) // Should not have github.com/? - expect(fixedUrl).not.toMatch(/github\.com\/\?/) // Should not contain extra slash before query + expect(fixedUrl).toMatch(/^https:\/\/github\.com\?ref_product=/) + expect(fixedUrl).not.toMatch(/github\.com\/\?/) }) test('mixed parameter scenarios - new format takes precedence over old', async () => { @@ -115,13 +112,12 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n expect(errors.length).toBe(1) expect(errors[0].fixInfo).toBeDefined() - // Should preserve existing new format parameters, only convert old ones not already covered const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() - expect(fixedUrl).toContain('ref_product=copilot') // Preserved from new format - expect(fixedUrl).toContain('ref_type=trial') // Preserved from new format - expect(fixedUrl).not.toContain('ref_cta=') // Old parameter removed - expect(fixedUrl).not.toContain('ref_loc=') // Old parameter removed + expect(fixedUrl).toContain('ref_product=copilot') + expect(fixedUrl).toContain('ref_type=trial') + expect(fixedUrl).not.toContain('ref_cta=') + expect(fixedUrl).not.toContain('ref_loc=') }) test('hash fragment preservation during conversion', async () => { @@ -135,7 +131,7 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() - expect(fixedUrl).toContain('#pricing') // Hash fragment preserved + expect(fixedUrl).toContain('#pricing') expect(fixedUrl).toContain('ref_product=copilot') }) @@ -150,11 +146,11 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n const fixedUrl = errors[0].fixInfo?.insertText expect(fixedUrl).toBeDefined() - expect(fixedUrl).toContain('utm_source=docs') // UTM preserved - expect(fixedUrl).toContain('utm_campaign=trial') // UTM preserved - expect(fixedUrl).toContain('other_param=value') // Other params preserved - expect(fixedUrl).toContain('ref_product=copilot') // New CTA params added - expect(fixedUrl).not.toContain('ref_cta=') // Old CTA params removed + expect(fixedUrl).toContain('utm_source=docs') + expect(fixedUrl).toContain('utm_campaign=trial') + expect(fixedUrl).toContain('other_param=value') + expect(fixedUrl).toContain('ref_product=copilot') + expect(fixedUrl).not.toContain('ref_cta=') }) test('multiple query parameter types handled correctly', async () => { @@ -163,8 +159,8 @@ try_ghec_for_free: '{% ifversion ghec %}https://github.com/account/enterprises/n ` const result = await runRule(ctasSchema, { strings: { markdown } }) const errors = result.markdown - expect(errors.length).toBe(1) // Only old format conversion error + expect(errors.length).toBe(1) expect(errors[0].errorDetail).toContain('old parameter format') - expect(errors[0].fixInfo).toBeDefined() // Should have autofix + expect(errors[0].fixInfo).toBeDefined() }) }) diff --git a/src/content-linter/tests/unit/frontmatter-children.ts b/src/content-linter/tests/unit/frontmatter-children.ts index 388550747f31..32fa9eca920f 100644 --- a/src/content-linter/tests/unit/frontmatter-children.ts +++ b/src/content-linter/tests/unit/frontmatter-children.ts @@ -10,7 +10,7 @@ const NO_CHILDREN = 'src/content-linter/tests/fixtures/frontmatter-children/no-c const ruleName = frontmatterChildren.names[1] -// Configure the test fixture to not split frontmatter and content +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(ruleName, () => { diff --git a/src/content-linter/tests/unit/frontmatter-content-type.ts b/src/content-linter/tests/unit/frontmatter-content-type.ts index 84e5a5ed83e3..ec084e4c7385 100644 --- a/src/content-linter/tests/unit/frontmatter-content-type.ts +++ b/src/content-linter/tests/unit/frontmatter-content-type.ts @@ -6,19 +6,16 @@ import { resetCache, } from '@/content-linter/lib/linting-rules/frontmatter-content-type' -// Disable frontMatter stripping so the rule can parse frontmatter itself +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } -// Helper: build a Markdown string with valid frontmatter function md(fmLines: string[], body = 'Some content.'): string { return ['---', ...fmLines, '---', '', body].join('\n') } -// Use the fixture content directory so the qualifying-products scan is -// hermetic and won't break if the real content/ layout changes. -// The fixture tree includes: -// content/copilot/{how-tos,concepts,tutorials,reference,get-started,getting-started,responsible-use} → qualifies -// content/actions/{category,using-workflows} → does NOT qualify +// Fixture content keeps qualifying product scans independent of the real content tree. +// content/copilot has how-tos, concepts, tutorials, reference, get-started, +// getting-started, and responsible-use; content/actions lacks required dirs. const FIXTURE_ROOT = 'src/fixtures/fixtures' describe('GHD065 - frontmatter-content-type', () => { @@ -32,14 +29,11 @@ describe('GHD065 - frontmatter-content-type', () => { process.env.ROOT = savedRoot }) - // Clear the qualifying-products cache between tests so that each - // test starts with a fresh filesystem scan. + // Reset the qualifying-products cache so each test scans the fixture filesystem. beforeEach(() => { resetCache() }) - // Passing cases - test('file with correct contentType matching directory passes', async () => { const strings = { 'content/copilot/how-tos/test-file.md': md([ @@ -69,7 +63,7 @@ describe('GHD065 - frontmatter-content-type', () => { }) test('file with contentType "get-started" in getting-started directory passes', async () => { - // Some products use "getting-started" instead of "get-started" as directory name + // Some products use getting-started instead of get-started as the directory name. const strings = { 'content/copilot/getting-started/test-file.md': md([ 'title: Getting Started', @@ -98,8 +92,7 @@ describe('GHD065 - frontmatter-content-type', () => { }) test('file outside qualifying product is not checked', async () => { - // actions in fixtures has non-EDI subdirs (category/, using-workflows/), - // so it does NOT qualify and the rule should skip it entirely. + // The actions fixture only has category and using-workflows, so the rule skips it. const strings = { 'content/actions/category/test-file.md': md(['title: Test', 'versions:', " fpt: '*'"]), } @@ -117,8 +110,6 @@ describe('GHD065 - frontmatter-content-type', () => { expect(errors).toEqual([]) }) - // Failing cases - test('missing contentType in qualifying product triggers error', async () => { const strings = { 'content/copilot/tutorials/test-file.md': md(['title: Tutorial', 'versions:', " fpt: '*'"]), diff --git a/src/content-linter/tests/unit/frontmatter-hero-image.ts b/src/content-linter/tests/unit/frontmatter-hero-image.ts index 5130cbbd4e02..c6f7085d1701 100644 --- a/src/content-linter/tests/unit/frontmatter-hero-image.ts +++ b/src/content-linter/tests/unit/frontmatter-hero-image.ts @@ -118,7 +118,6 @@ describe(frontmatterHeroImage.names.join(' - '), () => { }) test('all valid hero images pass', async () => { - // Test each valid hero image (extensionless) const validImages = [ "heroImage: '/assets/images/banner-images/hero-1'", "heroImage: '/assets/images/banner-images/hero-2'", diff --git a/src/content-linter/tests/unit/frontmatter-landing-carousels.ts b/src/content-linter/tests/unit/frontmatter-landing-carousels.ts index 2aaeeefd3f63..6b5ddd0e3301 100644 --- a/src/content-linter/tests/unit/frontmatter-landing-carousels.ts +++ b/src/content-linter/tests/unit/frontmatter-landing-carousels.ts @@ -19,7 +19,7 @@ const PRIORITY_VALIDATION = const ruleName = frontmatterLandingCarousels.names[1] -// Configure the test fixture to not split frontmatter and content +// Disable frontmatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(ruleName, () => { @@ -64,7 +64,7 @@ describe(ruleName, () => { files: [DUPLICATE_CAROUSELS], ...fmOptions, }) - expect(result[DUPLICATE_CAROUSELS]).toHaveLength(1) // Only duplicate error since all paths are valid + expect(result[DUPLICATE_CAROUSELS]).toHaveLength(1) expect(result[DUPLICATE_CAROUSELS][0].errorDetail).toContain( "Found duplicate articles in carousel 'recommended': /article-one", ) @@ -91,10 +91,10 @@ describe(ruleName, () => { expect(result[VALID_LANDING]).toEqual([]) }) + // /article-one exists in src/fixtures/fixtures/content/article-one.md and + // src/content-linter/tests/fixtures/landing-carousels/article-one.md. + // Absolute resolution wins. test('absolute paths are prioritized over relative paths', async () => { - // /article-one exists both as src/fixtures/fixtures/content/article-one.md - // and as src/content-linter/tests/fixtures/landing-carousels/article-one.md. - // The absolute resolution wins. const result = await runRule(frontmatterLandingCarousels, { files: [ABSOLUTE_PRIORITY], ...fmOptions, @@ -121,8 +121,7 @@ describe(ruleName, () => { }) test('mixed valid and invalid absolute paths are handled correctly', async () => { - // This test has both a valid absolute path (/article-one) and an invalid one (/nonexistent-absolute) - // It should fail because of the invalid path, proving our absolute path resolution is working + // Include one valid absolute path so the error isolates /nonexistent-absolute. const result = await runRule(frontmatterLandingCarousels, { files: [PRIORITY_VALIDATION], ...fmOptions, diff --git a/src/content-linter/tests/unit/frontmatter-schema.ts b/src/content-linter/tests/unit/frontmatter-schema.ts index 8fa299079eb2..7315cbbd2ce2 100644 --- a/src/content-linter/tests/unit/frontmatter-schema.ts +++ b/src/content-linter/tests/unit/frontmatter-schema.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { frontmatterSchema } from '../../lib/linting-rules/frontmatter-schema' -// Configure the test fixture to not split frontmatter and content +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(frontmatterSchema.names.join(' - '), () => { diff --git a/src/content-linter/tests/unit/frontmatter-search-replace.ts b/src/content-linter/tests/unit/frontmatter-search-replace.ts index 788f41102dbf..e8240f54acfe 100644 --- a/src/content-linter/tests/unit/frontmatter-search-replace.ts +++ b/src/content-linter/tests/unit/frontmatter-search-replace.ts @@ -21,7 +21,7 @@ describe('search-replace rule in frontmatter', () => { const todosErrors = errors.filter((e) => e.errorDetail && /TODOCS/.test(e.errorDetail)) expect(todosErrors.length).toBe(1) - expect(todosErrors[0].lineNumber).toBe(2) // title: TODOCS + expect(todosErrors[0].lineNumber).toBe(2) }) test('multiple TODOCS in frontmatter are all detected', async () => { @@ -48,9 +48,9 @@ describe('search-replace rule in frontmatter', () => { const todosErrors = errors.filter((e) => e.errorDetail && /TODOCS/.test(e.errorDetail)) expect(todosErrors.length).toBe(3) - expect(todosErrors[0].lineNumber).toBe(2) // title: TODOCS - expect(todosErrors[1].lineNumber).toBe(3) // shortTitle: TODOCS - expect(todosErrors[2].lineNumber).toBe(4) // intro: TODOCS + expect(todosErrors[0].lineNumber).toBe(2) + expect(todosErrors[1].lineNumber).toBe(3) + expect(todosErrors[2].lineNumber).toBe(4) }) test('domain rules work in frontmatter', async () => { @@ -79,9 +79,9 @@ describe('search-replace rule in frontmatter', () => { (e) => e.errorDetail && /docs-domain|help-domain|developer-domain/.test(e.errorDetail), ) expect(domainErrors.length).toBe(3) - expect(domainErrors[0].lineNumber).toBe(2) // docs domain in title - expect(domainErrors[1].lineNumber).toBe(3) // help domain in shortTitle - expect(domainErrors[2].lineNumber).toBe(4) // developer domain in intro + expect(domainErrors[0].lineNumber).toBe(2) + expect(domainErrors[1].lineNumber).toBe(3) + expect(domainErrors[2].lineNumber).toBe(4) }) test('deprecated liquid syntax in frontmatter is detected', async () => { @@ -109,7 +109,7 @@ describe('search-replace rule in frontmatter', () => { (e) => e.errorDetail && /site\.data|octicon/.test(e.errorDetail), ) expect(deprecatedErrors.length).toBe(2) - expect(deprecatedErrors[0].lineNumber).toBe(2) // site.data syntax - expect(deprecatedErrors[1].lineNumber).toBe(3) // octicon syntax + expect(deprecatedErrors[0].lineNumber).toBe(2) + expect(deprecatedErrors[1].lineNumber).toBe(3) }) }) diff --git a/src/content-linter/tests/unit/frontmatter-versions-whitespace.ts b/src/content-linter/tests/unit/frontmatter-versions-whitespace.ts index d81bf4350f8d..e82874f25c01 100644 --- a/src/content-linter/tests/unit/frontmatter-versions-whitespace.ts +++ b/src/content-linter/tests/unit/frontmatter-versions-whitespace.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '@/content-linter/lib/init-test' import { frontmatterVersionsWhitespace } from '@/content-linter/lib/linting-rules/frontmatter-versions-whitespace' -// Configure the test fixture to not split frontmatter and content +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } interface ValidTestCase { diff --git a/src/content-linter/tests/unit/image-alt-text-end-punctuation.ts b/src/content-linter/tests/unit/image-alt-text-end-punctuation.ts index 956692c95240..6d0b2f15bd09 100644 --- a/src/content-linter/tests/unit/image-alt-text-end-punctuation.ts +++ b/src/content-linter/tests/unit/image-alt-text-end-punctuation.ts @@ -54,13 +54,11 @@ describe(imageAltTextEndPunctuation.names.join(' - '), () => { const markdown = [ '# Heading', '', - // Completely empty + // The incorrect-alt-text-length rule owns empty alt text. '![](/images/this-is-ok.png)', ].join('\n') const result = await runRule(imageAltTextEndPunctuation, { strings: { markdown } }) const errors = result.markdown - // This rule is not concerned with empty alt text. The - // incorrect-alt-text-length rule catches that instead. expect(errors.length).toBe(0) }) }) diff --git a/src/content-linter/tests/unit/image-alt-text-exclude-start-words.ts b/src/content-linter/tests/unit/image-alt-text-exclude-start-words.ts index 885c7f470e80..b63b6674828b 100644 --- a/src/content-linter/tests/unit/image-alt-text-exclude-start-words.ts +++ b/src/content-linter/tests/unit/image-alt-text-exclude-start-words.ts @@ -34,13 +34,11 @@ describe(imageAltTextExcludeStartWords.names.join(' - '), () => { const markdown = [ '# Heading', '', - // Completely empty + // The incorrect-alt-text-length rule owns empty alt text. '![](/images/this-is-ok.png)', ].join('\n') const result = await runRule(imageAltTextExcludeStartWords, { strings: { markdown } }) const errors = result.markdown - // This rule is not concerned with empty alt text. The - // incorrect-alt-text-length rule catches that instead. expect(errors.length).toBe(0) }) }) diff --git a/src/content-linter/tests/unit/image-alt-text-length.ts b/src/content-linter/tests/unit/image-alt-text-length.ts index 5990c3d10416..5490d13bbf49 100644 --- a/src/content-linter/tests/unit/image-alt-text-length.ts +++ b/src/content-linter/tests/unit/image-alt-text-length.ts @@ -31,14 +31,13 @@ describe(incorrectAltTextLength.names.join(' - '), () => { const markdown = [ '# Heading', '', - // Completely empty + // Empty alt text has no valid range. '![](/images/this-is-ok.png)', ].join('\n') const result = await runRule(incorrectAltTextLength as Rule, { strings: { markdown } }) const errors = result.markdown expect(errors.length).toBe(1) expect(errors[0].lineNumber).toBe(3) - // Because you can't get a valid range when it's entirely empty expect(errors[0].errorRange).toEqual(null) }) }) diff --git a/src/content-linter/tests/unit/internal-links-no-lang.ts b/src/content-linter/tests/unit/internal-links-no-lang.ts index 66d8eaf970bb..b1c6cc3c1ca1 100644 --- a/src/content-linter/tests/unit/internal-links-no-lang.ts +++ b/src/content-linter/tests/unit/internal-links-no-lang.ts @@ -24,12 +24,11 @@ describe(internalLinksNoLang.names.join(' - '), () => { }) test('internal links with no hardcoded language codes pass', async () => { const markdown = [ - // This is caught by the internal-links-slashes rule + // The internal-links-slash rule owns relative links without a slash. '[Internal Link Fail Docs](en/docs)', - // a // means the link is external + // Protocol-relative URLs count as external links. 'These are the [Docs](//ja/actions) we need.', 'This is the [actions Docs](/actions)', - // Starts with a path segment that is not a language code '[Enterprise](/enterprise/overview)', ].join('\n') const result = await runRule(internalLinksNoLang as Rule, { strings: { markdown } }) diff --git a/src/content-linter/tests/unit/internal-links-old-version.ts b/src/content-linter/tests/unit/internal-links-old-version.ts index 39e4e4590de6..03e02d6d6508 100644 --- a/src/content-linter/tests/unit/internal-links-old-version.ts +++ b/src/content-linter/tests/unit/internal-links-old-version.ts @@ -22,9 +22,9 @@ describe(internalLinksOldVersion.names.join(' - '), () => { test('links without old hardcoded versions pass', async () => { const markdown = [ - // External links with enterprise in them + // External links with enterprise paths stay external. '[External link](https://someservice.com/enterprise/1.0/admin/yes)', - // Current versioning links are excluded from this test + // Current versioning paths stay valid. '[New versioning](/github/site-policy/enterprise/2.2/yes)', ].join('\n') const result = await runRule(internalLinksOldVersion as Rule, { strings: { markdown } }) diff --git a/src/content-linter/tests/unit/internal-links-slash.ts b/src/content-linter/tests/unit/internal-links-slash.ts index 14350e4851fc..5bd6969c3fef 100755 --- a/src/content-linter/tests/unit/internal-links-slash.ts +++ b/src/content-linter/tests/unit/internal-links-slash.ts @@ -34,9 +34,9 @@ describe(internalLinksSlash.names.join(' - '), () => { const markdown = [ 'Hello [GitHub Actions](/actions/index.md)', '- "[Actions](/actions/index.md)"', - // Not a relative page link + // Anchors stay outside relative page link checks. '[Anchor on page](#anchor-on-page)', - // Not internal links + // External URLs stay outside internal link checks. '[External Link](https://git-scm.com/)', '[External link](http://example.com)', '[External Link](mailto:email@example.com)', diff --git a/src/content-linter/tests/unit/journey-tracks.ts b/src/content-linter/tests/unit/journey-tracks.ts index bdee809e765a..a3c793e83846 100644 --- a/src/content-linter/tests/unit/journey-tracks.ts +++ b/src/content-linter/tests/unit/journey-tracks.ts @@ -23,9 +23,7 @@ describe('journey-tracks-liquid', () => { }) test('invalid liquid syntax fails', async () => { - // Using inline content instead of a fixture file to avoid CI conflicts. - // Malformed Liquid syntax in fixture files causes other rules (like liquid-versioning) - // to crash when they try to parse the same file during content linting. + // Keep malformed Liquid inline because fixture-wide runs let other rules parse it and crash. const invalidLiquidContent = `--- title: Journey with Liquid Syntax layout: journey-landing @@ -49,7 +47,7 @@ This journey landing page has invalid liquid syntax in journeyTracks. strings: { 'test-invalid-liquid.md': invalidLiquidContent }, ...fmOptions, }) - expect(result['test-invalid-liquid.md']).toHaveLength(2) // title and description both have invalid liquid + expect(result['test-invalid-liquid.md']).toHaveLength(2) expect(result['test-invalid-liquid.md'][0].ruleDescription).toMatch(/liquid syntax/i) expect(result['test-invalid-liquid.md'][1].ruleDescription).toMatch(/liquid syntax/i) }) diff --git a/src/content-linter/tests/unit/link-punctuation.ts b/src/content-linter/tests/unit/link-punctuation.ts index c12ebdaa3fce..701398ee69f4 100644 --- a/src/content-linter/tests/unit/link-punctuation.ts +++ b/src/content-linter/tests/unit/link-punctuation.ts @@ -8,8 +8,7 @@ describe(linkPunctuation.names.join(' - '), () => { const markdown = [ '[This should pass](./image.png)', '[AUTOTITLE](./image.png)', - // These are not necessarily good descriptions, but they are valid - // per the requirements of the rule + // The rule allows imperfect descriptions when their punctuation is valid. "[A link with end quote'](./image.png)", '["A link with start quote](./image.png)', '[A link with a question mark?](./image.png)', diff --git a/src/content-linter/tests/unit/lint-report-exclusions.ts b/src/content-linter/tests/unit/lint-report-exclusions.ts index e9fe1bd8c6d3..8050062efec6 100644 --- a/src/content-linter/tests/unit/lint-report-exclusions.ts +++ b/src/content-linter/tests/unit/lint-report-exclusions.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest' import { getAllRuleNames } from '../../lib/helpers/rule-utils' -// Use static config objects for testing to avoid Commander.js conflicts +// Static config objects avoid Commander.js conflicts in tests. const globalConfig = { excludePaths: ['content/contributing/'], } @@ -26,28 +26,25 @@ describe('content linter configuration', () => { }) test('simulates path exclusion logic', () => { - // Simulate the cleanPaths function logic from lint-content.ts + // Mirror cleanPaths excludePaths prefix checks from lint-content.ts. function isPathExcluded(filePath: string): boolean { return globalConfig.excludePaths.some((excludePath) => filePath.startsWith(excludePath)) } - // Files in contributing directory should be excluded expect(isPathExcluded('content/contributing/README.md')).toBe(true) expect(isPathExcluded('content/contributing/how-to-contribute.md')).toBe(true) expect(isPathExcluded('content/contributing/collaborating-on-github-docs/file.md')).toBe(true) - // Files outside contributing directory should not be excluded expect(isPathExcluded('content/actions/README.md')).toBe(false) expect(isPathExcluded('content/copilot/getting-started.md')).toBe(false) expect(isPathExcluded('data/variables/example.yml')).toBe(false) - // Edge case: partial matches should not be excluded expect(isPathExcluded('content/contributing-guide.md')).toBe(false) }) }) describe('report filtering (lint-report.ts)', () => { - // Helper function that matches the actual logic in lint-report.ts + // Mirror lint-report.ts so config tests use the same rule-name extraction. function shouldIncludeInReport(flaw: LintFlaw): boolean { const allRuleNames = getAllRuleNames(flaw) @@ -55,7 +52,6 @@ describe('content linter configuration', () => { return true } - // Check if any rule name is in the include list that overrides severity const hasIncludedRule = allRuleNames.some((ruleName: string) => reportingConfig.includeRules.includes(ruleName), ) @@ -97,7 +93,6 @@ describe('content linter configuration', () => { ruleNames: ['expired-content'], } - // Should be included because expired-content is in includeRules expect(shouldIncludeInReport(expiredContentWarning)).toBe(true) }) @@ -108,8 +103,6 @@ describe('content linter configuration', () => { errorDetail: 'todocs-placeholder: Catch occurrences of TODOCS placeholder.', } - // Should extract 'todocs-placeholder' as a rule name and check against includeRules - // This will depend on your actual includeRules configuration const result = shouldIncludeInReport(searchReplaceFlaw) expect(typeof result).toBe('boolean') }) @@ -118,10 +111,9 @@ describe('content linter configuration', () => { const searchReplaceFlawNoDetail = { severity: 'warning', ruleNames: ['search-replace'], - // no errorDetail + // errorDetail deliberately absent. } - // Should not throw an error and return false (warning not in includeSeverities) expect(shouldIncludeInReport(searchReplaceFlawNoDetail)).toBe(false) }) @@ -153,29 +145,20 @@ describe('content linter configuration', () => { }) describe('integration between systems', () => { + // Path-excluded files never reach report filtering, so keep the two filters independent. test('path exclusions happen before report filtering', () => { - // This is a conceptual test - in practice, files excluded by globalConfig.excludePaths - // never reach the reporting stage, so they never get filtered by reportingConfig - - // Files in excluded paths should never be linted at all const isExcluded = (path: string) => globalConfig.excludePaths.some((excludePath) => path.startsWith(excludePath)) expect(isExcluded('content/contributing/some-file.md')).toBe(true) - - // If a file is excluded at the path level, it doesn't matter what the reportingConfig says - // because the file will never be processed for linting in the first place }) test('configurations are independent', () => { - // globalConfig handles what gets linted expect(globalConfig.excludePaths).toBeDefined() - // reportingConfig handles what gets reported expect(reportingConfig.includeSeverities).toBeDefined() expect(reportingConfig.includeRules).toBeDefined() - // They should not overlap or depend on each other expect(globalConfig).not.toHaveProperty('includeSeverities') expect(reportingConfig).not.toHaveProperty('excludePaths') }) diff --git a/src/content-linter/tests/unit/liquid-data-tags.ts b/src/content-linter/tests/unit/liquid-data-tags.ts index faa423e1e504..339495e74e42 100644 --- a/src/content-linter/tests/unit/liquid-data-tags.ts +++ b/src/content-linter/tests/unit/liquid-data-tags.ts @@ -24,7 +24,7 @@ describe(liquidDataReferencesDefined.names.join(' - '), () => { const markdown = [ 'Hello {% data variables.empty %}', '{% data variables.no-file %}', - // Variables even when they exist can't be nested + // Existing variables cannot be nested. '{% data variables.location.foo.bar %}', '{% data reusables.gated-features.empty %}', '{% data reusables.no-file %}', diff --git a/src/content-linter/tests/unit/liquid-ifversion-versions.ts b/src/content-linter/tests/unit/liquid-ifversion-versions.ts index 7a3c831d3b8d..17319eb7b650 100644 --- a/src/content-linter/tests/unit/liquid-ifversion-versions.ts +++ b/src/content-linter/tests/unit/liquid-ifversion-versions.ts @@ -86,8 +86,7 @@ describe(liquidIfversionVersions.names.join(' - '), () => { }) test('ifversion all shortnames and an almost oldest ghes', async () => { - // Note that this will mean version will not catch the oldest version - // of ghes, so something is actually excluded by the ifversion tag. + // The oldest ghes remains excluded, so the ifversion tag still changes content. const markdown = [ ...placeholderAllVersionsFm, `{% ifversion ghec or fpt or ghes >${supported.at(-1)} %}{% endif %}`, @@ -101,7 +100,7 @@ describe(liquidIfversionVersions.names.join(' - '), () => { }) test.skip('ifversion using feature based version with all versions', async () => { - // That `features/them-and-all.yml` uses all versions. + // features/them-and-all.yml covers all versions. const markdown = [...placeholderAllVersionsFm, `{% ifversion them-and-all %}{% endif %}`].join( '\n', ) @@ -114,7 +113,7 @@ describe(liquidIfversionVersions.names.join(' - '), () => { }) test.skip('ifversion using feature based version extended with shortname all versions', async () => { - // That `features/volvo.yml` contains `fpt:'*', ghec:'*'`. + // features/volvo.yml contains fpt: "*" and ghec: "*". const markdown = ` {% ifversion volvo or ghes %}{% endif %} ` @@ -152,7 +151,6 @@ describe(liquidIfversionVersions.names.join(' - '), () => { const result = await runRule(liquidIfversionVersions, { strings: { markdown }, }) - // No crash; zero errors expected for valid ifversion usage const errors = result.markdown expect(errors.length).toBe(0) }) diff --git a/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts b/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts index 511761be7d64..d442c6d4633d 100644 --- a/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts +++ b/src/content-linter/tests/unit/liquid-quoted-conditional-args.ts @@ -119,7 +119,6 @@ describe(liquidQuotedConditionalArg.names.join(' - '), () => { ].join('\n') const result = await runRule(liquidQuotedConditionalArg, { strings: { markdown } }) const errors = result.markdown - // Only the standalone quoted arg (line 9) should be flagged expect(errors.length).toBe(1) expect(errors[0].lineNumber).toBe(9) }) diff --git a/src/content-linter/tests/unit/liquid-syntax.ts b/src/content-linter/tests/unit/liquid-syntax.ts index a503b869def0..ebf24833cb58 100644 --- a/src/content-linter/tests/unit/liquid-syntax.ts +++ b/src/content-linter/tests/unit/liquid-syntax.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { frontmatterLiquidSyntax, liquidSyntax } from '../../lib/linting-rules/liquid-syntax' -// Configure the test fixture to not split frontmatter and content +// Disable frontMatter stripping so the rule can parse frontmatter itself. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(frontmatterLiquidSyntax.names.join(' - '), () => { @@ -76,7 +76,7 @@ describe(liquidSyntax.names.join(' - '), () => { '---', '{% data reusables.foo.bar %}', '{% if true %}Permission statement{% endif %}', - // Not correct, but not caught by this rule. See liquid-ifversion-tags. + // The liquid-ifversion-tags rule owns invalid ifversion names. '{% ifversion ghhes %}bla{%endif%}', ].join('\n') const result = await runRule(liquidSyntax, { strings: { markdown } }) diff --git a/src/content-linter/tests/unit/liquid-versioning.ts b/src/content-linter/tests/unit/liquid-versioning.ts index 0d142a66b43d..8304924ae625 100644 --- a/src/content-linter/tests/unit/liquid-versioning.ts +++ b/src/content-linter/tests/unit/liquid-versioning.ts @@ -20,9 +20,8 @@ describe(liquidIfTags.names.join(' - '), () => { test('if tags with version names fail', async () => { const markdown = [ '{% if ghes %}', - // Valid test fixture feature name + // volvo is a feature-based version in fixture data. '{% if volvo %}', - // None of the args should contain a version name '{% if something and ghes %}', ] const result = await runRule(liquidIfTags, { strings: { markdown: markdown.join('\n') } }) @@ -54,11 +53,10 @@ describe(liquidIfVersionTags.names.join(' - '), () => { '{% ifversion ghec > 3.7 %}', '{% ifversion ghes !== 3.7 %}', '{% ifversion ghec === 3.7 %}', - // < 2.9 is not in the currently supported list + // 2.9 falls outside supported GHES releases. '{% ifversion ghes < 2.9 %}', - // Incorrect syntax '{% ifversion ghec or ifversion fpt %}', - // Typo: should be `not ghec` + // no ghec is an invalid spelling of not ghec. '{% ifversion no ghec %}', ] const result = await runRule(liquidIfVersionTags, { diff --git a/src/content-linter/tests/unit/rai-app-card-structure.ts b/src/content-linter/tests/unit/rai-app-card-structure.ts index ef1f838dacc1..516c4664a585 100644 --- a/src/content-linter/tests/unit/rai-app-card-structure.ts +++ b/src/content-linter/tests/unit/rai-app-card-structure.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { raiAppCardStructure } from '../../lib/linting-rules/rai-app-card-structure' -// A minimal valid RAI card with all required H2s, H3s, and reusables. function validCard(): string { return [ '---', @@ -98,8 +97,6 @@ function validCard(): string { } describe(raiAppCardStructure.names.join(' - '), () => { - // Happy path and filtering - test('valid RAI card produces zero errors', async () => { const markdown = validCard() const result = await runRule(raiAppCardStructure, { strings: { markdown } }) @@ -122,8 +119,6 @@ describe(raiAppCardStructure.names.join(' - '), () => { expect(errors.length).toBe(0) }) - // One negative test per validator, to prove each code path fires - test('missing a required H2 section reports an error', async () => { const markdown = validCard() .split('\n') diff --git a/src/content-linter/tests/unit/search-replace.ts b/src/content-linter/tests/unit/search-replace.ts index 2cc4cd9f17d5..17ff5b290a4e 100644 --- a/src/content-linter/tests/unit/search-replace.ts +++ b/src/content-linter/tests/unit/search-replace.ts @@ -76,13 +76,13 @@ describe(searchReplace.names.join(' - '), () => { const result = await runRule(searchReplace, { strings: { markdown }, ruleConfig: searchReplaceConfig['search-replace'], - markdownlintOptions: { frontMatter: null }, // Include frontmatter in linting + markdownlintOptions: { frontMatter: null }, }) const errors = result.markdown expect(errors.length).toBe(3) - expect(errors[0].lineNumber).toBe(2) // title: TODOCS - expect(errors[1].lineNumber).toBe(3) // shortTitle: TODOCS - expect(errors[2].lineNumber).toBe(4) // intro: TODOCS + expect(errors[0].lineNumber).toBe(2) + expect(errors[1].lineNumber).toBe(3) + expect(errors[2].lineNumber).toBe(4) }) test('TODOCS placeholder in both frontmatter and content', async () => { @@ -98,14 +98,14 @@ describe(searchReplace.names.join(' - '), () => { const result = await runRule(searchReplace, { strings: { markdown }, ruleConfig: searchReplaceConfig['search-replace'], - markdownlintOptions: { frontMatter: null }, // Include frontmatter in linting + markdownlintOptions: { frontMatter: null }, }) const errors = result.markdown expect(errors.length).toBe(4) - expect(errors[0].lineNumber).toBe(2) // title: TODOCS - expect(errors[1].lineNumber).toBe(3) // intro: TODOCS - expect(errors[2].lineNumber).toBe(6) // content TODOCS - expect(errors[3].lineNumber).toBe(7) // content TODOCS + expect(errors[0].lineNumber).toBe(2) + expect(errors[1].lineNumber).toBe(3) + expect(errors[2].lineNumber).toBe(6) + expect(errors[3].lineNumber).toBe(7) }) test('TODOCS placeholder in frontmatter is not caught with default frontmatter handling', async () => { @@ -123,17 +123,13 @@ describe(searchReplace.names.join(' - '), () => { const result = await runRule(searchReplace, { strings: { markdown }, ruleConfig: searchReplaceConfig['search-replace'], - // Default frontmatter handling (frontmatter is stripped from content) }) const errors = result.markdown - // When using default frontmatter handling (frontmatter is stripped from content), - // this unit test only tests the search-replace rule in isolation on the content portion. - // Frontmatter linting happens separately in the actual linting system. + // Default frontmatter handling strips frontmatter, so this only tests Markdown content. expect(errors.length).toBe(0) }) test('TODOCS in frontmatter is detected when frontmatter is included in content', async () => { - // This test shows that search-replace works on frontmatter when it's included in content const frontmatterOnly = [ '---', 'title: TODOCS', @@ -142,24 +138,21 @@ describe(searchReplace.names.join(' - '), () => { '---', ].join('\n') - // When frontmatter is treated as content, search-replace works const result = await runRule(searchReplace, { strings: { markdown: frontmatterOnly }, ruleConfig: searchReplaceConfig['search-replace'], - markdownlintOptions: { frontMatter: null }, // Include frontmatter in content + markdownlintOptions: { frontMatter: null }, }) const errors = result.markdown - // Finds all 3 TODOCS in frontmatter when frontmatter is included in content expect(errors.length).toBe(3) - expect(errors[0].lineNumber).toBe(2) // title: TODOCS - expect(errors[1].lineNumber).toBe(3) // shortTitle: TODOCS - expect(errors[2].lineNumber).toBe(4) // intro: TODOCS + expect(errors[0].lineNumber).toBe(2) + expect(errors[1].lineNumber).toBe(3) + expect(errors[2].lineNumber).toBe(4) }) test('TODOCS placeholder found in documentation about TODOCS usage', async () => { - // This test verifies that the TODOCS rule detects instances in documentation files - // The actual exclusion happens in the reporting layer, not in the rule itself + // content/contributing docs are path-excluded before this rule detects TODOCS placeholders. const markdown = [ '---', 'title: Using the TODOCS placeholder to leave notes', @@ -182,15 +175,13 @@ describe(searchReplace.names.join(' - '), () => { }) const errors = result.markdown - // The rule should find TODOCS in frontmatter because markdownlint-disable doesn't apply there - // However, since we're testing the actual behavior, let's check what we get const frontmatterErrors = errors.filter((e) => e.lineNumber <= 6) const contentErrors = errors.filter((e) => e.lineNumber > 6) - // The markdownlint-disable comment should suppress content errors + // markdownlint-disable suppresses content errors, not frontmatter errors. expect(contentErrors.length).toBe(0) - // Frontmatter errors depend on the configuration - this test documents current behavior + // frontMatter: null keeps frontmatter in content, so these TODOCS errors appear. expect(frontmatterErrors.length).toBeGreaterThanOrEqual(0) }) }) diff --git a/src/content-linter/tests/unit/table-column-integrity-simple.ts b/src/content-linter/tests/unit/table-column-integrity-simple.ts index dcbb9947a0d7..14137e8709e8 100644 --- a/src/content-linter/tests/unit/table-column-integrity-simple.ts +++ b/src/content-linter/tests/unit/table-column-integrity-simple.ts @@ -167,8 +167,7 @@ describe(tableColumnIntegrity.names.join(' - '), () => { }) test('File paths with pipes are handled correctly (regression test)', async () => { - // This test catches the specific issue from content/actions/tutorials/build-and-test-code/python.md - // where the old regex /[^\\]\|/ was consuming characters before pipes and miscounting columns + // content/actions/tutorials/build-and-test-code/python.md exposed /[^\\]\|/ pipe miscounts. const markdown = [ '| Directory | Ubuntu | macOS |', '|-----------|--------|-------|', @@ -182,7 +181,6 @@ describe(tableColumnIntegrity.names.join(' - '), () => { }) test('Complex file paths with multiple characters before pipes', async () => { - // Additional test to ensure the lookbehind regex works with various characters before pipes const markdown = [ '| Pattern | Linux Path | Windows Path |', '|---------|------------|--------------|', diff --git a/src/content-linter/tests/unit/third-party-actions-reusable.ts b/src/content-linter/tests/unit/third-party-actions-reusable.ts index 6227dff9a08f..b485ebbbcbff 100644 --- a/src/content-linter/tests/unit/third-party-actions-reusable.ts +++ b/src/content-linter/tests/unit/third-party-actions-reusable.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest' import { runRule } from '../../lib/init-test' import { thirdPartyActionsReusable } from '../../lib/linting-rules/third-party-actions-reusable' -// Configure the test figure to not split frontmatter and content +// Keep frontmatter in params.lines so disclaimer lookback uses source line offsets. const fmOptions = { markdownlintOptions: { frontMatter: null } } describe(thirdPartyActionsReusable.names.join(' - '), () => { diff --git a/src/content-linter/types.ts b/src/content-linter/types.ts index 43f1b3cdf9b2..b5ae84e1f8de 100644 --- a/src/content-linter/types.ts +++ b/src/content-linter/types.ts @@ -1,4 +1,3 @@ -// Interfaces for content linter rule parameters and callbacks export interface MarkdownToken { type: string tag?: string @@ -12,9 +11,9 @@ export interface MarkdownToken { export interface RuleParams { name: string // file path - lines: string[] // array of lines from the file - frontMatterLines: string[] // array of frontmatter lines - tokens?: MarkdownToken[] // markdown tokens (when using markdownit parser) + lines: string[] + frontMatterLines: string[] + tokens?: MarkdownToken[] // present only when the rule uses the markdownit parser config?: { [key: string]: unknown // rule-specific configuration } diff --git a/src/content-pipelines/state/copilot-cli.sha b/src/content-pipelines/state/copilot-cli.sha index 649c47c84294..ec8fc78723b7 100644 --- a/src/content-pipelines/state/copilot-cli.sha +++ b/src/content-pipelines/state/copilot-cli.sha @@ -1 +1 @@ -c619492f08c4ca46107b70a0633a3e9f8b3adbf9 +1ba5557551d36124e81c7e860dc99b09aa16a000 diff --git a/src/content-render/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/scripts/add-content-type.ts b/src/content-render/scripts/add-content-type.ts index f6286ea7b4a2..0f5925e55768 100644 --- a/src/content-render/scripts/add-content-type.ts +++ b/src/content-render/scripts/add-content-type.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Auto-populate the `contentType` frontmatter property based on the directory location of the content file - */ +// @purpose Writer tool +// @description Auto-populate the `contentType` frontmatter property based on the directory location of the content file import fs from 'fs' import path from 'path' @@ -54,8 +52,7 @@ async function main() { if (file.includes('early-access')) return false if (!options.paths) return true return options.paths.some((p: string) => { - // Allow either a full content path like "content/foo/bar.md" - // or a top-level directory name like "copilot" + // Accept full content paths like content/foo/bar.md or top-level dirs like copilot. if (!p.startsWith('content')) { p = path.join('content', p) } @@ -130,7 +127,7 @@ function processFile(filePath: string, scriptOptions: ScriptOptions) { frontmatter.stringify( content, data, - // lineWidth is a js-yaml option passed through gray-matter, not in gray-matter's type definitions + // gray-matter passes lineWidth to js-yaml, but its types omit it. { lineWidth: -1 } as unknown as Parameters[2], ), ) @@ -144,38 +141,31 @@ function processFile(filePath: string, scriptOptions: ScriptOptions) { } function determineContentType(relativePath: string): string { - // The split path array will be structured like: - // [ 'copilot', 'how-tos', 'troubleshoot', 'index.md' ] - // where the content type we want is in slot 1. + // For copilot/how-tos/troubleshoot/index.md, pathSegments[1] is the content type. const pathSegments = relativePath.split(path.sep) const topLevelDirectory = pathSegments[0] const derivedContentType = pathSegments[1] - // There is only one content/index.md, and it's the homepage. + // content/index.md is the only homepage. if (topLevelDirectory === 'index.md') return 'homepage' - // SPECIAL HANDLING FOR RAI - // If a directory name includes a responsible-use string, assume the 'rai' type. + // Responsible-use directories map to the rai content type. if (derivedContentType.includes(RESPONSIBLE_USE_STRING)) { return RAI_TYPE } - // Allow 'getting-started' as an alternative directory name for 'get-started'. + // getting-started directories map to get-started. if (derivedContentType === 'getting-started') { return 'get-started' } - // When the content directory matches any of the allowed - // content type values (such as 'get-started', - // 'concepts', 'how-tos', 'reference', and 'tutorials'), - // immediately return it. We're satisfied. + // Directories matching contentTypesEnum map to their content type. if (contentTypesEnum.includes(derivedContentType)) { return derivedContentType } - // There is only one content//index.md file per doc set. - // This index.md is always a landing page. + // Product index.md files are landing pages. if (derivedContentType === 'index.md') { return LANDING_TYPE } diff --git a/src/content-render/scripts/all-documents/cli.ts b/src/content-render/scripts/all-documents/cli.ts index 3e4893ef9793..5b304222c4f2 100644 --- a/src/content-render/scripts/all-documents/cli.ts +++ b/src/content-render/scripts/all-documents/cli.ts @@ -1,43 +1,14 @@ -/** - * You specify one or more languages and versions, and this script - * will output a JSON file with the metadata needed. - * You run it with: - * - * npm run all-documents -- -o /tmp/all-documents.json - * - * By default, it will do free-pro-team, enterprise-cloud, and whatever - * the latest enterprise-server is. You can specify versions with: --version - * For example: - * - * npm run all-documents -- -v free-pro-team@latest -v ghes-3.12 - * - * By default it will include all languages, but you can specify - * with --language - * - * npm run all-documents -- -l en -l de - * - * For debugging purposes, because there are so *many* documents you can - * apply a filter by URL matching, for example: - * - * npm run all-documents -- -f get-started/using-github - * - * This will only include documents whose URL contains the string - * 'get-started/using-github'. - * - * If you don't specify an output file (the --output flag or -o for short), - * it will print all the JSON to stdout. - * - * By default the fields set to include are: title, shortTitle, intro, url. - * You can instead specify the fields you only want. For example - * - * npm run all-documents -- --field url --field title - * - * Now the JSON will look like this: - * - * ... - * {"title": "Some title", "url": "/some-url"} - * ... - */ +// Generates JSON metadata for documents. +// Run npm run all-documents -- -o /tmp/all-documents.json. +// Defaults to all languages, free-pro-team, enterprise-cloud, latest enterprise-server, +// fields title, shortTitle, intro, and url, and output file all-documents.json. +// Use --version for versions such as free-pro-team@latest and ghes-3.12. +// Use --language for languages such as en and de. +// Use --filter to include only documents whose URL contains the given string. +// Use --field to choose output fields, such as url and title. +// Filter example: npm run all-documents -- -f get-started/using-github. +// Field example: npm run all-documents -- --field url --field title. +// Example field output: {"title":"Some title","url":"/some-url"}. import { writeFileSync, statSync } from 'fs' @@ -47,7 +18,7 @@ import { languageKeys } from '@/languages/lib/languages-server' import { allVersions } from '@/versions/lib/all-versions' import { allDocuments, POSSIBLE_FIELDS, type AllDocument } from './lib' -// E.g. enteprise-server@3.12, free-pro-team@latest, etc +// Version flags accept enterprise-server@3.12 and free-pro-team@latest. const fullVersions = Object.keys(allVersions) const defaultVersions: string[] = [] const shortAlias = new Map() diff --git a/src/content-render/scripts/cta-builder.ts b/src/content-render/scripts/cta-builder.ts index 96ca90b65f00..3cd26ab9597b 100644 --- a/src/content-render/scripts/cta-builder.ts +++ b/src/content-render/scripts/cta-builder.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Create a properly formatted Call-to-Action URL with tracking parameters - */ +// @purpose Writer tool +// @description Create a properly formatted Call-to-Action URL with tracking parameters import { Command } from 'commander' import readline from 'readline' import chalk from 'chalk' @@ -92,7 +90,7 @@ program.action(() => { interactiveBuilder() }) -// Only run CLI when script is executed directly, not when imported +// Avoid parsing CLI arguments when tests import this module. if (import.meta.url === `file://${process.argv[1]}`) { program.parse() } @@ -106,7 +104,7 @@ async function selectFromOptions( console.log(chalk.yellow(`\n${message} (${paramName}):`)) for (let index = 0; index < options.length; index++) { const option = options[index] - const letter = String.fromCharCode(97 + index) // 97 is 'a' in ASCII + const letter = String.fromCharCode(97 + index) // 97 is the ASCII code for a. console.log(chalk.white(` ${letter}. ${option}`)) } @@ -115,7 +113,7 @@ async function selectFromOptions( const answer = await promptFn('Enter the letter of your choice: ') if (!answer) continue - const letterIndex = answer.toLowerCase().charCodeAt(0) - 97 // Convert letter to index + const letterIndex = answer.toLowerCase().charCodeAt(0) - 97 if (letterIndex >= 0 && letterIndex < options.length && answer.length === 1) { return options[letterIndex] @@ -124,7 +122,7 @@ async function selectFromOptions( const validLetters = options.map((_, index) => String.fromCharCode(97 + index)).join(', ') console.log(chalk.red(`Invalid choice. Please enter one of: ${validLetters}`)) - // Safety: prevent infinite loops in automated scenarios + // Cap invalid answers for automated runs; empty answers reprompt without counting. if (++attempts > 50) { throw new Error('Too many invalid attempts. Please restart the tool.') } @@ -145,7 +143,7 @@ async function confirmChoice( if (lower === 'n' || lower === 'no') return false console.log(chalk.red('Please enter y or n')) - // Safety: prevent infinite loops in automated scenarios + // Cap invalid answers for automated runs; empty answers reprompt without counting. if (++attempts > 50) { throw new Error('Too many invalid attempts. Please restart the tool.') } @@ -176,7 +174,6 @@ interface AjvError { params: AjvErrorParams } -// Process AJV validation errors into readable messages function formatValidationErrors(ctaParams: CTAParams, errors: AjvError[]): string[] { const errorMessages: string[] = [] for (const error of errors) { @@ -198,7 +195,6 @@ function formatValidationErrors(ctaParams: CTAParams, errors: AjvError[]): strin return errorMessages } -// Full validation using AJV schema (consistent across all commands) function validateCTAParams(params: CTAParams): { isValid: boolean; errors: string[] } { const isValid = validateCTASchema(params) const ajvErrors = validateCTASchema.errors || [] @@ -234,7 +230,7 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin const newParams: CTAParams = {} - // Preserve any new-style params that are already on the URL. + // Keep CTA params that already pass the schema. for (const [key, value] of url.searchParams.entries()) { for (const param of Object.keys(ctaSchema.properties)) { if (key === param && key in ctaSchema.properties) { @@ -277,7 +273,7 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin } } - // Build new URL - preserve all existing parameters except old ref_ parameters + // Keep existing query parameters except ref_cta, ref_loc, and ref_page. const newUrl = new URL(url.toString()) newUrl.searchParams.delete('ref_cta') @@ -290,15 +286,12 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin } } - // The URL constructor may add a slash before the question mark in - // "github.com?foo", but we don't want that. First, check if original - // URL had trailing slash before query params. + // URL serializes github.com?foo as github.com/?foo; preserve the original slash shape. const urlBeforeQuery = oldUrl.split('?')[0] const hadTrailingSlash = urlBeforeQuery.endsWith('/') let finalUrl = newUrl.toString() - // Remove unwanted trailing slash if original didn't have one. if (!hadTrailingSlash && finalUrl.includes('/?')) { finalUrl = finalUrl.replace('/?', '?') } @@ -321,19 +314,19 @@ function inferProductFromUrl(url: string, refCta: string): string { try { hostname = new URL(url).hostname.toLowerCase() } catch { - // Fallback if url isn't valid: leave hostname empty + // Invalid URLs fall back to ref_cta or the default product. } if (hostname === 'desktop.github.com' || refCta.includes('desktop')) { return 'desktop' } - // Hostname contains 'copilot' (e.g., copilot.github.com), or refCta mentions copilot + // GitHub subdomains containing copilot and ref_cta values containing copilot map to copilot. if ( (hostname.includes('copilot') && hostname.endsWith('.github.com')) || refCta.toLowerCase().includes('copilot') ) { return 'copilot' } - // Hostname contains 'enterprise' (e.g. enterprise.github.com), or refCta mentions GHEC + // GitHub subdomains containing enterprise and ref_cta values containing GHEC map to ghec. if ( (hostname.includes('enterprise') && hostname.endsWith('.github.com')) || refCta.includes('GHEC') @@ -344,8 +337,7 @@ function inferProductFromUrl(url: string, refCta: string): string { } function inferStyleFromContext(refLoc: string): string { - // If location suggests it's in a button context, return button - // Otherwise default to text for inline links + // Button-like ref_loc values map to button; everything else defaults to text. const isButton = buttonKeywords.some((keyword) => refLoc.toLowerCase().includes(keyword)) return isButton ? 'button' : 'text' } @@ -393,7 +385,6 @@ async function interactiveBuilder(): Promise { ) } - // Optional parameters (properties not in required array) console.log(chalk.white(`\nOptional parameters:\n`)) const allProperties = Object.keys(ctaSchema.properties) @@ -458,7 +449,6 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise< const result = convertOldCTAUrl(options.url) if (options.quiet) { - // In quiet mode, only output the new URL console.log(result.newUrl) return } @@ -469,7 +459,6 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise< console.log(chalk.white('\nNew URL:')) console.log(chalk.cyan(result.newUrl)) - // Validate the converted URL using shared validation function try { const newParams = extractCTAParams(result.newUrl) const validation = validateCTAParams(newParams) @@ -507,7 +496,7 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise< } } - // The convert command doesn't use readline, so script should exit naturally + // The convert command opens no readline handle, so Node exits after logging. } async function validateUrl(options: { url?: string }): Promise { @@ -531,7 +520,6 @@ async function validateUrl(options: { url?: string }): Promise { return } - // Validate against schema using shared validation function const validation = validateCTAParams(ctaParams) if (validation.isValid) { @@ -595,7 +583,6 @@ async function buildProgrammaticCTA(options: { const validation = validateCTAParams(params) if (!validation.isValid) { - // Output validation errors to stderr and exit with error code for (const error of validation.errors) { console.error(`Validation error: ${error}`) } diff --git a/src/content-render/scripts/liquid-tags.ts b/src/content-render/scripts/liquid-tags.ts index e24fdf6cfc73..5f9aaac84914 100644 --- a/src/content-render/scripts/liquid-tags.ts +++ b/src/content-render/scripts/liquid-tags.ts @@ -1,7 +1,5 @@ -/* - * @purpose Writer tool - * @description Expand and restore Liquid data references in content files - */ +// @purpose Writer tool +// @description Expand and restore Liquid data references in content files // Usage: npm run liquid-tags -- expand --paths content/pull-requests/about.md // Usage: npm run liquid-tags -- restore --paths content/pull-requests/about.md @@ -38,23 +36,20 @@ function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } -// Regex pattern to match expanded content blocks const EXPANDED_PATTERN = /(.+?)/gs -// Validates and normalizes the incoming dataPath to prevent path traversal -// and ensure the final resolved path remains within the expected root. +// Reject absolute, traversal, empty, and unsafe data paths before resolving under data root. function getDataFilePath(type: 'reusable' | 'variable', dataPath: string): string { if (path.isAbsolute(dataPath)) { throw new Error(`Invalid ${type} data path: absolute paths are not allowed: ${dataPath}`) } - // Disallow path traversal and empty segments const segments = dataPath.split(/[\\/]/) if (segments.some((segment) => segment === '..' || segment === '')) { throw new Error(`Invalid ${type} data path: contains disallowed segments: ${dataPath}`) } - // Restrict allowed characters to a conservative safe set + // Restrict data paths to filename characters used by reusables and variables. if (!/^[A-Za-z0-9_.\-/]+$/.test(dataPath)) { throw new Error(`Invalid ${type} data path: contains disallowed characters: ${dataPath}`) } @@ -147,11 +142,11 @@ function getAllowedTypes(options: ExpandOptions): Array<'reusable' | 'variable'> async function expandReferences(options: ExpandOptions): Promise { const { paths, verbose, markers, shallow } = options - // markers will be true by default, false when --no-markers is used + // --no-markers sets markers to false; missing flag leaves it true. const withMarkers = markers !== false - const recursive = !shallow // Recursive by default unless --shallow is specified + const recursive = !shallow // Omitting --shallow enables recursive expansion. const allowedTypes = getAllowedTypes(options) - const maxIterations = 10 // Safety limit for recursive expansion + const maxIterations = 10 // Stop recursive expansion after 10 passes to avoid circular references. if (paths.length === 0) { console.error(chalk.red('Error: No paths provided. Use --paths option.')) @@ -204,7 +199,6 @@ async function expandReferences(options: ExpandOptions): Promise { hasRemainingRefs = remainingRefs.length > 0 if (shallow) { - // Shallow mode: show remaining references and break if (hasRemainingRefs) { console.log( chalk.yellow( @@ -296,10 +290,10 @@ async function restoreReferences(options: ExpandOptions): Promise { console.log(chalk.dim(' Use --verbose to see details of the edits')) } - // Update data files with the edited content before restoring + // Write edited expanded blocks back to data files before restoring Liquid tags. const updatedDataFiles = updateDataFiles(filePath, verbose, false, allowedTypes) - // Automatically restore any updated data files back to liquid tags + // Restore updated data files so nested references return to Liquid tags too. if (updatedDataFiles.length > 0) { if (verbose) console.log(chalk.blue(' Restoring updated data files back to liquid tags...')) @@ -324,7 +318,7 @@ async function restoreReferences(options: ExpandOptions): Promise { } } - // Always restore the main file content regardless of edits + // Restore the main file even when no data file changed. const restoredContent = restoreFileContent(content, verbose, allowedTypes) if (restoredContent !== content) { @@ -414,12 +408,10 @@ async function detectContentEdits( if (!allowedTypes || allowedTypes.includes(refType)) { try { - // Load the original content from data files const originalContent = loadDataValue(refType, dataPath.trim()) if (originalContent !== null) { - // Compare against the original content directly, not re-resolved - // This avoids nested resolution issues that cause false positives + // Compare direct data file content to avoid false positives from nested resolution. const currentContent = resolvedContent.trim() if (currentContent !== originalContent.trim()) { @@ -458,7 +450,7 @@ function loadDataValue(type: 'reusable' | 'variable', dataPath: string): string if (type === 'reusable') { const content = fs.readFileSync(targetPath, 'utf8') - // Remove any frontmatter if present (same as resolveReusable) + // Strip reusable frontmatter before comparing content, matching resolveReusable. const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') return contentWithoutFrontmatter.trim() } else { @@ -478,7 +470,7 @@ function loadDataValue(type: 'reusable' | 'variable', dataPath: string): string return typeof current === 'string' ? current.trim() : String(current).trim() } } catch { - // Silently return null for any errors + // Unreadable data returns null so callers can treat it as unverifiable. } return null } @@ -561,7 +553,7 @@ function extractDataUpdates( const refType = type as 'reusable' | 'variable' if (!allowedTypes || allowedTypes.includes(refType)) { - // Check if this content was actually changed before including it + // Compare expanded blocks with their source before updating data files. try { const originalContent = loadDataValue(refType, dataPath.trim()) if (originalContent !== null && resolvedContent.trim() !== originalContent.trim()) { @@ -572,7 +564,7 @@ function extractDataUpdates( }) } } catch { - // If we can't verify, assume it was changed to be safe + // Keep blocks on unexpected errors; unreadable files return null from loadDataValue. updates.push({ type: refType, path: dataPath.trim(), @@ -619,19 +611,18 @@ function applyDataUpdates( } else { console.log(chalk.green(` Updated: ${targetPath}`)) } - return targetPath // Return path even in dry run + return targetPath // Dry runs return the target path so callers can report it. } try { if (type === 'reusable') { - // For reusables, replace entire file content if (contents.length > 1) { console.log( chalk.yellow(` Warning: Multiple content blocks found for ${dataPath}, using first one`), ) } - // Preserve original file's newline behavior + // Preserve a trailing newline from the original reusable file. const originalContent = fs.readFileSync(targetPath, 'utf8') const hasTrailingNewline = originalContent.endsWith('\n') const newContent = @@ -642,12 +633,11 @@ function applyDataUpdates( console.log(chalk.green(` Updated: ${type}s.${dataPath}`)) } } else { - // For variables, update YAML structure const yamlContent = fs.readFileSync(targetPath, 'utf8') const data = load(yamlContent) as Record const pathParts = dataPath.split('.') - const propertyPath = pathParts.slice(1) // Skip the file name + const propertyPath = pathParts.slice(1) let current: Record = data for (let i = 0; i < propertyPath.length - 1; i++) { @@ -665,7 +655,7 @@ function applyDataUpdates( } current[finalKey] = contents[0] - // Preserve original file's newline behavior for YAML + // Preserve a trailing newline from the original YAML file. const hasTrailingNewline = yamlContent.endsWith('\n') const yamlOutput = dump(data) const finalYaml = @@ -692,13 +682,13 @@ function findLiquidReferences( const references: LiquidReference[] = [] const types = allowedTypes || ['reusable', 'variable'] - // Pattern to match {% data reusables.path %} and {% data variables.path %} + // Match data references for reusables and variables. const liquidPattern = /{%\s*data\s+(reusables|variables)\.([^%]+)\s*%}/g let match while ((match = liquidPattern.exec(content)) !== null) { const [original, type, dataPath] = match - const refType = type.slice(0, -1) as 'reusable' | 'variable' // Remove 's' from end + const refType = type.slice(0, -1) as 'reusable' | 'variable' if (types.includes(refType)) { references.push({ @@ -745,7 +735,7 @@ async function resolveReusable(reusablePath: string, verbose?: boolean): Promise try { const content = fs.readFileSync(filePath, 'utf-8') - // Remove any frontmatter if present + // Strip reusable frontmatter before inserting its body. const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '') return contentWithoutFrontmatter.trim() } catch (error: unknown) { @@ -781,8 +771,8 @@ async function resolveVariable(variablePath: string, verbose?: boolean): Promise const yamlContent = fs.readFileSync(filePath, 'utf-8') const data = load(yamlContent) as Record - // Navigate through the key path to find the value - const [, ...keyPath] = pathParts // Skip filename, get remaining path + // Variable paths start with the file name; remaining segments address YAML keys. + const [, ...keyPath] = pathParts let value: unknown = data for (const key of keyPath) { if (value && typeof value === 'object' && key in value) { diff --git a/src/content-render/scripts/move-by-content-type.ts b/src/content-render/scripts/move-by-content-type.ts index e7773d92d881..1b4d395e5078 100644 --- a/src/content-render/scripts/move-by-content-type.ts +++ b/src/content-render/scripts/move-by-content-type.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Move files to the relevant directory based on `contentType` frontmatter - */ +// @purpose Writer tool +// @description Move files to the relevant directory based on `contentType` frontmatter import { program } from 'commander' import fs from 'fs/promises' @@ -16,8 +14,7 @@ const CONTENT_TYPES = contentTypesEnum.filter( (type) => type !== 'homepage' && type !== 'other' && type !== 'landing', ) -// The number of path segments at the product level (e.g., "content//..."). -// Used when determining whether a target directory is a deeper subdirectory. +// Three segments identify content//index.md and top-level content-type directories. const PRODUCT_LEVEL_PATH_SEGMENTS = 3 const contentTypeToDir = (contentType: string): string => { @@ -31,10 +28,10 @@ function shouldSkipIndexFile(filePath: string): boolean { const parts = relativePath.split(path.sep) const contentIndex = parts.indexOf('content') - // Skip product-level index.md: content/product/index.md + // Keep product-level index.md files in place. if (parts.length === contentIndex + PRODUCT_LEVEL_PATH_SEGMENTS) return true - // Skip content-type-level index.md that's already in place: content/product/content-type/index.md + // Keep content-type index.md files that already sit at content/product/content-type/index.md. if (parts.length === contentIndex + 4) { const parentDir = parts[parts.length - 2] if (validContentTypeDirs.has(parentDir)) return true @@ -52,18 +49,16 @@ function calculateTarget(filePath: string, contentType: string, productDir: stri const targetContentType = contentTypeToDir(contentType) if (targetContentType === 'how-tos') { - // Preserve subdirectory structure for how-tos + // How-to pages keep their product subdirectory structure. const pathAfterProduct = parts.slice(contentIndex + 2, -1) if (pathAfterProduct[0] === 'how-tos') { - // Already in how-tos, no change return { targetDir: path.dirname(filePath), targetPath: filePath } } else { - // Move to how-tos preserving structure const targetDir = path.join(productDir, targetContentType, ...pathAfterProduct) return { targetDir, targetPath: path.join(targetDir, fileName) } } } else { - // Flatten to content-type directory + // Other content types flatten into their content-type directory. const targetDir = path.join(productDir, targetContentType) return { targetDir, targetPath: path.join(targetDir, fileName) } } @@ -81,7 +76,6 @@ program .description('Reorganize content files into subdirectories based on their contentType property') .argument('[paths...]', 'Content paths to process') .action(async (paths: string[]) => { - // Gather files. const filesToProcess: string[] = [] if (paths?.length > 0) { for (const p of paths) { @@ -102,8 +96,8 @@ program const filesToMove: FileMove[] = [] const skipped: Array<{ file: string; reason: string }> = [] - const targetDirs = new Set() // Relative paths of all target directories - const subdirTargets = new Set() // Subdirectories receiving index.md files + const targetDirs = new Set() + const subdirTargets = new Set() const productDirs = new Set() const productsWithRai = new Set() @@ -111,7 +105,6 @@ program const relativePath = path.relative(process.cwd(), filePath) try { - // Skip certain index.md files if (path.basename(filePath) === 'index.md' && shouldSkipIndexFile(filePath)) { continue } @@ -129,7 +122,7 @@ program const parts = relativePath.split(path.sep) const contentIndex = parts.indexOf('content') - // Skip all landing pages - they should only be product-level index.md and don't move + // Landing pages belong at product-level index.md files; this script does not move them. if (contentType === 'landing') { console.log(chalk.gray(`→ Skipping ${relativePath}: landing pages don't move`)) continue @@ -166,7 +159,7 @@ program console.log(chalk.yellow(`⚠ Skipping ${relativePath}: Target file already exists`)) continue } catch { - // Good, doesn't exist + // Missing target means the move can proceed. } filesToMove.push({ filePath, targetDir, targetPath, contentType }) @@ -174,7 +167,6 @@ program const relativeTargetDir = path.relative(process.cwd(), targetDir) targetDirs.add(relativeTargetDir) - // Track subdirectories that will receive index.md files if ( path.basename(filePath) === 'index.md' && relativeTargetDir.split(path.sep).length > PRODUCT_LEVEL_PATH_SEGMENTS @@ -195,7 +187,6 @@ program console.log(chalk.white('Ensuring standard content-type directories exist...\n')) - // Add standard content-type directories for each affected product if (paths?.length > 0) { for (const p of paths) { const fullPath = path.resolve(process.cwd(), p) @@ -237,10 +228,10 @@ program await fs.access(indexPath) console.log(chalk.gray(`- Skipping ${dirPath}/index.md (already exists)`)) } catch { - // Only create placeholders for top-level content-type directories (not subdirectories) + // Create placeholders only for top-level content-type directories. if (dirPath.split(path.sep).length > PRODUCT_LEVEL_PATH_SEGMENTS) continue - // Skip if an index.md will be moved here + // Moved index.md files become the placeholder for their target directory. if (subdirTargets.has(dirPath)) { console.log(chalk.gray(`- Skipping ${dirPath}/index.md (will be moved)`)) continue @@ -249,8 +240,6 @@ program const contentTypeName = path.basename(dirPath) const title = titleMap[contentTypeName] || contentTypeName - // Determine the correct contentType for this placeholder - // Map directory name back to contentType enum value const placeholderContentType = contentTypeName === 'responsible-use' ? 'rai' : contentTypeName @@ -316,7 +305,7 @@ contentType: ${placeholderContentType} const moved: Array<{ file: string; from: string; to: string }> = [] - // Categorize files by type for correct move order + // Move regular files and index.md files in separate groups to avoid path conflicts. const regularFiles = filesToMove.filter((f) => path.basename(f.filePath) !== 'index.md') const topLevelIndexFiles = filesToMove.filter((f) => { if (path.basename(f.filePath) !== 'index.md') return false @@ -333,7 +322,7 @@ contentType: ${placeholderContentType} ) }) - // Move subdirectory index files first (copy only, delete later) + // Copy subdirectory index.md files first; delete sources after regular files move. const indexFilesToDeleteLater: string[] = [] for (const file of subdirIndexFiles) { try { @@ -341,7 +330,7 @@ contentType: ${placeholderContentType} const content = await fs.readFile(file.filePath, 'utf-8') const { data, content: body } = readFrontmatter(content) - // Clear children array because paths will be invalid in the new content-type directory structure + // Clear children because the new content-type directory structure invalidates child paths. if (data?.children) data.children = [] await fs.writeFile( @@ -526,7 +515,7 @@ contentType: ${placeholderContentType} if (!data) continue - // For how-tos, build children from subdirectories + // how-tos children point to subdirectories. if (path.basename(dirPath) === 'how-tos') { const entries = await fs.readdir(absoluteDirPath, { withFileTypes: true }) const subdirs = entries @@ -544,7 +533,7 @@ contentType: ${placeholderContentType} ) } } - // For others, sort with about-* first + // Other content types sort about-* pages first. else if (data.children && Array.isArray(data.children) && data.children.length > 0) { const sorted = [...data.children].sort((a, b) => { const aBasename = path.basename(a) diff --git a/src/content-render/scripts/move-content.ts b/src/content-render/scripts/move-content.ts index d0f02a9e0f14..7c4b35603053 100755 --- a/src/content-render/scripts/move-content.ts +++ b/src/content-render/scripts/move-content.ts @@ -1,25 +1,13 @@ -/** - * @purpose Writer tool - * @description Move or rename a file or a folder and automatically add redirects - */ -// [start-readme] -// -// Use this script to help you move or rename a single file or a folder. The script will move or rename the file or folder for you, update relevant `children` in the index.md file(s), and add a `redirect_from` to frontmatter in the renamed file(s). Note: You will still need to manually update the `title` if necessary. -// -// By default, the `move-content.ts` script will commit the changes it makes. If you don't want the script to run any git commands for you, run it with the `--no-git` flag. Note: In most cases it will be easier and safer to let the script run the git commands for you, since git can get confused when a file is both renamed and edited. -// -// To learn more about the script, you can run `npm run move-content --help`. -// -// To run the script for a file: -// - `npm run move-content PATH/TO/CURRENT-FILE.md PATH/TO/DESIRED-FILE-LOCATION-OR-NAME.md` -// -// To run the script for a folder: -// - `npm run move-content PATH/TO/CURRENT-FOLDER PATH/TO/DESIRED-FOLDER-LOCATION-OR-NAME` -// -// To undo the script, run the same command that you used to run the script, but add an `--undo` flag: -// - `npm run move-content --undo PATH/TO/OLD PATH/TO/NEW` -// -// [end-readme] +// @purpose Writer tool +// @description Move or rename a file or a folder and automatically add redirects +// Moves one file or folder, updates relevant children entries, and adds redirect_from. +// It does not update title frontmatter. +// By default, it runs git mv and git commit; pass --no-git to avoid git commands. +// Keeping git enabled records rename and edit commits separately. +// Run npm run move-content --help for options. +// Run file: npm run move-content PATH/TO/CURRENT-FILE.md PATH/TO/DESIRED-FILE-LOCATION-OR-NAME.md. +// Run folder: npm run move-content PATH/TO/CURRENT-FOLDER PATH/TO/DESIRED-FOLDER-LOCATION-OR-NAME. +// Undo: npm run move-content --undo PATH/TO/OLD PATH/TO/NEW. import fs from 'fs' import path from 'path' @@ -45,7 +33,7 @@ interface PositionInfo { childGroupPositions: number[][] } -// This is so you can optionally run it again the test fixtures root. +// ROOT lets tests run against a fixture content root. const ROOT = process.env.ROOT || '.' const CONTENT_ROOT = path.resolve(path.join(ROOT, 'content')) @@ -99,7 +87,6 @@ async function main(opts: MoveOptions, nameTuple: string[]) { newPath = new_ } - // The file you're about to move needs to exist if (!fs.existsSync(oldPath)) { console.error(chalk.red(`${oldPath} does not exist.`)) process.exit(1) @@ -107,20 +94,11 @@ async function main(opts: MoveOptions, nameTuple: string[]) { let isFolder = fs.lstatSync(oldPath).isDirectory() - // Before validating, see if we need to fake that the newPath should be. - // This is to mimic how bash `mv` works where you can do: - // - // mv some/place/a/file.txt destin/ation/ - // - // which is implied to mean the same as; - // - // mv some/place/a/file.txt destin/ation/file.txt - // + // Emulate mv: moving path/file.md to an existing path/dir resolves to path/dir/file.md. if (undo) { if (isFolder) { const wouldBe = path.join(oldPath, path.basename(newPath)) - // We can't know if the `newPath` is a directory or file because - // whichever it is, it doesn't exist. + // For undo, infer a file move from the old folder plus the new file basename. if (fs.existsSync(wouldBe) && !fs.lstatSync(wouldBe).isDirectory()) { isFolder = false oldPath = wouldBe @@ -142,22 +120,19 @@ async function main(opts: MoveOptions, nameTuple: string[]) { process.exit(2) } - // This will exit non-zero if anything is wrong with these inputs validateFileInputs(oldPath, newPath, isFolder) const oldHref = makeHref(CONTENT_ROOT, undo ? newPath : oldPath) const newHref = makeHref(CONTENT_ROOT, undo ? oldPath : newPath) if (isFolder) { - // The folder must have an index.md file + // Folders can move only when they have an index.md landing file. const indexFilePath = path.join(oldPath, 'index.md') if (!fs.existsSync(indexFilePath)) { throw new Error(`${oldPath} does not have an index.md file`) } - // Gather individual files by walking `oldPath` recursively. const files = findFilesInFolder(oldPath, newPath, opts) - // First take care of the `git mv` (or regular rename) part. if (undo) { undoFolder(oldPath, newPath, files, opts) } else { @@ -172,10 +147,8 @@ async function main(opts: MoveOptions, nameTuple: string[]) { editFiles(files, false, opts) } } else { - // When it's just an individual file, it's easier. const files: FileTuple[] = [[oldPath, newPath, oldHref, newHref]] - // First take care of the `git mv` (or regular rename) part. moveFiles(files, opts) if (undo) { @@ -185,11 +158,9 @@ async function main(opts: MoveOptions, nameTuple: string[]) { } } - // Updating featuredLinks front matter actually doesn't care if - // the file is a folder or not. It just needs to know the old and new hrefs. + // featuredLinks updates need old and new hrefs, not whether the path is a file or folder. changeFeaturedLinks(oldHref, newHref) - // Update any links in ChildGroups on the homepage. changeHomepageLinks(oldHref, newHref, verbose) if (!undo) { @@ -205,8 +176,7 @@ async function main(opts: MoveOptions, nameTuple: string[]) { function validateFileInputs(oldPath: string, newPath: string, isFolder: boolean) { if (isFolder) { - // Make sure that only the last portion of the path is different - // and that all preceding are equal. + // Directory moves can change only the last path segment unless the destination base exists. const [oldBase, oldName] = splitDirectory(oldPath) const [newBase] = splitDirectory(newPath) if (oldBase !== newBase && !existsAndIsDirectory(newBase)) { @@ -333,9 +303,7 @@ function undoFolder(oldPath: string, newPath: string, files: FileTuple[], opts: } function getBasename(fileOrDirectory: string) { - // Note, can't use fs.lstatSync().isDirectory() because it's just a string - // at this point. It might not exist. - + // Infer file or directory names from path strings because the destination may not exist. if (fileOrDirectory.endsWith('index.md')) { return path.basename(path.dirname(fileOrDirectory)) } @@ -444,9 +412,9 @@ function addToChildren(newPath: string, positions: PositionInfo, opts: MoveOptio } } +// When git runs, commit pure renames before edits so later merges avoid complex three-way diffs. function moveFiles(files: FileTuple[], opts: MoveOptions) { const { verbose, git: useGit } = opts - // Before we do anything, assert that the files are valid for (const [oldPath] of files) { const fileContent = fs.readFileSync(oldPath, 'utf-8') const { errors } = fm(fileContent, { filepath: oldPath }) @@ -458,13 +426,6 @@ function moveFiles(files: FileTuple[], opts: MoveOptions) { if (errors.length > 0) throw new Error('There were more than 0 parse errors') } - // In the first loop, we exclusively perform the rename. No file edits! - // The reason is that we don't want lump renaming and edits in the same - // git commit. - // By having a dedicated git commit that purely renames (without changing - // any content) is best practice to avoid complex 3-way diffs that - // `git merge` does when you later have to merge in the latest `main` - // into your ongoing renaming branch. for (const [oldPath, newPath] of files) { if (verbose) { console.log(`Moving ${chalk.bold(oldPath)} to ${chalk.bold(newPath)}`) @@ -493,13 +454,10 @@ function moveFiles(files: FileTuple[], opts: MoveOptions) { } } +// editFiles keeps redirect_from edits in a separate commit from renames when git runs. function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) { const { verbose, git: useGit } = opts - // Second loop. This time our only job is to edit the `redirects_from` - // frontmatter key. - // See comment in the first loop above for why we're looping over the files - // two times. for (const [oldPath, newPath, oldHref] of files) { const fileContent = fs.readFileSync(newPath, 'utf-8') const { content, data } = readFrontmatter(fileContent) @@ -518,7 +476,7 @@ function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) } } - // Add contentType frontmatter to moved files + // Moved files get contentType from target paths. if (files.length > 0) { const filePaths = files.map(([, newPath]) => newPath) try { @@ -553,7 +511,6 @@ function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) function undoFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) { const { verbose, git: useGit } = opts - // First undo any edits to the file for (const [oldPath, newPath, oldHref] of files) { const fileContent = fs.readFileSync(newPath, 'utf-8') const { content, data } = readFrontmatter(fileContent) @@ -580,10 +537,9 @@ function undoFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) } } +// Regex replacement preserves YAML formatting and comments that serialization would lose. +// Homepage childGroup hrefs omit the leading slash. function changeHomepageLinks(oldHref: string, newHref: string, verbose: boolean) { - // Can't deserialize and serialize the Yaml because it would lose - // formatting and comments. So regex replace it. - // Homepage childGroup links do not have a leading '/', so we need to remove that. const homepageOldHref = oldHref.replace('/', '') const homepageNewHref = newHref.replace('/', '') const escapedHomepageOldHref = RegExp.escape(homepageOldHref) diff --git a/src/content-render/scripts/reusables-cli.ts b/src/content-render/scripts/reusables-cli.ts index d253cd6d2a36..4c84f3496d0a 100644 --- a/src/content-render/scripts/reusables-cli.ts +++ b/src/content-render/scripts/reusables-cli.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Find all content files that use a specific reusable - */ +// @purpose Writer tool +// @description Find all content files that use a specific reusable // Usage: npm run reusables -- --help // Usage: npm run reusables -- find used accounts/create-account.md // Usage: npm run reusables -- find unused accounts/create-account.md diff --git a/src/content-render/scripts/reusables-cli/find/potential-uses.ts b/src/content-render/scripts/reusables-cli/find/potential-uses.ts index c0827117caa9..af2886568cbe 100644 --- a/src/content-render/scripts/reusables-cli/find/potential-uses.ts +++ b/src/content-render/scripts/reusables-cli/find/potential-uses.ts @@ -63,7 +63,7 @@ export function findPotentialUses({ reusableCount += 1 for (const { filePath, fileContents } of allFileContents) { - // Skip the reusable file itself + // Do not report a reusable as a use of itself. if (filePath === reusableFilePath) continue const indices = findIndicesOfSubstringInString(reusableContents.trim(), fileContents) diff --git a/src/content-render/scripts/reusables-cli/find/unused.ts b/src/content-render/scripts/reusables-cli/find/unused.ts index 1f7bf29e8711..9906fb5b663e 100644 --- a/src/content-render/scripts/reusables-cli/find/unused.ts +++ b/src/content-render/scripts/reusables-cli/find/unused.ts @@ -33,7 +33,7 @@ export function findUnused({ absolute }: { absolute: boolean }) { args.startsWith('reusables.') ) { const reusableName = `${path.join('data', ...args.split(' ')[0].split('.'))}.md` - // Special cases where we don't want them to count as reusables. It's an example in a how-to doc + // Ignore how-to examples that use fake reusable names. if ( reusableName.includes('foo/bar.md') || reusableName.includes('foo/par.md') || diff --git a/src/content-render/scripts/reusables-cli/find/used.ts b/src/content-render/scripts/reusables-cli/find/used.ts index 6f56c31512d6..23e44a37d38f 100644 --- a/src/content-render/scripts/reusables-cli/find/used.ts +++ b/src/content-render/scripts/reusables-cli/find/used.ts @@ -26,7 +26,7 @@ export function findUsed(reusablePath: string, { absolute }: { absolute: boolean const filesWithReusables: FilesWithLineNumbers = [] for (const filePath of allFilePaths) { - // Skip the reusable file itself + // Do not report a reusable as a use of itself. if (filePath === reusableFilePath) continue const fileContents = fs.readFileSync(filePath, 'utf-8') diff --git a/src/content-render/scripts/reusables-cli/ignore-reusables.ts b/src/content-render/scripts/reusables-cli/ignore-reusables.ts index 9c9979f80f54..2460a9878523 100644 --- a/src/content-render/scripts/reusables-cli/ignore-reusables.ts +++ b/src/content-render/scripts/reusables-cli/ignore-reusables.ts @@ -1,5 +1,4 @@ -// List of reusables to ignore when checking for potential uses of reusables -// Make sure paths are relative to the root of the repo +// List repo-relative reusables excluded from potential-use checks. export const reusablesToIgnore = [ - 'data/reusables/copilot/trial-period.md', // Just a number, so it pops up in unrelated files + 'data/reusables/copilot/trial-period.md', // This numeric reusable matches unrelated files. ] diff --git a/src/content-render/scripts/reusables-cli/shared.ts b/src/content-render/scripts/reusables-cli/shared.ts index c04e24725d15..454e0477159e 100644 --- a/src/content-render/scripts/reusables-cli/shared.ts +++ b/src/content-render/scripts/reusables-cli/shared.ts @@ -73,12 +73,12 @@ export function getIndicesOfLiquidVariable(liquidVariable: string, fileContents: } export function resolveReusablePath(reusablePath: string): string { - // Try .md if extension is not provided + // Append .md when the reusable path has no extension. if (!reusablePath.endsWith('.md') && !reusablePath.endsWith('.yml')) { reusablePath += '.md' } - // Allow user to just pass the name of the file. If it's not ambiguous, we'll find it. + // Resolve a path fragment only when it matches exactly one reusable file. const allReusableFiles = getAllReusablesFilePaths() const foundPaths = [] for (const possiblePath of allReusableFiles) { @@ -130,13 +130,12 @@ export function findIndicesOfSubstringInString(substr: string, str: string): num } export function findSimilarSubStringInString(substr: string, str: string) { - // Take every sentence in the substr, lower case it, and compare it to every sentence in the str to get a similarity score + // Score each substring sentence against each corpus sentence by shared words. const substrSentences = substr.split('.').map((sentence) => sentence.toLowerCase()) const corpus = str.split('.').map((sentence) => sentence.toLowerCase()) let similarityScore = 0 - // Find how similar every two strings are based on the words they share for (const substrSentence of substrSentences) { for (const sentence of corpus) { const substrTokens = substrSentence.split(' ') diff --git a/src/content-render/scripts/update-filepaths.ts b/src/content-render/scripts/update-filepaths.ts index 7760d5c7b441..45bc147c26d8 100755 --- a/src/content-render/scripts/update-filepaths.ts +++ b/src/content-render/scripts/update-filepaths.ts @@ -1,7 +1,5 @@ -/** - * @purpose Writer tool - * @description Update content filenames to match short titles - */ +// @purpose Writer tool +// @description Update content filenames to match short titles import fs from 'fs' import path from 'path' @@ -53,11 +51,12 @@ const estimateScriptMinutes = (numberOfFiles: number): string => { return estNum === 0 ? '<1' : estNum.toString() } +// main processes files sequentially because move-content must move files before directories, +// and deepest directories before parents. +// Async does not shorten this work because each path move depends on the ordered result. async function main(): Promise { const slugger = new GithubSlugger() const contentDir: string = path.join(process.cwd(), 'content') - // Filter to get all the content files we want to read in. - // Then sort them from longest > shortest so we can do the file moves in order. const filesToProcess: string[] = sortFiles(filterFiles(contentDir, options)) if (filesToProcess.length === 0) { @@ -71,11 +70,6 @@ async function main(): Promise { console.log(`Estimated time: ${estimate} min\n`) } - // Process files sequentially to maintain the correct order of operations. - // Files must be moved before directories, and directories must be moved - // from deepest to shallowest to avoid path conflicts during the move operations. - // The result is rather slow, but an asynchronous approach that ensures - // sequential processing would not be faster. for (const file of filesToProcess) { try { slugger.reset() @@ -110,24 +104,16 @@ async function processFile( stringToSlugify = await renderContent(stringToSlugify, context, { textOnly: true }) } - // Slugify the short title of each article. - // Where: shortTitle = Foo bar - // Returns: slug = foo-bar - // Fall back to title if shortTitle doesn't exist. + // Slug shortTitle, or title when shortTitle is absent, to get the target basename. const slug: string = slugger.slug(decode(stringToSlugify)) let basename: string if (isDirectory) { - // Where: content location = content/foobar/index.md - // Returns: basename = foobar basename = path.basename(path.dirname(file)) } else { - // Where: content location = content/foobar.md - // Returns: basename = foobar basename = path.basename(file, '.md') } - // If slug and basename already match, all set here. Return early. if (slug === basename) return null const newPath = isDirectory @@ -153,7 +139,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void { return } - // Call out to well-tested move-content script for the moving and redirect adding functions. + // move-content handles file moves, redirects, and children updates. const stdout = execFileSync( 'tsx', [ @@ -166,7 +152,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void { { encoding: 'utf8' }, ) - // Grab just the "Moving..." and "Renamed..." output from stdout; otherwise output is too noisy. + // Print only Moving or Renamed lines unless verbose; full move-content output is noisy. const moveMsg = stdout.split('\n').find((l) => l.startsWith('Moving') || l.startsWith('Renamed')) if (moveMsg && !options.verbose) { console.log(moveMsg, '\n') @@ -176,11 +162,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void { } function sortFiles(filesArray: string[]): string[] { - // The order of operations is important. - // We need to return an array so that the moving operations happens in this order: - // 1. Filepaths - // 2. Deepest subdirectory path - // 3. Shallowest subdirectory path (up to category level, e.g., content/product/category) + // Move files before directories, then deepest directories before parents. return filesArray.toSorted((a, b) => { if (!isDirectoryCheck(a) && isDirectoryCheck(b)) { return -1 @@ -194,7 +176,7 @@ function sortFiles(filesArray: string[]): string[] { if (isDirectoryCheck(a) && isDirectoryCheck(b)) { const aDepth = a.split(path.sep).length const bDepth = b.split(path.sep).length - return bDepth - aDepth // Deeper paths first + return bDepth - aDepth } return 0 @@ -203,21 +185,19 @@ function sortFiles(filesArray: string[]): string[] { function filterFiles(contentDir: string, scriptOptions: ScriptOptions) { return walkFiles(contentDir, ['.md']).filter((file: string) => { - // Never move readmes + // Keep README paths unchanged. if (file.endsWith('README.md')) return false - // Never move early access files + // Keep early access paths unchanged. if (file.includes('early-access')) return false - // Never move the homepage (content/index.md) + // Keep the homepage path unchanged. if (path.relative(contentDir, file) === 'index.md') return false - // Never move product landings (content/foo/index.md) + // Keep product landing paths unchanged. if (path.relative(contentDir, file).split(path.sep)[1] === 'index.md') return false - // If no specific paths are passed, we are done filtering. if (!scriptOptions.paths) return true return scriptOptions.paths.some((p: string) => { - // Allow either a full content path like "content/foo/bar.md" - // or a top-level directory name like "copilot" + // Accept full content paths like content/foo/bar.md or top-level dirs like copilot. if (!p.startsWith('content')) { p = path.join('content', p) } @@ -236,7 +216,7 @@ function determineProcessStatus( isDirectory: boolean, scriptOptions: ScriptOptions, ): boolean { - // A directory is never processed when dirs are excluded, whatever else is set. + // exclude-dirs prevents directory moves even when force is set. if (isDirectory && scriptOptions.excludeDirs) { return false } diff --git a/src/content-render/tests/annotate.ts b/src/content-render/tests/annotate.ts index c47a78d6fb76..05e220bd62d8 100644 --- a/src/content-render/tests/annotate.ts +++ b/src/content-render/tests/annotate.ts @@ -124,7 +124,6 @@ on: [push] \`\`\` ` - // Create a mock context with pages for AUTOTITLE resolution const mockPages: Record = { '/get-started/start-your-journey/hello-world': { href: '/get-started/start-your-journey/hello-world', @@ -141,7 +140,7 @@ on: [push] currentVersion: 'free-pro-team@latest', pages: mockPages, redirects: {}, - // Mock test object doesn't need all Context properties, using 'as unknown as' to bypass strict type checking + // AUTOTITLE resolution reads only these Context fields. } as unknown as Context const res = await renderContent(autotitleExample, mockContext) diff --git a/src/content-render/tests/collect-mini-toc.ts b/src/content-render/tests/collect-mini-toc.ts index ae8b6942ccc4..eaf110d5d833 100644 --- a/src/content-render/tests/collect-mini-toc.ts +++ b/src/content-render/tests/collect-mini-toc.ts @@ -62,7 +62,7 @@ describe('collect-mini-toc rehype plugin', () => { }) test('does not collect when collectMiniToc is not provided', async () => { - // Should not throw — plugin is a no-op without collectInto + // Without collectMiniToc, the plugin is a no-op. const result = await renderContent('## Heading') expect(result).toContain('Heading') }) diff --git a/src/content-render/tests/data.ts b/src/content-render/tests/data.ts index 85fbe23faa54..99365061b34e 100644 --- a/src/content-render/tests/data.ts +++ b/src/content-render/tests/data.ts @@ -42,9 +42,7 @@ describe('data tag', () => { currentPath: '/en/liquid-tags/good-data-variable', } const rendered = await page!.render(context) - // The test fixture contains: - // {% data variables.stuff.foo %} - // which we control the value of here in the test. + // good-data-variable.md uses {% data variables.stuff.foo %} from the test data directory. expect(rendered.includes('Foo')).toBeTruthy() }) test('should throw if the data tag is used with something unrecognized', async () => { diff --git a/src/content-render/tests/link-error-line-numbers.ts b/src/content-render/tests/link-error-line-numbers.ts index 36cd3d1f842e..734e2fa2d77c 100644 --- a/src/content-render/tests/link-error-line-numbers.ts +++ b/src/content-render/tests/link-error-line-numbers.ts @@ -54,9 +54,6 @@ More content here.` } catch (error) { expect(error).toBeInstanceOf(TitleFromAutotitleError) - // The broken link is on line 10 in the original file - // (3 lines of frontmatter + 1 blank line + 1 title + 1 blank + 1 content + 1 blank + 1 link line) - // The error message should reference the correct line number expect((error as TitleFromAutotitleError).message).toContain('/nonexistent/page') expect((error as TitleFromAutotitleError).message).toContain('could not be resolved') expect((error as TitleFromAutotitleError).message).toContain('(Line: 10)') diff --git a/src/content-render/tests/liquid-tags.ts b/src/content-render/tests/liquid-tags.ts index db28d494733b..5151423349d5 100644 --- a/src/content-render/tests/liquid-tags.ts +++ b/src/content-render/tests/liquid-tags.ts @@ -55,7 +55,8 @@ This uses {% data variables.product.prodname_dotcom %} in content. const expandedContent = await fs.readFile(testFile, 'utf8') expect(expandedContent).not.toBe(testContent) - expect(expandedContent).toContain('GitHub') // Should expand to actual fixture value + // The fixture data tag expands to GitHub. + expect(expandedContent).toContain('GitHub') }) test('restore command should complete successfully', async () => { diff --git a/src/content-render/tests/liquid.ts b/src/content-render/tests/liquid.ts index e38b32f68ba7..82e8053b10c4 100644 --- a/src/content-render/tests/liquid.ts +++ b/src/content-render/tests/liquid.ts @@ -8,10 +8,7 @@ import { allVersions } from '@/versions/lib/all-versions' import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases' import type { Context, ExtendedRequest, Page } from '@/types' -// Setup these variables so we don't need to manually update tests as GHES -// versions continually get deprecated. For example, if we deprecate GHES 3.0, -// oldestSupportedGhes will be 3.1, secondOldestSupportedGhes will be 3.2, and -// thirdOldestSupportedGhes will be 3.3. +// Derive GHES versions from supported releases so deprecations do not require test updates. const oldestSupportedGhes = enterpriseServerReleases.supported[enterpriseServerReleases.supported.length - 1] const secondOldestSupportedGhes = @@ -50,7 +47,7 @@ describe('liquid template parser', () => { vi.setConfig({ testTimeout: 60 * 1000 }) describe('short versions', () => { - // Create a fake req so we can test the shortVersions middleware + // shortVersionsMiddleware reads and mutates a request context. const req = { language: 'en', query: {} } as ExtendedRequest test('FPT works as expected when it is FPT', async () => { @@ -61,7 +58,7 @@ describe('liquid template parser', () => { } as Context contextualize(req) const output = await liquid.parseAndRender(shortVersionsTemplate, req.context) - // We should have TWO results because we are supporting two shortcuts + // FPT matches directly and through the fpt or ghes shortcut. expect(output.replace(/\s\s+/g, ' ').trim()).toBe( `I am FPT I am FTP or GHES < ${secondOldestSupportedGhes}`, ) @@ -70,7 +67,6 @@ describe('liquid template parser', () => { test('GHEC works as expected', async () => { req.context = { currentVersion: 'enterprise-cloud@latest', - // page: {}, allVersions, enterpriseServerReleases, } as Context @@ -144,13 +140,13 @@ describe('liquid template parser', () => { }) describe('feature versions', () => { - // Create a fake req so we can test the feature versions middleware + // featureVersionsMiddleware reads and mutates a request context. const req = { language: 'en', query: {} } as ExtendedRequest test('does not render in FPT because feature is not available in FPT', async () => { req.context = { currentVersion: 'free-pro-team@latest', - page: {} as Page, // it just has to be any truthy value + page: {} as Page, // featureVersionsMiddleware only checks that page is truthy. allVersions, enterpriseServerReleases, } as Context @@ -162,7 +158,7 @@ describe('liquid template parser', () => { test('renders in GHES because feature is available in GHES', async () => { req.context = { currentVersion: `enterprise-server@${enterpriseServerReleases.latest}`, - page: {} as Page, // it just has to be any truthy value + page: {} as Page, // featureVersionsMiddleware only checks that page is truthy. allVersions, enterpriseServerReleases, } as Context @@ -174,7 +170,7 @@ describe('liquid template parser', () => { test('renders in GHEC because feature is available in GHEC', async () => { req.context = { currentVersion: 'enterprise-cloud@latest', - page: {} as Page, // it just has to be any truthy value + page: {} as Page, // featureVersionsMiddleware only checks that page is truthy. allVersions, enterpriseServerReleases, } as Context diff --git a/src/content-render/tests/prompt-id.ts b/src/content-render/tests/prompt-id.ts index 71e046b0fd48..ff26162f4653 100644 --- a/src/content-render/tests/prompt-id.ts +++ b/src/content-render/tests/prompt-id.ts @@ -39,13 +39,13 @@ describe('generatePromptId', () => { }) test('generates deterministic IDs (regression test)', () => { - // These specific values ensure the hash function remains consistent + // Fixed hash outputs catch unintended murmurhash changes. expect(generatePromptId('hello world')).toBe('1730621824') expect(generatePromptId('test')).toBe('4180565944') }) test('handles prompts with code context (ref pattern)', () => { - // When ref= is used, the prompt includes referenced code + prompt text separated by newline + // ref= prompts include referenced code, a newline, then prompt text. const codeContext = 'function logPersonAge(name, age, revealAge) {\n if (revealAge) {\n console.log(name);\n }\n}' const promptText = 'Improve the variable names in this function' @@ -59,15 +59,15 @@ describe('generatePromptId', () => { }) test('handles very long prompts', () => { - // Real-world prompts can include entire code blocks (100+ lines) - const longCode = 'x\n'.repeat(500) // 500 lines + // Real prompts can include code blocks longer than 100 lines. + const longCode = 'x\n'.repeat(500) const id = generatePromptId(longCode) expect(typeof id).toBe('string') expect(id.length).toBeGreaterThan(0) }) test('handles prompts with backticks and template literals', () => { - // Prompts often include inline code with backticks + // Prompts can include inline code delimiters. const prompt = "In JavaScript I'd write: `The ${numCats === 1 ? 'cat is' : 'cats are'} hungry.`" const id = generatePromptId(prompt) expect(typeof id).toBe('string') @@ -75,7 +75,7 @@ describe('generatePromptId', () => { }) test('handles prompts with placeholders', () => { - // Content uses placeholders like NEW-LANGUAGE, OWNER/REPOSITORY + // Content uses placeholders like NEW-LANGUAGE and OWNER/REPOSITORY. const id1 = generatePromptId('What is NEW-LANGUAGE best suited for?') const id2 = generatePromptId('In OWNER/REPOSITORY, create a feature request') expect(id1).not.toBe(id2) @@ -84,7 +84,7 @@ describe('generatePromptId', () => { }) test('handles unicode and international characters', () => { - // May encounter non-ASCII characters in prompts + // Prompts can include non-ASCII text. const id1 = generatePromptId('Explique-moi le code en français') const id2 = generatePromptId('コードを説明してください') const id3 = generatePromptId('Объясните этот код') diff --git a/src/content-render/tests/render-changed-and-deleted-files.ts b/src/content-render/tests/render-changed-and-deleted-files.ts index 617089e59ea4..b61d0b715cc5 100644 --- a/src/content-render/tests/render-changed-and-deleted-files.ts +++ b/src/content-render/tests/render-changed-and-deleted-files.ts @@ -1,37 +1,13 @@ -/** - * To "debug" this test locally, you need to set at least one of these - * environment variables: - * - * - CHANGED_FILES - * - DELETED_FILES - * - RENAMED_FILES - * - * `CHANGED_FILES` and `DELETED_FILES` are whitespace-separated lists of - * paths to content files. `RENAMED_FILES` is a whitespace-separated list - * of `oldPath,newPath` pairs (as emitted by tj-actions/changed-files - * `all_old_new_renamed_files` output). For example: - * - * export CHANGED_FILES="content/get-started/index.md content/get-started/start-your-journey/hello-world.md" - * export RENAMED_FILES="content/old/path.md,content/new/path.md" - * - * If any of the paths in there, split by ' ', don't match real files, the - * test will fail before it even starts. Meaning, it will throw an error - * rather than failing an `expect(...)` assertion. - * - * Technically, the value is any whitespace. So you can actually use: - * - * export DELETED_FILES=`git diff --name-only main...` - * - * which will make the environment variable be newline-separated and that - * works too. - * - * So, for example, if you've made some deletions and some edits the - * staged files: - * - * export DELETED_FILES=`git diff --name-only --diff-filter=D main...` - * export CHANGED_FILES=`git diff --name-only --diff-filter=M main...` - * npm run test -- src/content-render/tests/render-changed-and-deleted-files.ts - */ +// To run this test locally, set CHANGED_FILES, DELETED_FILES, or RENAMED_FILES. +// CHANGED_FILES and DELETED_FILES contain whitespace-separated content paths. +// RENAMED_FILES contains oldPath,newPath pairs from tj-actions/changed-files. +// CHANGED_FILES paths must identify loaded pages or the test throws before expectations run. +// Newline-separated git diff output works because the parser accepts all whitespace. +// Example: +// export CHANGED_FILES="content/get-started/index.md content/actions/index.md" +// export RENAMED_FILES="content/old/path.md,content/new/path.md" +// export DELETED_FILES="$(git diff --name-only --diff-filter=D main...)" +// npm run test -- src/content-render/tests/render-changed-and-deleted-files.ts import path from 'path' @@ -52,10 +28,8 @@ function getDeletedContentFiles() { return getContentFiles(process.env.DELETED_FILES) } -// Parse `RENAMED_FILES` from tj-actions/changed-files `all_old_new_renamed_files` -// output. Each whitespace-separated entry is an `oldPath,newPath` pair. We return -// the OLD paths so they can be checked the same way deleted files are: the test -// will fail if the old URL 404s (i.e. no redirect was set up for the rename). +// RENAMED_FILES comes from tj-actions/changed-files all_old_new_renamed_files. +// Each oldPath,newPath entry adds the old path because old URLs must not return 404. function getRenamedOldContentFiles() { const raw = (process.env.RENAMED_FILES || '').split(/\s+/g).filter(Boolean) const oldPaths = raw.map((pair) => pair.split(',')[0]).filter(Boolean) @@ -64,7 +38,7 @@ function getRenamedOldContentFiles() { function getContentFiles(spaceSeparatedList: string | undefined): string[] { return (spaceSeparatedList || '').split(/\s+/g).filter((filePath) => { - // This filters out things like '', or `data/foo.md` or `content/something/README.md` + // Only content Markdown pages count; data files and content README files do not render. return ( filePath.endsWith('.md') && filePath.split(path.sep)[0] === 'content' && @@ -73,23 +47,18 @@ function getContentFiles(spaceSeparatedList: string | undefined): string[] { }) } -// If the list of changed pages is very large, this test can take a long time. -// It can also happen if some of the pages involves are infamously slow. -// For example guide pages because they involved a lot of processing -// to gather and preview linked data. +// Large changes and guide pages can render slowly because guides gather linked data. vi.setConfig({ testTimeout: 60 * 1000 }) describe('changed-content', () => { const changedContentFiles = getChangedContentFiles() - // `test.each` will throw if the array is empty, so we need to add a dummy - // when there are no changed files in the environment. + // test.each throws on an empty array, so EMPTY stands in when no files are present. const testFiles: Array = changedContentFiles.length ? changedContentFiles : [EMPTY] test.each(testFiles)('changed-content: %s', async (file: string | symbol) => { - // Necessary because `test.each` will throw if the array is empty if (file === EMPTY) return const page = pageList.find((p) => { @@ -98,7 +67,7 @@ describe('changed-content', () => { if (!page) { throw new Error(`Could not find page for ${file as string} in all loaded English content`) } - // Each version of the page should successfully render + // Every permalink must render because changed files can affect all versions. for (const { href } of page.permalinks) { const res = await get(href) if (!res.ok) { @@ -114,19 +83,16 @@ describe('changed-content', () => { }) describe('deleted-content', () => { - // Renamed files (status `R` from git) don't appear in `DELETED_FILES`, but - // the old path is just as gone from the user's perspective and needs a - // redirect. Treat the old path of each rename the same as a deleted file. + // RENAMED_FILES provides old paths separately because git status R paths skip DELETED_FILES. const deletedContentFiles = [...getDeletedContentFiles(), ...getRenamedOldContentFiles()] - // `test.each` will throw if the array is empty, so we need to add a dummy - // when there are no deleted files in the environment. + // test.each throws on an empty array, so EMPTY stands in when no files are present. const testFiles: Array = deletedContentFiles.length ? deletedContentFiles : [EMPTY] + // Deleted pages no longer have versions frontmatter, so this checks the versionless permalink. test.each(testFiles)('deleted-content: %s', async (file: string | symbol) => { - // Necessary because `test.each` will throw if the array is empty if (file === EMPTY) return const page = pageList.find((p) => { @@ -137,9 +103,6 @@ describe('deleted-content', () => { `The supposedly deleted file ${file as string} is still in list of loaded pages`, ) } - // You can't know what the possible permalinks were for a deleted page, - // because it's deleted so we can't look at its `versions` front matter. - // However, we always make sure all pages work in versionless. const indexmdSuffixRegex = new RegExp(`${path.sep}index\\.md$`) const mdSuffixRegex = /\.md$/ const relativePath = (file as string).split(path.sep).slice(1).join(path.sep) @@ -150,9 +113,7 @@ describe('deleted-content', () => { res.statusCode === 404 ? `The deleted or renamed file ${file as string} did not set up a redirect.` : '' - // Certain articles that are deleted and moved under a directory with the same article name - // should just route to the subcategory page instead of redirecting (docs content team confirmed). - // So, in this scenario, we'd get a 200 status code. + // Same-name subcategory moves return 200 instead of redirecting. expect(res.statusCode === 301 || res.statusCode === 200, error).toBe(true) }) }) diff --git a/src/content-render/tests/render-content.ts b/src/content-render/tests/render-content.ts index dc1cdbbf9576..939abf540582 100644 --- a/src/content-render/tests/render-content.ts +++ b/src/content-render/tests/render-content.ts @@ -4,8 +4,7 @@ import { describe, expect, test } from 'vitest' import { renderContent } from '@/content-render/index' import { EOL } from 'os' -// Use platform-specific line endings for realistic tests when templates have -// been loaded from disk +// Disk-loaded templates use platform line endings, so tests do too. const nl = (str: string): string => str.replace(/\n/g, EOL) describe('renderContent', () => { @@ -240,8 +239,8 @@ var a = 1 const html = await renderContent(template) const $ = load(html) const el = $('button.js-btn-copy') + // Copy buttons use a murmurhash ID that matches the paired pre element. expect(el.data('clipboard')).toBe(2967273189) - // Generates a murmurhash based ID that matches a
   })
 
   describe('wrap-code-terms ( in table code)', () => {
diff --git a/src/content-render/tests/render-to-hast.ts b/src/content-render/tests/render-to-hast.ts
index c50f67640d84..0bfddca3c574 100644
--- a/src/content-render/tests/render-to-hast.ts
+++ b/src/content-render/tests/render-to-hast.ts
@@ -4,11 +4,8 @@ import { renderContentToHast } from '@/content-render/index'
 import { renderUnified, renderUnifiedToHast } from '@/content-render/unified/index'
 import type { Context } from '@/types'
 
-// A corpus that exercises the parts of the pipeline most likely to differ
-// between "stringify the processed vfile" (today) and "stringify the hast tree
-// we stopped at" (the new hast path): headings (slug + anchor links), code
-// blocks (highlight + code-header), tables (several rewrite plugins), alerts,
-// raw inline HTML (rehype-raw), and images.
+// This corpus covers pipeline stages where vfile HTML and hast-derived HTML can diverge:
+// headings, highlighted code, tables, alerts, raw inline HTML, images, and blockquotes.
 const fixtures: Array<{ name: string; template: string }> = [
   { name: 'paragraph', template: 'Hello **world**, this is a [link](https://github.com).' },
   {
diff --git a/src/content-render/tests/table-accessibility-labels.ts b/src/content-render/tests/table-accessibility-labels.ts
index e17e246cf096..a69844db08c0 100644
--- a/src/content-render/tests/table-accessibility-labels.ts
+++ b/src/content-render/tests/table-accessibility-labels.ts
@@ -4,8 +4,7 @@ import { describe, expect, test } from 'vitest'
 import { renderContent } from '@/content-render/index'
 import { EOL } from 'os'
 
-// Use platform-specific line endings for realistic tests when templates have
-// been loaded from disk
+// Disk-loaded templates use platform line endings, so tests do too.
 const nl = (str: string) => str.replace(/\n/g, EOL)
 
 describe('table accessibility labels', () => {
@@ -170,7 +169,7 @@ Some additional context here.
     const tables = $('table')
     expect(tables.length).toBe(2)
     expect($(tables[0]).attr('aria-labelledby')).toBe('first-heading')
-    // Second table should not get the same heading since the first table is in between
+    // A prior table stops heading lookup, so the second table stays unlabeled.
     expect($(tables[1]).attr('aria-labelledby')).toBeUndefined()
   })
 
diff --git a/src/fixtures/helpers/color-contrast.ts b/src/fixtures/helpers/color-contrast.ts
index 4d2e6fc8fe77..1f6463defa36 100644
--- a/src/fixtures/helpers/color-contrast.ts
+++ b/src/fixtures/helpers/color-contrast.ts
@@ -1,5 +1,5 @@
-// WCAG contrast for computed `rgb()`/`rgba()` colours. Keywords, hex and
-// translucent values throw rather than being coerced — `rgba(0, 0, 0, 0)` would
+// Computes WCAG contrast only for opaque computed rgb()/rgba() colours.
+// Reject keywords, hex, and translucent values, because rgba(0, 0, 0, 0) would
 // otherwise read as opaque black and yield a confident, wrong ratio.
 
 function parseComputedColor(color: string) {
diff --git a/src/fixtures/helpers/turn-off-experiments.ts b/src/fixtures/helpers/turn-off-experiments.ts
index cfb9547b4e2e..b26fb37bee9e 100644
--- a/src/fixtures/helpers/turn-off-experiments.ts
+++ b/src/fixtures/helpers/turn-off-experiments.ts
@@ -18,7 +18,7 @@ async function alterExperimentsInPage(
   variation: typeof TREATMENT_VARIATION | typeof CONTROL_VARIATION,
 ) {
   const experiments = getActiveExperiments('all')
-  // Include a page.evaluate call to simulate the same # of events as if an experiment were active
+  // When no experiments run, page.evaluate keeps the Playwright event count matching active runs.
   if (!experiments.length) {
     await page.evaluate(() => {
       console.log('No experiments to turn off, skipping')
@@ -28,7 +28,7 @@ async function alterExperimentsInPage(
   for (const experiment of getActiveExperiments('all')) {
     await page.evaluate(
       ({ experimentKey, variationType }) => {
-        // @ts-expect-error overrideControlGroup is a custom function added to the window object
+        // @ts-expect-error -- overrideControlGroup is a custom window helper for experiment tests.
         window.overrideControlGroup(experimentKey, variationType)
       },
       { experimentKey: experiment.key, variationType: variation },
@@ -36,8 +36,7 @@ async function alterExperimentsInPage(
   }
 }
 
-// Place Playwright tests in control group for every active experiment
-// To write a test for an experiment, explicitly turn that experiment on in the test
+// Playwright fixtures start in the control group; tests opt into treatments explicitly.
 export function turnOffExperimentsBeforeEach(test: typeof Test) {
   test.beforeEach(async ({ page }) => {
     await page.goto('/')
diff --git a/src/fixtures/playwright.config.ts b/src/fixtures/playwright.config.ts
index 7d4e382171aa..b0d0bbda814a 100644
--- a/src/fixtures/playwright.config.ts
+++ b/src/fixtures/playwright.config.ts
@@ -5,14 +5,8 @@ const CI = Boolean(JSON.parse(process.env.CI || 'false'))
 const PLAYWRIGHT_START_SERVER_COMMAND =
   process.env.PLAYWRIGHT_START_SERVER_COMMAND || 'npm run start-for-playwright'
 
-// All of these "patience" related settings follow a simple pattern;
-// If the env var are explicitly set, use that value, otherwise, if
-// we're in CI, be very patient, otherwise, be much less patient.
-// The reasoning is that most engineer laptops are faster than CI
-// and most importantly, if a test gets stuck it's probably not because
-// of a slow CPU, but because the test is plainly wrong. The engineer
-// working on it doesn't want to have to wait half a minute to find out
-// they have a bug in a test action or an assertion.
+// Environment variables override the retry and timeout defaults. CI gets longer waits
+// than local runs, so broken local tests fail quickly instead of waiting on CI-sized timeouts.
 const RETRIES = process.env.PLAYWRIGHT_RETRIES ? Number(process.env.PLAYWRIGHT_RETRIES) : CI ? 2 : 0
 const TIMEOUT = process.env.PLAYWRIGHT_TIMEOUT
   ? Number(process.env.PLAYWRIGHT_TIMEOUT)
@@ -25,17 +19,12 @@ const EXPECT_TIMEOUT = process.env.PLAYWRIGHT_EXPECT_TIMEOUT
     ? 5 * 1000
     : 2 * 1000
 
-/**
- * See https://playwright.dev/docs/test-configuration.
- */
+// See https://playwright.dev/docs/test-configuration.
 export default defineConfig({
   testDir: './tests',
   timeout: TIMEOUT,
   expect: {
-    /**
-     * Maximum time expect() should wait for the condition to be met.
-     * For example in `await expect(locator).toHaveText();`
-     */
+    // EXPECT_TIMEOUT controls waits such as await expect(locator).toHaveText().
     timeout: EXPECT_TIMEOUT,
   },
   fullyParallel: true,
@@ -46,61 +35,21 @@ export default defineConfig({
     : CI
       ? 1
       : undefined,
-  /* Reporter to use. See https://playwright.dev/docs/test-reporters */
-  // reporter: 'html',
-  /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+  // See https://playwright.dev/docs/api/class-testoptions for shared project options.
   use: {
-    /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
     actionTimeout: 0,
     baseURL: 'http://localhost:4000',
 
-    /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+    // See https://playwright.dev/docs/trace-viewer for trace collection behavior.
     trace: 'on-first-retry',
   },
 
   projects: [
-    // {
-    //   name: 'chromium',
-    //   use: {
-    //     ...devices['Desktop Chrome'],
-    //     // need this wider width because of our slightly wider than normal xl
-    //     // breakpoint that helps prevent overlapping main content with the minitoc
-    //     viewport: {
-    //       width: 1400,
-    //       height: 720,
-    //     },
-    //   },
-    // },
-
-    // {
-    //   name: 'firefox',
-    //   use: { ...devices['Desktop Firefox'] },
-    // },
-
-    // {
-    //   name: 'webkit',
-    //   use: { ...devices['Desktop Safari'] },
-    // },
-
-    /* Test against mobile viewports. */
-    // {
-    //   name: 'Mobile Chrome',
-    //   use: { ...devices['Pixel 5'] },
-    // },
-    // {
-    //   name: 'Mobile Safari',
-    //   use: { ...devices['iPhone 12'] },
-    // },
-
-    /* Test against branded browsers. */
-    // {
-    //   name: 'Microsoft Edge',
-    //   use: { channel: 'msedge' },
-    // },
     {
       name: 'Google Chrome',
       use: {
         channel: 'chromium',
+        // The 1400px width avoids overlap between main content and the mini table of contents.
         viewport: {
           width: 1400,
           height: 720,
@@ -109,9 +58,6 @@ export default defineConfig({
     },
   ],
 
-  /* Folder for test artifacts such as screenshots, videos, traces, etc. */
-  // outputDir: 'test-results/',
-
   webServer: {
     command: PLAYWRIGHT_START_SERVER_COMMAND,
     port: 4000,
diff --git a/src/fixtures/tests/annotations.ts b/src/fixtures/tests/annotations.ts
index 87f35190137e..0f9b7122146e 100644
--- a/src/fixtures/tests/annotations.ts
+++ b/src/fixtures/tests/annotations.ts
@@ -8,13 +8,9 @@ describe('annotations', () => {
     const $: CheerioAPI = await getDOM('/get-started/foo/code-snippet-with-hashbang')
     const annotations = $('#article-contents .annotate')
 
-    // Check http://localhost:4000/en/get-started/foo/code-snippet-with-hashbang
-    // to understand the confidence in the assertions.
-
-    // This fixture page has 2 bash annotations and 1 yaml
+    // The fixture page intentionally has 2 Bash annotations and 1 YAML annotation.
     expect(annotations.length).toBe(2 + 1)
 
-    // First code snippet block
     {
       const annotation = annotations.eq(0)
       expect(annotation.find('.annotate-header').length).toBe(1)
@@ -25,7 +21,6 @@ describe('annotations', () => {
       const noteTexts = notes.map((_, el) => $(el).text()).get()
       expect(noteTexts).toEqual(["Let's get started", 'This is just a sample', 'End of the script'])
     }
-    // Second code snippet block
     {
       const annotation = annotations.eq(1)
       expect(annotation.find('.annotate-header').length).toBe(1)
@@ -36,7 +31,7 @@ describe('annotations', () => {
       const noteTexts = notes.map((_, el) => $(el).text()).get()
       expect(noteTexts).toEqual(['Has to start with a comment.', 'This is the if statement'])
     }
-    // Yaml code snippet that starts with an empty comment
+    // The YAML snippet starts with an empty comment.
     {
       const annotation = annotations.eq(2)
       expect(annotation.find('.annotate-header').length).toBe(1)
diff --git a/src/fixtures/tests/api-article-body.ts b/src/fixtures/tests/api-article-body.ts
index fb4d3de6d23a..d74f787a25ad 100644
--- a/src/fixtures/tests/api-article-body.ts
+++ b/src/fixtures/tests/api-article-body.ts
@@ -6,15 +6,13 @@ const makeURL = (pathname: string) => `/api/article/body?${new URLSearchParams({
 
 describe('article body api', () => {
   beforeAll(() => {
-    // If you didn't set the `ROOT` variable, the tests will fail rather
-    // cryptically. So as a warning for engineers running these tests,
-    // alert in case it was accidentally forgotten.
+    // Missing ROOT makes local fixture failures hard to trace.
     if (!process.env.ROOT) {
       console.warn(
         'WARNING: The articlebody tests require the ROOT environment variable to be set to the fixture root',
       )
     }
-    // Ditto for fixture-based translations to work
+    // Missing TRANSLATIONS_FIXTURE_ROOT breaks fixture-based translations.
     if (!process.env.TRANSLATIONS_FIXTURE_ROOT) {
       console.warn(
         'WARNING: The articlebody tests require the TRANSLATIONS_FIXTURE_ROOT environment variable to be set',
@@ -28,7 +26,7 @@ describe('article body api', () => {
     expect(res.headers['content-type']).toContain('text/markdown')
     expect(res.body).toContain('## About GitHub')
     expect(res.body).toContain('## About Git')
-    expect(res.body).toMatch(/^#+\s+\w+/m) // Check for any markdown heading pattern
+    expect(res.body).toMatch(/^#+\s+\w+/m)
 
     expect(res.headers['set-cookie']).toBeUndefined()
     expect(res.headers['cache-control']).toContain('public')
@@ -123,7 +121,7 @@ describe('article body api', () => {
   })
 
   test('codespaces content included in production markdown API', async () => {
-    // Test a real production page that has codespaces content
+    // This production URL exercises real Codespaces tool content when fixtures can reach it.
     const res = await get(
       makeURL(
         '/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/reviewing-proposed-changes-in-a-pull-request',
@@ -144,8 +142,7 @@ describe('article body api', () => {
   })
 
   test('verifies original issue #5400 is resolved', async () => {
-    // This test specifically addresses the original issue where tool picker
-    // content was missing from the Markdown API response
+    // This production URL verifies the Markdown API includes Codespaces tool content.
     const res = await get(
       makeURL(
         '/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/reviewing-proposed-changes-in-a-pull-request',
@@ -162,7 +159,6 @@ describe('article body api', () => {
     expect(res.statusCode).toBe(200)
     expect(res.headers['content-type']).toContain('text/markdown')
 
-    // The original issue was that only webui content was returned, missing codespaces
     expect(res.body).toContain('
') expect(res.body).toContain('
') diff --git a/src/fixtures/tests/breadcrumbs.ts b/src/fixtures/tests/breadcrumbs.ts index 9559fcd43eef..d53b2fc4bd00 100644 --- a/src/fixtures/tests/breadcrumbs.ts +++ b/src/fixtures/tests/breadcrumbs.ts @@ -6,13 +6,11 @@ describe('breadcrumbs', () => { test('links always prefixed with language', async () => { const $ = await getDOM('/get-started/start-your-journey/hello-world') const links = $('[data-testid=breadcrumbs-bar] a') - // Home and the two ancestors are links; the current article is static text. + // The current article is static text, so only Home and two ancestors are links. expect(links.length).toBe(3) links.each((i, element) => { const href = $(element).attr('href')! - // The Home crumb points at the locale root (`/en` on the default version, - // no trailing slash); every other crumb is under `/en/…`. Both are - // language-prefixed, which is what this test guards. + // Home uses /en; every other crumb starts with /en/. expect(href === '/en' || href.startsWith('/en/')).toBe(true) }) }) @@ -45,7 +43,7 @@ describe('breadcrumbs', () => { expect(current.text()).toBe('Hello World') expect(current.is('a')).toBe(false) expect(current.attr('href')).toBeUndefined() - // The secondary-bar variant shows the full trail (no hidden last crumb). + // The secondary bar shows the full trail, including the last crumb. expect(current.hasClass('d-none')).toBe(false) }) diff --git a/src/fixtures/tests/categories-and-subcategory.ts b/src/fixtures/tests/categories-and-subcategory.ts index 24aa47af6056..36abceb149d5 100644 --- a/src/fixtures/tests/categories-and-subcategory.ts +++ b/src/fixtures/tests/categories-and-subcategory.ts @@ -12,12 +12,10 @@ describe('subcategories', () => { const links = $('[data-testid=table-of-contents] a[href]') expect(links.length).toBeGreaterThan(0) - // They all have the same prefix const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get() expect( hrefs.every((href: string) => href.startsWith('/en/get-started/start-your-journey/')), ).toBeTruthy() - // They all resolve to a 200 OK without redirects const responses = await Promise.all(hrefs.map((href: string) => head(href))) expect(responses.every((r: { statusCode: number }) => r.statusCode === 200)).toBeTruthy() }) @@ -35,7 +33,7 @@ describe('subcategories', () => { expect(firstArticleH2.text()).toMatch('Article title') const firstArticleIntro = $('[data-testid=table-of-contents] p').first() - // Its HTML in the intro is escaped and Markdown converted + // The intro escapes title HTML and converts Markdown. expect(firstArticleIntro.html()).toMatch( 'This page uses < and > in the title and shortTitle', ) @@ -50,10 +48,8 @@ describe('categories', () => { const links = $('[data-testid=table-of-contents] a[href]') expect(links.length).toBeGreaterThan(0) - // They all have the same prefix const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get() expect(hrefs.every((href: string) => href.startsWith('/en/actions/category/'))).toBeTruthy() - // They all resolve to a 200 OK without redirects const responses = await Promise.all(hrefs.map((href: string) => head(href))) expect(responses.every((r: { statusCode: number }) => r.statusCode === 200)).toBeTruthy() }) diff --git a/src/fixtures/tests/footer.ts b/src/fixtures/tests/footer.ts index 2df4b449b796..ac302a742c55 100644 --- a/src/fixtures/tests/footer.ts +++ b/src/fixtures/tests/footer.ts @@ -14,7 +14,7 @@ describe('footer', () => { }) test('renders minimal 404 page', async () => { - // 404 pages now render a minimal HTML response without the full layout + // Minimal 404 responses omit the full layout. const $ = await getDOM('/en/delicious-snacks/donuts.php', { allow404: true }) expect($('p').text()).toContain('Page not found.') }) diff --git a/src/fixtures/tests/glossary.ts b/src/fixtures/tests/glossary.ts index e214b7927aa4..dab192b44eef 100644 --- a/src/fixtures/tests/glossary.ts +++ b/src/fixtures/tests/glossary.ts @@ -17,7 +17,7 @@ describe('glossary', () => { const $: CheerioAPI = await getDOM('/get-started/learning-about-github/github-glossary') const internalLink = $('#article-contents a[href="/en/get-started/foo"]') expect(internalLink.length).toBe(1) - // That link used AUTOTITLE so it should be "expanded" + // AUTOTITLE expands this fixture link to the page title. expect(internalLink.text()).toBe('Fooing Around') }) @@ -29,7 +29,6 @@ describe('glossary', () => { }) test('liquid in one of the description depends on version', async () => { - // fpt { const $: CheerioAPI = await getDOM('/get-started/learning-about-github/github-glossary') const paragraphs = $('#article-contents p') @@ -40,7 +39,6 @@ describe('glossary', () => { expect(paragraphTexts).toContain('status check on HubGit.') } - // ghes { const $: CheerioAPI = await getDOM( '/enterprise-server@latest/get-started/learning-about-github/github-glossary', diff --git a/src/fixtures/tests/head.ts b/src/fixtures/tests/head.ts index 253f43190423..6eb6ab0fd8d0 100644 --- a/src/fixtures/tests/head.ts +++ b/src/fixtures/tests/head.ts @@ -6,10 +6,10 @@ import { getDOM } from '@/tests/helpers/e2etest' describe('', () => { test('includes page intro in `description` meta tag', async () => { const $: CheerioAPI = await getDOM('/get-started/markdown/intro') - // The intro has Markdown syntax which becomes HTML encoded in the lead element. + // The lead renders Markdown syntax as HTML. const lead = $('[data-testid="lead"] p') expect(lead.html()).toMatch('syntax') - // As a meta description its content is stripped of all HTML + // Meta descriptions strip all HTML from Markdown-rendered intros. const description = $('head meta[name="description"]') expect(description.attr('content')).toBe('This intro has Markdown syntax for HubGit') }) diff --git a/src/fixtures/tests/homepage.ts b/src/fixtures/tests/homepage.ts index 8ba9d7ece5e0..db1536eac79a 100644 --- a/src/fixtures/tests/homepage.ts +++ b/src/fixtures/tests/homepage.ts @@ -21,7 +21,8 @@ describe('home page', () => { for (const href of hrefs) { if (!href.attr('href')?.startsWith('https://')) { const res = await get(href.attr('href')!) - expect(res.statusCode).toBe(200) // Not needing to redirect + // Product group links resolve without redirects. + expect(res.statusCode).toBe(200) expect(href.text().includes('{%')).toBe(false) } else { externalLinks++ diff --git a/src/fixtures/tests/images.ts b/src/fixtures/tests/images.ts index a1eaaccfec5d..b9aee2601786 100644 --- a/src/fixtures/tests/images.ts +++ b/src/fixtures/tests/images.ts @@ -6,15 +6,16 @@ import type { Element } from 'domhandler' import { get, head, getDOM } from '@/tests/helpers/e2etest' import { MAX_WIDTH } from '@/content-render/unified/rewrite-asset-img-tags' -// `getDOM` parses with `xmlMode: true`, which is case-sensitive on attribute -// names. The legacy string render path emits a lowercase `srcset`, but the -// React render path (hast -> JSX) emits React 19's camelCase `srcSet`. Both are -// valid HTML (attribute names are case-insensitive in browsers), so read either. +// getDOM parses in xmlMode, so attribute names are case-sensitive. +// The string render path emits srcset, and the React render path emits srcSet. +// Browsers treat both as valid HTML, so read either spelling. function srcsetOf(el: Cheerio): string | undefined { return el.attr('srcset') ?? el.attr('srcSet') } describe('render Markdown image tags', () => { + // _fixtures/screenshot.png is 2000x1494 and wider than MAX_WIDTH, so picture + // sources include mw-XXXXX resizing and preserve aspect ratio at 1076px tall. test('page with a single image', async () => { const $: CheerioAPI = await getDOM('/get-started/images/single-image') @@ -41,16 +42,9 @@ describe('render Markdown image tags', () => { expect(res.statusCode).toBe(200) expect(res.headers['content-type']).toBe('image/webp') - // The fixture image `_fixtures/screenshot.png` is known to be very - // large. Larger than MAX_WIDTH pixels wide. - // When transformed as a source in a `` tag, it's automatically - // injected with the `mw-XXXXX` virtual indicator in the URL that - // resizes it on-the-fly. const image = sharp(Buffer.from(res.body as ArrayBuffer)) const { width, height } = await image.metadata() expect(width).toBe(MAX_WIDTH) - // The `_fixtures/screenshot.png` is 2000x1494. - // So if 2000/1494==MAX_WIDTH/x, then x becomes 1494*MAX_WIDTH/2000=1076 expect(height).toBe(Math.round((1494 * MAX_WIDTH) / 2000)) }) @@ -63,9 +57,9 @@ describe('render Markdown image tags', () => { const sources = $('source', pictures) expect(sources.length).toBe(3) - expect(srcsetOf(sources.eq(0))).toContain('1x') // 0 - expect(srcsetOf(sources.eq(1))).toContain('2x') // 1 - expect(srcsetOf(sources.eq(2))).toContain('2x') // 2 + expect(srcsetOf(sources.eq(0))).toContain('1x') + expect(srcsetOf(sources.eq(1))).toContain('2x') + expect(srcsetOf(sources.eq(2))).toContain('2x') }) test('image inside a list keeps its span', async () => { @@ -77,10 +71,10 @@ describe('render Markdown image tags', () => { test("links directly to images aren't rewritten", async () => { const $: CheerioAPI = await getDOM('/get-started/images/link-to-image') - // There is only 1 link inside that page - const links = $('#article-contents a[href^="/"]') // exclude header link + // The fixture has one article link; header links are out of scope. + const links = $('#article-contents a[href^="/"]') expect(links.length).toBe(1) - // This proves that the link didn't get rewritten to `/en/...` + // Asset links must stay under /assets instead of gaining a language prefix. expect(links.attr('href'), '/assets/images/_fixtures/screenshot.png') const res = await head(links.attr('href')!) expect(res.statusCode).toBe(200) diff --git a/src/fixtures/tests/internal-links.ts b/src/fixtures/tests/internal-links.ts index 353dc286168c..e659aebf97dc 100644 --- a/src/fixtures/tests/internal-links.ts +++ b/src/fixtures/tests/internal-links.ts @@ -15,13 +15,12 @@ describe('autotitle', () => { expect($(element).text()).toBe('Hello World') } }) - // There are 4 links on the `autotitling.md` content. + // autotitling.md has 4 AUTOTITLE links. expect.assertions(4) }) test('typos lead to error when NODE_ENV !== production', async () => { - // The fixture typo-autotitling.md contains two different typos - // of the word "AUTOTITLE", separated by `{% if version ghes %}` + // typo-autotitling.md contains two AUTOTITLE typos split by {% if version ghes %}. { const res = await get('/get-started/foo/typo-autotitling', { followRedirects: true }) expect(res.statusCode).toBe(500) @@ -48,14 +47,14 @@ describe('cross-version-links', () => { const $: CheerioAPI = await getDOM(URL) const links = $('#article-contents a[href]') - // Tests that the hardcoded prefix is always removed + // Cross-version links drop hardcoded free-pro-team prefixes. const firstLink = links.filter( (i: number, element: Element) => $(element).text() === 'Hello world always in free-pro-team', ) expect(firstLink.attr('href')).toBe('/en/get-started/start-your-journey/hello-world') - // Tests that the second link always goes to enterprise-server@X.Y + // Cross-version links keep explicit enterprise-server targets. const secondLink = links.filter( (i: number, element: Element) => $(element).text() === 'Autotitling page always in enterprise-server latest', @@ -79,7 +78,7 @@ describe('link-rewriting', () => { expect(link.attr('href')).toMatch('/en/get-started/') } - // Some links are left untouched + // External, asset, public, and enterprise links keep their original prefixes. { const link = links.filter((i: number, element: Element) => @@ -120,7 +119,7 @@ describe('link-rewriting', () => { }) test('/en and current version number is injected', async () => { - // enterprise-server, unlike enterprise-cloud, use numbers + // enterprise-server URLs use numbered releases, unlike enterprise-cloud. const $: CheerioAPI = await getDOM( '/enterprise-server@latest/get-started/start-your-journey/link-rewriting', ) diff --git a/src/fixtures/tests/liquid.ts b/src/fixtures/tests/liquid.ts index af380472590f..78da5ef5b547 100644 --- a/src/fixtures/tests/liquid.ts +++ b/src/fixtures/tests/liquid.ts @@ -56,70 +56,44 @@ describe('post', () => { expect(html).toMatch('
  • HubGit
  • ') expect(html).toMatch('CramFPTped') - // Test what happens to `Cram{% ifversion fpt %}FPT{% endif %}ped.` - // when it's not free-pro-team. + // Cram{% ifversion fpt %}FPT{% endif %}ped renders as Cramped outside free-pro-team. { const $inner: CheerioAPI = await getDOM( '/enterprise-server@latest/get-started/liquid/whitespace', ) const innerHtml = $inner('#article-contents').html() - // Assures that there's not whitespace left when the `{% ifversion %}` - // yields an empty string. + // Empty ifversion output must not leave extra whitespace. expect(innerHtml).toMatch('Cramped') } }) }) describe('rowheaders', () => { + // The first fixture table rewrites the first cell in each of two tbody rows to th, + // leaving three td cells per row. + // The second fixture table has three tbody rows with three td cells each. + // Axe's scope-attr-valid rule requires col scope on thead th and row scope on tbody th. + // https://dequeuniversity.com/rules/axe/4.1/scope-attr-valid?application=RuleDescription test('rowheaders', async () => { const $: CheerioAPI = await getDOM('/get-started/liquid/table-row-headers') const tables = $('#article-contents table') expect(tables.length).toBe(2) - // The first table should have this structure: - // - // table - // tbody - // tr - // th - // td - // td - // td - // - // (and there are 2 of these rows) - // - // That's because a Liquid + Markdown solution rewrites the - // *first* `tbody td` to become a `th` instead. const firstTable = tables.filter((i: number) => i === 0) expect($('tbody tr th', firstTable).length).toBe(2) expect($('tbody tr td', firstTable).length).toBe(2 * 3) - // The second table should have this structure: - // - // table - // tbody - // tr - // td - // td - // td - // - // (and there are 3 of these rows) const secondTable = tables.filter((i: number) => i === 1) expect($('tbody tr th', secondTable).length).toBe(0) expect($('tbody tr td', secondTable).length).toBe(3 * 3) - // More specifically, the tags should have the appropriate - // `scope` attribute. - // See "Scope attribute should be used correctly on tables" - // https://dequeuniversity.com/rules/axe/4.1/scope-attr-valid?application=RuleDescription $('thead th', firstTable).each((i, element) => { expect($(element).attr('scope')).toBe('col') }) $('tbody th', firstTable).each((i, element) => { expect($(element).attr('scope')).toBe('row') }) - // The 5 here is the other `expect(...)` that happens before these - // two, just above, `expect(...)` inside the `.each(...)` loops. + // Start with the five fixed assertions before counting each loop assertion. let totalAssertions = 5 totalAssertions += $('thead th', firstTable).length totalAssertions += $('tbody th', firstTable).length @@ -128,9 +102,7 @@ describe('rowheaders', () => { }) describe('ifversion', () => { - // the matchesPerVersion object contains a list of conditions that - // should match per version tested, but we also operate against it - // to find out versions that shouldn't match + // matchesPerVersion lists expected conditions and also defines the inverse set per version. const ghesLast = `enterprise-server@${supported[supported.length - 1]}` const ghesPenultimate = `enterprise-server@${supported[supported.length - 2]}` const matchesPerVersion: Record = { @@ -174,12 +146,10 @@ describe('ifversion', () => { const allConditions = Object.values(matchesPerVersion).flat() - // this is all conditions that should match for this rendered version const wantedConditions = allConditions.filter((condition: string) => { return matchesPerVersion[version].includes(condition) }) - // this is the inverse of the above, conditions that shouldn't match for this rendered version const unwantedConditions = allConditions.filter((condition: string) => { return !matchesPerVersion[version].includes(condition) }) @@ -197,7 +167,6 @@ describe('ifversion', () => { describe('misc Liquid', () => { test('links with liquid from data', async () => { const $: CheerioAPI = await getDOM('/get-started/liquid/links-with-liquid') - // The URL comes from variables.product.pricing_url const url = getDataByLanguage('variables.product.pricing_url', 'en') if (!url) throw new Error('variable could not be found') const links = $(`#article-contents a[href="${url}"]`) @@ -212,10 +181,7 @@ describe('misc Liquid', () => { }) test('page with tool Liquid tag followed by Markdown', async () => { - // This test tests Markdown being correctly rendered when the - // Markdown directly follows a tool tag like `{% linux %}...{% endlinux %}`. - // The next line immediately after the `{% endlinux %}` should not - // leave the Markdown unrendered + // Markdown must render when it immediately follows a {% linux %}...{% endlinux %} tag. const $: CheerioAPI = await getDOM('/get-started/liquid/tool-platform-switcher') const innerHTML = $('#article-contents').html() expect(innerHTML).not.toMatch('On *this* line is `Markdown` too.') @@ -227,62 +193,33 @@ describe('data tag', () => { test('injects data reusables with the right whitespace', async () => { const $: CheerioAPI = await getDOM('/get-started/liquid/data') - // This proves that the two injected reusables tables work. - // CommonMark is finicky if the indentation isn't perfect, so - // if you don't get exactly 2 tables, something is wrong, and if it's - // wrong it's most likely because of the leading whitespaces. + // Incorrect reusable indentation can break CommonMark parsing, so expect exactly two tables. expect($('#article-contents table').length).toBe(2) - // To truly understand this test, you have to see - // http://localhost:4000/en/get-started/liquid/data to understand it. - // The page uses `{% data ... %}` within the bodies of bullet points. - // If the whitespace isn't correct and working, the bullet points - // would get confused and think the bullet point "body" is a new - // bullet point on its own. + // Data tags inside ordered-list items must not split item bodies into new list items. expect($('#article-contents ol').length).toBe(3) expect($('#article-contents ol li').length).toBe(2 + 1 + 2) - // In the very first bullet point we inject something that multiple - // linebreaks in it. The source looks like this: - // - // 1. Bullet point - // - // {% data reusables.injectables.multiple_numbers %} - // - // (The code comment itself here has 3 spaces of manual indentation) - // What's important is that all the expected lines of that reusables - // stick inside this `ul li` block. + // The indented {% data reusables.injectables.multiple_numbers %} call keeps every line in the first list item. const liText = $('#article-contents ol li').first().text() expect(liText).toMatch(/Bullet point\nOne\nTwo\nThree\nFour/) - // The code block uses `{% data ... %}` and it should be indented - // so that it aligns perfectly with the code block itself. - // One of the injected data reusables contains multiple lines. - // It's important that each line from that starts at the far - // left. No more or less whitespace. + // Multi-line code-block reusables start at the far left, with no extra indentation. const codeBlock = $('#article-contents li pre').text() expect(codeBlock).toMatch(/^One\n/) expect(codeBlock).toMatch(/^One\nTwo\n/) expect(codeBlock).toMatch(/^One\nTwo\nThree\n/) - // The code block also a reusables that is just one line. + // The code block also receives one single-line reusable. expect(codeBlock).toMatch(/One Two Three Four\n/) - // On its own, if you look at - // src/fixtures/fixtures/data/reusables/injectables/paragraphs.md, you'll - // see each line is NOT prefixed with whitespace indentation. - // But because `{% data reusables.injectables.paragraphs %}` is - // inserted with some indentation, that's replicated on every line. + // src/fixtures/fixtures/data/reusables/injectables/paragraphs.md inherits indentation from its data call. const li = $('#article-contents li') .filter((_, element) => { return $(element).text().trim().startsWith('Point 1') }) .eq(0) - // You can't really test the exact whitespace with cheerio, - // of the original HTML, but it doesn't actually matter. What - // matters is that within the bullet point, that starts with "Point 1", - // it *contains* all the paragraphs - // from src/fixtures/fixtures/data/reusables/injectables/paragraphs.md. + // Cheerio cannot test original HTML whitespace, so the bullet text checks every paragraph. expect(li.text()).toMatch(/Paragraph one/) expect(li.text()).toMatch(/Paragraph two/) expect(li.text()).toMatch(/Paragraph three/) diff --git a/src/fixtures/tests/markdown.ts b/src/fixtures/tests/markdown.ts index b7f15b614a3a..cd2a8280532c 100644 --- a/src/fixtures/tests/markdown.ts +++ b/src/fixtures/tests/markdown.ts @@ -17,8 +17,7 @@ describe('alerts', () => { test('basic rendering', async () => { const $: CheerioAPI = await getDOM('/get-started/markdown/alerts') const alerts = $('#article-contents .ghd-alert') - // See src/fixtures/fixtures/content/get-started/markdown/alerts.md - // to be this confident in the assertions. + // src/fixtures/fixtures/content/get-started/markdown/alerts.md defines five alert types. expect(alerts.length).toBe(5) const svgs = $('svg', alerts) expect(svgs.length).toBe(5) diff --git a/src/fixtures/tests/permissions-callout.ts b/src/fixtures/tests/permissions-callout.ts index 93cc31cd9d87..e23b9af0f615 100644 --- a/src/fixtures/tests/permissions-callout.ts +++ b/src/fixtures/tests/permissions-callout.ts @@ -11,11 +11,7 @@ describe('permission statements', () => { }) test('callout disappears depend on Liquid inside it', async () => { - // This page has `product:` property which is a piece of Liquid - // which makes it so that the rendered output of that becomes - // an empty string. - // This test tests that alert is not rendered if its output - // "exits" but is empty. + // Liquid in the product: frontmatter property renders empty, so the product statement disappears. const $: CheerioAPI = await getDOM( '/enterprise-server@latest/get-started/foo/page-with-callout', ) @@ -32,16 +28,13 @@ describe('permission statements', () => { test('page with permission frontmatter', async () => { const $: CheerioAPI = await getDOM('/get-started/markdown/permissions') const html = $('[data-testid=permissions-statement] div').html() - // Markdown expect(html).toMatch('admin') - // Liquid expect(html).toMatch('HubGit Pages site') }) test('page with permission frontmatter and product statement', async () => { const $: CheerioAPI = await getDOM('/get-started/foo/page-with-permissions-and-product-callout') const html = $('[data-testid=permissions-callout] div').html() - // part of the UI expect(html).toMatch('Who can use this feature') const permission = $('[data-testid=permissions-statement] div') diff --git a/src/fixtures/tests/playwright-a11y.spec.ts b/src/fixtures/tests/playwright-a11y.spec.ts index 1c2017dce6ce..8cb28892669d 100644 --- a/src/fixtures/tests/playwright-a11y.spec.ts +++ b/src/fixtures/tests/playwright-a11y.spec.ts @@ -7,12 +7,9 @@ const SEARCH_TESTS = !!process.env.ELASTICSEARCH_URL const pages: { [key: string]: string } = { category: '/actions/category', codeAnnotations: '/get-started/markdown/code-annotations', - // The only fixture page that renders a CTA button. A `.btn-primary` anchor is the - // one shape the brand article-link override can drive under 4.5:1 — its label sits - // on a coloured fill rather than the page background — which is exactly what it did - // before `:not(.btn)` was added to - // src/frame/stylesheets/article-link-overrides.scss. Without this entry that - // exclusion has no test at all. + // This CTA fixture is the only page that covers the .btn-primary article-link override. + // Its filled label can fall below 4.5:1 without the :not(.btn) exclusion in + // src/frame/stylesheets/article-link-overrides.scss. ctaButton: '/get-started/foo/page-with-permissions-and-product-callout', homepage: '/', learningPath: @@ -28,7 +25,6 @@ const pages: { [key: string]: string } = { tableWithHeaders: '/get-started/liquid/table-row-headers', } -// create a test for each page, will eventually be separated into finer grain tests for (const pageName of Object.keys(pages)) { test.describe(`${pageName}`, () => { test('full page axe scan without experiments', async ({ page }) => { @@ -55,14 +51,12 @@ for (const pageName of Object.keys(pages)) { }) } -// The search facet filters collapse behind a "Show filters" disclosure below -// Primer Brand's `medium` breakpoint. The scans above run at the default desktop -// viewport, where that disclosure is display:none, so the expanded panel would +// The search facet filters collapse behind a Show filters disclosure below +// Primer Brand's medium breakpoint. The scans above run at the default desktop +// viewport, where that disclosure has display: none, so the expanded panel would // otherwise never be scanned. test.describe('search filters (narrow viewport)', () => { - // Without a local Elasticsearch the middleware proxies to production, so there are no - // aggregations, the disclosure never renders, and this would time out rather than - // skip. Matches the guard every search test in playwright-rendering.spec.ts uses. + // Without local Elasticsearch, the production proxy returns no aggregations, so the disclosure never renders. test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search') test('expanded filter disclosure passes axe', async ({ page }) => { @@ -76,8 +70,7 @@ test.describe('search filters (narrow viewport)', () => { await toggle.click() await expect(toggle).toHaveAttribute('aria-expanded', 'true') - // Scoped to the disclosure's own panel: a bare `fieldset` locator would hit strict - // mode the moment anything else on the page renders one. + // Scope to the panel, because other fieldsets would trigger Playwright strict mode. const panelId = await toggle.getAttribute('aria-controls') await expect(page.locator(`#${panelId} fieldset`)).toBeVisible() diff --git a/src/fixtures/tests/playwright-header.spec.ts b/src/fixtures/tests/playwright-header.spec.ts index 36261f1d7ffc..8fdeb5661b79 100644 --- a/src/fixtures/tests/playwright-header.spec.ts +++ b/src/fixtures/tests/playwright-header.spec.ts @@ -9,35 +9,29 @@ import { } from '../../frame/lib/constants' const ARTICLE = '/en/get-started/foo/bar' -// `find-page.ts` narrows `context.languages` to English alone for early-access -// pages, which makes this the production route through the single-language -// branch of the header's language slot. +// find-page.ts narrows context.languages to English for early-access pages, so +// this route exercises the single-language branch of the header's language slot. const ENGLISH_ONLY_ARTICLE = '/en/early-access/secrets/deeper/mariana-trench' const SEARCH_LABEL = 'Search or ask Copilot' const LANGUAGE_LABEL = 'Select language: current language is English' const PLAN_LABEL = 'Select your plan:' const VERSION_LABEL = 'Select your version:' -// The pill's line-height is the Docs design's own decision, set in -// HeaderPicker.module.scss -- Brand's --brand-text-lineHeight-100 is 1.5 -- so -// unlike the sizes below it is not resolved from a token. +// The pill's line-height comes from the Docs design, not Brand's +// --brand-text-lineHeight-100 value of 1.5. const PILL_LINE_HEIGHT = 1.2 const PLAN_TRIGGER_TESTID = 'version-picker-button' const LANGUAGE_TRIGGER_TESTID = 'language-picker-button' -// Brand renders the trailing slot on `trailingComponent != null`, so the wrapper -// survives a child that renders nothing. Its class name is CSS-module hashed, so -// only the stable fragment can be matched -- and an absence assertion on a name -// Brand might rename would pass vacuously, which is why the test below always -// pairs it with a page where the same selector must still match. +// Brand renders the trailing slot when trailingComponent != null, so the wrapper +// survives a child that renders nothing. +// The CSS module hash leaves only this stable fragment to match; the paired +// presence test prevents a vacuous absence check after a Brand rename. const BRAND_TRAILING_SLOT = '[class*="SubdomainNavBar-trailing-component"]' -/** - * Resolve Brand custom properties in whatever theme the page is currently in, - * instead of hardcoding light-mode RGB values. The probe is appended inside - * `locator` on purpose: the plan menu renders inside its own nested Brand - * ThemeProvider, so tokens have to be read from within that subtree to reflect - * the color mode the menu actually paints with. The hidden probe only - * normalizes CSS color syntax into rgb(); it never styles the UI. - */ +// Resolve Brand custom properties in the page's current theme instead of +// hardcoding light-mode RGB values. +// Append the probe inside locator because the plan menu has its own nested Brand +// ThemeProvider, so tokens must come from that subtree. +// The hidden probe normalizes CSS color syntax into rgb() without styling the UI. async function resolveThemeTokens(locator: Locator, tokens: string[]) { return locator.evaluate((element, tokenNames: string[]) => { const probe = document.createElement('span') @@ -59,12 +53,10 @@ async function resolveThemeTokens(locator: Locator, tokens: string[]) { }, tokens) } -/** - * Resolve Brand length tokens to pixels, so the pill's geometry can be checked - * against the tokens it is built from instead of the numbers those tokens happen - * to produce today. The probe is laid out (absolute + hidden rather than - * `hidden`) so `width` resolves through calc()/max() to a used pixel value. - */ +// Resolve Brand length tokens to pixels so the pill geometry stays tied to +// tokens, not their current numeric values. +// The absolute hidden probe stays laid out so width resolves through calc() and +// max() to a used pixel value. async function resolveTokenPixels(locator: Locator, tokens: string[]) { return locator.evaluate((element, tokenNames: string[]) => { const probe = document.createElement('div') @@ -92,7 +84,7 @@ async function resolveTokenPixels(locator: Locator, tokens: string[]) { }, tokens) } -/** Read raw custom-property values (font weights resolve to plain numbers). */ +// Font weights resolve to plain numbers, so this reads raw custom-property values. async function resolveTokenValues(locator: Locator, tokens: string[]) { return locator.evaluate((element, tokenNames: string[]) => { const resolved: Record = {} @@ -122,10 +114,7 @@ async function expectHeaderPlanPicker(page: Page) { expect(valueId).toBeTruthy() await expect(button).toHaveAttribute('aria-labelledby', `${labelId} ${valueId}`) - // Every size below is arithmetic over Brand tokens, so resolve the tokens and - // derive the expectations rather than hardcoding today's pixels: a - // @primer/react-brand bump that moves --base-size-* then updates both sides at - // once, instead of failing CI with no user-visible regression. + // Resolve Brand tokens so expected sizes move with --base-size-* changes instead of failing. const sizes = await resolveTokenPixels(picker, [ '--brand-text-size-100', '--base-size-2', @@ -197,8 +186,7 @@ async function expectHeaderPlanPicker(page: Page) { expect(buttonBox.x - (labelBox.x + labelBox.width)).toBeCloseTo(labelGap, 0) expect(labelBox.y + labelBox.height / 2).toBeCloseTo(buttonBox.y + buttonBox.height / 2, 0) expect(buttonBox.height).toBeCloseTo(pillHeight, 0) - // The normal plan name must fit even with Signup visible at 1012px. Keep - // ellipsis available for unusually long labels, not this default English one. + // Default English plan name must fit with Signup at 1012px; ellipsis is for longer labels. await expect .poll(() => value.evaluate((element) => element.scrollWidth - element.clientWidth)) .toBeLessThanOrEqual(0) @@ -217,16 +205,12 @@ async function expectHeaderPlanPicker(page: Page) { await expectFilledTriangleCaret(button, colors.text) } -/** - * Both header triggers end in the same caret, so both are checked the same way. - * The design's caret is a filled triangle. Brand's ActionMenu.Button hardcodes a - * ChevronDownIcon and only loses to a caller-supplied trailingVisual because it - * spreads rest props after that default -- a single shared cast (ActionMenuTrigger) - * relies on that. A Brand upgrade that destructures trailingVisual would silently - * restore the chevron on both controls at once, so assert the chevron is gone and - * that the glyph really has the triangle's geometry: the triangle's path is - * ~7.15 x 3.82 user units, where chevron-down's is ~9.56 x 5.31. - */ +// Both header triggers use the same filled triangle caret, checked through one helper. +// Brand's ActionMenu.Button defaults to ChevronDownIcon; the ActionMenuTrigger +// cast relies on a caller-supplied trailingVisual overriding it. +// A Brand change that destructures trailingVisual would restore chevrons on both controls. +// Assert the chevron is gone and the triangle path is about 7.15 by 3.82 user +// units, not chevron-down's 9.56 by 5.31. async function expectFilledTriangleCaret(trigger: Locator, color: string) { const caret = trigger.locator('svg.octicon-triangle-down') await expect(caret).toBeVisible() @@ -244,13 +228,10 @@ async function expectFilledTriangleCaret(trigger: Locator, color: string) { expect(glyph.height).toBeLessThan(4.6) } -/** - * The language trigger deliberately does *not* match the plan pill: Figma draws - * it as a flat control -- a 16px globe, the language in muted 14px regular, then - * the same filled caret. Only the dropdown below it is shared, so this asserts - * the trigger keeps its own treatment and never drifts into the pill (which is - * exactly what reusing the shared pill class would do). - */ +// The language trigger deliberately does not match the plan pill. +// Figma specifies a flat control: 16px globe, muted 14px regular language text, +// then the same filled caret. +// Only the dropdown is shared, so this catches accidental reuse of the shared pill class. async function expectHeaderLanguageTrigger(page: Page) { const picker = page.getByTestId('desktop-header').getByTestId('language-picker') const trigger = picker.getByTestId(LANGUAGE_TRIGGER_TESTID) @@ -272,10 +253,7 @@ async function expectHeaderLanguageTrigger(page: Page) { ) expect(valueFontSize).toBeCloseTo(sizes['--brand-text-size-100'], 1) - // Flat, not a pill: no fill at rest, no border, and a small corner rather than - // the pill's full radius. The canvas-subtle comparison keeps this honest -- it - // is the fill the pill carries and the fill this control only takes on hover - // and while open. + // Flat trigger: no rest fill or border, a 6px corner, and canvas-subtle on hover or open. await expect(trigger).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)') expect(tokens['--brand-color-canvas-subtle']).not.toBe('rgba(0, 0, 0, 0)') for (const side of ['top', 'right', 'bottom', 'left']) { @@ -285,9 +263,7 @@ async function expectHeaderLanguageTrigger(page: Page) { await expect(trigger).toHaveCSS(`border-${corner}-radius`, '6px') } const triggerBox = (await trigger.boundingBox())! - // Brand's ActionMenu remaps --brand-borderRadius-medium to the full radius on - // its own trigger, so a 6px corner is the difference between this control and - // a pill rather than a cosmetic detail. + // Brand's ActionMenu remaps --brand-borderRadius-medium to full radius; 6px prevents a pill. expect(triggerBox.height / 2).toBeGreaterThan(6) const globe = trigger.locator('svg.octicon-globe') @@ -301,29 +277,25 @@ async function expectHeaderLanguageTrigger(page: Page) { await expectFilledTriangleCaret(trigger, tokens['--brand-color-text-muted']) } -/** - * The two header dropdowns are the same control with different content: both are - * Brand ActionMenus whose surface and rows come entirely from the shared - * HeaderPicker.module.scss. Every design assertion below therefore runs against - * both -- that is what proves they are identical rather than merely similar -- - * so only the content is parameterized here. - */ +// The two header dropdowns use the same Brand ActionMenu surface and row styles +// from HeaderPicker.module.scss. +// Running each design assertion against both menus proves shared styling, not similar styling. type HeaderDropdown = { name: string pickerTestId: string triggerTestId: string - /** The span each row wraps its label in. */ + // The span each row wraps its label in. itemTestId: string expectTrigger: (page: Page) => Promise - /** The row that opens already chosen: tinted, with the trailing green dot. */ + // The row that opens already chosen: tinted, with the trailing green dot. selectedRow: string - /** Another selectable row: no tint, no dot. */ + // Another selectable row: no tint, no dot. unselectedRow: string - /** Rows that navigate instead of selecting, so they stay plain menuitems. */ + // Rows that navigate instead of selecting, so they stay plain menuitems. navigationRowCount: number - /** The plan menu keeps one rule between its versions and its navigation rows. */ + // The plan menu keeps one rule between its versions and its navigation rows. separatorCount: number - /** The final row -- whatever a clipped menu loses first. */ + // A clipped menu loses this final row first. lastRowRole: 'menuitem' | 'menuitemradio' lastRowName: RegExp } @@ -358,11 +330,8 @@ const LANGUAGE_DROPDOWN: HeaderDropdown = { lastRowName: /日本語/, } -/** - * A Docs 2026 header dropdown, rebuilt on Brand's ActionMenu. Opens the menu, - * checks the surface, rows, selection indicator and the absence of Brand's own - * leading check slot, then closes it and confirms focus returns to the trigger. - */ +// Docs 2026 rebuilds header dropdowns on Brand ActionMenu, so this helper checks +// the shared menu contract end to end. async function expectHeaderDropdownDesign( page: Page, colorScheme: 'light' | 'dark', @@ -374,7 +343,7 @@ async function expectHeaderDropdownDesign( await trigger.click() await expect(trigger).toHaveAttribute('aria-expanded', 'true') - // Brand's menu is not portalled -- it renders inside the picker wrapper. + // Brand's menu renders inside the picker wrapper, not a portal. const menu = picker.getByRole('menu') await expect(menu).toBeVisible() @@ -385,8 +354,7 @@ async function expectHeaderDropdownDesign( '--brand-color-text-default', '--brand-color-success-fg', ]) - // Proves the emulated scheme reached Brand's tokens: a dark run that silently - // stayed light would satisfy every assertion above on its own. + // A dark run that stays light would pass above, so verify Brand tokens changed. const luminance = relativeLuminance(tokens['--brand-color-canvas-default']) if (colorScheme === 'dark') { expect(luminance).toBeLessThan(0.2) @@ -394,8 +362,7 @@ async function expectHeaderDropdownDesign( expect(luminance).toBeGreaterThan(0.8) } - // Menu surface: canvas-default fill, 1px subtle border, 6px radius, 8px pad. - // Brand's own defaults are a border-muted border and a 16px radius. + // Overrides Brand's border-muted border and 16px radius; assertions also pin fill and 8px pad. await expect(menu).toHaveCSS('background-color', tokens['--brand-color-canvas-default']) for (const side of ['top', 'right', 'bottom', 'left']) { await expect(menu).toHaveCSS(`border-${side}-width`, '1px') @@ -406,13 +373,10 @@ async function expectHeaderDropdownDesign( for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { await expect(menu).toHaveCSS(`border-${corner}-radius`, '6px') } - // The design's menu is 256px wide; a long row may grow it, never shrink it. + // The design sets a 256px minimum menu width; long rows can grow it, never shrink it. const menuBox = (await menu.boundingBox())! expect(menuBox.width).toBeGreaterThanOrEqual(256) - // Brand anchors with `allowOutOfBounds`, so nothing clamps a menu that would - // overhang -- which matters most for the language menu, the one control sitting - // at the header's right edge. `menuAlignment` is what keeps it on screen, so - // assert the result instead of trusting the prop. + // Brand allowOutOfBounds can overhang the right-edge menu; menuAlignment keeps it on screen. const viewportWidth = page.viewportSize()!.width expect(menuBox.x).toBeGreaterThanOrEqual(-1) expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(viewportWidth + 1) @@ -420,16 +384,10 @@ async function expectHeaderDropdownDesign( const selectableRows = menu.getByRole('menuitemradio') const navigationRows = menu.getByRole('menuitem') expect(await selectableRows.count()).toBeGreaterThanOrEqual(2) - // In the plan menu "All Enterprise Server releases" and "About versions" - // navigate rather than select, so they stay plain menuitems. The language menu - // has no such rows. + // In the plan menu, All Enterprise Server releases and About versions stay navigation menuitems. await expect(navigationRows).toHaveCount(dropdown.navigationRowCount) - // A single rule divides the versions from those two navigation rows. Brand has - // no divider child, so the picker renders the separator itself; it must not be - // focusable, and must be neither the first nor the last row, because Brand - // focuses the first
  • and wires its arrow-key wrap-around to the first and - // the last. The language menu divides nothing, so it carries no separator. + // Brand lacks a divider child; keep the separator unfocusable and outside arrow-key wrap ends. const separator = menu.locator('[role="separator"]') await expect(separator).toHaveCount(dropdown.separatorCount) if (dropdown.separatorCount > 0) { @@ -462,8 +420,7 @@ async function expectHeaderDropdownDesign( expect(rule.previousRole).toBe('menuitemradio') expect(rule.nextRole).toBe('menuitem') expect(rule.nextText).toMatch(/All Enterprise Server releases/) - // A plain
  • is a block box, so the rule spans the menu's inner width - // rather than sitting inside a row's own 12px insets. + // A block li spans the menu's inner width instead of a row's 12px insets. expect(rule.width).toBeCloseTo(rule.innerWidth, 0) expect(rule.marginTop).toBeCloseTo(8, 0) expect(rule.marginBottom).toBeCloseTo(8, 0) @@ -476,8 +433,7 @@ async function expectHeaderDropdownDesign( const row = rows.nth(index) expect((await row.boundingBox())!.height).toBeCloseTo(32, 0) await expect(row).toHaveCSS('padding-left', '12px') - // The reserved indicator column replaces Brand's 48px single-selection - // gutter: a 12px inset, the 16px dot, then a 12px gap before the label. + // The indicator column reserves 12px, a 16px dot and a 12px gap, replacing Brand's 48px gutter. await expect(row).toHaveCSS('padding-right', '40px') for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) { await expect(row).toHaveCSS(`border-${corner}-radius`, '6px') @@ -509,10 +465,7 @@ async function expectHeaderDropdownDesign( expect(selectedBox.x + selectedBox.width - (dotBox.x + dotBox.width)).toBeCloseTo(12, 0) expect(dotBox.y + dotBox.height / 2).toBeCloseTo(selectedBox.y + selectedBox.height / 2, 0) - // Brand renders a leading check slot on every row of a single-selection menu; - // the design marks the current row with the trailing dot instead. Assert the - // rendered result rather than Brand's hashed class names: the selected row's - // only visible glyph is the dot. + // The selected row's only visible glyph must be the trailing dot, not Brand's leading check slot. await expect(selectedRow.locator('svg.octicon-check')).not.toBeVisible() const visibleGlyphs = await selectedRow .locator('svg') @@ -521,8 +474,7 @@ async function expectHeaderDropdownDesign( ) expect(visibleGlyphs).toHaveLength(1) expect(visibleGlyphs[0]).toContain('octicon-dot-fill') - // When Brand renders that slot it must be hidden outright. Written so a future - // Brand release that stops rendering it altogether does not fail the suite. + // Accept a missing leading slot so Brand can remove it without failing this suite. const leadingSlotDisplay = await selectedRow.evaluate((row) => { const first = row.firstElementChild return first && row.children.length > 1 ? getComputedStyle(first).display : null @@ -543,8 +495,7 @@ async function expectHeaderDropdownDesign( for (let index = 0; index < dropdown.navigationRowCount; index++) { const extra = navigationRows.nth(index) - // axe rejects aria-checked on role=menuitem, so the extras must opt out of - // the selection semantics ActionMenu.Overlay injects into its children. + // axe rejects aria-checked on menuitem, so navigation rows opt out of selection semantics. await expect(extra).not.toHaveAttribute('aria-checked') await expect(extra.locator('svg.octicon-dot-fill')).toHaveCount(0) } @@ -555,9 +506,8 @@ async function expectHeaderDropdownDesign( await expect(trigger).toBeFocused() } -// The properties a shared stylesheet is supposed to fix identically for both -// dropdowns. Content-dependent geometry (the menu's used width, a row's text) is -// deliberately absent: only the styling has to match. +// The shared stylesheet must fix these properties identically for both dropdowns. +// Content-dependent geometry is absent; only styling has to match. const SURFACE_PROPERTIES = [ 'background-color', 'min-width', @@ -593,14 +543,11 @@ const LABEL_PROPERTIES = [ ] const DOT_PROPERTIES = ['position', 'right', 'width', 'height', 'fill'] -/** - * A style fingerprint of an open header dropdown: the surface, the selected row, - * its label and its trailing dot. Two dropdowns whose styling really does come - * from one shared module produce equal fingerprints -- which is a stronger claim - * than each one separately matching the design, and it is the claim the user - * actually made ("the language dropdown needs to look like the version - * dropdown"). - */ +// An open header dropdown fingerprint covers the surface, selected row, label +// and trailing dot. +// Equal fingerprints prove the two menus share styling, not merely that each matches the design. +// This tests the user-visible request: the language dropdown needs to look like +// the version dropdown. async function dropdownStyleFingerprint(menu: Locator, dropdown: HeaderDropdown) { const selectedRow = menu.getByRole('menuitemradio', { name: dropdown.selectedRow, exact: true }) const read = (locator: Locator, properties: string[]) => @@ -614,8 +561,7 @@ async function dropdownStyleFingerprint(menu: Locator, dropdown: HeaderDropdown) row: await read(selectedRow, ROW_PROPERTIES), label: await read(selectedRow.getByTestId(dropdown.itemTestId), LABEL_PROPERTIES), dot: await read(selectedRow.locator('svg.octicon-dot-fill'), DOT_PROPERTIES), - // Brand's leading check slot is hidden structurally, so it has to be hidden - // in both menus or one of them grows a check icon the other does not have. + // Structural hiding must match so one menu cannot grow a Brand check icon the other lacks. leadingSlotDisplay: await selectedRow.evaluate((row) => { const first = row.firstElementChild return first && row.children.length > 1 ? getComputedStyle(first).display : null @@ -644,8 +590,7 @@ async function expectDesktopHeaderSections(page: Page, signupVisible: boolean) { const search = element.querySelector('[data-testid="toggle-search"]')! const language = element.querySelector('[data-testid="language-picker"]')! const signup = element.querySelector('[data-testid="header-signup"]') - // Find the native section wrappers from stable Docs control anchors, not - // Brand's private CSS class names or a hardcoded number of parent hops. + // Find section wrappers from stable Docs anchors, not Brand CSS hashes or parent-hop counts. let sectionRow = search.parentElement! while (!sectionRow.contains(language)) sectionRow = sectionRow.parentElement! const sectionFor = (control: HTMLElement) => { @@ -720,8 +665,7 @@ async function expectDesktopHeaderSections(page: Page, signupVisible: boolean) { expect(section.rect.top).toBeCloseTo(layout.header.top, 0) expect(section.rect.bottom).toBeCloseTo(layout.contentBottom, 0) } - // Search owns the full-height divider before Language. Language must not - // double that border; Signup owns its own separate full-height left divider. + // Search owns the divider before Language; Signup owns its own left divider. expect(layout.search.borderEnd).toBe('1px') expect(layout.search.borderEndStyle).toBe('solid') expect(layout.search.borderEndColor).not.toBe('rgba(0, 0, 0, 0)') @@ -744,23 +688,21 @@ async function expectDocsSearchOpen(page: Page) { await searchInput.click() await expect(searchInput).toBeFocused() await expect(page.getByRole('dialog')).toHaveCount(1) - // Brand mounts its native dialog even while closed. Only the existing Docs - // dialog may become modal; opening both would leave competing focus traps. + // Only Docs search may become modal; opening Brand's closed native dialog would add a focus trap. const brandDialog = page.getByTestId('desktop-header').locator('dialog') await expect(brandDialog).toHaveCount(1) await expect(brandDialog).toHaveJSProperty('open', false) await expect(page).toHaveURL((url) => url.searchParams.get('search-overlay-open') === 'true') } +// expectBackgroundIsolated includes Brand's skip link because it sits outside +// the inert wrapper as a sibling before header, yet still targets #main-content +// while the menu is open. +// CSS avoids getByText strict-mode matches from the wrapped label and getByRole +// misses after aria-hidden. async function expectBackgroundIsolated(page: Page, isolated: boolean) { for (const locator of [ page.getByText('Skip to main content', { exact: true }), - // Brand's own skip link sits outside the inert wrapper (it renders as a - // sibling before
    ) yet still targets #main-content, which is inert - // while the menu is open. Matched by CSS rather than text or role: Brand - // wraps the label in a span, so getByText resolves to both the and that - // span -- a strict mode violation -- and aria-hidden removes it from the - // accessibility tree that getByRole searches once isolated. page.locator('[data-container="header"] a[href="#main-content"]'), page.locator('#main-content'), page.getByTestId('sidebar-mobile-toggle'), @@ -777,8 +719,7 @@ async function expectBackgroundIsolated(page: Page, isolated: boolean) { test.describe('Brand header', () => { test.beforeEach(async ({ page }) => { - // These regressions cover header coordination, not remote search quality. - // Return empty suggestions so they also run without Elasticsearch or Copilot. + // Empty suggestions keep header coordination tests independent of Elasticsearch and Copilot. await page.route('**/api/search/combined-search/v1?**', (route) => route.fulfill({ json: { @@ -810,32 +751,18 @@ test.describe('Brand header', () => { await page.reload() } - // Wait for account detection/desktop slots before measuring the pill: - // Signup mounting must not shrink a name that only fit before hydration. + // Wait for account detection; Signup can mount after hydration and shrink the plan name. await expectDesktopHeaderSections(page, !hasAccount) await expectHeaderPlanPicker(page) - // 1012px is where the two triggers compete for room with Signup, so it is - // also where the flat language control is most likely to be "fixed" by - // giving it the pill's class. + // At 1012px, Signup pressure exposes accidental pill styling on the language trigger. await expectHeaderLanguageTrigger(page) }) } } - /** - * Brand renders its trailing slot whenever `trailingComponent` is not null, - * so a `LanguagePicker` that returned `null` from inside the slot would still - * leave the wrapper behind: an empty divided cell at the header's right edge - * on desktop, and a full-width 16px-padded block in the narrow menu. Header.tsx - * therefore withholds the prop itself rather than letting the picker opt out, - * and that decision is invisible to every other test here -- they all run on - * multi-language pages, where the slot is supposed to be present. - * - * Each absence is paired with the same assertion on a multi-language page. - * Brand's class name is hashed, so `BRAND_TRAILING_SLOT` on its own would keep - * passing the day Brand renames it; proving the selector still matches - * something is what stops this from becoming a test of nothing. - */ + // Header.tsx omits trailingComponent because Brand keeps wrapper if LanguagePicker returns null. + + // The multi-language assertion keeps BRAND_TRAILING_SLOT from passing after a Brand class rename. test('the language slot is omitted, not left empty, when only English is available', async ({ page, }) => { @@ -849,15 +776,12 @@ test.describe('Brand header', () => { await page.goto(ENGLISH_ONLY_ARTICLE) await turnOffExperimentsInPage(page) const header = page.getByTestId('desktop-header') - // The plan picker still renders here, so an empty header would fail this - // rather than passing as a trivially absent language control. + // Assert the plan picker first so an empty header cannot pass the absence checks below. await expect(header.getByRole('button', { name: PLAN_LABEL, exact: false })).toBeVisible() await expect(page.getByTestId('language-picker')).toHaveCount(0) await expect(header.locator(BRAND_TRAILING_SLOT)).toHaveCount(0) - // Independently of Brand's class names: every divided cell in the header's - // section row still holds a control. An empty slot is exactly a cell that - // does not, and it would carry its own gridline and margin. + // Every divided header cell must hold a control; an empty slot would add a gridline and margin. await page.evaluate(() => document.fonts.ready) await expect(async () => { const sections = await header.evaluate((element) => { @@ -882,8 +806,7 @@ test.describe('Brand header', () => { expect(sections.lastReachesEdge).toBe(true) }).toPass() - // The narrow menu is where the leftover wrapper would be most visible: a - // full-width padded block above Sign up rather than a thin cell. + // The narrow menu exposes a leftover wrapper as a full-width padded block above Sign up. await page.setViewportSize({ width: 390, height: 800 }) await page.getByRole('button', { name: 'Menu', exact: true }).click() await expect(page.getByTestId('header-signup')).toBeVisible() @@ -897,38 +820,21 @@ test.describe('Brand header', () => { page, }) => { await page.setViewportSize({ width: 1440, height: 800 }) - // No color_mode cookie, so colorModeScript resolves `auto` from this - // emulation. Set before navigating so the first paint already uses it. + // Emulate color before navigation so colorModeScript resolves auto without a cookie. await page.emulateMedia({ colorScheme }) await page.goto(ARTICLE) await turnOffExperimentsInPage(page) - // Each trigger resolves every color through tokens, so both are worth - // re-checking in dark mode rather than only in the light-mode loop above. - // The two triggers are intentionally different -- a filled pill for the - // plan, a flat control for the language -- which is why only the dropdown - // below them is shared. + // Recheck both token-based triggers in dark mode; only the dropdown below them is shared. await dropdown.expectTrigger(page) await expectHeaderDropdownDesign(page, colorScheme, dropdown) }) } } - /** - * The sticky ladder: header > Docs 2026 secondary bar > sticky table headers. - * - * Brand's ActionMenu is not portalled, so the plan and language dropdowns - * render inside the header's stacking context and hang well below it, across - * the secondary bar. The bar is sticky at every width and sits above sticky - * table headers, so if the header does not outrank the bar, the bar paints a - * band straight through the open menu and eats the clicks behind it -- which - * is invisible to every other test here, because the menu still has the right - * geometry, styling and roles while being covered. - * - * Asserted by hit-testing rather than by comparing z-index values: equal - * z-index is resolved by DOM order, so the numbers alone do not say which - * element a reader actually reaches. - */ + // The unportalled ActionMenu overlaps the sticky secondary bar, so the header must outrank it. + + // Hit test overlap because DOM-order z-index ties and blocked clicks do not change geometry. test('an open dropdown stays clickable where the secondary bar crosses it', async ({ page }) => { await page.setViewportSize({ width: 1440, height: 800 }) await page.emulateMedia({ colorScheme: 'light' }) @@ -946,7 +852,6 @@ test.describe('Brand header', () => { const b = bar.getBoundingClientRect() const m = menuEl.getBoundingClientRect() const crosses = m.bottom > b.top && m.top < b.bottom - // Sample the full height of the band the two share. const x = m.left + m.width / 2 const top = Math.max(m.top, b.top) + 2 const bottom = Math.min(m.bottom, b.bottom) - 2 @@ -955,7 +860,7 @@ test.describe('Brand header', () => { const el = document.elementFromPoint(x, y) if (!el || !el.closest('[role="menu"]')) covered.push(Math.round(y)) } - // A row the bar crosses must receive its own clicks, not just paint above. + // A crossed row must receive clicks, not merely paint above the bar. const row = [ ...document.querySelectorAll('[data-testid="version-picker"] [role="menuitemradio"]'), ].find((candidate) => { @@ -979,8 +884,7 @@ test.describe('Brand header', () => { } }) - // If the menu stopped overlapping the bar, this test would pass while - // asserting nothing, so require the overlap it exists to check. + // Require actual overlap so this cannot pass after the menu stops crossing the bar. expect(overlap.barFound).toBe(true) expect(overlap.crosses).toBe(true) expect(overlap.covered).toEqual([]) @@ -994,8 +898,7 @@ test.describe('Brand header', () => { await page.goto(ARTICLE) await turnOffExperimentsInPage(page) - // Opened one at a time: Brand closes a menu as soon as the other trigger is - // clicked, and both menus read their tokens from the same page and theme. + // Open one menu at a time because Brand closes the first; both read the same page theme. const fingerprints: Record = {} for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) { const picker = page.getByTestId('desktop-header').getByTestId(dropdown.pickerTestId) @@ -1009,13 +912,11 @@ test.describe('Brand header', () => { expect(fingerprints[LANGUAGE_DROPDOWN.name]).toEqual(fingerprints[PLAN_DROPDOWN.name]) }) - // Below 1012px both pickers move inside SubdomainNavBar's narrow menu, which is a - // scrolling panel. Brand's ActionMenu is absolutely positioned and — unlike the - // @primer/react menu it replaced — is not portalled, so it regresses easily into - // rendering outside that panel: cut off mid-list, or running past the viewport's - // right edge. Both of those still satisfy toBeVisible(), so assert geometry. The - // inline-flow rule that fixes it now lives in the shared module, so a change to it - // moves both dropdowns at once and both are covered here. + // Below 1012px, SubdomainNavBar's scrolling narrow menu contains both pickers. + + // Geometry catches an unportalled ActionMenu outside the panel while toBeVisible still passes. + + // The shared module owns the inline-flow rule, so both dropdowns must prove the geometry. for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) { for (const width of [390, 1000]) { test(`the ${dropdown.name} dropdown stays inside the narrow menu at ${width}px`, async ({ @@ -1033,7 +934,7 @@ test.describe('Brand header', () => { await expect(page.getByRole('menu')).toBeVisible() const layout = await page.getByRole('menu').evaluate((element) => { - // The panel is found by its scrolling, not by Brand's hashed class name. + // Find the panel by scrolling behavior, not Brand's hashed class name. let panel = element.parentElement while (panel) { const { overflowX, overflowY } = getComputedStyle(panel) @@ -1053,11 +954,11 @@ test.describe('Brand header', () => { }) expect(layout.panel).not.toBeNull() - // Inside the panel, so no row is cut off... + // The menu stays inside the panel so no row gets cut off. expect(layout.menu.bottom).toBeLessThanOrEqual(layout.panel.bottom + 1) expect(layout.menu.right).toBeLessThanOrEqual(layout.panel.right + 1) expect(layout.lastRowBottom).toBeLessThanOrEqual(layout.panel.bottom + 1) - // ...and inside the viewport, so no row is sliced by the screen edge. + // The menu stays inside the viewport so no row is sliced by the screen edge. expect(layout.menu.left).toBeGreaterThanOrEqual(-1) expect(layout.menu.right).toBeLessThanOrEqual(layout.viewportWidth + 1) expect(layout.scrollsHorizontally).toBe(false) @@ -1075,13 +976,9 @@ test.describe('Brand header', () => { } } - // Brand staggers the narrow menu's items in at 80ms per slot and hardcodes the - // signup CTA's wrapper to slot 10 -- the moment ten `SubdomainNavBar.Link` - // children would have finished cascading in. Docs passes zero links, so the - // shipped 800ms is a dead second: the pickers ride the panel's fade and - // "Sign up" trails them. Header.module.scss cuts it to a single slot, so assert - // the computed delay rather than a wall clock, and assert that only the delay - // moved -- duration and fill mode still have to be Brand's. + // Brand assigns signup to stagger slot 10, but Docs passes zero SubdomainNavBar.Link children. + + // Header.module.scss cuts the 800ms delay to one 80ms slot; duration and fill mode stay Brand's. test('signup follows the narrow menu pickers by one stagger step, not ten', async ({ page }) => { await page.setViewportSize({ width: 390, height: 800 }) await page.goto(ARTICLE) @@ -1090,9 +987,7 @@ test.describe('Brand header', () => { const signup = page.getByTestId('header-signup') await expect(signup).toBeVisible() const animation = await signup.evaluate((element) => { - // Brand hashes this class and exposes no test id for it, so match the - // stable part of the name -- the same anchor the override in - // Header.module.scss uses. + // Brand hashes class names, so match the stable SubdomainNavBar-button-area--visible part. const area = element.closest('[class*="SubdomainNavBar-button-area--visible"]') if (!area) throw new Error('Signup is not inside the narrow-menu button area') const { animationDelay, animationDuration, animationFillMode } = getComputedStyle(area) @@ -1103,7 +998,7 @@ test.describe('Brand header', () => { } }) - // Brand's untouched default is calc(10 * 80ms). + // Brand's untouched default delay equals 10 * 80ms. expect(animation.delay).not.toBeCloseTo(0.8, 3) // Still staggered after the pickers, but by one 80ms slot rather than ten. expect(animation.delay).toBeGreaterThan(0) @@ -1186,8 +1081,7 @@ test.describe('Brand header', () => { 'open', false, ) - // PRC restores focus during mousedown capture; the browser then transfers it - // to the clicked backdrop. Persistent return focus is an Escape contract only. + // PRC restores focus on mousedown, but backdrop click moves it; Escape owns return focus. await expect(searchTrigger).toBeVisible() await expect(searchTrigger).toBeEnabled() }) @@ -1197,7 +1091,7 @@ test.describe('Brand header', () => { }) => { await page.goto(ARTICLE) await expect(page.getByTestId('toggle-search')).toBeVisible() - // Use real DOM fields without depending on survey or search results data. + // Real DOM fields avoid survey or search-results data dependencies. await page.locator('#main-content').evaluate((main) => { const fields = document.createElement('div') fields.innerHTML = ` @@ -1471,8 +1365,7 @@ test.describe('Brand header', () => { const picker = page.getByTestId('desktop-header').getByTestId('version-picker') const button = picker.getByRole('button') const value = (await button.getByTestId('field').textContent())! - // versionTitle is `${planTitle} ${release}` for a numbered release, so the - // plan label would announce "Select your plan: Enterprise Server 3.19". + // A numbered release uses the version label instead of the plan label. expect(value).toMatch(/^Enterprise Server [\d.]+$/) await expect(picker.getByText(VERSION_LABEL, { exact: true })).toBeVisible() await expect(button).toHaveAccessibleName(`${VERSION_LABEL} ${value}`) diff --git a/src/fixtures/tests/playwright-secret-scanning.spec.ts b/src/fixtures/tests/playwright-secret-scanning.spec.ts index a7a190190f32..60df4fa6096c 100644 --- a/src/fixtures/tests/playwright-secret-scanning.spec.ts +++ b/src/fixtures/tests/playwright-secret-scanning.spec.ts @@ -9,11 +9,9 @@ test.describe('Secret scanning DataTable accessibility', () => { const table = page.getByRole('table') await expect(table).toBeVisible() - // The table should be labelled by the Table.Title heading const labelledBy = await table.getAttribute('aria-labelledby') expect(labelledBy).toBeTruthy() - // The referenced element should exist and contain text const titleEl = page.locator(`#${labelledBy}`) await expect(titleEl).toBeVisible() await expect(titleEl).not.toBeEmpty() @@ -22,7 +20,7 @@ test.describe('Secret scanning DataTable accessibility', () => { test('heading hierarchy does not skip levels within main content', async ({ page }) => { await page.goto(PAGE_PATH) - // Scope to main content area — nav/sidebar/footer may have their own heading structure + // Scope to main content because nav, sidebar, and footer have their own heading structure. const main = page.locator('main, article, [role="main"]').first() const headings = await main.locator('h1, h2, h3, h4, h5, h6').all() expect(headings.length).toBeGreaterThan(0) @@ -31,8 +29,7 @@ test.describe('Secret scanning DataTable accessibility', () => { for (const heading of headings) { const tagName = await heading.evaluate((el) => el.tagName.toLowerCase()) const level = parseInt(tagName.replace('h', ''), 10) - // Level can go up (same or smaller number) freely, but going deeper - // should never skip more than one level + // Heading levels may go up freely, but going deeper must not skip a level. if (level > previousLevel) { expect(level - previousLevel).toBeLessThanOrEqual(1) } @@ -43,7 +40,7 @@ test.describe('Secret scanning DataTable accessibility', () => { test('all interactive controls have accessible names', async ({ page }) => { await page.goto(PAGE_PATH) - // Search input — Primer TextInput renders as input[type="text"] with role "textbox" + // Primer TextInput renders the search input as input[type="text"] with role=textbox. const searchInput = page.locator('[role="search"] input') await expect(searchInput).toBeVisible() const searchLabel = @@ -51,7 +48,6 @@ test.describe('Secret scanning DataTable accessibility', () => { (await searchInput.getAttribute('placeholder')) expect(searchLabel).toBeTruthy() - // Filter buttons (ActionMenu triggers) const buttons = page.locator('[role="search"] button') const buttonCount = await buttons.count() expect(buttonCount).toBeGreaterThan(0) @@ -61,7 +57,6 @@ test.describe('Secret scanning DataTable accessibility', () => { expect(name.length).toBeGreaterThan(0) } - // Pagination (if present) const pagination = page.getByRole('navigation', { name: /pagination/i }) if ((await pagination.count()) > 0) { await expect(pagination).toHaveAttribute('aria-label', /.+/) @@ -71,8 +66,7 @@ test.describe('Secret scanning DataTable accessibility', () => { test('provider column cells are row headers', async ({ page }) => { await page.goto(PAGE_PATH) - // Primer DataTable uses CSS grid layout — row headers are rendered as - // elements with role="rowheader" (via scope="row" on the cell) + // Primer DataTable uses CSS grid, and scope=row cells render as role=rowheader. const rowHeaders = page.locator('[role="rowheader"]') const count = await rowHeaders.count() expect(count).toBeGreaterThan(0) @@ -87,16 +81,13 @@ test.describe('Secret scanning DataTable accessibility', () => { const table = page.getByRole('table') await expect(table).toBeVisible() - // At narrow viewports, the table should not be hidden or clipped. - // Content must remain reachable even if it overflows horizontally. - // Verify the table itself is not display:none or visibility:hidden + // At narrow viewports, the table must stay visible even when it overflows horizontally. await expect(table).toBeVisible() - // Verify data cells are present and accessible const cells = page.locator('[role="rowheader"], [role="cell"]') expect(await cells.count()).toBeGreaterThan(0) - // The table's container should allow horizontal scrolling (overflow not hidden) + // The overflow wrapper must allow horizontal scrolling. const overflowX = await table.evaluate((el) => { const wrapper = el.closest('[class*="OverflowWrapper"]') || el.parentElement return wrapper ? getComputedStyle(wrapper).overflowX : 'visible' @@ -106,8 +97,7 @@ test.describe('Secret scanning DataTable accessibility', () => { }) test('color contrast meets 4.5:1 minimum', async ({ page }) => { - // This is primarily covered by the axe scan in playwright-a11y.spec.ts, - // but we include a targeted check here for the table specifically + // Axe covers this broadly; this test isolates table color contrast. const { default: AxeBuilder } = await import('@axe-core/playwright') await page.goto(PAGE_PATH) diff --git a/src/fixtures/tests/sidebar.ts b/src/fixtures/tests/sidebar.ts index 8607f0ed57ee..5d5eadccc5b5 100644 --- a/src/fixtures/tests/sidebar.ts +++ b/src/fixtures/tests/sidebar.ts @@ -6,11 +6,10 @@ import { getDOMCached as getDOM } from '@/tests/helpers/e2etest' describe('sidebar', () => { test('top level product mentioned at top of sidebar', async () => { const $: CheerioAPI = await getDOM('/get-started') - // Desktop const sidebarProduct = $('[data-testid="sidebar-product-xl"]') expect(sidebarProduct.text()).toBe('Get started') expect(sidebarProduct.attr('href')).toBe('/en/get-started') - // Docs 2026 secondary bar (breadcrumbs + nav toggle) replaces the old subnav + // Docs 2026 uses the secondary bar for breadcrumbs and the nav toggle. expect($('[data-testid="docs-secondary-bar"]').length).toBe(1) expect($('[data-testid="sidebar-mobile-toggle"]').length).toBe(1) }) @@ -31,8 +30,7 @@ describe('sidebar', () => { test('sidebar should always use the shortTitle', async () => { const $: CheerioAPI = await getDOM('/get-started/foo/bar') - // The page /get-started/foo/bar has a short title that is different - // from its regular title. + // /get-started/foo/bar has a short title that differs from its regular title. expect( $( '[data-testid=sidebar] [data-testid=product-sidebar] a[href*="/get-started/foo/bar"] span span', @@ -49,19 +47,16 @@ describe('sidebar', () => { }) test('Liquid is rendered in short title used at top of sidebar', async () => { - // Free, pro, team { const $: CheerioAPI = await getDOM('/pages') const link = $('#allproducts-menu a') expect(link.text()).toBe('Pages (HubGit)') } - // Enterprise Server { const $: CheerioAPI = await getDOM('/enterprise-server@latest/pages') const link = $('#allproducts-menu a') expect(link.text()).toBe('Pages (HubGit Enterprise Server)') } - // Enterprise Cloud { const $: CheerioAPI = await getDOM('/enterprise-cloud@latest/pages') const link = $('#allproducts-menu a') @@ -71,45 +66,35 @@ describe('sidebar', () => { test('no docset link for early-access', async () => { const $: CheerioAPI = await getDOM('/early-access/secrets/deeper/mariana-trench') - // Deskop expect($('[data-testid="sidebar-product-xl"]').length).toBe(0) - // The secondary bar renders, but early-access has no nav toggle + // Early access renders the secondary bar without a nav toggle. expect($('[data-testid="docs-secondary-bar"]').length).toBe(1) expect($('[data-testid="sidebar-mobile-toggle"]').length).toBe(0) }) test('category-landing pages show title entry in sidebar', async () => { const $ = await getDOM('/get-started') - // Check that page loads and has proper sidebar structure - // This tests the core functionality using a guaranteed stable page const sidebarLinks = $('[data-testid="sidebar"] a') expect(sidebarLinks.length).toBeGreaterThan(0) - // Verify sidebar has proper structure indicating layout changes are in place const sidebar = $('[data-testid="sidebar"]') expect(sidebar.length).toBe(1) }) test('non-category-landing pages do not show specific copilot entries', async () => { - // Test a page from a different product that should have different sidebar content const $ = await getDOM('/rest') const sidebarLinks = $('[data-testid="sidebar"] a') expect(sidebarLinks.length).toBeGreaterThan(0) - // Verify this page has REST-specific sidebar structure expect($('[data-testid=rest-sidebar-reference]').length).toBe(1) }) test('layout property implementation exists in codebase', async () => { - // This test verifies the layout property changes are in place - // by testing a stable page and checking sidebar structure const $ = await getDOM('/pages') - // Verify basic sidebar functionality works const sidebar = $('[data-testid="sidebar"]') expect(sidebar.length).toBe(1) - // Check that sidebar has proper structure for testing the layout changes const sidebarLinks = $('[data-testid="sidebar"] a') expect(sidebarLinks.length).toBeGreaterThan(0) }) diff --git a/src/fixtures/tests/spotlight-processing.ts b/src/fixtures/tests/spotlight-processing.ts index 1716a402eb84..32b0b160088b 100644 --- a/src/fixtures/tests/spotlight-processing.ts +++ b/src/fixtures/tests/spotlight-processing.ts @@ -19,7 +19,6 @@ interface ProcessedSpotlightItem { image: string } -// Mock data to simulate tocItems and spotlight configurations const mockTocItems: TocItem[] = [ { title: 'Test Debug Article', @@ -38,7 +37,6 @@ const mockTocItems: TocItem[] = [ }, ] -// Helper function to simulate the spotlight processing logic from CategoryLanding function processSpotlight( spotlight: SpotlightItem[] | undefined, tocItems: TocItem[], diff --git a/src/fixtures/tests/translations.ts b/src/fixtures/tests/translations.ts index a35fa3e346c5..2f7a8316c3d0 100644 --- a/src/fixtures/tests/translations.ts +++ b/src/fixtures/tests/translations.ts @@ -15,7 +15,7 @@ describe('translations', () => { test('home page', async () => { const $: CheerioAPI = await getDOM('/ja') const h1 = $('h1').text() - // You gotta know your src/fixtures/fixtures/translations/ja-jp/data/ui.yml + // src/fixtures/fixtures/translations/ja-jp/data/ui.yml localizes the home-page h1. expect(h1).toBe('日本 GitHub Docs') const links = $('[data-testid=product] a[href]') @@ -61,12 +61,11 @@ describe('translations', () => { expect($(element).text()).toBe('こんにちは World') } }) - // There are 4 links on the `autotitling.md` content. + // autotitling.md has 4 AUTOTITLE links. expect.assertions(4) }) test('correction of linebreaks in translations', async () => { - // free-pro-team { const $: CheerioAPI = await getDOM('/ja/get-started/foo/table-with-ifversions') @@ -79,7 +78,6 @@ describe('translations', () => { expect(tds.length).toBe(2) expect(tds[1]).toBe('Not') } - // enterprise-server { const $: CheerioAPI = await getDOM( '/ja/enterprise-server@latest/get-started/foo/table-with-ifversions', @@ -96,37 +94,21 @@ describe('translations', () => { } }) + // Japanese translation fixtures include malformed AUTOTITLE links in content and reusables. + // Input: ["AUTOTITLE](/get-started/start-your-journey/hello-world)." + // Bad output: "AUTOTITLE + // Runtime correction must remove AUTOTITLE because translation CI does not catch this Markdown. test('automatic correction of bad AUTOTITLE in reusables', async () => { const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/hello-world') const links = $('#article-contents a[href]') const texts = links.map((i: number, element: Element) => $(element).text()).get() - // That Japanese page uses AUTOTITLE links. Both in the main `.md` file - // but also inside a reusable. - // E.g. `["AUTOTITLE](/get-started/start-your-journey/hello-world)."` - // If we didn't do the necessary string corrections on translations' - // content and reusables what *would* remain is a HTML link that - // would look like this: - // - // "AUTOTITLE - // - // This test makes sure no such string is left in any of the article - // content links. - // Note that, in English, it's not acceptable to have such a piece of - // Markdown. It would not be let into `main` by our CI checks. But - // by their nature, translations are not checked by CI in the same way. - // Its "flaws" have to be corrected at runtime. const stillAutotitle = texts.filter((text: string) => /autotitle/i.test(text)) expect(stillAutotitle.length).toBe(0) }) + // Translators wrote [[Bar](バー)](/get-started/foo/bar), which must render as + // [Bar](バー). test('markdown link looking constructs inside links', async () => { - // On this page, the translators had written: - // - // [[Bar](バー)](/get-started/foo/bar) - // - // which needs to become: - // - // [Bar](バー) const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/hello-world') const links = $('#article-contents a[href]') const texts = links @@ -136,7 +118,6 @@ describe('translations', () => { }) .map((i: number, element: Element) => $(element).text()) .get() - // Check that the text contains the essential parts rather than exact spacing const foundBarLink = texts.find( (text: string) => text.includes('[Bar]') && text.includes('(バー)'), ) @@ -146,18 +127,16 @@ describe('translations', () => { describe('localized category versioning', () => { test('category page works in all children versions', async () => { { - // for translated content, we expect this to be OK const res = await head('/ja/get-started') expect(res.statusCode).toBe(200) } { - // The actual versioning for get-started/empty-categories - // does not specify ghes, so it should 404. + // The category allows ghes, but its only child is ghec-only, so enterprise-server 404s. const res = await head('/ja/enterprise-server@latest/get-started/empty-categories') expect(res.statusCode).toBe(404) } { - // Yet this nested page shoudl work. + // The ghec-only child renders under enterprise-cloud. const res = await head('/ja/enterprise-cloud@latest/get-started/empty-categories/only-ghec') expect(res.statusCode).toBe(200) } diff --git a/src/fixtures/tests/versioning.ts b/src/fixtures/tests/versioning.ts index 5fe70db14127..33f7549f4d83 100644 --- a/src/fixtures/tests/versioning.ts +++ b/src/fixtures/tests/versioning.ts @@ -8,7 +8,7 @@ describe('article versioning', () => { test('only links to articles for fpt', async () => { const $: CheerioAPI = await getDOM('/get-started/versioning') const links = $('[data-testid="table-of-contents"] a') - // Only 1 link because there's only 1 article available in fpt + // /get-started/versioning has one free-pro-team article. expect(links.length).toBe(1) expect(links.attr('href')).toBe('/en/get-started/versioning/only-fpt') }) @@ -23,7 +23,7 @@ describe('article versioning', () => { expect(second.attr('href')).toBe( '/en/enterprise-cloud@latest/get-started/versioning/only-ghec-and-ghes', ) - // Both links should 200 if you go to them + // Both linked enterprise-cloud articles must resolve without redirects. expect((await head(first.attr('href')!)).statusCode).toBe(200) expect((await head(second.attr('href')!)).statusCode).toBe(200) }) @@ -38,7 +38,7 @@ describe('article versioning', () => { expect(res.statusCode).toBe(404) }) test('going to non-fpt article with fpt prefix will redirect', async () => { - // Viewing a ghec only article without ghec prefix + // Without the ghec prefix, a ghec-only article redirects to enterprise-cloud. const res = await head('/get-started/versioning/only-ghec', { followRedirects: false, }) @@ -52,15 +52,12 @@ describe('article versioning', () => { describe('category versioning', () => { test('category page work in all children versions', async () => { { - // Note that in the `versions:` of get-started/versioning/index.md - // it *lacks* fpt. It's a deliberate pretend omission/mistake. - // But clearly the page works. + // get-started/versioning/index.md deliberately omits fpt, but the category resolves. const res = await head('/en/get-started/versioning') expect(res.statusCode).toBe(200) } { - // The actual version number of get-started/versioning/index.md - // does not specify this version of ghes, it still works. + // get-started/versioning/index.md omits latest ghes, but it redirects to a number. const res = await head('/en/enterprise-server@latest/get-started/versioning') expect(res.statusCode).toBe(302) expect(res.headers.location).toMatch( @@ -68,8 +65,7 @@ describe('category versioning', () => { ) } { - // The actual version number of get-started/versioning/index.md - // does not specify this version of ghec, it still works. + // get-started/versioning/index.md omits latest ghec, but enterprise-cloud resolves. const res = await head('/en/enterprise-cloud@latest/get-started/versioning') expect(res.statusCode).toBe(200) } @@ -78,8 +74,7 @@ describe('category versioning', () => { describe('home page versioning', () => { test('invalid language and valid version', async () => { - // Don't use 'latest' here because that will trigger a redirect - // first to the latest actual number. + // Use a numbered release so the invalid language returns 404 before any version redirect. const res = await head(`/ennnnn/enterprise-server@${supported[0]}`) expect(res.statusCode).toBe(404) }) diff --git a/src/ghes-releases/lib/enterprise-dates.json b/src/ghes-releases/lib/enterprise-dates.json index db9a92ca6830..5e80070978bf 100644 --- a/src/ghes-releases/lib/enterprise-dates.json +++ b/src/ghes-releases/lib/enterprise-dates.json @@ -247,7 +247,7 @@ }, "3.17": { "releaseDate": "2025-05-13", - "deprecationDate": "2026-09-22", + "deprecationDate": "2026-09-24", "releaseCandidateDate": "2025-05-13", "generalAvailabilityDate": "2025-06-03" }, diff --git a/src/graphql/components/GraphqlCategoryPage.module.scss b/src/graphql/components/GraphqlCategoryPage.module.scss index c2c980db2ee4..fbd439aba014 100644 --- a/src/graphql/components/GraphqlCategoryPage.module.scss +++ b/src/graphql/components/GraphqlCategoryPage.module.scss @@ -1,22 +1,16 @@ -// Heading rhythm tweaks specific to GraphQL category pages. -// Each schema kind (Objects, Mutations, etc.) is a section H2; individual -// items inside a section are H3; sub-sections within an item (fields, -// arguments, return fields, etc.) are H4. +// GraphQL category pages render kinds as H2, items as H3, and item subsections as H4. .categoryPage { h2 { padding-top: 2.5rem; } - // Items inside a kind section need top spacing so consecutive items don't - // visually run together. The first item under each kind H2 doesn't need - // it: the H2's own padding-top already provides plenty of breathing room. + // Items inside a kind section need top spacing so consecutive items do not run together. + // The first item under each kind H2 relies on the H2 padding instead. h3 { padding-top: 2rem; } - // The very first H2 on the page sits directly below the intro/lead; the - // extra padding-top adds an awkward gap there. Same idea for the first H3 - // in each section, which sits directly under its kind H2. + // Skip top padding where the heading follows the intro or its kind H2, to avoid a double gap. > :first-child h2:first-child, section > h3:first-of-type { padding-top: 0; @@ -26,15 +20,8 @@ padding-top: 0; } - // The schema item description is rendered as HTML and often wraps in a - // single

    . The trailing paragraph margin leaves a "chin" between the - // description and the next sub-section, so strip it. The - // `graphql-item-description` class is added to the description wrapper - // inside `GraphqlItem` by sibling PR #61435; until that merges, this rule - // is inert (and it's only ever evaluated under `.categoryPage`, which - // doesn't render until the recat PR wires this component up). - // `:global` is required because CSS modules would otherwise rewrite the - // class name. + // GraphQL descriptions often render as one p whose bottom margin leaves a gap. + // CSS modules would rewrite graphql-item-description unless :global keeps the emitted class. :global(.graphql-item-description) > :last-child { margin-bottom: 0; } diff --git a/src/graphql/components/GraphqlCategoryPage.tsx b/src/graphql/components/GraphqlCategoryPage.tsx index f5408a316c06..90ef58893979 100644 --- a/src/graphql/components/GraphqlCategoryPage.tsx +++ b/src/graphql/components/GraphqlCategoryPage.tsx @@ -49,27 +49,16 @@ export type CategorySchema = Partial<{ type Props = { schema: CategorySchema - // All objects across every category. Used by `Interface` to list - // implementers regardless of which category page is being rendered. + // Interface needs objects from every category to list implementers across category pages. allObjects: ObjectT[] } -// Item-level heading level used when items render under a kind section -// heading (`

    `). Kept in one place so the matching mini-TOC builder in -// `pages/reference.tsx` can stay in sync with the on-page anchors. +// Keep item headings in sync with the mini-TOC builder in src/graphql/pages/reference.tsx. const ITEM_HEADING_LEVEL = 3 +// src/graphql/pages/reference.tsx sends empty categories to 404, so this renders populated pages. +// GraphqlItem keeps kind labels so deep-linked items remain self-describing outside the section. export function GraphqlCategoryPage({ schema, allObjects }: Props) { - // Render one section per kind, in the canonical `ALL_KIND_KEYS` order. - // Items inside each section are sorted case-insensitively by name. The - // per-kind label pill rendered by `GraphqlItem` is now somewhat redundant - // here (the kind is obvious from the section heading directly above), but - // we keep it for now so items stay visually self-describing if they're - // ever deep-linked or rendered outside the section context. - // - // Empty-category pages are short-circuited to a 404 in - // `pages/reference.tsx`, so this component is only ever rendered with at - // least one section. const sections = ALL_KIND_KEYS.flatMap((kind) => { const items = schema[kind] if (!items || items.length === 0) return [] @@ -123,6 +112,4 @@ function renderItem(kind: SchemaKindKey, item: AnySchemaItem, allObjects: Object } } -// Re-export the kind label map for callers that want to render a label -// outside of the page (e.g. mini-toc or breadcrumbs). export { KIND_LABELS } diff --git a/src/graphql/components/GraphqlItem.tsx b/src/graphql/components/GraphqlItem.tsx index fe05f8f0b74d..f30ff7528de6 100644 --- a/src/graphql/components/GraphqlItem.tsx +++ b/src/graphql/components/GraphqlItem.tsx @@ -12,15 +12,12 @@ type Props = { heading?: string headingLevel?: number children?: React.ReactNode - // When provided, the heading id is prefixed with the kind so two items - // with the same case-insensitive name across kinds get distinct anchors - // on a category page (e.g. `object-repository` vs `query-repository`). + // Prefix heading IDs so names shared across kinds get distinct anchors. + // For example, object-repository and query-repository can coexist. kind?: SchemaKindKey } -// Clamp a numeric heading level to the valid HTML range (2-6). Used to -// build heading tag names like `h2`/`h3` from a numeric `headingLevel` -// prop without producing invalid tags if a caller passes something odd. +// Clamp heading tags to h2 through h6 when callers pass odd headingLevel values. function headingTag(level: number): keyof JSX.IntrinsicElements { const clamped = Math.max(2, Math.min(6, level)) return `h${clamped}` as keyof JSX.IntrinsicElements @@ -31,9 +28,7 @@ export function GraphqlItem({ item, heading, children, headingLevel = 2, kind }: const slug = kind ? `${KIND_SLUG_PREFIX[kind]}-${baseSlug}` : baseSlug const hasNotice = Boolean(item.preview || item.isDeprecated) const kindLabel = kind ? KIND_LABELS[kind] : undefined - // Sub-headings rendered via the `heading` prop should sit one level below - // the item's own heading so the document outline stays well-formed when - // the item itself is nested under a kind section heading on category pages. + // Subheadings sit one level below the item heading to keep category page outlines valid. const SubHeading = headingTag(headingLevel + 1) return ( @@ -61,6 +56,5 @@ export function GraphqlItem({ item, heading, children, headingLevel = 2, kind }: ) } -// Re-exported so per-kind wrappers can build matching sub-sub-headings -// (e.g. Mutation's "Return fields" h-tag) without duplicating the clamp logic. +// Per-kind wrappers share headingTag so Mutation return fields use the same heading clamp. export { headingTag } diff --git a/src/graphql/components/GraphqlPage.tsx b/src/graphql/components/GraphqlPage.tsx index 053109f0cf5f..971200a41f73 100644 --- a/src/graphql/components/GraphqlPage.tsx +++ b/src/graphql/components/GraphqlPage.tsx @@ -28,10 +28,8 @@ type Props = { } export const GraphqlPage = ({ schema, pageName, objects }: Props) => { - const graphqlItems: JSX.Element[] = [] // In the case of the H2s for Queries + const graphqlItems: JSX.Element[] = [] - // The queries page has two heading sections (connections and fields), so add - // the heading component and its children once per section. if (pageName === 'queries') { graphqlItems.push( ...(schema as QueryT[]).map((item) => ), diff --git a/src/graphql/data/fpt/category-map.json b/src/graphql/data/fpt/category-map.json index dcbdb8f3c097..707c648eb014 100644 --- a/src/graphql/data/fpt/category-map.json +++ b/src/graphql/data/fpt/category-map.json @@ -130,6 +130,7 @@ "addcloseissuereferences": "issues", "addcomment": "issues", "addlabelstolabelable": "issues", + "addrelatesto": "issues", "addsubissue": "issues", "applypendingissuesuggestions": "issues", "clearlabelsfromlabelable": "issues", @@ -156,6 +157,7 @@ "removeblockedby": "issues", "removecloseissuereferences": "issues", "removelabelsfromlabelable": "issues", + "removerelatesto": "issues", "removesubissue": "issues", "reopenissue": "issues", "replaceactorsforassignable": "issues", @@ -1248,6 +1250,7 @@ "issuefieldupdateoperation": "issues", "issuefieldvisibility": "issues", "issueorderfield": "issues", + "issuerelatestoorderfield": "issues", "issuesearchtype": "issues", "issuestate": "issues", "issuestatereason": "issues", @@ -1576,6 +1579,7 @@ "addcloseissuereferencesinput": "issues", "addcommentinput": "issues", "addlabelstolabelableinput": "issues", + "addrelatestoinput": "issues", "addsubissueinput": "issues", "applypendingissuesuggestionsinput": "issues", "assigneeupdateinput": "issues", @@ -1603,6 +1607,7 @@ "issuefieldvaluefilter": "issues", "issuefilters": "issues", "issueorder": "issues", + "issuerelatestoorder": "issues", "issuestateupdateinput": "issues", "issuetypeorder": "issues", "issuetypeupdateinput": "issues", @@ -1619,6 +1624,7 @@ "removeblockedbyinput": "issues", "removecloseissuereferencesinput": "issues", "removelabelsfromlabelableinput": "issues", + "removerelatestoinput": "issues", "removesubissueinput": "issues", "reopenissueinput": "issues", "replaceactorsforassignableinput": "issues", diff --git a/src/graphql/data/fpt/changelog.json b/src/graphql/data/fpt/changelog.json index 79d9cdf3ee61..79dd889433df 100644 --- a/src/graphql/data/fpt/changelog.json +++ b/src/graphql/data/fpt/changelog.json @@ -1,4 +1,48 @@ [ + { + "schemaChanges": [ + { + "title": "The GraphQL schema includes these changes:", + "changes": [ + "

    Type AddRelatesToInput was added

    ", + "

    Input field clientMutationId of type String was added to input object type AddRelatesToInput

    ", + "

    Input field issueId of type ID! was added to input object type AddRelatesToInput

    ", + "

    Input field relatedIssueId of type ID! was added to input object type AddRelatesToInput

    ", + "

    Type AddRelatesToPayload was added

    ", + "

    Field clientMutationId was added to object type AddRelatesToPayload

    ", + "

    Field issue was added to object type AddRelatesToPayload

    ", + "

    Field relatedIssue was added to object type AddRelatesToPayload

    ", + "

    Type IssueRelatesToOrder was added

    ", + "

    Input field direction of type OrderDirection! was added to input object type IssueRelatesToOrder

    ", + "

    Input field field of type IssueRelatesToOrderField! was added to input object type IssueRelatesToOrder

    ", + "

    Type IssueRelatesToOrderField was added

    ", + "

    Enum value 'CREATED_ATwas added to enumIssueRelatesToOrderField'

    ", + "

    Enum value 'RELATES_TO_ADDED_ATwas added to enumIssueRelatesToOrderField'

    ", + "

    Type RemoveRelatesToInput was added

    ", + "

    Input field clientMutationId of type String was added to input object type RemoveRelatesToInput

    ", + "

    Input field issueId of type ID! was added to input object type RemoveRelatesToInput

    ", + "

    Input field relatedIssueId of type ID! was added to input object type RemoveRelatesToInput

    ", + "

    Type RemoveRelatesToPayload was added

    ", + "

    Field clientMutationId was added to object type RemoveRelatesToPayload

    ", + "

    Field issue was added to object type RemoveRelatesToPayload

    ", + "

    Field relatedIssue was added to object type RemoveRelatesToPayload

    ", + "

    Field relatesTo was added to object type Issue

    ", + "

    Argument after: String added to field Issue.relatesTo

    ", + "

    Argument before: String added to field Issue.relatesTo

    ", + "

    Argument first: Int added to field Issue.relatesTo

    ", + "

    Argument last: Int added to field Issue.relatesTo

    ", + "

    Argument orderBy: IssueRelatesToOrder (with default value) added to field Issue.relatesTo

    ", + "

    Field addRelatesTo was added to object type Mutation

    ", + "

    Argument input: AddRelatesToInput! added to field Mutation.addRelatesTo

    ", + "

    Field removeRelatesTo was added to object type Mutation

    ", + "

    Argument input: RemoveRelatesToInput! added to field Mutation.removeRelatesTo

    " + ] + } + ], + "previewChanges": [], + "upcomingChanges": [], + "date": "2026-09-28" + }, { "schemaChanges": [ { diff --git a/src/graphql/data/fpt/schema-issues.json b/src/graphql/data/fpt/schema-issues.json index 9a9b69cef8ce..071f422db655 100644 --- a/src/graphql/data/fpt/schema-issues.json +++ b/src/graphql/data/fpt/schema-issues.json @@ -181,6 +181,45 @@ ], "category": "issues" }, + { + "name": "addRelatesTo", + "id": "addrelatesto", + "href": "/graphql/reference/issues#mutation-addrelatesto", + "description": "

    Adds a 'relates to' relationship between two issues.

    ", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "AddRelatesToInput!", + "id": "addrelatestoinput", + "href": "/graphql/reference/issues#input-object-addrelatestoinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

    A unique identifier for the client performing the mutation.

    " + }, + { + "name": "issue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "

    The source issue.

    " + }, + { + "name": "relatedIssue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "

    The related issue.

    " + } + ], + "category": "issues" + }, { "name": "addSubIssue", "id": "addsubissue", @@ -1041,6 +1080,45 @@ ], "category": "issues" }, + { + "name": "removeRelatesTo", + "id": "removerelatesto", + "href": "/graphql/reference/issues#mutation-removerelatesto", + "description": "

    Removes a 'relates to' relationship between two issues.

    ", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "RemoveRelatesToInput!", + "id": "removerelatestoinput", + "href": "/graphql/reference/issues#input-object-removerelatestoinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

    A unique identifier for the client performing the mutation.

    " + }, + { + "name": "issue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "

    The previously targeted issue.

    " + }, + { + "name": "relatedIssue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "

    The previously related issue.

    " + } + ], + "category": "issues" + }, { "name": "removeSubIssue", "id": "removesubissue", @@ -3601,6 +3679,60 @@ } ] }, + { + "name": "relatesTo", + "description": "

    A list of issues related to this issue.

    ", + "type": "IssueConnection!", + "id": "issueconnection", + "href": "/graphql/reference/issues#object-issueconnection", + "arguments": [ + { + "name": "after", + "description": "

    Returns the elements in the list that come after the specified cursor.

    ", + "type": { + "name": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + }, + { + "name": "before", + "description": "

    Returns the elements in the list that come before the specified cursor.

    ", + "type": { + "name": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + }, + { + "name": "first", + "description": "

    Returns the first n elements from the list.

    ", + "type": { + "name": "Int", + "id": "int", + "href": "/graphql/reference/other#scalar-int" + } + }, + { + "name": "last", + "description": "

    Returns the last n elements from the list.

    ", + "type": { + "name": "Int", + "id": "int", + "href": "/graphql/reference/other#scalar-int" + } + }, + { + "name": "orderBy", + "description": "

    Ordering options for related issues.

    ", + "type": { + "name": "IssueRelatesToOrder", + "id": "issuerelatestoorder", + "href": "/graphql/reference/issues#input-object-issuerelatestoorder" + } + } + ] + }, { "name": "repository", "description": "

    The repository associated with this node.

    ", @@ -10031,6 +10163,24 @@ ], "category": "issues" }, + { + "name": "IssueRelatesToOrderField", + "id": "issuerelatestoorderfield", + "href": "/graphql/reference/issues#enum-issuerelatestoorderfield", + "description": "

    Properties by which related issues can be ordered.

    ", + "isDeprecated": false, + "values": [ + { + "name": "CREATED_AT", + "description": "

    Order related issues by the creation time of the related issue.

    " + }, + { + "name": "RELATES_TO_ADDED_AT", + "description": "

    Order related issues by time of when the relates-to relationship was added.

    " + } + ], + "category": "issues" + }, { "name": "IssueSearchType", "id": "issuesearchtype", @@ -11304,6 +11454,38 @@ ], "category": "issues" }, + { + "name": "AddRelatesToInput", + "id": "addrelatestoinput", + "href": "/graphql/reference/issues#input-object-addrelatestoinput", + "description": "

    Autogenerated input type of AddRelatesTo.

    ", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

    A unique identifier for the client performing the mutation.

    ", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "issueId", + "description": "

    The ID of the issue.

    ", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + }, + { + "name": "relatedIssueId", + "description": "

    The ID of the related issue.

    ", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "issues" + }, { "name": "AddSubIssueInput", "id": "addsubissueinput", @@ -12436,6 +12618,30 @@ ], "category": "issues" }, + { + "name": "IssueRelatesToOrder", + "id": "issuerelatestoorder", + "href": "/graphql/reference/issues#input-object-issuerelatestoorder", + "description": "

    Ordering options for related issues.

    ", + "isDeprecated": false, + "inputFields": [ + { + "name": "direction", + "description": "

    The ordering direction.

    ", + "type": "OrderDirection!", + "id": "orderdirection", + "href": "/graphql/reference/meta#enum-orderdirection" + }, + { + "name": "field", + "description": "

    The field to order related issues by.

    ", + "type": "IssueRelatesToOrderField!", + "id": "issuerelatestoorderfield", + "href": "/graphql/reference/issues#enum-issuerelatestoorderfield" + } + ], + "category": "issues" + }, { "name": "IssueStateUpdateInput", "id": "issuestateupdateinput", @@ -12960,6 +13166,38 @@ ], "category": "issues" }, + { + "name": "RemoveRelatesToInput", + "id": "removerelatestoinput", + "href": "/graphql/reference/issues#input-object-removerelatestoinput", + "description": "

    Autogenerated input type of RemoveRelatesTo.

    ", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

    A unique identifier for the client performing the mutation.

    ", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "issueId", + "description": "

    The ID of the issue.

    ", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + }, + { + "name": "relatedIssueId", + "description": "

    The ID of the previously related issue.

    ", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "issues" + }, { "name": "RemoveSubIssueInput", "id": "removesubissueinput", diff --git a/src/graphql/data/fpt/schema.docs.graphql b/src/graphql/data/fpt/schema.docs.graphql index e3c1e2b7b8b9..94fb6074bb62 100644 --- a/src/graphql/data/fpt/schema.docs.graphql +++ b/src/graphql/data/fpt/schema.docs.graphql @@ -1253,6 +1253,46 @@ type AddReactionPayload { subject: Reactable } +""" +Autogenerated input type of AddRelatesTo +""" +input AddRelatesToInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the issue. + """ + issueId: ID! @possibleTypes(concreteTypes: ["Issue"]) + + """ + The ID of the related issue. + """ + relatedIssueId: ID! @possibleTypes(concreteTypes: ["Issue"]) +} + +""" +Autogenerated return type of AddRelatesTo. +""" +type AddRelatesToPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The source issue. + """ + issue: Issue + + """ + The related issue. + """ + relatedIssue: Issue +} + """ Autogenerated input type of AddStar """ @@ -20715,6 +20755,36 @@ type Issue implements Assignable & orderBy: ReactionOrder ): ReactionConnection! + """ + A list of issues related to this issue. + """ + relatesTo( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for related issues + """ + orderBy: IssueRelatesToOrder = {field: RELATES_TO_ADDED_AT, direction: DESC} + ): IssueConnection! + """ The repository associated with this node. """ @@ -22700,6 +22770,36 @@ enum IssueOrderField @docsCategory(name: "issues") { UPDATED_AT } +""" +Ordering options for related issues +""" +input IssueRelatesToOrder @docsCategory(name: "issues") { + """ + The ordering direction. + """ + direction: OrderDirection! + + """ + The field to order related issues by. + """ + field: IssueRelatesToOrderField! +} + +""" +Properties by which related issues can be ordered. +""" +enum IssueRelatesToOrderField @docsCategory(name: "issues") { + """ + Order related issues by the creation time of the related issue + """ + CREATED_AT + + """ + Order related issues by time of when the relates-to relationship was added + """ + RELATES_TO_ADDED_AT +} + """ Type of issue search performed """ @@ -27440,6 +27540,16 @@ type Mutation @docsCategory(name: "meta") { input: AddReactionInput! ): AddReactionPayload @docsCategory(name: "reactions") + """ + Adds a 'relates to' relationship between two issues. + """ + addRelatesTo( + """ + Parameters for AddRelatesTo + """ + input: AddRelatesToInput! + ): AddRelatesToPayload @docsCategory(name: "issues") + """ Adds a star to a Starrable. """ @@ -28913,6 +29023,16 @@ type Mutation @docsCategory(name: "meta") { input: RemoveReactionInput! ): RemoveReactionPayload @docsCategory(name: "reactions") + """ + Removes a 'relates to' relationship between two issues. + """ + removeRelatesTo( + """ + Parameters for RemoveRelatesTo + """ + input: RemoveRelatesToInput! + ): RemoveRelatesToPayload @docsCategory(name: "issues") + """ Removes a star from a Starrable. """ @@ -50403,6 +50523,46 @@ type RemoveReactionPayload { subject: Reactable } +""" +Autogenerated input type of RemoveRelatesTo +""" +input RemoveRelatesToInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the issue. + """ + issueId: ID! @possibleTypes(concreteTypes: ["Issue"]) + + """ + The ID of the previously related issue. + """ + relatedIssueId: ID! @possibleTypes(concreteTypes: ["Issue"]) +} + +""" +Autogenerated return type of RemoveRelatesTo. +""" +type RemoveRelatesToPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The previously targeted issue. + """ + issue: Issue + + """ + The previously related issue. + """ + relatedIssue: Issue +} + """ Autogenerated input type of RemoveStar """ diff --git a/src/graphql/data/ghec/category-map.json b/src/graphql/data/ghec/category-map.json index dcbdb8f3c097..707c648eb014 100644 --- a/src/graphql/data/ghec/category-map.json +++ b/src/graphql/data/ghec/category-map.json @@ -130,6 +130,7 @@ "addcloseissuereferences": "issues", "addcomment": "issues", "addlabelstolabelable": "issues", + "addrelatesto": "issues", "addsubissue": "issues", "applypendingissuesuggestions": "issues", "clearlabelsfromlabelable": "issues", @@ -156,6 +157,7 @@ "removeblockedby": "issues", "removecloseissuereferences": "issues", "removelabelsfromlabelable": "issues", + "removerelatesto": "issues", "removesubissue": "issues", "reopenissue": "issues", "replaceactorsforassignable": "issues", @@ -1248,6 +1250,7 @@ "issuefieldupdateoperation": "issues", "issuefieldvisibility": "issues", "issueorderfield": "issues", + "issuerelatestoorderfield": "issues", "issuesearchtype": "issues", "issuestate": "issues", "issuestatereason": "issues", @@ -1576,6 +1579,7 @@ "addcloseissuereferencesinput": "issues", "addcommentinput": "issues", "addlabelstolabelableinput": "issues", + "addrelatestoinput": "issues", "addsubissueinput": "issues", "applypendingissuesuggestionsinput": "issues", "assigneeupdateinput": "issues", @@ -1603,6 +1607,7 @@ "issuefieldvaluefilter": "issues", "issuefilters": "issues", "issueorder": "issues", + "issuerelatestoorder": "issues", "issuestateupdateinput": "issues", "issuetypeorder": "issues", "issuetypeupdateinput": "issues", @@ -1619,6 +1624,7 @@ "removeblockedbyinput": "issues", "removecloseissuereferencesinput": "issues", "removelabelsfromlabelableinput": "issues", + "removerelatestoinput": "issues", "removesubissueinput": "issues", "reopenissueinput": "issues", "replaceactorsforassignableinput": "issues", diff --git a/src/graphql/data/ghec/schema-issues.json b/src/graphql/data/ghec/schema-issues.json index 9a9b69cef8ce..071f422db655 100644 --- a/src/graphql/data/ghec/schema-issues.json +++ b/src/graphql/data/ghec/schema-issues.json @@ -181,6 +181,45 @@ ], "category": "issues" }, + { + "name": "addRelatesTo", + "id": "addrelatesto", + "href": "/graphql/reference/issues#mutation-addrelatesto", + "description": "

    Adds a 'relates to' relationship between two issues.

    ", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "AddRelatesToInput!", + "id": "addrelatestoinput", + "href": "/graphql/reference/issues#input-object-addrelatestoinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

    A unique identifier for the client performing the mutation.

    " + }, + { + "name": "issue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "

    The source issue.

    " + }, + { + "name": "relatedIssue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "

    The related issue.

    " + } + ], + "category": "issues" + }, { "name": "addSubIssue", "id": "addsubissue", @@ -1041,6 +1080,45 @@ ], "category": "issues" }, + { + "name": "removeRelatesTo", + "id": "removerelatesto", + "href": "/graphql/reference/issues#mutation-removerelatesto", + "description": "

    Removes a 'relates to' relationship between two issues.

    ", + "isDeprecated": false, + "inputFields": [ + { + "name": "input", + "type": "RemoveRelatesToInput!", + "id": "removerelatestoinput", + "href": "/graphql/reference/issues#input-object-removerelatestoinput" + } + ], + "returnFields": [ + { + "name": "clientMutationId", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string", + "description": "

    A unique identifier for the client performing the mutation.

    " + }, + { + "name": "issue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "

    The previously targeted issue.

    " + }, + { + "name": "relatedIssue", + "type": "Issue", + "id": "issue", + "href": "/graphql/reference/issues#object-issue", + "description": "

    The previously related issue.

    " + } + ], + "category": "issues" + }, { "name": "removeSubIssue", "id": "removesubissue", @@ -3601,6 +3679,60 @@ } ] }, + { + "name": "relatesTo", + "description": "

    A list of issues related to this issue.

    ", + "type": "IssueConnection!", + "id": "issueconnection", + "href": "/graphql/reference/issues#object-issueconnection", + "arguments": [ + { + "name": "after", + "description": "

    Returns the elements in the list that come after the specified cursor.

    ", + "type": { + "name": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + }, + { + "name": "before", + "description": "

    Returns the elements in the list that come before the specified cursor.

    ", + "type": { + "name": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + } + }, + { + "name": "first", + "description": "

    Returns the first n elements from the list.

    ", + "type": { + "name": "Int", + "id": "int", + "href": "/graphql/reference/other#scalar-int" + } + }, + { + "name": "last", + "description": "

    Returns the last n elements from the list.

    ", + "type": { + "name": "Int", + "id": "int", + "href": "/graphql/reference/other#scalar-int" + } + }, + { + "name": "orderBy", + "description": "

    Ordering options for related issues.

    ", + "type": { + "name": "IssueRelatesToOrder", + "id": "issuerelatestoorder", + "href": "/graphql/reference/issues#input-object-issuerelatestoorder" + } + } + ] + }, { "name": "repository", "description": "

    The repository associated with this node.

    ", @@ -10031,6 +10163,24 @@ ], "category": "issues" }, + { + "name": "IssueRelatesToOrderField", + "id": "issuerelatestoorderfield", + "href": "/graphql/reference/issues#enum-issuerelatestoorderfield", + "description": "

    Properties by which related issues can be ordered.

    ", + "isDeprecated": false, + "values": [ + { + "name": "CREATED_AT", + "description": "

    Order related issues by the creation time of the related issue.

    " + }, + { + "name": "RELATES_TO_ADDED_AT", + "description": "

    Order related issues by time of when the relates-to relationship was added.

    " + } + ], + "category": "issues" + }, { "name": "IssueSearchType", "id": "issuesearchtype", @@ -11304,6 +11454,38 @@ ], "category": "issues" }, + { + "name": "AddRelatesToInput", + "id": "addrelatestoinput", + "href": "/graphql/reference/issues#input-object-addrelatestoinput", + "description": "

    Autogenerated input type of AddRelatesTo.

    ", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

    A unique identifier for the client performing the mutation.

    ", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "issueId", + "description": "

    The ID of the issue.

    ", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + }, + { + "name": "relatedIssueId", + "description": "

    The ID of the related issue.

    ", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "issues" + }, { "name": "AddSubIssueInput", "id": "addsubissueinput", @@ -12436,6 +12618,30 @@ ], "category": "issues" }, + { + "name": "IssueRelatesToOrder", + "id": "issuerelatestoorder", + "href": "/graphql/reference/issues#input-object-issuerelatestoorder", + "description": "

    Ordering options for related issues.

    ", + "isDeprecated": false, + "inputFields": [ + { + "name": "direction", + "description": "

    The ordering direction.

    ", + "type": "OrderDirection!", + "id": "orderdirection", + "href": "/graphql/reference/meta#enum-orderdirection" + }, + { + "name": "field", + "description": "

    The field to order related issues by.

    ", + "type": "IssueRelatesToOrderField!", + "id": "issuerelatestoorderfield", + "href": "/graphql/reference/issues#enum-issuerelatestoorderfield" + } + ], + "category": "issues" + }, { "name": "IssueStateUpdateInput", "id": "issuestateupdateinput", @@ -12960,6 +13166,38 @@ ], "category": "issues" }, + { + "name": "RemoveRelatesToInput", + "id": "removerelatestoinput", + "href": "/graphql/reference/issues#input-object-removerelatestoinput", + "description": "

    Autogenerated input type of RemoveRelatesTo.

    ", + "inputFields": [ + { + "name": "clientMutationId", + "description": "

    A unique identifier for the client performing the mutation.

    ", + "type": "String", + "id": "string", + "href": "/graphql/reference/other#scalar-string" + }, + { + "name": "issueId", + "description": "

    The ID of the issue.

    ", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + }, + { + "name": "relatedIssueId", + "description": "

    The ID of the previously related issue.

    ", + "type": "ID!", + "id": "id", + "href": "/graphql/reference/other#scalar-id", + "isDeprecated": false + } + ], + "category": "issues" + }, { "name": "RemoveSubIssueInput", "id": "removesubissueinput", diff --git a/src/graphql/data/ghec/schema.docs.graphql b/src/graphql/data/ghec/schema.docs.graphql index e3c1e2b7b8b9..94fb6074bb62 100644 --- a/src/graphql/data/ghec/schema.docs.graphql +++ b/src/graphql/data/ghec/schema.docs.graphql @@ -1253,6 +1253,46 @@ type AddReactionPayload { subject: Reactable } +""" +Autogenerated input type of AddRelatesTo +""" +input AddRelatesToInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the issue. + """ + issueId: ID! @possibleTypes(concreteTypes: ["Issue"]) + + """ + The ID of the related issue. + """ + relatedIssueId: ID! @possibleTypes(concreteTypes: ["Issue"]) +} + +""" +Autogenerated return type of AddRelatesTo. +""" +type AddRelatesToPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The source issue. + """ + issue: Issue + + """ + The related issue. + """ + relatedIssue: Issue +} + """ Autogenerated input type of AddStar """ @@ -20715,6 +20755,36 @@ type Issue implements Assignable & orderBy: ReactionOrder ): ReactionConnection! + """ + A list of issues related to this issue. + """ + relatesTo( + """ + Returns the elements in the list that come after the specified cursor. + """ + after: String + + """ + Returns the elements in the list that come before the specified cursor. + """ + before: String + + """ + Returns the first _n_ elements from the list. + """ + first: Int + + """ + Returns the last _n_ elements from the list. + """ + last: Int + + """ + Ordering options for related issues + """ + orderBy: IssueRelatesToOrder = {field: RELATES_TO_ADDED_AT, direction: DESC} + ): IssueConnection! + """ The repository associated with this node. """ @@ -22700,6 +22770,36 @@ enum IssueOrderField @docsCategory(name: "issues") { UPDATED_AT } +""" +Ordering options for related issues +""" +input IssueRelatesToOrder @docsCategory(name: "issues") { + """ + The ordering direction. + """ + direction: OrderDirection! + + """ + The field to order related issues by. + """ + field: IssueRelatesToOrderField! +} + +""" +Properties by which related issues can be ordered. +""" +enum IssueRelatesToOrderField @docsCategory(name: "issues") { + """ + Order related issues by the creation time of the related issue + """ + CREATED_AT + + """ + Order related issues by time of when the relates-to relationship was added + """ + RELATES_TO_ADDED_AT +} + """ Type of issue search performed """ @@ -27440,6 +27540,16 @@ type Mutation @docsCategory(name: "meta") { input: AddReactionInput! ): AddReactionPayload @docsCategory(name: "reactions") + """ + Adds a 'relates to' relationship between two issues. + """ + addRelatesTo( + """ + Parameters for AddRelatesTo + """ + input: AddRelatesToInput! + ): AddRelatesToPayload @docsCategory(name: "issues") + """ Adds a star to a Starrable. """ @@ -28913,6 +29023,16 @@ type Mutation @docsCategory(name: "meta") { input: RemoveReactionInput! ): RemoveReactionPayload @docsCategory(name: "reactions") + """ + Removes a 'relates to' relationship between two issues. + """ + removeRelatesTo( + """ + Parameters for RemoveRelatesTo + """ + input: RemoveRelatesToInput! + ): RemoveRelatesToPayload @docsCategory(name: "issues") + """ Removes a star from a Starrable. """ @@ -50403,6 +50523,46 @@ type RemoveReactionPayload { subject: Reactable } +""" +Autogenerated input type of RemoveRelatesTo +""" +input RemoveRelatesToInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the issue. + """ + issueId: ID! @possibleTypes(concreteTypes: ["Issue"]) + + """ + The ID of the previously related issue. + """ + relatedIssueId: ID! @possibleTypes(concreteTypes: ["Issue"]) +} + +""" +Autogenerated return type of RemoveRelatesTo. +""" +type RemoveRelatesToPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The previously targeted issue. + """ + issue: Issue + + """ + The previously related issue. + """ + relatedIssue: Issue +} + """ Autogenerated input type of RemoveStar """ diff --git a/src/graphql/lib/categories.ts b/src/graphql/lib/categories.ts index ab62decb8d54..76eb35cd02c9 100644 --- a/src/graphql/lib/categories.ts +++ b/src/graphql/lib/categories.ts @@ -1,9 +1,4 @@ -// Canonical mapping of internal schema kinds to: -// - urlKind: the URL/folder segment used in href anchors before categorization -// (kept for backward-compat with `helpers.getFullLink` signature) -// - slugPrefix: the kind-disambiguating slug prefix used on category pages -// so two items sharing a case-insensitive name don't collide -// - label: human-readable label rendered as a Primer Label next to each item +// Schema kind tables keep legacy URL segments, category-page slug prefixes, and visible labels. export type SchemaKindKey = | 'queries' @@ -26,8 +21,7 @@ export const KIND_LABELS: Record = { scalars: 'Scalar', } -// Plural form of `KIND_LABELS`, used as the section heading (and mini-TOC -// parent label) when a GraphQL category page groups its items by kind. +// Plural labels appear in category-page sections and mini-TOC section entries. export const KIND_LABELS_PLURAL: Record = { queries: 'Queries', mutations: 'Mutations', @@ -39,10 +33,7 @@ export const KIND_LABELS_PLURAL: Record = { scalars: 'Scalars', } -// Slug prefix used to disambiguate items across kinds on a category page. -// For example, a `Repository` object and a `repository` query both have id -// `repository`; on a category page they become `object-repository` and -// `query-repository` respectively. +// Category-page anchors prefix the kind, so Repository object and repository query stay distinct. export const KIND_SLUG_PREFIX: Record = { queries: 'query', mutations: 'mutation', @@ -54,9 +45,8 @@ export const KIND_SLUG_PREFIX: Record = { scalars: 'scalar', } -// The "URL kind" / `pageType` value used by `helpers.getTypeKind` and -// `helpers.getFullLink`. `inputObjects` (camelCase internal key) becomes -// `input-objects` in URLs. +// These URL segments match helpers.getTypeKind output and helpers.getFullLink input. +// For example, inputObjects becomes input-objects. export const KIND_URL_SEGMENT: Record = { queries: 'queries', mutations: 'mutations', @@ -79,28 +69,22 @@ export const ALL_KIND_KEYS: SchemaKindKey[] = [ 'scalars', ] -// Reverse map from the URL-kind segment used in hrefs (e.g. `input-objects`) -// to the slug prefix used to disambiguate items in category page anchors -// (e.g. `input-object`). Derived from KIND_URL_SEGMENT + KIND_SLUG_PREFIX so -// the three tables stay in sync automatically. +// Derive URL-segment to slug-prefix mappings from the source tables so anchor helpers stay in sync. +// For example, input-objects maps to input-object. export const SLUG_PREFIX_BY_URL_SEGMENT: Record = Object.fromEntries( ALL_KIND_KEYS.map((k) => [KIND_URL_SEGMENT[k], KIND_SLUG_PREFIX[k]]), ) -// Given a URL-kind segment (as returned by helpers.getTypeKind, e.g. -// `objects`, `input-objects`), return the slug prefix used to disambiguate -// items in category page anchors. Falls back to the input for unknown kinds. +// Unknown URL-kind segments fall back to themselves so callers can handle future kinds. export function slugPrefixForUrlKind(urlKind: string): string { return SLUG_PREFIX_BY_URL_SEGMENT[urlKind] ?? urlKind } -// Bucket all items that don't have an upstream `@docsCategory` directive. +// Unannotated upstream schema items fall into the other category. export const OTHER_CATEGORY = 'other' -// Canonical list of categories emitted by the upstream `docs_category` DSL. -// Keep this list in sync with the allowlist in -// `github/github`'s `app/platform/objects/base/docs_category.rb`. -// `other` is a docs-internal bucket for un-annotated types. +// github/github app/platform/objects/base/docs_category.rb must allow each upstream category here. +// The other category belongs to docs-internal for unannotated types. export const CATEGORIES = [ 'actions', 'activity', @@ -153,7 +137,6 @@ export function isValidCategory(slug: string): slug is CategorySlug { return (CATEGORIES as readonly string[]).includes(slug) } -// Human-readable display title for a category. Falls back to slug. export function categoryTitle(slug: string): string { switch (slug) { case 'apps': diff --git a/src/graphql/lib/index.ts b/src/graphql/lib/index.ts index 96a7818f113c..8f0cfd8a8da2 100644 --- a/src/graphql/lib/index.ts +++ b/src/graphql/lib/index.ts @@ -13,27 +13,23 @@ import type { } from '@/graphql/components/types' import { ALL_KIND_KEYS, CATEGORIES, isValidCategory, type SchemaKindKey } from './categories' -// GraphqlContext describes the per-request context object that getMiniToc and -// getGraphqlSchema read language/version from. export interface GraphqlContext { currentLanguage: string currentVersion: string [key: string]: unknown } -// The GraphQL schema JSON is keyed by member type (e.g. "queries", "objects", -// "enums"), each holding a list of schema members. +// GraphQL schema JSON groups members by schema kind. type GraphqlSchemaData = Record export const GRAPHQL_DATA_DIR = 'src/graphql/data' -/* ADD LANGUAGE KEY */ const previews = new Map() const upcomingChanges = new Map() const changelog = new Map() const changelogMiniTocs = new Map() -// Per-category schema files. Key: `${graphqlVersion}:${category}` → bucket. +// Per-category schema cache keys combine graphqlVersion and category. const graphqlCategorySchemas = new Map() -// All objects across categories (for interface implementer lookup). +// Interface renderers need object items from every category to list implementers. const allObjectsByVersion = new Map() const miniTocs = new Map>>() @@ -41,9 +37,7 @@ for (const language of Object.keys(languages)) { miniTocs.set(language, new Map()) } -// Returns the per-category schema bucket `{queries, mutations, ...}` for a -// given category slug (e.g. 'repos', 'issues'). Throws via the loader if the -// category slug is not valid for this version. +// Reject invalid category slugs before the loader reads a missing schema file. export function getGraphqlSchema(version: string, category: string): GraphqlSchemaData { if (!isValidCategory(category)) { throw new Error(`Invalid GraphQL category: ${category}`) @@ -65,9 +59,7 @@ function getGraphqlSchemaByCategory(graphqlVersion: string, category: string): G return graphqlCategorySchemas.get(key)! } -// Returns all object-kind items across every category for the given version. -// Used by the interface renderer to list implementers regardless of which -// category page is being rendered. +// Interface renderers need objects from every category to list implementers. export function getAllGraphqlObjects(version: string): GraphqlT[] { const graphqlVersion: string = getGraphqlVersion(version) if (!allObjectsByVersion.has(graphqlVersion)) { @@ -81,7 +73,6 @@ export function getAllGraphqlObjects(version: string): GraphqlT[] { return allObjectsByVersion.get(graphqlVersion)! } -// Returns the canonical render order of kinds within a category page. export function getKindOrder(): SchemaKindKey[] { return ALL_KIND_KEYS } @@ -100,17 +91,11 @@ export function getGraphqlChangelog(version: string): ChangelogItemT[] { return changelog.get(graphqlVersion)! } -/** - * Return changelog entries filtered by year. - */ export function getGraphqlChangelogByYear(version: string, year: number): ChangelogItemT[] { const all = getGraphqlChangelog(version) return all.filter((entry) => entry.date.startsWith(String(year))) } -/** - * Return the distinct years present in the changelog, sorted descending (newest first). - */ export function getGraphqlChangelogYears(version: string): number[] { const all = getGraphqlChangelog(version) const years = new Set() diff --git a/src/graphql/lib/validator.ts b/src/graphql/lib/validator.ts index 89283c83e5a5..eac02970e166 100644 --- a/src/graphql/lib/validator.ts +++ b/src/graphql/lib/validator.ts @@ -1,5 +1,4 @@ -// the tests in tests/graphql.ts use this schema to ensure the integrity -// of the data in src/graphql/data/*.json +// src/graphql/tests/validate-schema.ts reads these schemas to validate generated data. interface JSONSchema { type?: string @@ -79,7 +78,6 @@ export const upcomingChangesValidator: ValidatorSchema = { }, } -// many GraphQL schema members have these core properties const coreProps: JSONSchema = { properties: { name: { @@ -107,7 +105,6 @@ const coreProps: JSONSchema = { }, } -// some GraphQL schema members have the core properties plus an 'args' object const corePropsPlusArgs = dup(coreProps) corePropsPlusArgs.properties!.args = { @@ -118,7 +115,6 @@ corePropsPlusArgs.properties!.args = { }, } -// the args object can have defaultValue prop corePropsPlusArgs.properties!.args.items!.properties!.defaultValue = { type: 'boolean', } diff --git a/src/graphql/pages/breaking-changes.tsx b/src/graphql/pages/breaking-changes.tsx index b39033517190..1f960e9c4760 100644 --- a/src/graphql/pages/breaking-changes.tsx +++ b/src/graphql/pages/breaking-changes.tsx @@ -49,9 +49,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => const schema = getGraphqlBreakingChanges(currentVersion) if (!schema) throw new Error(`No graphql breaking changes schema found for ${currentVersion}`) - // Gets the miniTocItems in the article context. At this point it will only - // include miniTocItems that exist in Markdown pages in - // content/graphql/reference/* + // Start from the current page's Markdown headings, then append the generated ones. const automatedPageContext = getAutomatedPageContextFromRequest(req) const slugger = new GithubSlugger() const headings = Object.fromEntries( @@ -69,7 +67,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => ) const titles = Object.values(headings).map((heading) => heading.title) const changelogMiniTocItems = await getAutomatedPageMiniTocItems(titles, req.context!, 2) - // Update the existing context to include the miniTocItems from GraphQL automatedPageContext.miniTocItems.push(...changelogMiniTocItems) return { diff --git a/src/graphql/pages/changelog.tsx b/src/graphql/pages/changelog.tsx index 1aee0f1921bd..74b9bfb9f9e5 100644 --- a/src/graphql/pages/changelog.tsx +++ b/src/graphql/pages/changelog.tsx @@ -69,10 +69,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => } } -/** - * Strip wrapping `

    ` tags from HTML change descriptions to allow - * rendering as `

  • ` content without nested block elements. - */ +// Strip wrapping p tags so list items do not contain nested block elements. export function stripParagraphWrappers(schema: ChangelogItemT[]) { for (const item of schema) { for (const group of [item.schemaChanges, item.previewChanges, item.upcomingChanges]) { diff --git a/src/graphql/pages/reference.tsx b/src/graphql/pages/reference.tsx index bef78e785891..77dba5be2983 100644 --- a/src/graphql/pages/reference.tsx +++ b/src/graphql/pages/reference.tsx @@ -40,10 +40,7 @@ export default function GraphqlReferencePage({ allObjects, categorySlug, }: Props) { - // Key the schema content by category slug. Without this, client-side - // navigation between category pages reuses the same React tree and - // dangerouslySetInnerHTML descriptions from a previous category can stick - // around in the DOM. Keying forces a clean unmount/remount on route change. + // Keying by categorySlug prevents navigation from reusing stale dangerouslySetInnerHTML content. const content = return ( @@ -74,12 +71,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => const schema = getGraphqlSchema(currentVersion, page) as CategorySchema const allObjects = getAllGraphqlObjects(currentVersion) as ObjectT[] - // If a category has no types in the current version, 404 the page rather - // than render an empty document. Empty buckets typically happen when a - // category exists in fpt/ghec but not in GHES (or vice versa). The content - // .md files for categories that are empty in every version are removed - // from `content/graphql/reference/`, but the dynamic [page].tsx route would - // still serve them otherwise. This guard makes the response a real 404. + // Return 404 when a version has no types because the route can serve categories without files. const hasAnyTypes = ALL_KIND_KEYS.some((kind) => { const items = (schema as Record)[kind] return Array.isArray(items) && items.length > 0 @@ -88,12 +80,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => return { notFound: true } } - // Build a two-level mini-TOC mirroring the page's kind sections. Top-level - // entries are kind labels (e.g. "Objects") pointing at the matching section - // heading; nested entries are the items inside each section. The mini-TOC - // React component (`MiniTocs`) renders `item.items` recursively, so pushing - // a nested structure here yields a two-level sidebar even though the - // default heading-collection path is capped at one level globally. + // Build nested kind entries because MiniTocs recurses and default collection stops at one level. const automatedPageContext = getAutomatedPageContextFromRequest(req) for (const kind of ALL_KIND_KEYS) { const kindItems = (schema as Record>)[kind] diff --git a/src/graphql/pages/schema-previews.tsx b/src/graphql/pages/schema-previews.tsx index f2eda650a55f..23d781ae7cfc 100644 --- a/src/graphql/pages/schema-previews.tsx +++ b/src/graphql/pages/schema-previews.tsx @@ -45,13 +45,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => const schema = getPreviews(currentVersion) as PreviewT[] if (!schema) throw new Error(`No graphql preview schema found for ${currentVersion}`) - // Gets the miniTocItems in the article context. At this point it will only - // include miniTocItems that exist in Markdown pages in - // content/graphql/reference/* + // Start from the current page's Markdown headings, then append the generated ones. const automatedPageContext = getAutomatedPageContextFromRequest(req) const titles = schema.map((item) => item.title) const changelogMiniTocItems = await getAutomatedPageMiniTocItems(titles, req.context!, 2) - // Update the existing context to include the miniTocItems from GraphQL automatedPageContext.miniTocItems.push(...changelogMiniTocItems) const mainContext = await getMainContext(req, res as unknown as Response) diff --git a/src/graphql/scripts/build-changelog.ts b/src/graphql/scripts/build-changelog.ts index 0b645a4e7f52..319c2a5d6a8b 100644 --- a/src/graphql/scripts/build-changelog.ts +++ b/src/graphql/scripts/build-changelog.ts @@ -60,10 +60,7 @@ interface IgnoredChangesSummary { let lastIgnoredChanges: Change[] = [] -/** - * Tag `changelogEntry` with `date: YYYY-mm-dd`, then prepend it to the JSON - * structure written to `targetPath`. (`changelogEntry` and that file are modified in place.) - */ +// Add today's date to changelogEntry and prepend it to the JSON array at targetPath. export function prependDatedEntry(changelogEntry: ChangelogEntry, targetPath: string): void { const todayString = new Date().toISOString().slice(0, 10) changelogEntry.date = todayString @@ -73,17 +70,13 @@ export function prependDatedEntry(changelogEntry: ChangelogEntry, targetPath: st previousChangelog.unshift(changelogEntry) fs.writeFileSync(targetPath, JSON.stringify(previousChangelog, null, 2)) - // Ensure a content page exists for this entry's year const year = todayString.slice(0, 4) ensureYearPage(year) } const DEFAULT_CHANGELOG_CONTENT_DIR = nodePath.join('content', 'graphql', 'overview', 'changelog') -/** - * If a year-specific content page doesn't exist yet (e.g. 2027.md), - * create it and prepend it to the children list in index.md. - */ +// Create a missing year page and prepend it to the changelog index when its first entry arrives. export function ensureYearPage( year: string, contentDir: string = DEFAULT_CHANGELOG_CONTENT_DIR, @@ -110,12 +103,6 @@ export function ensureYearPage( fs.writeFileSync(indexPath, updated) } -/** - * Compare `oldSchemaString` to `newSchemaString`, and if there are any - * changes that warrant a changelog entry, return a changelog entry. - * Based on the parsed `previews`, identify changes that are under a preview. - * Otherwise, return null. - */ export async function createChangelogEntry( oldSchemaString: string, newSchemaString: string, @@ -159,8 +146,7 @@ export async function createChangelogEntry( ) const addedUpcomingChanges = newUpcomingChanges.filter(function (change): boolean { - // Manually check each of `newUpcomingChanges` for an equivalent entry - // in `oldUpcomingChanges`. + // Match upcoming changes by location, date, and description. return !oldUpcomingChanges.find(function (oldChange) { return ( oldChange.location === change.location && @@ -189,7 +175,6 @@ export async function createChangelogEntry( ) const schemaChange: ChangelogSchemaChange = { title: 'The GraphQL schema includes these changes:', - // Replace single quotes which wrap field/argument/type names with backticks changes: renderedScheamChanges, } changelogEntry.schemaChanges.push(schemaChange) @@ -236,9 +221,7 @@ export async function createChangelogEntry( } } -/** - * Prepare the preview title from github/github source for the docs. - */ +// github/github preview titles need docs-style wording before rendering. export function cleanPreviewTitle(title: string): string { if (title === 'UpdateRefsPreview') { title = 'Update refs preview' @@ -250,10 +233,7 @@ export function cleanPreviewTitle(title: string): string { return title } -/** - * Turn the given title into an HTML-ready anchor. - * (ported from graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L281) - */ +// Anchor generation matches the changelog URL format. export function previewAnchor(previewTitle: string): string { return previewTitle .toLowerCase() @@ -261,29 +241,18 @@ export function previewAnchor(previewTitle: string): string { .replace(/[^\w-]/g, '') } -/** - * Turn changes from graphql-inspector into messages for the HTML changelog. - */ export function cleanMessagesFromChanges(changes: Change[]): string[] { return changes.map(function (change): string { - // replace single quotes around graphql names with backticks, - // to match previous behavior from graphql-schema-comparator + // Wrap quoted GraphQL names in Markdown code spans for changelog rendering. return change.message.replace(/'([a-zA-Z. :!]+)'/g, '`$1`') }) } -/** - * Split `changesToReport` into two parts, - * one for changes in the main schema, - * and another for changes that are under preview. - * (Ported from /graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L230) - */ +// Preview-toggled paths and their ancestors move changes out of the main schema section. export function segmentPreviewChanges( changesToReport: Change[], previews: Preview[], ): SegmentedChanges { - // Build a map of `{ path => previewTitle` } - // for easier lookup of change to preview const pathToPreview: Record = {} for (const preview of previews) { for (const path of preview.toggled_on) { @@ -294,8 +263,7 @@ export function segmentPreviewChanges( const changesByPreview: Record = {} for (const change of changesToReport) { - // For each change, see if its path _or_ one of its ancestors - // is covered by a preview. If it is, mark this change as belonging to a preview + // Preview ownership applies when the change path or an ancestor path is toggled on. const pathParts = change.path?.split('.') || [] let testPath: string | null = null let previewTitle: string | null = null @@ -303,8 +271,6 @@ export function segmentPreviewChanges( while (pathParts.length > 0 && !previewTitle) { testPath = pathParts.join('.') previewTitle = pathToPreview[testPath] - // If that path didn't find a match, then we'll - // check the next ancestor. pathParts.pop() } if (previewTitle) { @@ -322,11 +288,8 @@ export function segmentPreviewChanges( return { schemaChangesToReport: schemaChanges, previewChangesToReport: changesByPreview } } -// We only want to report changes to schema structure. -// Deprecations are covered by "upcoming changes." -// By listing the changes explicitly here, we can make sure that, -// if the library changes, we don't miss publishing anything that we mean to. -// This was originally ported from graphql-docs/lib/graphql_docs/update_internal_developer/change_log.rb#L35-L103 +// Report only schema-structure changes; deprecations come from upcoming changes. +// Unknown change types log for review instead of appearing in the changelog. const CHANGES_TO_REPORT = [ ChangeType.FieldArgumentDefaultChanged, ChangeType.FieldArgumentTypeChanged, @@ -354,9 +317,6 @@ const CHANGES_TO_REPORT = [ ChangeType.DirectiveUsageFieldDefinitionRemoved, ] -// Anything not in CHANGES_TO_REPORT is logged as ignored rather than reported, -// so a new change type added upstream cannot break this script. - export function getLastIgnoredChanges(): Change[] { return lastIgnoredChanges } diff --git a/src/graphql/scripts/sync.ts b/src/graphql/scripts/sync.ts index 9bcc3257bb38..d7c6b8e2e06b 100755 --- a/src/graphql/scripts/sync.ts +++ b/src/graphql/scripts/sync.ts @@ -58,17 +58,13 @@ const dataFilenames = JSON.parse( await fs.readFile('src/graphql/scripts/utils/data-filenames.json', 'utf8'), ) -// check for required PAT if (!process.env.GITHUB_TOKEN) { throw new Error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.') } const versionsToBuild = Object.keys(allVersions) -// Tracks, per category, the set of docs versions in which the category has at -// least one type. Populated inside the per-version loop and consumed after it -// to manage the per-category content pages. Declared before `main()` runs so -// the loop never reads it in the temporal dead zone. +// Declare categoryPresence before the main() call so the loop never reads it in the temporal dead zone. const categoryPresence: CategoryPresence = new Map() main() @@ -77,12 +73,9 @@ const allIgnoredChanges: IgnoredChange[] = [] async function main() { for (const version of versionsToBuild) { - // Get the relevant GraphQL name for the current version. - // For example, free-pro-team@latest corresponds to dotcom, - // enterprise-server@2.22 corresponds to ghes-2.22. + // Examples: free-pro-team@latest maps to dotcom; enterprise-server@2.22 maps to ghes-2.22. const graphqlVersion = allVersions[version].openApiVersionName - // 1. UPDATE PREVIEWS const previewsPath = getDataFilepath('previews', graphqlVersion) const rawPreviews = load( await getRemoteRawContent(previewsPath, graphqlVersion), @@ -94,7 +87,6 @@ async function main() { path.join(graphqlStaticDir, graphqlVersion, 'previews.json'), ) - // 2. UPDATE UPCOMING CHANGES const upcomingChangesPath = getDataFilepath('upcomingChanges', graphqlVersion) const previousUpcomingChanges = load( await fs.readFile(upcomingChangesPath, 'utf8'), @@ -107,8 +99,6 @@ async function main() { path.join(graphqlStaticDir, graphqlVersion, 'upcoming-changes.json'), ) - // 3. UPDATE SCHEMAS - // note: schemas live in separate files per version const previewFilePath = getDataFilepath('schemas', graphqlVersion) const previousSchemaString = await fs.readFile(previewFilePath, 'utf8') const latestSchema = await getRemoteRawContent(previewFilePath, graphqlVersion) @@ -117,11 +107,7 @@ async function main() { ...preview, toggled_by: [preview.toggled_by].flat(), })) - // Fallback category source for GHES versions that pre-date the upstream - // `@docsCategory` DSL (DSL landed on master 2026-05-07; GHES 3.16-3.21 - // were cut at the 3.21 freeze 2026-03-19). Without this, every type on - // those versions gets bucketed as "other". GHES 3.22+ is expected to - // include the DSL natively so it's excluded from the fallback. + // GHES schemas before 3.22 lack @docsCategory, so fall back to the fpt category map. let fallbackCategoryMap: Record> | undefined const ghesMatch = /^ghes-(\d+)\.(\d+)$/.exec(graphqlVersion) if (ghesMatch) { @@ -134,8 +120,7 @@ async function main() { ) console.log(`Using fpt/category-map.json as @docsCategory fallback for ${graphqlVersion}`) } catch { - // fpt hasn't been processed yet (shouldn't happen given iteration - // order, but stay defensive). Without it, ghes types fall to "other". + // fpt runs first; if category-map.json is unavailable, GHES types fall back to other. } } } @@ -144,18 +129,13 @@ async function main() { previewsForSchema, fallbackCategoryMap, { currentLanguage: 'en', currentVersion: version }, - ) // This is slow! + ) - // Split the schema by category so the runtime can lazily load only the - // bucket it needs for a given page request. The monolithic `schema.json` - // is no longer written; per-category files are the only on-disk format. + // processSchemas is slow; per-category files are the only on-disk format for scoped loads. const perCategoryFiles = bucketSchemaByCategory(schemaJsonPerVersion) await writeCategoryFiles(path.join(graphqlStaticDir, graphqlVersion), perCategoryFiles) - // Record which categories have at least one type in this version so the - // content pages and their `versions` frontmatter can be managed after the - // loop. `version` is the docs version key (e.g. `enterprise-server@3.22`), - // which is the format `convertVersionsToFrontmatter` expects. + // Store docs version keys so convertVersionsToFrontmatter can update pages after the loop. for (const [cat, bucket] of perCategoryFiles.entries()) { const hasTypes = ALL_KIND_KEYS.some((kind) => (bucket[kind]?.length ?? 0) > 0) if (!hasTypes) continue @@ -163,9 +143,8 @@ async function main() { categoryPresence.get(cat)!.add(version) } - // 4. UPDATE CHANGELOG if (allVersions[version].nonEnterpriseDefault) { - // The changelog is only built for free-pro-team@latest + // Build the changelog only for free-pro-team@latest. const changelogEntry = await createChangelogEntry( previousSchemaString, latestSchema, @@ -180,7 +159,6 @@ async function main() { ) } - // Capture ignored changes for potential workflow notifications const ignoredSummary = getIgnoredChangesSummary() if (ignoredSummary) { allIgnoredChanges.push({ @@ -191,15 +169,11 @@ async function main() { } } - // Manage the per-category content pages (create new categories, delete - // emptied ones, narrow `versions` frontmatter) plus the reference index - // children and disappearance redirects, based on the presence collected above. + // Sync category pages, index children, and disappearance redirects after all versions run. await syncCategoryContentFiles(categoryPresence) - // Run the YAML linter before anything is checked in. execSync('npx prettier -w "**/*.{yml,yaml}"') - // Output ignored changes for GitHub Actions if (allIgnoredChanges.length > 0) { const totalIgnored = allIgnoredChanges.reduce((sum, item) => sum + item.totalCount, 0) const uniqueTypes = [ @@ -221,14 +195,12 @@ async function main() { } } -// get latest from github/github async function getRemoteRawContent(filepath: string, graphqlVersion: string) { const options: GitHubRepoOptions = { owner: 'github', repo: 'github', } - // find the relevant branch in github/github and set it as options.ref let t0 = new Date().getTime() options.ref = await getBranchAsRef(options, graphqlVersion) let took = new Date().getTime() - t0 @@ -244,11 +216,10 @@ async function getRemoteRawContent(filepath: string, graphqlVersion: string) { return contents } -// find the relevant filepath in src/graphql/scripts/util/data-filenames.json function getDataFilepath(id: string, graphqlVersion: string) { const versionType = getVersionName(graphqlVersion) - // for example, dataFilenames['schema']['ghes'] = schema.docs-enterprise.graphql + // Example: dataFilenames.schema.ghes maps to schema.docs-enterprise.graphql. const filename = dataFilenames[id][versionType] return path.join(graphqlStaticDir, graphqlVersion, filename) @@ -268,15 +239,12 @@ async function getBranchAsRef( ghes: `enterprise-${graphqlVersion.replace('ghes-', '')}-release`, } - // the first time this runs, it uses the branch found for the version above if (!branch) branch = branches[versionType] const ref = `heads/${branch}` - // check whether the branch can be found in github/github const exists = await hasMatchingRef(options.owner, options.repo, ref) - // if ref is not found, the branch cannot be found, so try a fallback if (!exists) { const fallbackBranch = defaultBranch return await getBranchAsRef(options, graphqlVersion, fallbackBranch) @@ -284,8 +252,7 @@ async function getBranchAsRef( return ref } -// given a GraphQL version like `ghes-2.22`, return `ghes`; -// given a GraphQL version like `dotcom`, return as is +// Examples: ghes-2.22 returns ghes; dotcom returns dotcom. function getVersionName(graphqlVersion: string) { return graphqlVersion.split('-')[0] } @@ -296,8 +263,7 @@ async function updateFile(filepath: string, content: string) { return fs.writeFile(filepath, content, 'utf8') } -// JSON data from GraphQL schema processing - complex nested structures -// Serialize unknown shapes because the structure varies (arrays, objects, nested schemas, etc.) +// Serialize unknown GraphQL shapes because schema processing returns nested arrays and objects. async function updateStaticFile(json: unknown, filepath: string) { console.log(`Updating static file ${filepath}`) const jsonString = JSON.stringify(json, null, 2) diff --git a/src/graphql/scripts/utils/bucket-by-category.ts b/src/graphql/scripts/utils/bucket-by-category.ts index 794f1b85ea73..78c47487aada 100644 --- a/src/graphql/scripts/utils/bucket-by-category.ts +++ b/src/graphql/scripts/utils/bucket-by-category.ts @@ -9,15 +9,12 @@ import { type SchemaKindKey, } from '@/graphql/lib/categories' -// Item shape from process-schemas; we only need the `category` field here so -// we keep this loose to avoid pulling all the precise interfaces. +// Keep this loose so bucket-by-category does not import every process-schemas interface. type CategorizedItem = { category?: string; name?: string; id?: string } export type CategoryBuckets = Map>> -// Matches the legacy href format that process-schemas emits, e.g. -// `/graphql/reference/objects#repository`. Captures the url-kind segment -// and the id so the bucketer can rewrite into the category-aware form. +// Example: /graphql/reference/objects#repository captures url kind objects and id repository. const LEGACY_HREF_RE = /^\/graphql\/reference\/([a-z][a-z-]*)#([a-z0-9-]+)$/ type CategoryLookup = Map> @@ -46,10 +43,7 @@ function rewriteHref(href: string, lookup: CategoryLookup): string { return `/graphql/reference/${category}#${slugPrefixForUrlKind(urlKind)}-${id}` } -// Walk a processed item recursively, rewriting any string value that looks -// like a legacy `/graphql/reference/#` href into the -// category-aware form. Mutates in place; the monolithic schema.json has -// already been written to disk before this runs. +// rewriteHrefsInPlace mutates processed items so category files link to sibling files. function rewriteHrefsInPlace(value: unknown, lookup: CategoryLookup): void { if (Array.isArray(value)) { for (const v of value) rewriteHrefsInPlace(v, lookup) @@ -68,9 +62,6 @@ function rewriteHrefsInPlace(value: unknown, lookup: CategoryLookup): void { } } -// Group a processed schema (one big `{queries, mutations, ...}` object) into -// one bucket per category. Each bucket only contains the kinds that have -// items in that category. export function bucketSchemaByCategory( schema: Record, ): CategoryBuckets { @@ -89,12 +80,7 @@ export function bucketSchemaByCategory( } } - // After grouping, rewrite cross-reference hrefs from the legacy - // `/graphql/reference/#` form into the category-aware - // `/graphql/reference/#-` form so per-category - // files link to their sibling files. The monolithic `schema.json` is - // serialized to disk before this runs (see sync.ts), so it keeps the - // legacy hrefs the existing runtime expects. + // Rewriting happens after buckets exist, so hrefs can point to sibling category files. const lookup = buildCategoryLookup(buckets) for (const bucket of buckets.values()) { rewriteHrefsInPlace(bucket, lookup) @@ -103,13 +89,10 @@ export function bucketSchemaByCategory( return buckets } -// Write `schema-.json` files into `dir`. Categories with no items -// for this version get an empty file so the loader has a deterministic file -// to consume (rather than relying on filesystem stat). +// Emit every schema-.json file so the loader never stats missing categories. export async function writeCategoryFiles(dir: string, buckets: CategoryBuckets): Promise { await fs.mkdir(dir, { recursive: true }) - // First, delete any stale schema-*.json files so a category that becomes - // empty in a new sync doesn't leave behind a stale file. + // Remove schema-*.json files before writing, so categories with no items keep no data. let existing: string[] = [] try { existing = await fs.readdir(dir) @@ -121,7 +104,7 @@ export async function writeCategoryFiles(dir: string, buckets: CategoryBuckets): try { await fs.unlink(path.join(dir, file)) } catch { - // ignore + // Keep writing other category files if one stale file cannot be removed. } } } @@ -132,8 +115,7 @@ export async function writeCategoryFiles(dir: string, buckets: CategoryBuckets): await fs.writeFile(filepath, JSON.stringify(bucket, null, 2), 'utf8') } - // Also emit a small category-map.json used at runtime by the GraphQL - // category redirect middleware. Shape: { [kindKey]: { [id]: category } } + // category-map.json shape is { [kindKey]: { [id]: category } } for GraphQL redirects. const categoryMap: Partial>> = {} for (const kind of ALL_KIND_KEYS) { const byId: Record = {} diff --git a/src/graphql/scripts/utils/process-previews.ts b/src/graphql/scripts/utils/process-previews.ts index 4a9197faa42f..8563ec9f7ccb 100644 --- a/src/graphql/scripts/utils/process-previews.ts +++ b/src/graphql/scripts/utils/process-previews.ts @@ -22,19 +22,17 @@ const inputOrPayload = /(Input|Payload)$/m export default function processPreviews(previews: RawPreview[]): ProcessedPreview[] { return previews.map((raw) => { let title = sentenceCase(raw.title) - .replace(/ -.+/, '') // remove any extra info that follows a hyphen - .replace('it hub', 'itHub') // fix overcorrected `git hub` from sentenceCasing - .replace(' s ', "'s ") // sentenceCase replaces apostrophes with spaces + .replace(/ -.+/, '') + .replace('it hub', 'itHub') // sentenceCase rewrites GitHub as Git hub. + .replace(' s ', "'s ") // sentenceCase replaces apostrophes with spaces. - // Add `preview` to the end of titles if needed title = title.endsWith('preview') ? title : `${title} preview` - // filter out schema members that end in `Input` or `Payload` + // Preview pages omit generated Input and Payload members. const toggled_on = raw.toggled_on.filter( (schemaMember: string) => !inputOrPayload.test(schemaMember), ) - // remove unnecessary leading colon const toggled_by = raw.toggled_by.replace(':', '') const accept_header = `application/vnd.github.${toggled_by}+json` @@ -42,7 +40,6 @@ export default function processPreviews(previews: RawPreview[]): ProcessedPrevie slugger.reset() const href = `/graphql/overview/schema-previews#${slugger.slug(title)}` - // Preserve all original properties except announcement/updates return { title, description: raw.description, diff --git a/src/graphql/scripts/utils/process-schemas.ts b/src/graphql/scripts/utils/process-schemas.ts index 9abfd2995ee2..034691b44248 100755 --- a/src/graphql/scripts/utils/process-schemas.ts +++ b/src/graphql/scripts/utils/process-schemas.ts @@ -22,7 +22,6 @@ interface PreviewInfo { toggled_by: string[] } -// Interface for arguments returned by helpers.getArguments() interface FieldArgumentInfo { name: string // GraphQL scalar default values come through the AST as a string or boolean. @@ -207,23 +206,16 @@ interface ProcessedSchemaData { scalars: ScalarInfo[] } -// All processed items get an optional `category` field once the schema has -// been categorized. Using `& { category: string }` at the type level would -// require touching every interface, so we keep it loose here and rely on the -// runtime guarantee that every emitted item has a category. - +// Category stays loose on emitted items to avoid duplicating it across every output interface. const externalScalarsJSON: Array<{ name: string; description: string }> = JSON.parse( await fs.readFile(path.join(process.cwd(), './src/graphql/lib/non-schema-scalars.json'), 'utf-8'), ) const externalScalars: ScalarInfo[] = await Promise.all( externalScalarsJSON.map(async (scalar): Promise => { - // These live in a local JSON file rather than the versioned schema, and - // their only link is external, so they need no version context. + // Local non-schema scalars have external links and need no docs version context. const description = await baseHelpers.getDescription(scalar.description) const id = baseHelpers.getId(scalar.name) - // External scalars (e.g. Date, URI) are not annotated upstream and live - // in the "other" bucket. Emit the legacy href; bucket-by-category will - // rewrite it to the category-aware form for per-category files. + // External scalars like Date and URI start in other with hrefs for bucket rewriting. const href = baseHelpers.getFullLink('scalars', id) return { name: scalar.name, @@ -235,38 +227,40 @@ const externalScalars: ScalarInfo[] = await Promise.all( }), ) -// Shape of the per-version `category-map.json` used both at runtime by the -// redirect middleware and (here) at build time as a fallback source of -// categories when a schema lacks `@docsCategory` directives. +// category-map.json supplies runtime redirects and build-time fallback. +// The fallback covers schemas without @docsCategory. type CategoryMapFallback = Partial>> -// Selects and formats the schema data the docs need. Runs in the build step. +// processSchemas assigns GraphQL categories before rendering. Explicit @docsCategory wins. +// category-map.json fills GHES schemas without directives. Mutation inputs inherit their owning +// mutation. Connection and Edge types come from graphql-ruby Relay pagination and inherit from +// node, nodes, or edges. Unannotated enum, union, and input object targets inherit only when +// every referrer resolves to one category. Interfaces do not contribute because they are +// cross-cutting. Input object candidates propagate through nested inputs. +// Examples include IssueTimelineItemsItemType, PullRequestTimelineItemsItemType, +// RepositoryRuleType, RuleParameters, and RuleParametersInput. Candidate sets make derivation +// order-independent and retain later conflicting referrers. +// Input-suffixed objects stay included because docs pages exist outside the v4 sidebar. +// https://developer.github.com/v4/input_object/acceptenterpriseadministratorinvitationinput/ +// Categories missing from CATEGORIES in src/graphql/lib/categories.ts normalize to other. export default async function processSchemas( idl: Buffer | string, previewsPerVersion: PreviewInfo[], - // Optional fallback used when the IDL itself has no `@docsCategory` - // directives (e.g. GHES branches cut before the upstream DSL existed). - // Lookups for type-level categories use the type id; mutations look up - // by mutation field name under the `mutations` key. + // Optional fallback for schemas without @docsCategory. + // Type ids are keys, and the mutations map uses field names. fallbackCategoryMap?: CategoryMapFallback, - // The docs version being generated, e.g. `enterprise-server@3.22`. Without - // it, links inside schema descriptions render without a version segment. + // Context carries the docs version key so schema description links get a version segment. context: Context = {}, ): Promise { const helpers = createSchemaHelpers(context) const schemaAST: DocumentNode = parse(idl.toString()) const schema: GraphQLSchema = buildASTSchema(schemaAST) - // list of objects is used when processing mutations const objectsInSchema = schemaAST.definitions.filter( (def): def is ObjectTypeDefinitionNode => def.kind === 'ObjectTypeDefinition', ) - // PASS 1: Build a typeId -> category map by reading the @docsCategory - // directive on every categorizable definition. Queries derive their - // category from the return type's category; mutations are annotated on - // each Mutation root field rather than on a type, so we collect those - // separately. + // Read @docsCategory before deriving fallback categories. const typeCategoryMap = new Map() const mutationFieldCategoryMap = new Map() @@ -295,51 +289,32 @@ export default async function processSchemas( } } - // Build a flat fallback id -> cat map across every type-level kind. (We - // exclude queries: query categories are derived from the return type. - // Mutations are kept separately since they're keyed by field name.) + // Fallback type categories skip queries and keep mutations keyed by field name. const fallbackTypeMap: Record = {} if (fallbackCategoryMap) { for (const kind of Object.keys(fallbackCategoryMap)) { if (kind === 'queries' || kind === 'mutations') continue const sub = fallbackCategoryMap[kind] || {} for (const id of Object.keys(sub)) { - // First write wins; in practice ids don't collide across kinds. + // First write wins; ids do not collide across kinds in practice. if (!(id in fallbackTypeMap)) fallbackTypeMap[id] = sub[id] } } } const fallbackMutationMap = fallbackCategoryMap?.mutations || {} - // PASS 1.5: derive categories for types that github/github cannot annotate - // directly. Two rules apply, both run before fallback / OTHER assignment so - // they take effect for fpt and ghec (where the IDL has the annotations) and - // also propagate into the per-version category-map.json that GHES <3.22 - // consumes as its fallback. - // - // (a) Input objects inherit from their owning mutation. The DSL can mark - // a mutation field with @docsCategory but the generated *Input type - // isn't annotated; we copy the mutation's category onto each input - // argument's named type. - // (b) Connection / Edge types inherit from their underlying type. These - // are emitted by graphql-ruby's Relay pagination and never get a - // hand-written docs_category. We walk `node`/`nodes`/`edges` to the - // referenced object type and copy its category. - // - // Explicit annotations always win; derivation only fills gaps. + // Derive missing categories before fallback and other assignment so GHES fallback inherits them. const lookupCat = (id: string): string | undefined => typeCategoryMap.get(id) ?? fallbackTypeMap[id] const getMutationCat = (mutFieldName: string): string | undefined => mutationFieldCategoryMap.get(mutFieldName) ?? fallbackMutationMap[mutFieldName.toLowerCase()] - // Walk through a TypeNode chain (NonNull/List wrappers) to the NamedType. const namedTypeName = (typeNode: TypeNode): string | undefined => { let t: TypeNode = typeNode while ('type' in t) t = t.type return t.kind === 'NamedType' ? t.name.value : undefined } - // (a) input objects from mutation field args const mutationDef = schemaAST.definitions.find( (def): def is ObjectTypeDefinitionNode => def.kind === 'ObjectTypeDefinition' && def.name.value === 'Mutation', @@ -362,9 +337,7 @@ export default async function processSchemas( } } - // (b) Connection / Edge types from their underlying type. Run multiple - // passes so an XConnection that points at XEdge can still resolve after - // XEdge itself has been derived (Connection -> Edge -> object). + // Multiple passes let Connection to Edge to object chains inherit the object category. const objectDefs = schemaAST.definitions.filter( (def): def is ObjectTypeDefinitionNode => def.kind === 'ObjectTypeDefinition', ) @@ -378,7 +351,7 @@ export default async function processSchemas( if (!isEdge && !isConn) continue const id = helpers.getId(name) if (lookupCat(id)) continue - // Edge: walk `node`. Connection: prefer `nodes` (direct), else `edges`. + // Edge types use node; Connection types prefer nodes, then edges. const fields = def.fields || [] let underlyingName: string | undefined if (isEdge) { @@ -402,39 +375,7 @@ export default async function processSchemas( if (!changed) break } - // (c) General reference-based inheritance. An un-annotated enum, union, or - // input object inherits the category of the type(s) that reference it, but - // only when every referrer resolves to a single category; ambiguous types - // (referrers disagree, or a referrer is itself ambiguous) stay in `other`. - // This is the derived successor to a static exception list: it catches - // generated/indirect types that github/github never annotates directly while - // still letting the upstream team own the outcome via the parent type's - // `docs_category`. - // - // Examples this resolves today: - // - `IssueTimelineItemsItemType` / `PullRequestTimelineItemsItemType`: - // runtime-generated enums used only as the `itemTypes` argument on - // `Issue.timelineItems` (issues) / `PullRequest.timelineItems` (pulls). - // - `RepositoryRuleType` (enum) and `RuleParameters` (union): referenced - // from the annotated `RepositoryRule` object (repos). - // - `RuleParametersInput` (input): referenced from the annotated - // `RepositoryRuleInput` input object (repos). - // - // A "referrer category" is the category of: - // - the owning object type, for a field's return type or a field argument's - // type (interfaces are intentionally excluded: they are cross-cutting and - // make coincidental single-category matches likely); - // - the Mutation root field, for that field's arguments; - // - the owning input object, for an input field's type. Input objects can - // themselves be uncategorized-but-derivable, so this rule propagates - // transitively through nested inputs. - // - // Implemented as a monotone fixpoint over candidate category *sets* rather - // than committing categories as we go: a type is only assigned once its - // candidate set has stopped growing, so the result is independent of - // definition/derivation order and a later-discovered conflicting referrer - // can never be missed. Explicit annotations and derivations (a)/(b) always - // win: we only compute candidates for ids `lookupCat` still can't resolve. + // Reference-based inheritance assigns a category only when referrers resolve to one category. const derivableTargets = schemaAST.definitions.filter( ( def, @@ -457,10 +398,7 @@ export default async function processSchemas( const candidates = new Map>() for (const id of targetIds) candidates.set(id, new Set()) - // Categories a type contributes when it appears as a referrer. Annotated / - // fallback types contribute their single category; an uncommitted derivable - // referrer (only ever an input object here) contributes its current - // candidate set so ambiguity propagates downstream. + // Uncommitted input object referrers contribute candidate sets so ambiguity propagates. const contribution = (referrerId: string): Iterable => { const explicit = lookupCat(referrerId) if (explicit) return [explicit] @@ -481,8 +419,7 @@ export default async function processSchemas( return grew } - // Bounded by the worst-case propagation depth; each pass only adds to sets, - // so this terminates well before the cap. + // Each pass only adds candidates, so maxPasses bounds the propagation depth. const maxPasses = targetIds.size + 2 for (let pass = 0; pass < maxPasses; pass++) { let changed = false @@ -492,8 +429,7 @@ export default async function processSchemas( if (name === 'Query') continue const isMutation = name === 'Mutation' for (const field of def.fields || []) { - // Mutation fields carry their own category and their payload return - // type is already annotated, so (like rule (a)) we only walk args. + // Mutation categories apply to args; payload return types already carry categories. const fieldCats: Iterable = isMutation ? ((c) => (c ? [c] : []))(getMutationCat(field.name.value)) : contribution(helpers.getId(name)) @@ -514,33 +450,18 @@ export default async function processSchemas( if (!changed) break } - // Assign only the targets whose final candidate set is unambiguous. for (const [id, cats] of candidates) { if (cats.size === 1) typeCategoryMap.set(id, [...cats][0]) } } - // Populates the top-level `.category` field on every processed item. The - // bucketer reads `.category` to split the schema into per-category files and - // to rewrite cross-reference hrefs. - // - // Unknown categories (e.g. `:checks`, `:search`, `:packages`, - // `:security_advisories`) normalize to `other`. The upstream gh/gh allowlist - // permits many categories that docs-internal has not built per-category - // landing pages for; without this fallback those types would be silently - // dropped by `writeCategoryFiles` (which only emits files for slugs in - // CATEGORIES) and their redirects would 404. Once a page exists for a - // category, add it to CATEGORIES in src/graphql/lib/categories.ts and types - // will move out of `other` on the next sync. + // Unknown categories normalize to other so writeCategoryFiles does not drop types or redirects. const resolveCategory = (typeId: string): string => { const cat = typeCategoryMap.get(typeId) ?? fallbackTypeMap[typeId] ?? OTHER_CATEGORY return isValidCategory(cat) ? cat : OTHER_CATEGORY } - // process-schemas emits legacy `/graphql/reference/#` hrefs - // throughout so the monolithic `schema.json` stays compatible with the - // existing runtime loader. The bucketer rewrites these to the - // category-aware form when emitting per-category schema files. + // linkTo emits reference hrefs; bucket-by-category rewrites only per-category schema files. const linkTo = (urlKind: string, id: string): string => helpers.getFullLink(urlKind, id) const data: ProcessedSchemaData = { @@ -635,10 +556,7 @@ export default async function processSchemas( mutation.name = field.name.value mutation.id = helpers.getId(mutation.name) - // Mutation fields carry @docsCategory at the field level on the - // Mutation root, not on the payload type, so use the field map. - // Normalize via isValidCategory so an upstream-only category - // doesn't produce hrefs/buckets we don't ship pages for. + // Mutation fields carry @docsCategory on the field, not the payload type. const rawMutationCategory = mutationFieldCategoryMap.get(mutation.name) ?? fallbackMutationMap[mutation.name.toLowerCase()] ?? @@ -661,7 +579,7 @@ export default async function processSchemas( previewsPerVersion, ) - // there is only ever one input field argument, but loop anyway + // Mutation fields have one input argument in practice, but the schema exposes an array. await Promise.all( (field.arguments || []).map(async (arg: InputValueDefinitionNode) => { const inputField: Partial = {} @@ -679,8 +597,7 @@ export default async function processSchemas( mutation.inputFields = sortBy(inputFields, 'name') - // get return fields - // first get the payload, then find payload object's fields. these are the mutation's return fields. + // Mutation return fields come from the payload object's fields. const returnType = helpers.getType(field) if (!returnType) return const mutationReturnFields = objectsInSchema.find( @@ -731,8 +648,7 @@ export default async function processSchemas( } if (def.kind === 'ObjectTypeDefinition') { - // objects ending with 'Payload' are only used to derive mutation values - // they are not included in the objects docs + // Payload objects provide mutation return fields and stay out of the object docs. if (def.name.value.endsWith('Payload')) return const object: Partial = {} @@ -756,8 +672,7 @@ export default async function processSchemas( previewsPerVersion, ) - // an object's interfaces render in the `Implements` section - // interfaces do not have directives so they cannot be under preview/deprecated + // Implements links carry only name, id, and href, without preview or deprecation data. if (def.interfaces && def.interfaces.length) { await Promise.all( def.interfaces.map(async (graphqlInterface) => { @@ -771,7 +686,7 @@ export default async function processSchemas( ) } - // an object's fields render in the `Fields` section + // Object fields render under Fields. if (def.fields && def.fields.length) { await Promise.all( def.fields.map(async (field: FieldDefinitionNode) => { @@ -835,7 +750,7 @@ export default async function processSchemas( previewsPerVersion, ) - // an interface's fields render in the "Fields" section + // Interface fields render under Fields. if (def.fields && def.fields.length) { await Promise.all( def.fields.map(async (field: FieldDefinitionNode) => { @@ -938,7 +853,7 @@ export default async function processSchemas( previewsPerVersion, ) - // union types do not have directives so cannot be under preview/deprecated + // Union member links carry no preview or deprecation state. await Promise.all( (def.types || []).map(async (type) => { const possibleType: PossibleTypeInfo = { @@ -956,10 +871,7 @@ export default async function processSchemas( return } - // INPUT OBJECTS - // NOTE: input objects ending with `Input` are NOT included in the v4 input objects sidebar - // but they are still present in the docs (e.g., https://developer.github.com/v4/input_object/acceptenterpriseadministratorinvitationinput/) - // so we will include them here + // Include Input-suffixed objects; docs pages exist outside the v4 sidebar. if (def.kind === 'InputObjectTypeDefinition') { const inputObject: Partial = {} const inputFields: InputFieldDetailInfo[] = [] @@ -1048,7 +960,6 @@ export default async function processSchemas( }), ) - // add non-schema scalars and sort all scalars alphabetically data.scalars = sortBy(data.scalars.concat(externalScalars), 'name') data.queries = sortBy(data.queries, 'name') diff --git a/src/graphql/scripts/utils/schema-helpers.ts b/src/graphql/scripts/utils/schema-helpers.ts index f7914cd309c4..4cbf514a967b 100644 --- a/src/graphql/scripts/utils/schema-helpers.ts +++ b/src/graphql/scripts/utils/schema-helpers.ts @@ -54,13 +54,11 @@ const graphqlTypes: GraphQLTypeInfo[] = JSON.parse( const singleQuotesInsteadOfBackticks = / '(\S+?)' / -// Upstream schema descriptions link with a `${externalDocsUrl}` placeholder, -// but nothing in this pipeline expands it. It ships percent-encoded as -// `href="$%7BexternalDocsUrl%7D/code-security/..."`, which the browser -// resolves against the current page and 404s. Dropping the placeholder leaves -// a root-relative link, which `getDescription` then versions using the -// `context` handed to `createSchemaHelpers`, so a GHES reader stays on GHES. -// The bare `helpers` export has no context and leaves links unversioned. +// Upstream schema descriptions include ${externalDocsUrl}, but this pipeline never expands it. +// It ships as href="$%7BexternalDocsUrl%7D/code-security/..." and resolves against the +// current page, causing 404s. +// Dropping it leaves a root-relative link that getDescription versions with createSchemaHelpers. +// The bare helpers export has no context and leaves links unversioned. const unexpandedExternalDocsUrl = /\$\{externalDocsUrl\}(?=\/)/g function addPeriod(string: string): string { @@ -84,15 +82,12 @@ async function getArguments( arg.defaultValue && 'value' in arg.defaultValue ? arg.defaultValue.value : undefined newArg.description = arg.description ? await getDescription(arg.description.value, context) : '' const typeName = getType(arg) - if (!typeName) continue // Skip if type cannot be determined + if (!typeName) continue type.name = typeName type.id = getId(typeName) const typeKind = getTypeKind(typeName, schema) - if (!typeKind) continue // Skip if type kind cannot be determined - // process-schemas always emits legacy `/graphql/reference/#` - // hrefs. bucket-by-category rewrites them into the category-aware form - // when splitting into per-category files, so monolithic schema.json stays - // byte-stable with what the existing runtime expects. + if (!typeKind) continue + // getFullLink keeps reference hrefs stable; bucket-by-category rewrites only category files. type.href = getFullLink(typeKind, type.id!) newArg.type = type as TypeInfo newArgs.push(newArg as ArgumentInfo) @@ -101,9 +96,7 @@ async function getArguments( return newArgs } -// Build a category-aware anchor link for a type, e.g. -// `/graphql/reference/repos#object-repository`. Exposed for the bucketer's -// href-rewrite pass; process-schemas itself uses the legacy `getFullLink`. +// buildCategoryHref returns anchors like /graphql/reference/repos#object-repository for rewrites. export function buildCategoryHref(category: string, urlKind: string, id: string): string { return `/graphql/reference/${category}#${slugPrefixForUrlKind(urlKind)}-${id}` } @@ -115,10 +108,10 @@ async function getDeprecationReason( ): Promise { if (!schemaMember.isDeprecated) return - // it's possible for a schema member to be deprecated and under preview + // Deprecated and preview can both apply to one schema member. const deprecationDirective = directives.filter((dir) => dir.name.value === 'deprecated') - // catch any schema members that have more than one deprecation (none currently) + // Multiple deprecation directives indicate upstream schema data needs review. if (deprecationDirective.length > 1) console.log(`more than one deprecation found for ${schemaMember.name}`) @@ -146,8 +139,6 @@ function getFullLink(baseType: string, id: string): string { return `/graphql/reference/${baseType}#${id}` } -// Extract the `@docsCategory(name: "...")` value from a directive list. -// Returns undefined when the directive is absent. function getDocsCategory(directives: readonly ConstDirectiveNode[]): string | undefined { const directive = directives.find((dir) => dir.name.value === 'docsCategory') if (!directive) return @@ -162,7 +153,7 @@ function getId(typeName: string): string { return removeMarkers(typeName).toLowerCase() } -// e.g., given `ObjectTypeDefinition`, get `objects` +// Example: ObjectTypeDefinition maps to objects. function getKind(type: string): string { return graphqlTypes.find((graphqlType) => graphqlType.type === type)!.kind } @@ -174,15 +165,15 @@ async function getPreview( ): Promise { if (!directives.length) return - // it's possible for a schema member to be deprecated and under preview + // Deprecated and preview can both apply to one schema member. const previewDirective = directives.filter((dir) => dir.name.value === 'preview') if (!previewDirective.length) return - // catch any schema members that are under more than one preview (none currently) + // Log multiple preview directives from the schema AST; the script expects at most one. if (previewDirective.length > 1) console.log(`more than one preview found for ${schemaMember.name}`) - // an input object's input field may have a ListValue directive that is not relevant to previews + // Ignore ListValue preview directives on input fields because previews use string values. const firstArg = previewDirective[0]?.arguments?.[0] if (!firstArg) return const argValue = firstArg.value @@ -196,48 +187,40 @@ async function getPreview( return preview } -// the docs use brackets to denote list types: `[foo]` -// and an exclamation mark to denote non-nullable types: `foo!` -// both single items and lists can be non-nullable -// so the permutations are: -// 1. single items: `foo`, `foo!` -// 2. nullable lists: `[foo]`, `[foo!]` -// 3. non-null lists: `[foo]!`, `[foo!]!` -// see https://github.com/rmosolgo/graphql-ruby/blob/master/guides/type_definitions/lists.md#lists-nullable-lists-and-lists-of-nulls +// GraphQL list and non-null wrappers combine as foo, foo!, [foo], [foo!], [foo]!, +// and [foo!]!. +// See https://github.com/rmosolgo/graphql-ruby/blob/master/guides/type_definitions/lists.md#lists-nullable-lists-and-lists-of-nulls function getType(field: FieldNode): string | undefined { - // 1. single items if (field.type.kind !== 'ListType') { - // nullable item, e.g. `license` query has `License` type + // Nullable item example: license query has License type. if (field.type.kind === 'NamedType') { return field.type.name.value } - // non-null item, e.g. `meta` query has `GitHubMetadata!` type + // Non-null item example: meta query has GitHubMetadata! type. if (field.type.kind === 'NonNullType' && field.type.type.kind === 'NamedType') { return `${field.type.type.name.value}!` } } - // 2. nullable lists if (field.type.kind === 'ListType') { - // nullable items, e.g. `codesOfConduct` query has `[CodeOfConduct]` type + // Nullable list example: codesOfConduct query has [CodeOfConduct] type. if (field.type.type.kind === 'NamedType') { return `[${field.type.type.name.value}]` } - // non-null items, e.g. `severities` arg has `[SecurityAdvisorySeverity!]` type + // Nullable list example: severities arg has [SecurityAdvisorySeverity!] type. if (field.type.type.kind === 'NonNullType' && field.type.type.type.kind === 'NamedType') { return `[${field.type.type.type.name.value}!]` } } - // 3. non-null lists if (field.type.kind === 'NonNullType' && field.type.type.kind === 'ListType') { - // nullable items, e.g. `licenses` query has `[License]!` type + // Non-null list example: licenses query has [License]! type. if (field.type.type.type.kind === 'NamedType') { return `[${field.type.type.type.name.value}]!` } - // non-null items, e.g. `marketplaceCategories` query has `[MarketplaceCategory!]!` type + // Non-null list example: marketplaceCategories query has [MarketplaceCategory!]! type. if ( field.type.type.type.kind === 'NonNullType' && field.type.type.type.type.kind === 'NamedType' @@ -296,12 +279,9 @@ const helpers = { getTypeKind, } -// The three helpers that render Markdown need to know which docs version they -// are rendering for, otherwise `rewrite-local-links` bails out and root-relative -// links ship without a language or version segment. Binding the context once -// here keeps the ~30 call sites in `process-schemas` unchanged, and keeps the -// context per-call rather than in module state, so two versions can never -// render against each other's context. +// Markdown helpers need docs version context, or rewrite-local-links omits language and version. +// Binding context once keeps the ~30 process-schemas call sites unchanged and per-call. +// Per-call context prevents concurrent versions from rendering against each other. export function createSchemaHelpers(context: Context): typeof helpers { return { ...helpers, diff --git a/src/graphql/scripts/utils/sync-category-content.ts b/src/graphql/scripts/utils/sync-category-content.ts index 02a58d150c5d..5e71068971e5 100644 --- a/src/graphql/scripts/utils/sync-category-content.ts +++ b/src/graphql/scripts/utils/sync-category-content.ts @@ -10,36 +10,27 @@ import { } from '@/automated-pipelines/lib/update-markdown' import { CATEGORIES, OTHER_CATEGORY, categoryTitle } from '@/graphql/lib/categories' -// Default directory holding the per-category GraphQL reference content pages. -// Overridable via options for tests; production always uses this path. +// Tests can override the content directory; production uses content/graphql/reference. const DEFAULT_CONTENT_DIR = path.join('content', 'graphql', 'reference') -// Value of the `autogenerated` frontmatter on managed category pages. The -// content-directory helper uses this to know which files it owns (and may -// therefore delete when a category empties). +// updateContentDirectory deletes only pages whose autogenerated frontmatter matches graphql. const AUTOGENERATED_TYPE = 'graphql' // Breadcrumb category the reference pages sit under in the sidebar. const CATEGORY_BREADCRUMB = 'Explore the schema reference' -// Maps a category slug to the set of docs version keys (e.g. -// `free-pro-team@latest`, `enterprise-server@3.22`) in which the category has -// at least one type. Built by sync.ts from the per-version buckets. +// Maps each category slug to docs version keys where at least one type exists; sync.ts builds it. export type CategoryPresence = Map> const categoryUrlPath = (cat: string) => `/graphql/reference/${cat}` -// Matches a bare category reference URL (no fragment), e.g. -// `/graphql/reference/code-scanning`. Kind pages like -// `/graphql/reference/queries` also match this shape but are filtered out -// because their slug is not in CATEGORIES. +// CATEGORY_URL_RE matches bare category URLs like /graphql/reference/code-scanning. +// Kind pages like /graphql/reference/queries match this shape but get filtered later. const CATEGORY_URL_RE = /^\/graphql\/reference\/([a-z][a-z0-9-]*)$/ function isPresentInAnyVersion(presence: CategoryPresence, cat: string): boolean { return (presence.get(cat)?.size ?? 0) > 0 } -// Read the `redirect_from` of every managed category page before the content -// helper potentially deletes those files, so redirect chains aren't lost when a -// category disappears. Returns a map of category slug -> redirect_from entries. +// Capture managed category redirects before deletion so disappearance redirects keep resolving. async function captureCategoryRedirects(contentDir: string): Promise> { const captured = new Map() let files: string[] = [] @@ -72,10 +63,7 @@ function normalizeRedirects(value: unknown): string[] { return [] } -// Build the `sourceContent` map the content-directory helper expects: -// `{ : { data: , content: } }`. Only categories -// that are non-empty in at least one version get a page; emptied categories are -// omitted so the helper deletes their stale files. +// buildSourceContent omits empty categories so updateContentDirectory deletes stale pages. async function buildSourceContent(presence: CategoryPresence, contentDir: string) { const sourceContent: Record; content: string }> = {} for (const cat of CATEGORIES) { @@ -84,9 +72,7 @@ async function buildSourceContent(presence: CategoryPresence, contentDir: string const versions = await convertVersionsToFrontmatter([...versionsSet]) const title = categoryTitle(cat) const file = path.join(contentDir, `${cat}.md`) - // For pages that already exist, the helper only refreshes `versions` and the - // autogenerated body, preserving any writer edits to title/intro/category. - // These values therefore only seed brand-new category pages. + // Managed pages keep writer-edited title, intro, and category; values apply only at creation. sourceContent[file] = { data: { title, @@ -102,10 +88,8 @@ async function buildSourceContent(presence: CategoryPresence, contentDir: string return sourceContent } -// Reconcile the reference index `redirect_from` so that a bare category URL -// redirects to the reference root when (and only when) that category is empty in -// every version. Categories present in at least one version must NOT have a -// redirect, otherwise a still-valid versioned page would be shadowed. +// Empty categories redirect to the reference root only when every version lacks that category. +// Present categories must not redirect, or they shadow still-valid versioned pages. async function reconcileIndexRedirects( presence: CategoryPresence, capturedRedirects: Map, @@ -120,9 +104,7 @@ async function reconcileIndexRedirects( const { data, content } = matter(raw) const existing = normalizeRedirects(data.redirect_from) - // Drop redirects for managed categories that are now present (e.g. a category - // that previously emptied and has since come back). Leave kind-page redirects - // (queries, mutations, ...) and non-category redirects (/v4/reference) intact. + // Drop redirects for categories that returned; keep kind-page and non-category redirects intact. const next = existing.filter((entry) => { const match = CATEGORY_URL_RE.exec(entry) if (!match) return true @@ -131,16 +113,13 @@ async function reconcileIndexRedirects( return !isPresentInAnyVersion(presence, cat) }) - // Add a root redirect for every managed category that is empty in all - // versions. `other` is always present (un-annotated types), so it never - // disappears, but guard against it defensively. + // Add root redirects for categories empty in all versions; other never disappears. for (const cat of CATEGORIES) { if (cat === OTHER_CATEGORY) continue if (isPresentInAnyVersion(presence, cat)) continue const url = categoryUrlPath(cat) if (!next.includes(url)) next.push(url) - // Preserve any redirect_from the deleted category page carried so existing - // inbound redirect chains keep resolving. + // Carry redirect_from from deleted category pages so inbound chains keep resolving. for (const inherited of capturedRedirects.get(cat) ?? []) { if (!next.includes(inherited)) next.push(inherited) } @@ -151,10 +130,8 @@ async function reconcileIndexRedirects( await fs.writeFile(indexFile, matter.stringify(content, data)) } -// Entry point used by sync.ts after it has bucketed every version. Creates, -// updates, and deletes the per-category content pages, refreshes the reference -// index children, and reconciles disappearance redirects. `contentDir` is -// overridable for tests; production uses the default reference directory. +// Creates, updates, and deletes category pages after sync.ts buckets every version. +// Also refreshes index children and disappearance redirects; tests can override contentDir. export async function syncCategoryContentFiles( presence: CategoryPresence, options: { contentDir?: string } = {}, diff --git a/src/graphql/tests/build-changelog.ts b/src/graphql/tests/build-changelog.ts index 420d659ec06d..ed140730d217 100644 --- a/src/graphql/tests/build-changelog.ts +++ b/src/graphql/tests/build-changelog.ts @@ -59,8 +59,6 @@ describe('creating a changelog from old schema and new schema', () => { }) test('ignores unknown change types without throwing errors', async () => { - // Create a minimal test that would generate an unknown change type - // This test ensures the system gracefully handles new change types const oldSchemaString = ` type Query { field: String @@ -76,8 +74,7 @@ describe('creating a changelog from old schema and new schema', () => { } ` - // This should generate TypeDescriptionAdded change type - // which should be silently ignored if not in CHANGES_TO_REPORT + // CHANGES_TO_REPORT omits TypeDescriptionAdded, so createChangelogEntry ignores it. const entry: ChangelogEntry | null = await createChangelogEntry( oldSchemaString, newSchemaString, @@ -86,14 +83,10 @@ describe('creating a changelog from old schema and new schema', () => { [], ) - // Should return null since TypeDescriptionAdded is not in CHANGES_TO_REPORT - // and will be silently ignored without throwing an error expect(entry).toBeNull() }) test('handles new directive usage change types gracefully', async () => { - // Test that verifies the system can handle new directive-related change types - // that were previously causing errors in the pipeline const oldSchemaString = ` directive @example on FIELD_DEFINITION @@ -110,8 +103,7 @@ describe('creating a changelog from old schema and new schema', () => { } ` - // This should generate DirectiveUsage* change types that are not in CHANGES_TO_REPORT - // The system should silently ignore these and not throw errors + // CHANGES_TO_REPORT omits added field directives, so createChangelogEntry ignores this one. const entry: ChangelogEntry | null = await createChangelogEntry( oldSchemaString, newSchemaString, @@ -120,7 +112,6 @@ describe('creating a changelog from old schema and new schema', () => { [], ) - // Should return null since directive usage changes are typically ignored expect(entry).toBeNull() }) @@ -219,12 +210,10 @@ upcoming_changes: describe('Preparing preview links', () => { test('fixes preview names', () => { - // These two are special cases + // UpdateRefsPreview and MergeInfoPreview are hand-written title exceptions. expect(cleanPreviewTitle('UpdateRefsPreview')).toEqual('Update refs preview') expect(cleanPreviewTitle('MergeInfoPreview')).toEqual('Merge info preview') - // Previews that don't end in " preview" have it added expect(cleanPreviewTitle('something interesting')).toEqual('something interesting preview') - // Other things are left as-is expect(cleanPreviewTitle('nice preview')).toEqual('nice preview') }) @@ -253,7 +242,6 @@ describe('updating the changelog file', () => { prependDatedEntry(exampleEntry, testTargetPath) const newContents: string = await fs.readFile(testTargetPath, 'utf8') - // reset the file: await fs.writeFile(testTargetPath, previousContents.toString()) expect(exampleEntry).toEqual({ @@ -299,10 +287,8 @@ describe('ensureYearPage', () => { ensureYearPage('2026', tmpDir) - // Should not modify the existing file const yearPage = await fs.readFile(`${tmpDir}/2026.md`, 'utf8') expect(yearPage).toContain('title: existing') - // index.md should be unchanged const updatedIndex = await fs.readFile(`${tmpDir}/index.md`, 'utf8') expect(updatedIndex).toBe(indexContent) }) @@ -325,7 +311,7 @@ describe('ignored changes tracking', () => { } ` - // This should generate a TypeDescriptionAdded change type that gets ignored + // Ignored-change tracking records TypeDescriptionAdded. await createChangelogEntry(oldSchemaString, newSchemaString, [], [], []) const ignoredChanges: IgnoredChange[] = getLastIgnoredChanges() as unknown as IgnoredChange[] @@ -350,7 +336,7 @@ describe('ignored changes tracking', () => { } ` - // This should generate multiple DirectiveUsage changes that get ignored + // Ignored-change summary groups multiple DirectiveUsage changes under one type. await createChangelogEntry(oldSchemaString, newSchemaString, [], [], []) const summary = getIgnoredChangesSummary() @@ -369,7 +355,6 @@ describe('ignored changes tracking', () => { } ` - // No changes should be generated await createChangelogEntry(schemaString, schemaString, [], [], []) const summary = getIgnoredChangesSummary() diff --git a/src/graphql/tests/derive-categories.ts b/src/graphql/tests/derive-categories.ts index fa3138ef1baa..4a0b675d88b8 100644 --- a/src/graphql/tests/derive-categories.ts +++ b/src/graphql/tests/derive-categories.ts @@ -2,14 +2,12 @@ import { describe, expect, test } from 'vitest' import processSchemas from '../scripts/utils/process-schemas' -// Minimal `@docsCategory` directive declaration so `buildASTSchema` can parse -// the fixtures below. Mirrors the real declaration emitted by github/github. +// Minimal @docsCategory lets buildASTSchema parse fixtures and mirrors github/github output. const DIRECTIVE = ` directive @docsCategory(name: String!) on ENUM | FIELD_DEFINITION | INPUT_OBJECT | INTERFACE | OBJECT | UNION ` -// Run processSchemas over an inline IDL and return a flat name -> category map -// across every kind, so tests can assert where a type landed. +// A flat name to category map lets each fixture assert where every kind landed. async function categoriesFor(idl: string): Promise> { const data = await processSchemas(`${DIRECTIVE}\n${idl}`, []) const out: Record = {} @@ -21,7 +19,7 @@ async function categoriesFor(idl: string): Promise> { return out } -// Every fixture needs a Query root; a `viewer` field keeps it non-empty. +// GraphQL fixtures need a non-empty Query root. const QUERY = ` type Query { viewer: String @@ -53,7 +51,7 @@ describe('reference-based category derivation (PASS 1.5 rule c)', () => { }) test('enum inherits from an annotated object field argument', async () => { - // Mirrors Issue.timelineItems(itemTypes: [IssueTimelineItemsItemType!]). + // Mirrors Issue.timelineItems with itemTypes [IssueTimelineItemsItemType!]. const cats = await categoriesFor(` ${QUERY} type Issue @docsCategory(name: "issues") { @@ -74,7 +72,7 @@ describe('reference-based category derivation (PASS 1.5 rule c)', () => { input NestedParametersInput { value: String } `) expect(cats.RuleParametersInput).toBe('repos') - // Propagates another hop through the still-uncategorized input chain. + // NestedParametersInput proves inheritance propagates through an uncategorized input chain. expect(cats.NestedParametersInput).toBe('repos') }) @@ -117,8 +115,7 @@ describe('reference-based category derivation (PASS 1.5 rule c)', () => { }) test('interfaces are not a referrer source', async () => { - // Interface is annotated and references the enum, but no object does, so - // the enum must not inherit the interface's category. + // Interfaces are not referrer sources, so their referenced enums stay in other. const cats = await categoriesFor(` ${QUERY} interface Rulable @docsCategory(name: "repos") { diff --git a/src/graphql/tests/description-links.ts b/src/graphql/tests/description-links.ts index 19a20e1ea6b2..1f07d84e5aca 100644 --- a/src/graphql/tests/description-links.ts +++ b/src/graphql/tests/description-links.ts @@ -13,8 +13,7 @@ describe('GraphQL description links', () => { test('strips the unexpanded externalDocsUrl placeholder', async () => { const rendered = await helpers.getDescription(PLACEHOLDER_LINK) - // Left in place the placeholder percent-encodes into the href and the - // browser resolves it against the current page, which 404s. + // An unexpanded placeholder becomes a page-relative href and 404s. expect(rendered).not.toContain('externalDocsUrl') expect(rendered).toContain('href="/code-security/code-scanning#levels"') }) diff --git a/src/graphql/tests/server-rendering.ts b/src/graphql/tests/server-rendering.ts index 177bb94f37d3..deace3a4f0fe 100644 --- a/src/graphql/tests/server-rendering.ts +++ b/src/graphql/tests/server-rendering.ts @@ -23,12 +23,9 @@ describe('server rendering certain GraphQL pages', () => { expect.assertions(hrefs.length + 1) }) + // Request the changelog twice because github-slugger state can add suffixes on the second render. + // The mini-TOC hrefs must match those heading IDs. test('minitoc hrefs on changelog match and verify slugger behavior', async () => { - // Testing the minitoc links match the heading ids but also validating - // slugger behavior see docs-engineering/issues#5792. - // Little funky because we need to make 2 requests to the page to test - // the problem behavior where slugger state accumulates across - // requests, it won't fail the first time around. await getDOM('/graphql/overview/changelog') const $ = await getDOM('/graphql/overview/changelog') const links = $('[data-testid="minitoc"] a[href]') diff --git a/src/graphql/tests/sync-category-content.ts b/src/graphql/tests/sync-category-content.ts index ef3690219664..6de96d3afc5b 100644 --- a/src/graphql/tests/sync-category-content.ts +++ b/src/graphql/tests/sync-category-content.ts @@ -17,8 +17,7 @@ const GHEC = 'enterprise-cloud@latest' const REFERENCE_DIR = path.join('content', 'graphql', 'reference') -// The full set of categories present in a steady-state fixture. Returned as a -// fresh Map each call so tests never share mutable state. +// A fresh steady-state presence map prevents tests from sharing mutable state. function steadyPresence(): CategoryPresence { return new Map([ ['actions', new Set([FPT, GHEC])], @@ -28,7 +27,6 @@ function steadyPresence(): CategoryPresence { ]) } -// Write an autogenerated category page with the given versions frontmatter. async function writeCategoryFile( root: string, cat: string, @@ -104,28 +102,23 @@ describe('syncCategoryContentFiles', () => { await writeCategoryFile(root, 'other', { fpt: '*', ghec: '*' }) const presence = steadyPresence() - presence.delete('code-scanning') // emptied -> absent in all versions + presence.delete('code-scanning') // A missing category means it has no types in any version. await syncCategoryContentFiles(presence, { contentDir }) - // Emptied category file is deleted; populated ones remain. expect(existsSync(path.join(root, REFERENCE_DIR, 'code-scanning.md'))).toBe(false) expect(existsSync(path.join(root, REFERENCE_DIR, 'actions.md'))).toBe(true) expect(existsSync(path.join(root, REFERENCE_DIR, 'other.md'))).toBe(true) - // Versions are narrowed to where the category actually has types. const sponsors = matter(await readFile(path.join(root, REFERENCE_DIR, 'sponsors.md'), 'utf8')) expect(sponsors.data.versions).toEqual({ fpt: '*' }) const index = await readIndex(root) - // Sidebar children drop the emptied category. expect(index.data.children).not.toContain('/code-scanning') expect(index.data.children).toContain('/actions') expect(index.data.children).toContain('/sponsors') expect(index.data.children).toContain('/other') - // Disappeared category gets a root redirect; unrelated redirects are kept; - // present categories are never redirected. expect(index.data.redirect_from).toContain('/graphql/reference/code-scanning') expect(index.data.redirect_from).toContain('/v4/reference') expect(index.data.redirect_from).toContain('/graphql/reference/queries') @@ -133,8 +126,7 @@ describe('syncCategoryContentFiles', () => { }) test('recreates a reappearing category and removes its stale redirect', async () => { - // Seed the post-deletion state: code-scanning has no page and carries a - // disappearance redirect on the index. + // Seed the post-deletion state: code-scanning has no page but redirects to the index. await writeIndex( root, ['/actions', '/sponsors', '/other'], @@ -150,7 +142,6 @@ describe('syncCategoryContentFiles', () => { const index = await readIndex(root) expect(index.data.children).toContain('/code-scanning') expect(index.data.redirect_from).not.toContain('/graphql/reference/code-scanning') - // Unrelated redirects survive the reconciliation. expect(index.data.redirect_from).toContain('/v4/reference') }) @@ -161,10 +152,8 @@ describe('syncCategoryContentFiles', () => { await writeCategoryFile(root, 'code-scanning', { fpt: '*', ghec: '*' }) await writeCategoryFile(root, 'other', { fpt: '*', ghec: '*' }) - // First run establishes the canonical steady state for this presence. await syncCategoryContentFiles(steadyPresence(), { contentDir }) const before = await snapshotReferenceDir(root) - // Second run with identical input must be a no-op. await syncCategoryContentFiles(steadyPresence(), { contentDir }) const after = await snapshotReferenceDir(root) diff --git a/src/graphql/tests/validate-schema.ts b/src/graphql/tests/validate-schema.ts index 55ce99991162..19ee80a23e54 100644 --- a/src/graphql/tests/validate-schema.ts +++ b/src/graphql/tests/validate-schema.ts @@ -20,13 +20,11 @@ const upcomingChangesValidate = getJsonValidator(upcomingChangesValidator) describe('graphql json files', () => { vi.setConfig({ testTimeout: 3 * 60 * 1000 }) - // The typeObj is repeated thousands of times across the per-category files - // so cache validated objects to speed this test up significantly. + // typeObj repeats thousands of times across category files. + // Cache validated objects to keep this test fast. const typeObjsTested = new Set() for (const version of graphqlVersions) { - // Merge every per-category schema-*.json into one in-memory shape - // mirroring the legacy monolithic schema.json so the rest of the test - // logic stays unchanged. + // Merge category schema files into the monolithic shape that schemaValidator expects. const schemaJsonPerVersion: Record> = {} for (const type of graphqlTypes) schemaJsonPerVersion[type] = [] for (const category of CATEGORIES) { @@ -83,7 +81,6 @@ describe('graphql json files', () => { `${GRAPHQL_DATA_DIR}/${version}/upcoming-changes.json`, ) as Record for (const changes of Object.values(upcomingChanges)) { - // each object value is an array of changes for (const changeObj of changes) { const isValid = upcomingChangesValidate(changeObj) let errors: string | undefined diff --git a/src/landings/components/ArticleList.module.scss b/src/landings/components/ArticleList.module.scss index 4bd4497feaea..11f743e8ba81 100644 --- a/src/landings/components/ArticleList.module.scss +++ b/src/landings/components/ArticleList.module.scss @@ -12,7 +12,6 @@ } } -// Muted secondary copy (article intro, published date) under each link title. .textMuted { color: var(--brand-color-text-muted); } diff --git a/src/landings/components/CategoryLanding.tsx b/src/landings/components/CategoryLanding.tsx index 995261e8edd7..7b4909bee7cc 100644 --- a/src/landings/components/CategoryLanding.tsx +++ b/src/landings/components/CategoryLanding.tsx @@ -17,12 +17,11 @@ export const CategoryLanding = () => { const router = useRouter() const { title, intro, tocItems, spotlight, filters } = useCategoryLandingContext() - // The category filter is always shown. Surface and complexity are opt-in via - // the `filters` frontmatter array on the landing page. + // Always show the category filter; filters frontmatter controls surface and complexity. const showSurface = filters ? filters.includes('surface') : true const showComplexity = filters ? filters.includes('complexity') : false - // tocItems contains directories and its children, we only want the child articles + // Category landing cards use only child articles, not directory nodes. const onlyFlatItems: ArticleCardItems = tocItems.flatMap((item) => item.childTocItems || []) const [searchQuery, setSearchQuery] = useState('') @@ -112,8 +111,7 @@ export const CategoryLanding = () => { {router.route === '/[versionId]/rest/[category]' && } - {/* Position does not matter, because it will - never render anything. It always just return null. */} + {/* ClientSideRedirects renders null, so placement does not affect layout. */}
    diff --git a/src/landings/components/CookBookArticleCard.module.scss b/src/landings/components/CookBookArticleCard.module.scss index e1648569a6e3..f4c019e0d5f4 100644 --- a/src/landings/components/CookBookArticleCard.module.scss +++ b/src/landings/components/CookBookArticleCard.module.scss @@ -26,17 +26,13 @@ gap: 0.25rem; } -// Accent colour shared by the card's leading octicon and its title link, so the -// icon matches the title. Sits directly on the Primer React `Link` rather than -// the wrapping `

    `: `Link` paints its own `color`, so it does not inherit. +// The accent color belongs on the Primer React Link because h3 cannot override Link color. .linkAccent { color: var(--brand-color-text-link-rest); } -// The leading octicon's tinted circle. Replaces primer/css's -// `bgColor-accent-muted`, which resolves to Primer's blue wash (#388bfd1a in -// dark) behind a brand-blue icon. Brand ships no accent-muted surface token, so -// tint brand's own link blue — the same token the icon itself is painted with. +// bgColor-accent-muted resolves to Primer's blue wash, #388bfd1a in dark mode, +// behind a brand-blue icon. Tint with brand link blue to match the icon. .iconBackdrop { background-color: color-mix( in srgb, @@ -45,7 +41,6 @@ ); } -// The card's description copy. .textMuted { color: var(--brand-color-text-muted); } diff --git a/src/landings/components/HomePageHero.module.scss b/src/landings/components/HomePageHero.module.scss index 7c558975a89d..3e57f1577931 100644 --- a/src/landings/components/HomePageHero.module.scss +++ b/src/landings/components/HomePageHero.module.scss @@ -1,5 +1,5 @@ -// Docs 2026 homepage hero: a left-aligned title block stacked above a muted -// search band, both inside bordered rails. No background image — the mountains +// The homepage hero stacks a left-aligned title block above a muted search band, +// both inside bordered rails. No background image; the mountains // graphic is a separate, out-of-scope band. .hero { border-bottom: var(--brand-borderWidth-thin, 1px) solid @@ -26,9 +26,8 @@ margin: 1rem 0 0; } -// Search entry that mimics an input but opens the shared SearchOverlay on -// activation. A full-width muted band with the placeholder on the left and a -// trailing "/" key hint, separated from the title by a top border. +// The search entry mimics an input but opens the shared SearchOverlay on activation. +// The slash key hint sits in a muted band separated from the title by a top border. .searchRow { display: flex; align-items: center; diff --git a/src/landings/components/ProductSelectionCard.module.scss b/src/landings/components/ProductSelectionCard.module.scss index 5a621578ae8a..422d1f6f5126 100644 --- a/src/landings/components/ProductSelectionCard.module.scss +++ b/src/landings/components/ProductSelectionCard.module.scss @@ -1,10 +1,7 @@ // A single "All Docs" grid cell: category heading at the top, product links -// top-aligned directly beneath it. Cells in a row still stretch to the tallest -// cell's height via the grid. Internal dividers are the cell's left -// border (skipped on the first column of each row, per breakpoint, so they don't -// double the container rail) plus a bottom border for row dividers. The column -// count steps 1 -> 2 -> 3 -> 4, so each breakpoint re-applies the left border to -// every cell and then clears it on the new first-of-row. +// top-aligned directly beneath it. Internal dividers come from the cell's left +// border, skipped on the first column of each row, plus bottom row dividers. +// Each breakpoint re-applies the left border and clears its first column. .cell { display: flex; flex-direction: column; @@ -14,11 +11,9 @@ border-bottom: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-muted); - // Mobile: single column — no internal vertical dividers. border-left: 0; @media (min-width: 34rem) { - // 2 columns. border-left: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-muted); @@ -28,7 +23,6 @@ } @media (min-width: 63.25rem) { - // 3 columns. &:nth-child(n) { border-left: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-muted); @@ -40,7 +34,6 @@ } @media (min-width: 80rem) { - // 4 columns. &:nth-child(n) { border-left: var(--brand-borderWidth-thin, 1px) solid var(--brand-color-border-muted); diff --git a/src/landings/components/ProductSelectionCard.tsx b/src/landings/components/ProductSelectionCard.tsx index b052b97e68e4..9e0edeb2b35b 100644 --- a/src/landings/components/ProductSelectionCard.tsx +++ b/src/landings/components/ProductSelectionCard.tsx @@ -10,7 +10,7 @@ type ProductSelectionCardProps = { } export const ProductSelectionCard = ({ group }: ProductSelectionCardProps) => { - // Don't display the group if it has no children due to versioning + // Versioning can remove every child, so hide empty groups. if (!group.children || group.children.length === 0) { return null } diff --git a/src/landings/components/ProductSelections.module.scss b/src/landings/components/ProductSelections.module.scss index 4ec64165ad96..6a30d4a823d4 100644 --- a/src/landings/components/ProductSelections.module.scss +++ b/src/landings/components/ProductSelections.module.scss @@ -1,8 +1,6 @@ -// "All Docs" section — bordered gridline layout mirroring the Docs 2026 design. +// The All Docs section uses a bordered gridline layout. // The container supplies the outer left/right rails; internal dividers are drawn -// by each cell (left border, skipping the first column) and each row (bottom -// border). There is deliberately no divider directly under the "All Docs" -// heading. +// by each cell and row. There is no divider directly under the All Docs heading. .section { max-width: 82rem; margin: 0 auto; diff --git a/src/landings/components/SidebarProduct.module.scss b/src/landings/components/SidebarProduct.module.scss index b4d073687e20..842f5e4184c9 100644 --- a/src/landings/components/SidebarProduct.module.scss +++ b/src/landings/components/SidebarProduct.module.scss @@ -1,15 +1,10 @@ -// The tree's typography now comes from @primer/react-brand NavList's own scale -// (level-1 section headers at size-200, leaves at size-100). This class is kept -// as the wrapper hook for `data-testid="sidebar"`. +// Brand NavList owns the tree typography: level-1 section headers use size-200, +// and leaves use size-100. This class stays as the data-testid sidebar hook. .sidebar { - // Brand's NavList draws the active accent bar only on nested leaves — its rule - // explicitly excludes level-1 (`.item--leaf:not(.item--level-1) .link[aria-current]`), - // so a top-level article like "Quickstart" gets only the subtle background pill - // and no green bar. Restore the bar for active top-level leaves to match nested - // items. Brand's classes are hashed, so match on the stable substrings. - // - // This also covers `[data-pending]` — the optimistic marker on a just-clicked item - // (see the pending rules below) — so a clicked top-level leaf gets the bar too. + // Brand NavList excludes level-1 leaves from its active accent bar rule, so + // top-level articles such as Quickstart get only the subtle background pill. + // Match stable hashed-class substrings and restore the bar for aria-current. + // Cover data-pending too, so clicked top-level leaves get the same visual bar. :global([class*="NavList__item--leaf"][class*="NavList__item--level-1"]) :global([class*="NavList__link"])[aria-current]:not( [aria-current="false"] @@ -18,34 +13,26 @@ :global([class*="NavList__link"])[data-pending]::before { content: ""; position: absolute; - // Level-1 links are shorter (~24px) than nested leaves, so use a small inset - // to keep the bar roughly the height of the background pill rather than the - // larger inset brand applies to taller nested items. + // Level-1 links are about 24px tall, so a small inset matches the background pill height. inset-block: var(--base-size-2); // Level-1 leaves have no inline padding, so pull the bar into the NavList's - // own inline padding gutter to sit left of the label — matching where brand - // places the level-1 indicator on expandable section toggles. + // inline padding gutter where Brand places level-1 expandable indicators. inset-inline-start: calc(-1 * var(--base-size-8)); width: var(--base-size-4); border-radius: var(--base-size-2); background-color: var(--brand-NavList-activeIndicator-color); } - // Optimistic "pending" highlight. Article pages are getServerSideProps routes and can - // be slow to load, so router.asPath — and thus aria-current — only updates once the - // page has loaded. We keep aria-current on the *loaded* page (assistive tech must not - // be told a still-loading destination is the current page), and instead mark the - // just-clicked link with `data-pending` for a VISUAL-ONLY accent. Brand couples its - // active styling to `[aria-current]`, so mirror that treatment here for `[data-pending]`. - // data-pending is only set while a *different* page is loading, so it never collides - // with the real aria-current item. + // Article pages can load slowly through getServerSideProps, so aria-current stays + // on the loaded page and data-pending carries a visual-only accent for the clicked + // destination. data-pending is only set for a different loading page, so it never + // collides with the real aria-current item. :global([class*="NavList__link"])[data-pending] { color: var(--brand-color-text-default); background-color: var(--brand-color-canvas-subtle); } - // Nested leaves: brand moves the background pill onto the label and adds a leading - // accent bar (mirrors `.item--leaf:not(.item--level-1) .link[aria-current]`). + // Nested leaves need the same background pill and leading bar as Brand's active rule. :global([class*="NavList__item--leaf"]:not([class*="NavList__item--level-1"])) :global([class*="NavList__link"])[data-pending] { background-color: transparent; diff --git a/src/landings/components/SidebarProduct.tsx b/src/landings/components/SidebarProduct.tsx index 5e66cf0f056d..030a03070d31 100644 --- a/src/landings/components/SidebarProduct.tsx +++ b/src/landings/components/SidebarProduct.tsx @@ -22,10 +22,8 @@ import { flattenDescendants, MAX_NAVLIST_LEVEL } from './sidebar-navlist-depth' import styles from './SidebarProduct.module.scss' -// The nearest ancestor that actually scrolls vertically. Brand's NavList.SubNav -// wrappers use `overflow-y: hidden`, so match only auto/scroll to skip past them -// and land on the sidebar's own overflow container. Returns null when the rail is -// hidden (below the xxl breakpoint it is `display: none`, so nothing scrolls). +// Brand NavList.SubNav wrappers use overflow-y hidden, so match only auto or scroll +// to find the sidebar's own overflow container. Hidden rails return null. function findScrollableAncestor(element: Element): HTMLElement | null { let node = element.parentElement while (node) { @@ -40,12 +38,10 @@ function findScrollableAncestor(element: Element): HTMLElement | null { type Router = ReturnType -// Brand NavList.Item renders a plain (its `as` prop only accepts 'a' | 'button', -// not next/link), so intercept clicks to restore next/link-style client-side -// navigation. Modifier/middle clicks fall through to the browser so open-in-new-tab -// still works, and the keeps links crawlable for SSR. Mirrors Breadcrumbs.tsx. -// Returns true when it performed a client-side navigation (so the caller can move the -// optimistic selection), false when the click was left to the browser. +// Brand NavList.Item renders a plain anchor, not next/link, so intercept plain +// left-clicks to restore client-side navigation. Modified clicks fall through for +// separate tabs, the href keeps links crawlable for server-side rendering, and true +// tells the caller to move the optimistic selection. Mirrors Breadcrumbs.tsx. function handleNavClick(router: Router, event: MouseEvent, href: string): boolean { if ( event.defaultPrevented || @@ -59,24 +55,18 @@ function handleNavClick(router: Router, event: MouseEvent, href: st return false } event.preventDefault() - // hrefs already include the locale prefix (e.g. /en/...), so disable Next.js - // locale handling to avoid double-prefixing. + // Locale-prefixed hrefs need locale false so Next.js does not add the locale twice. router.push(href, undefined, { locale: false }) return true } -// The sidebar renders the full product tree (hundreds of nodes) and fully remounts -// on every navigation (key={asPath} in SidebarNav). To keep per-item cost down we -// subscribe to the router ONCE here and hand items a stable routePath plus stable -// navigate/prefetch callbacks, instead of every item calling useRouter itself. +// The sidebar renders hundreds of nodes and remounts on every navigation. Subscribe +// once here so each item gets stable routePath, navigate, and prefetch values instead +// of calling useRouter itself. type SidebarNavValue = { - // The real loaded route. Drives aria-current (the semantic "current page") and the - // auto-expanded active ancestor chain. Both must reflect the page actually loaded. + // The loaded route drives aria-current and the auto-expanded active ancestor chain. routePath: string - // The in-flight click target, or null. Drives a VISUAL-ONLY optimistic accent bar - // (via data-pending) so the click feels acknowledged before the slow - // getServerSideProps page loads, without lying to assistive tech about the current - // page. Once navigation completes, the keyed remount clears it and routePath catches up. + // The in-flight click target drives a visual-only data-pending accent during slow loads. pendingHref: string | null navigate: (event: MouseEvent, href: string) => void prefetch: (href: string) => void @@ -91,10 +81,8 @@ function useSidebarNav(): SidebarNavValue { return value } -// Props for a leaf link's : aria-current tracks the loaded page (semantics), while -// data-pending marks the in-flight click so CSS can move the accent bar optimistically -// without changing what screen readers announce as current. data-pending is only set -// while a *different* page is loading, so it never double-marks the already-current item. +// Leaf links keep aria-current on the loaded page and data-pending on a different +// in-flight destination, so screen readers do not hear a loading page as current. function leafLinkProps(nav: SidebarNavValue, href: string) { return { 'aria-current': (nav.routePath === href ? 'page' : false) as 'page' | false, @@ -102,9 +90,8 @@ function leafLinkProps(nav: SidebarNavValue, href: string) { } } -// Separate context for the REST-only scroll-spy state (full asPath with query+hash, -// and query). Kept out of SidebarNavValue so its per-navigation identity churn -// doesn't invalidate the memoized common items — only RestNavListItem consumes it. +// Keep REST-only scroll-spy state out of SidebarNavValue so its per-navigation +// identity churn invalidates only RestNavListItem. type RestNavValue = { asPath: string query: ReturnType['query'] @@ -119,7 +106,6 @@ function useRestNav(): RestNavValue { return value } -// Hover/focus handlers for a leaf link: warm the destination so the click is fast. function prefetchHandlers(prefetch: (href: string) => void, href: string) { return { onMouseEnter: () => prefetch(href), @@ -127,12 +113,13 @@ function prefetchHandlers(prefetch: (href: string) => void, href: string) { } } +// pendingHref survives slow getServerSideProps navigations because SidebarNav remounts +// only after asPath changes. aria-current stays on the loaded route. export const SidebarProduct = () => { const router = useRouter() const { currentProduct, - // For the sidebar we only need the short titles so we can use the - // more "compressed" tree that is as light as possible. + // The sidebar only needs short titles, so MainContext supplies the compressed tree. sidebarTree, sidebarExpanded, } = useMainContext() @@ -141,20 +128,14 @@ export const SidebarProduct = () => { const { asPath, locale, query } = router const routePath = `/${locale}${asPath.split('?')[0].split('#')[0]}` - // Optimistic selection: the href of an in-flight click. Used to move the accent bar - // visually (data-pending) the instant a link is clicked, even while the destination - // page is still loading. This SidebarProduct instance persists during the pending - // fetch (SidebarNav keys it on asPath, which only changes once navigation completes), - // so the state survives the wait and is discarded by the keyed remount when the new - // route lands. aria-current is NOT derived from this: it stays on the loaded route. + // pendingHref moves only the visual accent while aria-current stays on the loaded route. const [pendingHref, setPendingHref] = useState(null) const prefetchHref = usePrefetchOnInteraction() - // Stable callbacks so memoized items don't re-render on unrelated changes. + // Stable callbacks keep memoized items from re-rendering on unrelated changes. const navigate = useCallback( (event: MouseEvent, href: string) => { - // Only move the optimistic highlight on a real client-side nav, not on a - // modifier/middle click that opens a new tab (the current page stays put). + // Move the optimistic highlight only for client-side navigation, not modified clicks. if (handleNavClick(router, event, href)) setPendingHref(href) }, [router], @@ -168,10 +149,7 @@ export const SidebarProduct = () => { const rootRef = useRef(null) useEffect(() => { - // Clear the optimistic highlight if a navigation genuinely fails, so it doesn't - // stick on a page that never loaded. Skip cancellations (err.cancelled): those - // fire when a second click supersedes the first, and pendingHref already points at - // that newer target, which we want to keep highlighted. + // Failed navigations clear pendingHref; cancellations keep the newer click highlighted. const clearPending = (err: { cancelled?: boolean }) => { if (!err?.cancelled) setPendingHref(null) } @@ -180,27 +158,19 @@ export const SidebarProduct = () => { }, [router.events]) useEffect(() => { - // Skip all sidebar scroll adjustments when the URL carries landing-page - // article filters (search/category/page). Those are shallow same-page - // updates that must not move the reader (the article grid manages its own - // scroll position). + // Article filter query params are shallow same-page updates; the grid manages their scroll. if (/[?&]articles-(filter|category|page)=/.test(router.asPath)) return - // Brand NavList auto-expands the whole ancestor chain of the active item, so - // scroll to the item marked aria-current="page" (the active article) rather - // than the top-most expanded section. + // Brand expands every active ancestor, so scroll to the aria-current page item. const activeArticle = rootRef.current?.querySelector('[aria-current="page"]') if (!activeArticle) return - // Scroll the sidebar's own overflow container by hand. `scrollIntoView` would - // scroll every scrollable ancestor, including the document, which cancels the - // browser's scroll to a #anchor on load and leaves the reader at the top of - // the article. See BreadcrumbsScroller for the same approach. + // Scroll by hand to preserve hash anchors; BreadcrumbsScroller does the same. const container = findScrollableAncestor(activeArticle) if (!container) return const containerRect = container.getBoundingClientRect() const activeRect = activeArticle.getBoundingClientRect() - // Setting to the top doesn't give enough context of surrounding categories + // Centering shows surrounding categories. const delta = activeRect.top - containerRect.top - (container.clientHeight - activeRect.height) / 2 container.scrollBy({ top: delta, behavior: 'instant' }) @@ -263,9 +233,8 @@ export const SidebarProduct = () => { ) } -// Wraps a brand NavList expandable item (renders as a

    - {/* Text-style control, matching the Docs 2026 "Sort by" pattern. */} + {/* Text-style control matches the Sort by pattern. */}
    @@ -376,18 +366,14 @@ const ArticleCard = ({ article, includedCategories }: ArticleCardProps) => { ) : article.category - // Brand Card renders its own native anchor (no `as` prop), so intercept plain - // left-clicks to preserve client-side SPA navigation. Modified clicks - // (cmd/ctrl/shift/middle) fall through to the real href for new-tab/right-click. const handleClick = async (e: React.MouseEvent) => { + // Intercept Brand Card's anchor only for plain clicks; modified clicks keep browser behavior. if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return e.preventDefault() try { await router.push(article.fullPath) } catch { - // If the client-side navigation is rejected/aborted, fall back to a hard - // navigation so the card never goes dead (we already suppressed the - // anchor's default). Matters most for keyboard users with no obvious retry. + // Hard navigation keeps the card usable after suppressing the native anchor click. window.location.href = article.fullPath } } diff --git a/src/landings/components/shared/LandingCarousel.module.scss b/src/landings/components/shared/LandingCarousel.module.scss index 1453167dd65f..6c6bcce86377 100644 --- a/src/landings/components/shared/LandingCarousel.module.scss +++ b/src/landings/components/shared/LandingCarousel.module.scss @@ -3,7 +3,7 @@ --carousel-transition-duration: 0.1s; } -// Remove top margin for carousels without headings that come after another carousel +// Adjacent carousels without headings share the previous carousel's spacing. .carousel.noHeading { margin-top: 0; @@ -21,7 +21,7 @@ align-items: center; } -// When header only contains navigation (no heading), add top margin to separate from previous carousel +// Navigation-only headers need top spacing between adjacent carousels. .header:has(.navigation):not(:has(.heading)) { margin-top: 1rem; justify-content: flex-end; @@ -45,9 +45,7 @@ } } -// Prev/next controls: plain 16x16 arrow icon buttons (Docs 2026). No longer the -// old `btn btn-sm` bordered buttons — just the muted arrow glyph in a square -// hit target that tints toward default on hover. +// Prev and next controls use plain 16px arrows in 32px hit targets. .navButton { display: inline-flex; align-items: center; @@ -77,18 +75,16 @@ grid-template-columns: 1fr; transition: opacity var(--carousel-transition-duration) ease-in-out; opacity: 1; - // Bleed out to the frame's vertical border rules (cancel the frame's content - // padding) so the card dividers reach the border with no gap. Each card's own - // 32px padding then re-insets the text to align with the section header. + // Cancel the frame padding so card dividers meet its vertical border rules. + // Each card's 32px padding re-insets text to align with the section header. margin-inline: -16px; @media (min-width: 768px) { margin-inline: -32px; } - // Shared 1px dividers via each grid item's top + left border (matches the - // Articles grid). First row's top and first column's left are removed per - // breakpoint so only internal dividers show. + // Match the Articles grid with shared 1px top and left borders. Remove the first + // row's top and first column's left border per breakpoint. > * { border-top: 1px solid var(--brand-color-border-default); } @@ -134,35 +130,29 @@ } } -// Docs 2026 landing cards: seamless collapsed-border grid (dividers drawn by -// .itemsGrid above), NOT separate rounded cards. Flatten brand Card's radius/ -// shadow, reduce padding to 24px, and replace the stretched `1fr` description -// row with `auto` so cards hug their content instead of ballooning to ~365px. +// Landing cards use a seamless collapsed-border grid, not separate cards. +// Flatten Brand Card chrome and use auto rows so cards hug content instead of +// ballooning to about 365px. .card { border-radius: 0 !important; box-shadow: none !important; padding: 32px !important; --Card-grid-template-rows: auto auto auto auto auto auto auto auto !important; - // Top-align the card content so the tag/title sit at a consistent baseline + // Top-align the card content so tags and titles share a consistent baseline // across cards regardless of description length (brand Card otherwise // distributes rows down the full cell height). align-content: start !important; - // Green top border on hover, matching the brand NavList active indicator - // (--brand-color-accent-primary). Uses box-shadow (not border) so it overlays - // the seamless grid divider without shifting the 1px collapsed layout. + // box-shadow overlays the green hover bar on the grid divider without shifting layout. transition: box-shadow 0.1s ease-in-out; &:hover { box-shadow: inset 0 2px 0 0 var(--brand-color-accent-primary) !important; } - // Compress brand Card's generous inter-row margins so cards hug their content - // (~150px in Figma) instead of ballooning. Targets the sub-element classes - // that merge onto Card.Heading / Description. + // Compress Brand Card row margins so cards hug the roughly 150px Figma content height. :global([class*="Card__heading"]) { margin-block-end: 8px !important; - // Figma card title ("Subheading Medium"): 16px / 550, not brand Card's - // default 22px heading. + // Figma Subheading Medium uses 16px and 550 weight, not Brand Card's 22px default. font-size: 1rem !important; font-weight: 550 !important; line-height: 1.5 !important; @@ -170,7 +160,7 @@ :global([class*="Card__description"]) { margin-block-end: 0 !important; - // Figma card intro ("Body/Small"): 14px, not brand Card's default 16px. + // Figma Body/Small intro uses 14px, not Brand Card's 16px default. font-size: 0.875rem !important; line-height: 1.5 !important; } diff --git a/src/landings/components/shared/LandingCarousel.tsx b/src/landings/components/shared/LandingCarousel.tsx index 8251b74546d9..8e9a821cc0dd 100644 --- a/src/landings/components/shared/LandingCarousel.tsx +++ b/src/landings/components/shared/LandingCarousel.tsx @@ -11,24 +11,26 @@ import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML' type LandingCarouselProps = { heading?: string - carouselKey?: string // Optional key for translation lookup (e.g., "recommended") + // Optional key for translation lookup, such as "recommended". + carouselKey?: string carouselArticles?: ResolvedArticle[] } const useResponsiveItemsPerView = () => { - const [itemsPerView, setItemsPerView] = useState(3) // Default to desktop + // Default to the desktop 3-column carousel. + const [itemsPerView, setItemsPerView] = useState(3) useEffect(() => { const updateItemsPerView = () => { const width = window.innerWidth if (width < 768) { - // Mobile: 1 column + // Mobile shows one column. setItemsPerView(1) } else if (width < 1012) { - // Tablet: 2 columns + // Tablet shows two columns. setItemsPerView(2) } else { - // Desktop: 3 columns + // Desktop shows three columns. setItemsPerView(3) } } @@ -66,7 +68,7 @@ export const LandingCarousel = ({ const animationTimeoutRef = useRef(null) - // Reset to first page when itemsPerView changes (screen size changes) + // Viewport changes reset to the first page so the changed page count cannot strand the index. useEffect(() => { setCurrentPage(0) }, [itemsPerView]) diff --git a/src/landings/components/shared/LandingHero.module.scss b/src/landings/components/shared/LandingHero.module.scss index 715faa26790b..7d1efeda3908 100644 --- a/src/landings/components/shared/LandingHero.module.scss +++ b/src/landings/components/shared/LandingHero.module.scss @@ -1,14 +1,12 @@ -// Docs 2026 landing hero (Figma node 341:117125). A content column — brand -// Heading + muted lede + a large Button group — with the right-anchored -// isometric banner art on desktop. On mobile the art drops below the content as -// a full-width band (matching the Figma mobile layout). The framing borders are -// drawn by the wrapping LandingSection. +// The landing hero uses Figma node 341:117125. Desktop anchors the isometric +// banner art on the right; mobile moves the art below the content as a full-width band. +// The wrapping LandingSection draws the framing borders. .landingHero { display: flex; flex-direction: column; width: 100%; - // Desktop: right-anchored banner art behind the content column. `auto 100%` + // Desktop anchors banner art behind the content column. auto 100% // scales it to the band's own (content-driven) height; pinned to the right // border box so the right-side text gutter insets only the text, not the art. background-size: auto 100%; @@ -17,7 +15,7 @@ background-origin: border-box; } -// Content column: heading + lede + actions. Only vertical padding here — the +// The content column owns only vertical padding; the wrapping LandingSection // horizontal inset comes from the wrapping LandingSection frame so the hero // content aligns with the other sections' headers/cards. .heroContent { @@ -29,22 +27,21 @@ width: 100%; } -// Brand `Heading size="2"` owns the type scale; this only adds an optical +// Brand Heading size 2 owns the type scale; this only adds an optical // max-width so long titles wrap before the edge. .heroHeading { margin: 0; max-width: 48rem; } -// Wraps the RenderedHTML intro (kept as raw HTML rather than a Text `as="p"`, -// which can't hold block-level intro markup). Brand `Text size="200" muted` -// owns the type/color (16px, per Figma); this only bounds the line length. +// RenderedHTML keeps raw block-level intro markup that Text as p cannot hold. +// Brand Text size 200 muted owns the 16px Figma type and color; this bounds line length. .heroDescription { margin: 0; max-width: 48rem; } -// Slight top gap above the actions (Figma "buttons + text" spacing). +// Figma buttons plus text spacing needs a small top gap above the actions. .heroButtons { margin-top: 0.25rem; } @@ -59,8 +56,8 @@ padding-block: 1.5rem; } - // Stack the CTA buttons full-width (Figma mobile layout). Brand ButtonGroup - // renders a
    laying children in a row; force a full-width column and + // Figma mobile stacks calls to action full-width. Brand ButtonGroup renders + // a section laying children in a row; force a full-width column and // make each Button fill it. .heroButtons { width: 100%; @@ -79,21 +76,21 @@ } } -// Where the art IS shown, the intro can extend far enough right to overlap the +// Where the art is shown, the intro can extend far enough right to overlap the // right-anchored isometric art (the heading is short enough that it never // does). Instead of reserving a right gutter that shrinks the text column, put -// a frosted-glass panel behind the intro so it stays readable over the art — -// matching the homepage hero treatment (HomePageHero.module.scss `.content`). +// a frosted-glass panel behind the intro so it stays readable over the art, +// matching the homepage hero treatment in HomePageHero.module.scss. // Guarded to >=866px because the art is hidden below that, so no panel is // needed on mobile. @media (min-width: 866px) { .heroIntroScrim { display: inline-block; backdrop-filter: blur(1rem); - // A 70% wash of the page canvas, so the intro stays readable over the + // A 70% wash of the page canvas keeps the intro readable over the // isometric art without reading as a panel of its own. Brand's canvas is - // what `body` now paints (src/frame/stylesheets/index.scss), so - // mixing Brand's token keeps this invisible except where it overlays art. + // what body paints in src/frame/stylesheets/index.scss, so mixing Brand's + // token keeps this invisible except where it overlays art. background-color: color-mix( in srgb, var(--brand-color-canvas-default) 70%, diff --git a/src/landings/components/shared/LandingSection.module.scss b/src/landings/components/shared/LandingSection.module.scss index 7df4cffacdff..d82c3a24cdad 100644 --- a/src/landings/components/shared/LandingSection.module.scss +++ b/src/landings/components/shared/LandingSection.module.scss @@ -1,13 +1,13 @@ -// Docs 2026 "framed section": horizontal rules span the full content column +// Framed sections use horizontal rules that span the full content column, // while the vertical side rules are inset by a gutter, framing the section on -// all four sides. brand border-subtle adapts gray-2 light / gray-6 dark. +// all four sides. Brand border-subtle adapts gray-2 light and gray-6 dark. @import "@primer/css/support/variables/layout.scss"; @import "@primer/css/support/mixins/layout.scss"; // The band spans the full content column and draws the horizontal rules. It -// also holds the minimum horizontal gutter (as padding) so the framed content +// also holds the minimum horizontal gutter as padding so the framed content // never touches the band edges, and the horizontal rules always extend past the -// vertical ones. Each section draws its own top + bottom, so consecutive +// vertical ones. Each section draws its own top and bottom, so consecutive // sections separated by a gap show the paired-rule look from the Figma. .band { padding-inline: 16px; @@ -35,7 +35,7 @@ // The frame draws the vertical side rules and holds the content. It is capped at // container-xl and centered within the band, so on very wide viewports the -// content stays centered rather than hugging the left. Content padding (32px) +// content stays centered rather than hugging the left. Content padding, 32px, // sits inside the border and matches the cards' internal padding so section // headers/pagination align with the card text; the card grid bleeds back out to // the border with a negative margin so its dividers reach the vertical rules. diff --git a/src/landings/components/shared/LandingSection.tsx b/src/landings/components/shared/LandingSection.tsx index ec281e53fa32..9af58a020e16 100644 --- a/src/landings/components/shared/LandingSection.tsx +++ b/src/landings/components/shared/LandingSection.tsx @@ -8,7 +8,7 @@ type LandingSectionProps = { className?: string } -// A Docs 2026 "framed section". The outer band spans the full content column +// In framed sections, the outer band spans the full content column // and draws the horizontal rules; the inner frame is inset by a gutter and // draws the vertical side rules, so the horizontal rules always extend past the // vertical ones. diff --git a/src/landings/components/sidebar-navlist-depth.ts b/src/landings/components/sidebar-navlist-depth.ts index 9185f8fecbcc..6d6e38dd0868 100644 --- a/src/landings/components/sidebar-navlist-depth.ts +++ b/src/landings/components/sidebar-navlist-depth.ts @@ -1,23 +1,20 @@ -// Minimal structural shape needed for depth flattening: a subset of -// ProductTreeNode (which we avoid importing so this module stays free of the -// React/Next dependency chain and can be unit-tested in isolation). +// Depth flattening needs only this ProductTreeNode subset, keeping React and Next +// dependencies out so tests can import the module in isolation. type TreeNodeLike = { childPages: TreeNodeLike[] } // Brand NavList supports up to 5 nesting levels; a level-5 item cannot contain a // SubNav (it is dropped with a warning). SidebarProduct injects a hidden sentinel so // brand numbers its top-level items from level 1 (without it brand starts at level 2 -// and wastes a level, see navListLevelSentinel in SidebarProduct.tsx), so items can -// keep nesting while level < 5. Real docs content currently bottoms out at exactly -// level 5, so the guard that uses this is defensive: if a deeper tree ever appears, +// and wastes a level; see navListLevelSentinel in SidebarProduct.tsx), so items can +// keep nesting while level < 5. Real docs content bottoms out at exactly +// level 5, so this guard is defensive: if a deeper tree appears, // the overflow is flattened into leaf links rather than silently dropped by brand. // -// Kept in its own dependency-free module (no React/SCSS/Next imports) so it can -// be unit-tested without booting the Next.js app. +// Keeping this module dependency-free avoids booting the Next.js app in unit tests. export const MAX_NAVLIST_LEVEL = 5 -// Collects every descendant page of a node into a flat list, depth-first, so an -// over-deep subtree can still be rendered as (reachable) leaf links. Generic over -// the concrete node type so callers keep their richer node shape in the result. +// Flatten descendants depth-first so over-deep subtrees still render as reachable +// leaf links while callers keep their richer node shape in the result. export function flattenDescendants(node: T): T[] { const out: T[] = [] for (const child of node.childPages as T[]) { diff --git a/src/landings/components/useSidebarExpandState.tsx b/src/landings/components/useSidebarExpandState.tsx index a98ce98733be..d08b29c27838 100644 --- a/src/landings/components/useSidebarExpandState.tsx +++ b/src/landings/components/useSidebarExpandState.tsx @@ -5,21 +5,20 @@ import Cookies from '@/frame/components/lib/cookies' import { SIDEBAR_EXPANDED_COOKIE_NAME } from '@/frame/lib/constants' // Persists the docs sidebar's expand/collapse state across navigations. The tree -// remounts on every route change (SidebarNav renders ), -// so per-node open state can't live in ordinary component state — it's kept in a +// remounts on every route change, so per-node open state cannot live in component state. +// SidebarNav renders SidebarProduct keyed by asPath, so state is kept in a // cookie, read once per mount, and shared through context. // // Semantics: a category is open when the user has explicitly toggled it (their // choice wins and persists); otherwise it follows the active chain: the ancestor // path of the current page auto-opens. Because brand NavList only auto-expands the -// aria-current chain for *uncontrolled* items, a controlled item must fold that in -// itself, which is what the `onActiveChain` fallback does here. +// aria-current chain for uncontrolled items, a controlled item must fold that in +// itself, which is what the onActiveChain fallback does here. // -// SSR-safety: the cookie is read server-side in getMainContext and passed to the -// provider as `initial`, so the very first render (server + client hydration) already -// reflects the persisted state and markup matches, with no post-mount flash. When rendered -// without an `initial` (e.g. outside the SSR data path), it falls back to reading the -// cookie client-side via the SSR-safe cookie lib. +// Server-side rendering reads the cookie in getMainContext and passes it as initial, +// so the first server and client render match with no post-mount flash. Without +// initial, for example outside the server-side data path, it falls back to the +// SSR-safe cookie lib on the client. type ExpandedStore = Record @@ -43,8 +42,7 @@ function persistStore(store: ExpandedStore) { try { Cookies.set(SIDEBAR_EXPANDED_COOKIE_NAME, JSON.stringify(store)) } catch { - // Cookie writes may fail (disabled cookies, etc.), so degrade to non-persisted - // state rather than throwing. + // Cookie write failures degrade to non-persisted state instead of throwing. } } @@ -55,8 +53,7 @@ export function SidebarExpandStateProvider({ children: ReactNode initial?: ExpandedStore | null }) { - // Seed from the SSR-read cookie value so server and first client render agree. - // When no initial is supplied, fall back to reading the cookie client-side. + // Seed from the server-read cookie, or read the cookie client-side when initial is absent. const [store, setStore] = useState(() => initial ?? readStore()) const setExpanded = useCallback((key: string, expanded: boolean) => { @@ -77,20 +74,14 @@ export function SidebarExpandStateProvider({ return {children} } -/** - * Controlled expand state for one NavList category, backed by a cookie. - * @param key Stable per-node identifier (the node's locale-prefixed href). - * @param onActiveChain Whether this node is an ancestor of the current page. - * @returns `[expanded, onExpandedChange]` to spread onto a brand `NavList.Item`. - */ +// Controls one NavList category by its stable locale-prefixed href and active-chain state. +// Returns expanded state and the NavList.Item change handler, backed by a cookie. export function useSidebarExpandState( key: string, onActiveChain: boolean, ): [boolean, (expanded: boolean) => void] { const ctx = useContext(ExpandStateContext) - // Fallback keeps the tree interactive if a NavList is ever rendered outside the - // provider: expand/collapse works via local state (seeded from the active chain), - // just without cross-navigation persistence. + // Outside the provider, local state keeps the tree interactive without persistence. const [localExpanded, setLocalExpanded] = useState(onActiveChain) const expanded = ctx ? ctx.isExpanded(key, onActiveChain) : localExpanded const onExpandedChange = useCallback( diff --git a/src/landings/context/LandingContext.tsx b/src/landings/context/LandingContext.tsx index 1034bca70fef..c2145df52749 100644 --- a/src/landings/context/LandingContext.tsx +++ b/src/landings/context/LandingContext.tsx @@ -19,16 +19,13 @@ export type LandingContextT = { renderedPage: string currentLayout: string heroImage?: string - // For landing pages with carousels carousels?: Record< string, Array<{ title: string; intro: string; href: string; category: string[] }> > introLinks?: Record | null - // For journey landing pages journeyTracks?: JourneyTrack[] journeyArticlesHeading?: string | null - // For article grid category filtering includedCategories?: string[] } @@ -77,8 +74,7 @@ export const getLandingContextFromRequest = async ( ? (page.carousels as LandingContextT['carousels']) : {} - // Note: Journey tracks are resolved in middleware and added to the request - // context to avoid the error using server side apis client side + // Middleware resolves journey tracks because server-side APIs cannot run client-side. const journeyTracks: JourneyTrack[] = Array.isArray(context.journeyTracks) ? context.journeyTracks : Array.isArray(page.resolvedJourneyTracks) diff --git a/src/landings/lib/article-search.ts b/src/landings/lib/article-search.ts index 477fe5205a34..b38c6ba12f1f 100644 --- a/src/landings/lib/article-search.ts +++ b/src/landings/lib/article-search.ts @@ -3,8 +3,7 @@ import { fuzzyMatchScore, stripStopWords } from '@/landings/lib/fuzzy-match' const STOP_WORD_THRESHOLD = 0.8 -// Recursively flatten nested TOC items into leaf articles. -// Excludes index pages (pages with childTocItems). +// Parents with childTocItems are index pages, not article cards. const flattenArticlesRecursive = (articles: (TocItem | ChildTocItem)[]): ArticleCardItems => { const flattened: ArticleCardItems = [] @@ -19,7 +18,6 @@ const flattenArticlesRecursive = (articles: (TocItem | ChildTocItem)[]): Article return flattened } -// Flatten, deduplicate by fullPath, and sort alphabetically by title. export const flattenArticles = (articles: (TocItem | ChildTocItem)[]): ArticleCardItems => { const flattened = flattenArticlesRecursive(articles) const seen = new Set() @@ -31,8 +29,8 @@ export const flattenArticles = (articles: (TocItem | ChildTocItem)[]): ArticleCa return deduped.sort((a, b) => a.title.localeCompare(b.title)) } -// Find words appearing in a high percentage of article titles/intros. -// These add little signal to search since they match nearly everything. +// Words that appear in most titles or intros add little signal because they +// match nearly every article. export const deriveStopWords = ( articles: ArticleCardItems, threshold = STOP_WORD_THRESHOLD, @@ -49,9 +47,8 @@ export const deriveStopWords = ( return [...wordCounts.entries()].filter(([, count]) => count >= minCount).map(([word]) => word) } -// Score and rank articles against a search query, returning only matches. -// Searches title, intro, and category fields. Returns all articles (scored 0.5) -// when the query consists entirely of stop words. +// Search matches title, intro, and category fields; queries made only of stop +// words return every article with a neutral 0.5 score. export const searchArticles = ( articles: ArticleCardItems, query: string, diff --git a/src/landings/lib/count-articles.ts b/src/landings/lib/count-articles.ts index 7934b31b43d7..852aaac1dd54 100644 --- a/src/landings/lib/count-articles.ts +++ b/src/landings/lib/count-articles.ts @@ -1,6 +1,5 @@ import type { ProductTreeNode } from '@/frame/components/context/MainContext' -// Recursively counts all leaf articles (nodes without children) under a given node export const countArticles = (node: ProductTreeNode): number => { if (node.childPages.length === 0) { return 1 diff --git a/src/landings/lib/featured-links.ts b/src/landings/lib/featured-links.ts index d024a02ce482..de1694c406c1 100644 --- a/src/landings/lib/featured-links.ts +++ b/src/landings/lib/featured-links.ts @@ -14,9 +14,6 @@ type ReqWithFeaturedLinks = { } } -// Helper that reshapes the resolved featured-link data placed on the request -// by the `featuredLinks` middleware into the FeaturedLink shape consumed by -// landing page contexts (toc, category, discovery, journey, bespoke). export const getFeaturedLinksFromReq = (req: unknown): Record> => { const { context } = req as ReqWithFeaturedLinks return Object.fromEntries( diff --git a/src/landings/lib/fuzzy-match.ts b/src/landings/lib/fuzzy-match.ts index e1f2d525be27..667d3c1f6b2c 100644 --- a/src/landings/lib/fuzzy-match.ts +++ b/src/landings/lib/fuzzy-match.ts @@ -1,16 +1,16 @@ -// 70% threshold: Raised from 60% to reduce false positives from short queries. -// At 60%, "billing" matched "installing" and "pricing" matched "writing pr descriptions". -// 70% still captures singular/plural ("agent"→"agents" = 80%, "repository"→"repositories" = 73%) -// while filtering the worst noise (both of those false positives were at 67%). +// The 70% threshold keeps singular/plural matches like "agent" to "agents" at 80% +// and "repository" to "repositories" at 73%. +// It rejects noisy matches like "billing" to "installing" and "pricing" to +// "writing pr descriptions", both 67%. const BIGRAM_COVERAGE_THRESHOLD = 0.7 -// Short search terms produce very few bigrams, making spurious matches likely. -// Require exact substring match for terms with 4 or fewer non-space characters. +// Terms with 4 or fewer non-space characters need exact substring matches +// because they produce too few bigrams. const SHORT_TERM_MAX_LENGTH = 4 const bigramCache = new Map>() -// Extract character bigrams from a string, e.g. "agent" -> ["ag", "ge", "en", "nt"]. +// Bigrams are adjacent character pairs, so "agent" becomes "ag", "ge", "en", and "nt". const getBigrams = (str: string): Set => { const key = str.toLowerCase() if (bigramCache.has(key)) { @@ -27,8 +27,8 @@ const getBigrams = (str: string): Set => { return bigrams } -// Coverage: what percentage of search bigrams are found in text -// Better for matching short queries against long text +// Bigram coverage measures how many search bigrams appear in text. +// This works better than Jaccard for short queries against longer text. export const bigramCoverage = (text: string, search: string): number => { const textBigrams = getBigrams(text) const searchBigrams = getBigrams(search) @@ -39,19 +39,15 @@ export const bigramCoverage = (text: string, search: string): number => { return found / searchBigrams.size } -// Returns a match score: 1 for exact match, 0-1 for bigram coverage, -1 for no match +// Returns 1 for a substring match, bigram coverage at or above the threshold, or -1 otherwise. export const fuzzyMatchScore = (text: string, searchTerm: string): number => { const lowerText = text.toLowerCase() const lowerSearch = searchTerm.toLowerCase() if (lowerText.includes(lowerSearch)) return 1 - // Short search terms (e.g., "mcp", "pr", "test") produce too few bigrams - // for reliable fuzzy matching, so require exact substring only. if (lowerSearch.replace(/\s+/g, '').length <= SHORT_TERM_MAX_LENGTH) return -1 - // Bigram coverage works better than Jaccard when the text is much longer than - // the search term. const score = bigramCoverage(text, searchTerm) return score >= BIGRAM_COVERAGE_THRESHOLD ? score : -1 } @@ -60,9 +56,8 @@ export const fuzzyMatch = (text: string, searchTerm: string): boolean => { return fuzzyMatchScore(text, searchTerm) >= 0 } -// Strip stop words from a string, preserving other words. -// On product-specific landing pages (e.g. /copilot), the product name appears -// in nearly every article, drowning out the actual query. +// Product-specific landing pages, such as /copilot, repeat the product name in nearly +// every article, so stop words keep the product name from drowning out the query. export const stripStopWords = (text: string, stopWords: string[]): string => text .split(/\s+/) diff --git a/src/landings/lib/octicons.ts b/src/landings/lib/octicons.ts index 2f8d194c1692..78c246bb210e 100644 --- a/src/landings/lib/octicons.ts +++ b/src/landings/lib/octicons.ts @@ -14,8 +14,7 @@ import { LockIcon, } from '@primer/octicons-react' -// The single source of truth for supported octicons. The type and the validation -// array below are both derived from it. +// Derive the type and validation array from this map so supported octicons stay in sync. export const OCTICON_COMPONENTS = { bug: BugIcon, lightbulb: LightBulbIcon, @@ -40,7 +39,7 @@ export function isValidOcticon(octicon: string | null): octicon is ValidOcticon return octicon !== null && (octicon as ValidOcticon) in OCTICON_COMPONENTS } -// Falls back to CopilotIcon for an unknown name. +// Unknown names render CopilotIcon instead of breaking page rendering. export function getOcticonComponent(octicon: ValidOcticon | undefined) { if (!octicon || !isValidOcticon(octicon)) { return CopilotIcon diff --git a/src/landings/middleware/featured-links.ts b/src/landings/middleware/featured-links.ts index 0f5d91e2d381..8c41b53ea1ec 100644 --- a/src/landings/middleware/featured-links.ts +++ b/src/landings/middleware/featured-links.ts @@ -3,32 +3,13 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest, FeaturedLinkExpanded } from '@/types' import getLinkData from '@/frame/lib/get-link-data' -/** - * This is the max. number of featured links, by any category, that we - * display on index landing pages (homepage and TOC landings). - * The reason it's variable is that some featured links are conditional. - * For example: - * - * '/authentication/troubleshooting-ssh', - * '/authentication/connecting-to-github-with...', - * '/authentication/connecting-to-github-with-ssh/a...', - * '{% ifversion ghec %}/authentication/connecting-to-...{% endif %}', - * '/authentication/managing-commit-signature-verif...' - * - * In this case, if we'd "prematurely" sliced that list to the first 4, - * the final result might be 3 items because that conditional one - * would end up being blank and thus omitted. - * - * The reason we don't want to display too many is because it might - * make the landing page columns that lists links far too - * long ("high"). - */ +// getLinkData stops after MAX_FEATURED_LINKS resolved links, not frontmatter entries. +// For example, "{% ifversion ghec %}/authentication/troubleshooting-ssh{% endif %}" +// can render blank. +// That keeps a conditional entry that renders blank from leaving a category short, +// while still preventing landing-page columns from growing too tall. const MAX_FEATURED_LINKS = 4 -// This middleware resolves `featuredLinks` from the page's frontmatter into -// a `req.context.featuredLinks` object consumed by the homepage and toc -// landing renderers. It runs for any `index.md` page that defines -// `featuredLinks` in frontmatter. export default async function featuredLinks( req: ExtendedRequest, res: Response, @@ -54,8 +35,7 @@ export default async function featuredLinks( { title: true, intro: true, fullTitle: true }, MAX_FEATURED_LINKS, ) - // We need to use a type assertion here because the Page interfaces are incompatible - // between our local types and the global types, but the actual runtime objects are compatible + // Local and global Page interfaces differ, but the runtime featured-link objects match. req.context.featuredLinks[key] = (linkData || []) as unknown as FeaturedLinkExpanded[] } diff --git a/src/landings/pages/home.module.scss b/src/landings/pages/home.module.scss index 2e385e593710..4119d553523d 100644 --- a/src/landings/pages/home.module.scss +++ b/src/landings/pages/home.module.scss @@ -1,6 +1,5 @@ -// Full-bleed gap between the hero and the "All Docs" section. The hero supplies -// the top border (its own border-bottom); this band adds the responsive height -// and the bottom border, both spanning edge-to-edge. +// Full-bleed gap between hero and All Docs. The hero supplies the top border. +// This band adds responsive height and an edge-to-edge bottom border. .sectionGap { height: 1.5rem; border-bottom: var(--brand-borderWidth-thin, 1px) solid @@ -15,9 +14,8 @@ } } -// Full-bleed border closing off the bottom of the "All Docs" grid, mirroring the -// gap band at the top. The grid cells only draw rail-width borders, so this -// spans edge-to-edge before the footer. +// Full-bleed border closes the bottom of All Docs to mirror the top gap. +// Grid cells draw rail-width borders, so this spans edge-to-edge before the footer. .sectionEnd { height: 4rem; border-top: var(--brand-borderWidth-thin, 1px) solid diff --git a/src/landings/pages/home.tsx b/src/landings/pages/home.tsx index d5d4ad0d1acb..605bc008ebe5 100644 --- a/src/landings/pages/home.tsx +++ b/src/landings/pages/home.tsx @@ -23,8 +23,7 @@ type FeaturedLink = { type Props = { mainContext: MainContextT - // Retained in getServerSideProps so the "Getting started" / "Popular" lists - // can be restored later; the Docs 2026 homepage body is just the grid. + // getServerSideProps keeps Getting started and Popular data so the page can restore those lists. popularLinks: Array gettingStartedLinks: Array productGroups: Array diff --git a/src/landings/pages/product.tsx b/src/landings/pages/product.tsx index 4039ed73e486..3a1ba5781d87 100644 --- a/src/landings/pages/product.tsx +++ b/src/landings/pages/product.tsx @@ -6,8 +6,7 @@ import { useRouter } from 'next/router' import type { ExtendedRequest } from '@/types' import type { JourneyTrack } from '@/journeys/lib/journey-path-resolver' -// "legacy" javascript needed to maintain existing functionality -// typically operating on elements **within** an article. +// Article pages need these scripts for behavior inside rendered article content. import copyCode from '@/frame/components/lib/copy-code' import toggleAnnotation from '@/frame/components/lib/toggle-annotations' @@ -72,9 +71,9 @@ const GlobalPage = ({ const router = useRouter() useEffect(() => { - // https://stackoverflow.com/a/67063998 - initiateArticleScripts() // on initiate page - router.events.on('routeChangeComplete', initiateArticleScripts) // on client side route + // Mount and route-change init; Next.js keeps this mounted: https://stackoverflow.com/a/67063998 + initiateArticleScripts() + router.events.on('routeChangeComplete', initiateArticleScripts) return () => { router.events.off('routeChangeComplete', initiateArticleScripts) } @@ -118,9 +117,7 @@ const GlobalPage = ({ ) } else { - // In local dev, when Next.js needs the initial compiled version - // it will request `/_next/static/webpack/$HASH.webpack.hot-update.json` - // or `/_next/webpack-hmr` and then we just let the `content` be undefined. + // Let Next.js hot-reload probes render empty content during local development. if ( !(router.asPath.startsWith('/_next/static/') || router.asPath.startsWith('/_next/webpack')) ) { @@ -144,15 +141,14 @@ export const getServerSideProps: GetServerSideProps = async (context) => const additionalUINamespaces: string[] = [] - // This looks a little funky, but it's so we only send one context's data to the client + // Send only the active page context to the client to avoid unused page data. if (currentLayoutName === 'bespoke-landing') { props.bespokeContext = await getLandingContextFromRequest(req, 'bespoke') additionalUINamespaces.push('product_landing', 'carousels') } else if (currentLayoutName === 'journey-landing') { props.journeyContext = await getLandingContextFromRequest(req, 'journey') - // journey tracks are resolved in middleware and added to the request - // so we need to add them to the journey context here + // Middleware resolves journey tracks, so add them to the journey context before rendering. const page = req.context?.page as { resolvedJourneyTracks?: JourneyTrack[] } | undefined if (page?.resolvedJourneyTracks) { props.journeyContext.journeyTracks = page.resolvedJourneyTracks @@ -173,7 +169,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => ) } } else if (props.mainContext.page) { - // All articles that might have hover cards needs this + // Articles need the popovers namespace for hover cards. additionalUINamespaces.push('popovers') props.articleContext = getArticleContextFromRequest( diff --git a/src/landings/tests/article-search.ts b/src/landings/tests/article-search.ts index 50618a5a3f0c..04bc447873c1 100644 --- a/src/landings/tests/article-search.ts +++ b/src/landings/tests/article-search.ts @@ -93,7 +93,7 @@ describe('deriveStopWords', () => { ] // "copilot" appears in 3/3 = 100%, always a stop word expect(deriveStopWords(articles, 0.5)).toContain('copilot') - // At threshold 1.0, only words in every single article qualify + // With threshold 1.0, only words in every article qualify. const strict = deriveStopWords(articles, 1.0) expect(strict).toContain('copilot') expect(strict).not.toContain('agents') diff --git a/src/landings/tests/count-articles.ts b/src/landings/tests/count-articles.ts index b773744da5b3..3434c908f0a9 100644 --- a/src/landings/tests/count-articles.ts +++ b/src/landings/tests/count-articles.ts @@ -21,7 +21,7 @@ describe('countArticles', () => { }) test('counts all nested leaf articles recursively', () => { - // Structure: parent -> 2 sections -> each with 3 articles = 6 total + // Two sections with three articles each produce six leaf articles. const section1 = createNode([createNode(), createNode(), createNode()]) const section2 = createNode([createNode(), createNode(), createNode()]) const parent = createNode([section1, section2]) @@ -30,7 +30,7 @@ describe('countArticles', () => { }) test('handles deeply nested structure', () => { - // 3 levels deep: parent -> section -> subsection -> 2 articles + // Three nested levels end in two leaf articles. const subsection = createNode([createNode(), createNode()]) const section = createNode([subsection]) const parent = createNode([section]) @@ -39,7 +39,7 @@ describe('countArticles', () => { }) test('handles mixed depth structure', () => { - // parent -> section with 2 articles + section with subsection with 3 articles = 5 total + // Two direct leaves plus three nested leaves produce five articles. const section1 = createNode([createNode(), createNode()]) const subsection = createNode([createNode(), createNode(), createNode()]) const section2 = createNode([subsection]) diff --git a/src/landings/tests/featured-links.ts b/src/landings/tests/featured-links.ts index bb05cff07f97..f4e0eb8fbc36 100644 --- a/src/landings/tests/featured-links.ts +++ b/src/landings/tests/featured-links.ts @@ -12,7 +12,7 @@ describe('featuredLinks', () => { test('Enterprise get-started landing renders', async () => { const $ = await getDOM('/en/enterprise-server@latest/get-started') - // get-started uses discovery-landing, so it has hero/spotlight, not article-list. + // discovery-landing renders get-started hero and spotlight instead of article-list. expect($('h1').text()).toMatch(/Getting started/) }) }) @@ -24,7 +24,7 @@ describe('homepage', () => { const $ = await getDOM('/en') const $search = $('[data-testid=homepage-search]') expect($search).toHaveLength(1) - // The redesigned homepage no longer renders the featured article lists. + // The homepage renders the product grid, not featured article lists. expect($('[data-testid=article-list]')).toHaveLength(0) }) @@ -32,7 +32,6 @@ describe('homepage', () => { const $ = await getDOM('/en') const $grid = $('[data-testid=product]') expect($grid).toHaveLength(1) - // Category group headings and their product links. expect($grid.find('h3').length).toBeGreaterThan(0) expect($grid.find('a').length).toBeGreaterThan(0) }) diff --git a/src/landings/tests/fuzzy-match.ts b/src/landings/tests/fuzzy-match.ts index f96b0d3339a7..177069baf689 100644 --- a/src/landings/tests/fuzzy-match.ts +++ b/src/landings/tests/fuzzy-match.ts @@ -30,15 +30,12 @@ describe('fuzzyMatch', () => { }) test('short terms (<=4 chars) require exact substring match', () => { - // "test" is 4 chars, so exact substring only. expect(fuzzyMatch('Writing tests', 'test')).toBe(true) expect(fuzzyMatch('Generating tables', 'test')).toBe(false) - // "mcp" is 3 chars expect(fuzzyMatch('Using the GitHub MCP Server', 'mcp')).toBe(true) expect(fuzzyMatch('Coding agents', 'mcp')).toBe(false) - // "pr" is 2 chars, so exact substring only. expect(fuzzyMatch('Writing PR descriptions', 'pr')).toBe(true) - // "pr" is a substring of "enterprise", so this still matches (exact match) + // "pr" matches inside "enterprise" because short terms match substrings. expect(fuzzyMatch('Enterprise setup', 'pr')).toBe(true) }) @@ -59,7 +56,7 @@ describe('fuzzyMatch', () => { }) test('handles edge cases gracefully', () => { - expect(fuzzyMatch('GitHub Copilot', '')).toBe(true) // empty search matches anything + expect(fuzzyMatch('GitHub Copilot', '')).toBe(true) // Empty search matches anything. expect(fuzzyMatch('', 'copilot')).toBe(false) expect(fuzzyMatch('', '')).toBe(true) @@ -79,16 +76,13 @@ describe('fuzzyMatchScore', () => { }) test('returns bigram coverage score for fuzzy matches', () => { - // Bigram coverage should give a score between 0.7 and 1 const score = fuzzyMatchScore('About Copilot memory features', 'memory copilot') expect(score).toBeGreaterThanOrEqual(0.7) expect(score).toBeLessThan(1) }) test('matches singular vs plural via bigrams', () => { - // "agents" bigrams: ag, ge, en, nt, ts (5) - // "agent" in text has: ag, ge, en, nt (4) - // Coverage: 4/5 = 0.8, which is > 0.7 threshold + // "agents" has ag, ge, en, nt, ts; "agent" covers 4 of 5, so coverage is 0.8. const score = fuzzyMatchScore('GitHub Copilot agent', 'agents') expect(score).toBeGreaterThanOrEqual(0.7) }) @@ -120,17 +114,13 @@ describe('bigramCoverage', () => { }) test('handles singular vs plural with high coverage', () => { - // "agents" bigrams: ag, ge, en, nt, ts (5) - // "agent" in text has: ag, ge, en, nt (4) - // Coverage: 4/5 = 0.8 + // "agents" has ag, ge, en, nt, ts; "agent" covers 4 of 5, so coverage is 0.8. const coverage = bigramCoverage('agent', 'agents') expect(coverage).toBeCloseTo(4 / 5, 2) }) test('calculates partial coverage correctly', () => { - // Text "hello" has bigrams: he, el, ll, lo - // Search "help" has bigrams: he, el, lp - // Found: he, el (2 of 3) = 0.67 + // "hello" has he, el, ll, lo; "help" matches he and el, so coverage is 2 of 3. const coverage = bigramCoverage('hello', 'help') expect(coverage).toBeCloseTo(2 / 3, 2) }) diff --git a/src/landings/tests/octicons.test.ts b/src/landings/tests/octicons.test.ts index 845607848216..279b88906057 100644 --- a/src/landings/tests/octicons.test.ts +++ b/src/landings/tests/octicons.test.ts @@ -77,7 +77,7 @@ describe('octicons reference', () => { }) test('returns CopilotIcon as fallback for invalid octicons', () => { - // TypeScript should prevent this, but test runtime behavior + // Runtime content can bypass TypeScript, so invalid names still need a fallback. expect(getOcticonComponent('invalid' as ValidOcticon)).toBe(CopilotIcon) }) }) @@ -127,11 +127,7 @@ describe('octicons reference', () => { }) test('adding new octicon only requires updating OCTICON_COMPONENTS', () => { - // This test documents the single source of truth approach - // If you add a new octicon to OCTICON_COMPONENTS: - // 1. ValidOcticon type automatically includes it - // 2. VALID_OCTICONS array automatically includes it - // 3. All validation functions work with it + // OCTICON_COMPONENTS drives the type, validation array, and validation helpers. const componentCount = Object.keys(OCTICON_COMPONENTS).length const validOcticonsCount = VALID_OCTICONS.length diff --git a/src/landings/tests/sidebar-custom-links.ts b/src/landings/tests/sidebar-custom-links.ts index c2952eb79990..d170c3e354ae 100644 --- a/src/landings/tests/sidebar-custom-links.ts +++ b/src/landings/tests/sidebar-custom-links.ts @@ -12,7 +12,7 @@ describe('sidebar custom links', () => { }) test('page without sidebarLink frontmatter does not show custom link', async () => { - // Using a page that's not in the get-started section to avoid seeing the foo sidebarLink + // The /actions page avoids the get-started section, which has fixture sidebarLink data. const $ = await getDOM('/actions') const customLinks = $('[data-testid="sidebar"] a:contains("All sidebar test items")') @@ -22,7 +22,6 @@ describe('sidebar custom links', () => { test.skip('sidebarLink with custom text appears correctly', async () => { const $ = await getDOM('/get-started/sidebar-test') - // The fixture sidebar-test page should have "All sidebar test items" as custom text const customLink = $('[data-testid="sidebar"] a:contains("All sidebar test items")') expect(customLink.text().trim()).toBe('All sidebar test items') }) @@ -37,7 +36,7 @@ describe('sidebar custom links', () => { const testSection = customLink.closest('[role="group"], ul') const allLinks = testSection.find('a') const customLinkIndex = allLinks.index(customLink) - expect(customLinkIndex).toBe(0) // Should be the first link in the subnav + expect(customLinkIndex).toBe(0) // Custom sidebar links appear first in their subnav. }) test.skip('sidebar custom link has correct aria attributes', async () => { @@ -46,13 +45,12 @@ describe('sidebar custom links', () => { const customLink = $('[data-testid="sidebar"] a:contains("All sidebar test items")') expect(customLink.length).toBe(1) - // Verify the custom link has proper attributes (aria-current depends on current page logic) expect(customLink.attr('href')).toBeDefined() expect(customLink.text().trim()).toBe('All sidebar test items') }) test('sidebar custom link does not appear on unrelated pages', async () => { - // Using actions page which is completely unrelated to get-started/foo + // The /actions page avoids the get-started section, which has fixture sidebarLink data. const $ = await getDOM('/actions') const customLink = $('[data-testid="sidebar"] a:contains("All sidebar test items")') diff --git a/src/landings/tests/sidebar-navlist-depth.ts b/src/landings/tests/sidebar-navlist-depth.ts index 1d1058f99b61..395389e36f6f 100644 --- a/src/landings/tests/sidebar-navlist-depth.ts +++ b/src/landings/tests/sidebar-navlist-depth.ts @@ -2,11 +2,10 @@ import { describe, expect, test } from 'vitest' import { flattenDescendants, MAX_NAVLIST_LEVEL } from '../components/sidebar-navlist-depth' -// The sidebar renders on @primer/react-brand NavList, which supports at most 5 -// nesting levels, and a level-5 item that contains a SubNav is dropped. SidebarProduct -// guards against this: at MAX_NAVLIST_LEVEL it stops nesting and flattens the -// remaining subtree into leaf links so no page becomes unreachable. These tests -// pin that reachability guarantee. +// @primer/react-brand NavList supports at most 5 nesting levels; a level-5 item +// that contains a SubNav drops its children. SidebarProduct stops nesting at +// MAX_NAVLIST_LEVEL and flattens the remaining subtree into leaf links so every +// page stays reachable. These tests protect that guarantee. type TestNode = { title: string; href: string; childPages: TestNode[] } @@ -35,8 +34,7 @@ describe('sidebar NavList depth guard', () => { }) test('an over-deep subtree loses no pages when flattened', () => { - // A chain deeper than the cap: every node past the cap must still be reachable - // as a flat leaf, i.e. flattening the capped node surfaces all of them. + // Nodes deeper than the cap must surface as flat leaves instead of disappearing. const deepLeaf = node('/1/2/3/4/5/6/7') const chain = node('/1', [ node('/1/2', [ @@ -47,9 +45,8 @@ describe('sidebar NavList depth guard', () => { ]) const flattened = flattenDescendants(chain).map((n) => n.href) - // Nothing is dropped: the deepest page is present. expect(flattened).toContain('/1/2/3/4/5/6/7') - // And the count equals the total descendant node count (6 below the root). + // Six descendants below the root must remain reachable. expect(flattened).toHaveLength(6) }) }) diff --git a/src/landings/types.ts b/src/landings/types.ts index 0f69a4b861d8..e875c24ff4f3 100644 --- a/src/landings/types.ts +++ b/src/landings/types.ts @@ -1,6 +1,6 @@ import { ValidOcticon, isValidOcticon } from './lib/octicons' -// Re-export ValidOcticon and isValidOcticon for compatibility with existing imports +// Keep these re-exports for existing imports. export type { ValidOcticon } export { isValidOcticon } @@ -19,7 +19,7 @@ export type BaseTocItem = { intro?: string | null } -// Recursive: children can have their own children. +// Child items can nest recursively. export type ChildTocItem = BaseTocItem & { octicon?: ValidOcticon | null category?: string[] | null @@ -40,8 +40,7 @@ export type TocItem = BaseTocItem & { export type ArticleCardItems = ChildTocItem[] -// Matches the data shape returned by getTocItems(), including every property that -// may be present in the source data. +// Preserve every getTocItems() property that landings receive from source data. export type RawTocItem = { title: string fullPath: string diff --git a/src/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 { diff --git a/src/rest/api/anchor-redirect.ts b/src/rest/api/anchor-redirect.ts index 59d1c8c28831..519919e4187e 100644 --- a/src/rest/api/anchor-redirect.ts +++ b/src/rest/api/anchor-redirect.ts @@ -11,7 +11,6 @@ const clientSideRestAPIRedirects = readCompressedJsonFileFallbackLazily( const router = express.Router() -// Returns a client side redirect if one exists for the given path. const redirects: RequestHandler = (req, res) => { if (!req.query.path) { res.status(400).send("Missing 'path' query string") diff --git a/src/rest/components/ApiVersionPicker.tsx b/src/rest/components/ApiVersionPicker.tsx index 16f07260386f..fb9feaa21bd1 100644 --- a/src/rest/components/ApiVersionPicker.tsx +++ b/src/rest/components/ApiVersionPicker.tsx @@ -13,13 +13,11 @@ const API_VERSION_SUFFIX = ' (latest)' function rememberApiVersion(apiVersion: string) { try { - // We use this cookie to remember which API Version a user chooses - // when they navigate the REST docs. + // Remember the selected REST API version across REST docs pages. const apiVersionNormalized = apiVersion.replace(API_VERSION_SUFFIX, '') Cookies.set(API_VERSION_COOKIE_NAME, apiVersionNormalized) } catch (err) { - // Some browser extensions disallow setting cookies at all, so the - // `document.cookie` setter can throw. Swallow it and move on. + // Some extensions make document.cookie throw, so ignore cookie-write failures. console.warn('Unable to set preferred api version cookie', err) } } @@ -30,8 +28,7 @@ export const ApiVersionPicker = () => { const { allVersions } = useMainContext() const { t } = useTranslation('rest') const basePath = router.asPath.split('#')[0].split('?')[0] - // Use the version from the URL when it's valid, otherwise the latest date. - // RestRedirect is what applies the cookie preference to the URL. + // Use a valid URL version or latest; RestRedirect applies the cookie preference to the URL. const isValidApiVersion = (router.query.apiVersion && typeof router.query.apiVersion === 'string' && @@ -75,7 +72,7 @@ export const ApiVersionPicker = () => { }, }) - // A non-empty `apiVersions` means the version is calendar-date versioned. + // Calendar-date versioned docs expose at least one API version. return allVersions[currentVersion].apiVersions.length > 0 ? (
    { - // The fetch can resolve after the component unmounts and still call - // router.replace, so abort it during cleanup. + // Abort during cleanup because fetch can resolve after unmount and still call router.replace. const controller = new AbortController() const signal = controller.signal @@ -29,8 +26,7 @@ export default function ClientSideRedirectExceptions() { signal, }) - // A missing redirect is a 200 with an empty object, so only a - // successful response is worth parsing. + // Missing redirects return 200 with an empty object; parse only successful responses. if (response.ok) { const { to } = await response.json() if (to) { diff --git a/src/rest/components/ClientSideRedirects.tsx b/src/rest/components/ClientSideRedirects.tsx index ad21a8adb72e..bed8d7d6ac64 100644 --- a/src/rest/components/ClientSideRedirects.tsx +++ b/src/rest/components/ClientSideRedirects.tsx @@ -9,19 +9,16 @@ const ClientSideRedirectExceptions = dynamic( }, ) +// REST API code hardcodes some docs links in a separate repo. Fixing those links +// needs many file changes and team sign-off, so redirect exceptions repair one-offs. +// Hashes are client-only, so wait to load redirect logic until the browser can inspect them. export function ClientSideRedirects() { const { asPath } = useRouter() - // One-off redirects for the REST docs, as a workaround for fixing the - // hardcoded links in the REST API code, which lives in a separate repo and - // needs many file changes and sign-off from several teams. - // - // This decides whether to load the redirecting component at all. It can't - // happen server-side because the URL hash is only known on the client. const [load, setLoad] = useState(false) useEffect(() => { const { hash } = window.location - // Only /rest has these redirects today. More paths may need adding. + // Redirect exceptions apply only under /rest. if (hash && asPath.startsWith('/rest')) { setLoad(true) } diff --git a/src/rest/components/RestAuth.tsx b/src/rest/components/RestAuth.tsx index 3eabc959c6cf..6cc865ee484b 100644 --- a/src/rest/components/RestAuth.tsx +++ b/src/rest/components/RestAuth.tsx @@ -6,7 +6,7 @@ import { Link } from '@/frame/components/Link' import { ProgAccessT } from './types' import { RenderedHTML } from '@/frame/components/ui/RenderedHTML/RenderedHTML' -// Documentation paths may be moved around by content team in the future +// Keep these paths centralized because content can move docs pages. const USER_TOKEN_PATH = '/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app' const INSTALLATION_TOKEN_PATH = @@ -24,12 +24,11 @@ export function RestAuth({ progAccess, slug, operationTitle }: Props) { const { currentVersion } = useVersion() const { t } = useTranslation('rest_reference') - // This early return can be removed once GHES 3.9 is deprecated - // The GHES 3.8 and 3.9 releases don't support fine-grained access tokens + // GHES 3.8 and 3.9 lacked fine-grained tokens; both are deprecated, so this never matches. if (currentVersion === 'enterprise-server@3.9' || currentVersion === 'enterprise-server@3.8') return null - // Some operations define no progAccess at all. + // Some operations omit progAccess. if (!progAccess) return null const { userToServerRest, @@ -40,10 +39,7 @@ export function RestAuth({ progAccess, slug, operationTitle }: Props) { } = progAccess const noFineGrainedAccess = !(userToServerRest || serverToServer || fineGrainedPat) - // For endpoints on dotcom that do not support any fine-grained token types - // and allow permissionless (unauthenticated) access, do not render a - // fine-grained access section. Note: allowPermissionlessAccess is dotcom-only; - // GHES versions may still require authentication for these endpoints. + // Hide fine-grained access for dotcom permissionless endpoints; GHES may still require auth. if (!basicAuth && noFineGrainedAccess && allowPermissionlessAccess) return null const heading = basicAuth ? t('basic_auth_heading') : t('fine_grained_access') @@ -77,20 +73,14 @@ type FineGrainedProps = { progAccess: ProgAccessT } +// Each progAccess.permissions object is one acceptable permission set. +// Every key-value pair inside a set is required. function FineGrainedAccess({ progAccess }: FineGrainedProps) { const router = useRouter() const { currentVersion } = useVersion() const { t } = useTranslation('rest_reference') - // progAccess.permissions is an array of objects - // For example: [ {'"Actions" repository permissions': 'read', '"Administration" organization permissions': 'write'}, {'"Secrets" organization permissions"': 'write'} ] - // Each object represents a set of permissions containing one - // or more key-value pairs. All permissions in a set are required. - // If there is more than one set of permissions, any set can be used. const formattedPermissions = progAccess.permissions.map((permissionSet: object, index) => { - // Given the example above, the first object is now an array of tuples - // [['"Actions" repository permissions', 'read'], ['"Administration" organization permissions', 'read']] - // that can be formatted as a string like `"Administration" organization permissions (write)' const permissionSetPairs = Object.entries(permissionSet) const numPermissionSetPairs = permissionSetPairs.length diff --git a/src/rest/components/RestBanner.tsx b/src/rest/components/RestBanner.tsx index abb6fbf75f8b..5033925b58b6 100644 --- a/src/rest/components/RestBanner.tsx +++ b/src/rest/components/RestBanner.tsx @@ -36,8 +36,7 @@ const restRepoCategoryExceptionsTitles = { export const RestBanner = () => { const router = useRouter() const { t } = useTranslation('rest') - // A productId of 'rest' with no category is the product landing page, e.g. - // /en/rest?apiVersion=2022-08-09. + // The /en/rest?apiVersion=2022-08-09 page has productId=rest and no category. const isRestPage = router.query.productId === 'rest' || router.query.category const restPage = router.query.category as string const { currentVersion } = useVersion() @@ -53,8 +52,7 @@ export const RestBanner = () => { versionWithApiVersion = currentVersion } else { if (currentVersionObj.isGHES) { - // If this is a GHES release with no REST versions, - // find out if any GHES releases contain REST versioning yet. + // GHES releases without REST versioning link to the first GHES release that has it. const firstGhesReleaseWithApiVersions = Object.values(allVersions) .reverse() .find((v) => { @@ -72,7 +70,6 @@ export const RestBanner = () => { } } } - // Temporary banner for REST API Versioning if (isRestPage && bannerText !== '') { return (
    . Add cases as needed. +// Map REST code-sample languages to syntax highlighter language names. function getLanguageHighlight(selectedLanguage: string) { return selectedLanguage === CodeSampleKeys.javascript ? 'javascript' : 'curl' } @@ -42,7 +41,6 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { const { t } = useTranslation(['rest_reference']) const { isEnterpriseServer, isEnterpriseCloud } = useVersion() - // Ref for resetting scroll position when switching response views. const scrollRef = useRef(null) const { currentVersion } = useVersion() @@ -59,13 +57,11 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { const languageSelectOptions: CodeSampleKeys[] = [CodeSampleKeys.curl] - // Management Console and GHES Manage API operations are not supported - // by Octokit + // Management Console and GHES Manage API operations have no Octokit support. if (operation.subcategory !== 'management-console' && operation.subcategory !== 'manage-ghes') { languageSelectOptions.push(CodeSampleKeys.javascript) - // Not all examples support the GH CLI language option. If any of - // the examples don't support it, we don't show GH CLI as an option. + // Hide GitHub CLI when any example lacks GitHub CLI support. if (!languageExamples.some((example) => example.ghcli === undefined)) { languageSelectOptions.push(CodeSampleKeys.ghcli) } @@ -94,8 +90,7 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { } useEffect(() => { - // If the user previously selected a language preference and the language - // is available in this component set it as the selected language + // Honor the saved language preference only when this operation supports that language. const cookieValue = Cookies.get(CODE_SAMPLE_LANGUAGE_COOKIE_NAME) const preferredCodeLanguage = languageSelectOptions.find((item) => item === cookieValue) if (cookieValue && preferredCodeLanguage) { @@ -103,8 +98,7 @@ export function RestCodeSamples({ operation, slug, heading }: Props) { } }, []) - // Reset scroll position to the top when switching between example response and - // response schema. Highlighting is handled React-natively by . + // Reset scroll position when switching between the example response and response schema. useEffect(() => { const scrollElem = scrollRef.current if (scrollElem) { @@ -156,7 +150,6 @@ export function RestCodeSamples({ operation, slug, heading }: Props) {
    )} - {/* Request example section */}
    @@ -215,7 +208,6 @@ export function RestCodeSamples({ operation, slug, heading }: Props) {
    - {/* Response section */} 25 ? requestPath.replaceAll('/', '/').replaceAll('_', '_') diff --git a/src/rest/components/RestOperation.tsx b/src/rest/components/RestOperation.tsx index ec4d4e622244..2fe12c3a63d8 100644 --- a/src/rest/components/RestOperation.tsx +++ b/src/rest/components/RestOperation.tsx @@ -19,7 +19,7 @@ type Props = { operation: Operation } -// all REST operations have this accept header by default +// Use this as the default Accept header for REST operations. const DEFAULT_ACCEPT_HEADER = { name: 'accept', type: 'string', @@ -38,7 +38,7 @@ export function RestOperation({ operation }: Props) { const titleSlug = slug(operation.title) const { t } = useTranslation('rest_reference') const router = useRouter() - // omit the default header if ghes specific api + // Omit the default Accept header for Management Console and GHES Manage APIs. const headers = operation.subcategory === 'management-console' || operation.subcategory === 'manage-ghes' ? [] diff --git a/src/rest/components/RestRedirect.tsx b/src/rest/components/RestRedirect.tsx index 1cb50cbf5d03..dd7a557bf65e 100644 --- a/src/rest/components/RestRedirect.tsx +++ b/src/rest/components/RestRedirect.tsx @@ -6,8 +6,8 @@ import { useVersion } from '@/versions/components/useVersion' import { useMainContext } from '@/frame/components/context/MainContext' import { API_VERSION_COOKIE_NAME } from '@/frame/lib/constants' -// This component allows us to set the URL Param for the REST API Calendar Date version -// We set a cookie as well to remember what calendar date version the user is on +// RestRedirect adds a valid apiVersion to calendar-date versioned REST URLs. +// It reads a saved version from the cookie and otherwise uses the latest version. export function RestRedirect() { const router = useRouter() const { currentVersion } = useVersion() diff --git a/src/rest/components/RestReferencePage.tsx b/src/rest/components/RestReferencePage.tsx index 28fddddc7f26..e8408e5a066d 100644 --- a/src/rest/components/RestReferencePage.tsx +++ b/src/rest/components/RestReferencePage.tsx @@ -18,10 +18,7 @@ export const RestReferencePage = ({ restOperations }: StructuredContentT) => { const { title, intro, renderedPage, renderedPageHast, permissions, product } = useAutomatedPageContext() - // Scrollable code blocks in our REST API docs and elsewhere aren't accessible - // via keyboard navigation without setting tabindex="0". But we don't want to set - // this attribute on every `
    ` code block, only the ones where there are scroll
    -  // bars because the content isn't all visible.
    +  // Add tabindex=0 only when pre content overflows, because scrollable code needs keyboard access.
       useEffect(() => {
         const codeBlocks = document.querySelectorAll('pre')
     
    @@ -37,8 +34,6 @@ export const RestReferencePage = ({ restOperations }: StructuredContentT) => {
     
       return (
         
    -      {/* Doesn't matter *where* this is included because it will
    -      never render anything. It always just return null. */}
           
           
           
    diff --git a/src/rest/components/get-rest-code-samples.ts b/src/rest/components/get-rest-code-samples.ts index 7564007ebe33..028479067b69 100644 --- a/src/rest/components/get-rest-code-samples.ts +++ b/src/rest/components/get-rest-code-samples.ts @@ -5,13 +5,12 @@ import type { CodeSample, Operation } from '@/rest/components/types' import { type VersionItem } from '@/frame/components/context/MainContext' function shouldOmitAuthentication(operation: Operation, currentVersion: string): boolean { - // Only omit auth for operations that explicitly allow permissionless access + // Only explicitly permissionless operations can omit auth. if (!operation?.progAccess?.allowPermissionlessAccess) { return false } - // Only omit auth on dotcom versions (free-pro-team, enterprise-cloud) - // GHES and other versions still require authentication + // Dotcom versions can omit auth; GHES and other versions still require authentication. const isDotcomVersion = currentVersion.startsWith('free-pro-team') || currentVersion.startsWith('enterprise-cloud') @@ -26,16 +25,14 @@ function escapeShellValue(value: string): string { type CodeExamples = Record -// If the content type is application/x-www-form-urlencoded the format of -// the shell example is --data-urlencode param1=value1 --data-urlencode param2=value2 -// For example, this operation: +// Form-encoded shell examples use repeated --data-urlencode flags, such as +// param1=value1 and param2=value2. For example: // https://docs.github.com/en/enterprise/rest/reference/enterprise-admin#enable-or-disable-maintenance-mode const CURL_CONTENT_TYPE_MAPPING: { [key: string]: string } = { 'application/x-www-form-urlencoded': '--data-urlencode', 'multipart/form-data': '--form', 'application/octet-stream': '--data-binary', } -// Generates a curl example for one code sample. export function getShellExample( operation: Operation, codeSample: CodeSample, @@ -52,9 +49,9 @@ export function getShellExample( const omitAuth = shouldOmitAuthentication(operation, currentVersion) - // GHES Manage API requests differ from the dotcom API requests and make use of multipart/form-data and json content types + // GHES Manage requests need special handling for multipart/form-data and JSON content types. if (operation.subcategory === 'manage-ghes') { - // GET requests don't have a requestBody set, therefore let's default them to application/json + // GHES Manage GET operations omit requestBody, so default the content type to JSON. if (operation.verb === 'get') { contentTypeHeader = '-H "Content-Type: application/json"' } else { @@ -83,9 +80,7 @@ export function getShellExample( const contentType = codeSample.request.contentType if (contentType in CURL_CONTENT_TYPE_MAPPING) { requestBodyParams = '' - // Most of the time the example body parameters have a name and value - // and are included in an object. But, some cases are a single value - // and the type is a string. + // Mapped content types can pass a single scalar body instead of named parameters. const { bodyParameters } = codeSample.request if (bodyParameters && typeof bodyParameters === 'object' && !Array.isArray(bodyParameters)) { const paramNames = Object.keys(bodyParameters) @@ -108,15 +103,12 @@ export function getShellExample( : '' let acceptHeader = `-H "Accept: ${getAcceptHeader(codeSample)}"` let urlArg = `${operation.serverUrl}${requestPath}` - // If the `requestPath` contains a `?` character, if you need to escape - // the whole URL otherwise, when you paste it into your terminal, it - // will fail because the `?` is a bash control character. + // Quote URLs containing ? so shells don't expand it as a glob. if (requestPath.includes('?')) { urlArg = `"${urlArg}"` } - // The management-console and manage-ghes APIs don't follow the dotcom - // conventions, so replace the auth, API version and Accept headers. + // Management Console and GHES Manage APIs replace dotcom auth, API version, and Accept headers. if (operation.subcategory === 'management-console' || operation.subcategory === 'manage-ghes') { authHeader = '-u "api_key:your-password"' apiVersionHeader = '' @@ -147,15 +139,13 @@ export function getShellExample( return `curl -L \\\n ${args.join(' \\\n ')}` } -// Generates a GitHub CLI example for one code sample. Returns undefined when -// the operation only supports basic auth, which gh doesn't do. +// Return undefined when basicAuth is set because GitHub CLI does not support basic auth. export function getGHExample( operation: Operation, codeSample: CodeSample, currentVersion: string, allVersions: Record, ) { - // Basic authentication is not supported by GH CLI if (operation?.progAccess?.basicAuth) return const defaultAcceptHeader = getAcceptHeader(codeSample) @@ -175,21 +165,17 @@ export function getGHExample( requestPath += requiredQueryParams ? `?${requiredQueryParams}` : '' let requestBodyParams = '' - // Most of the time the example body parameters have a name and value - // and are included in an object. But, some cases are a single value - // and the type is a string. + // Request bodies can be named object parameters or a single scalar value. const { bodyParameters } = codeSample.request if (bodyParameters) { if (typeof bodyParameters === 'object') { - // Special handling for gist endpoints - use --input for nested file structures + // Gist create and update examples use --input for nested file structures. const isGistEndpoint = !Array.isArray(bodyParameters) && operation.requestPath.includes('/gists') && (operation.title === 'Create a gist' || operation.title === 'Update a gist') - // For top-level arrays or complex objects with arrays, use --input with JSON. - // The gh CLI -f/-F flags can't represent a request body that is itself an array, - // so we fall back to piping the JSON body via --input. + // Use --input for top-level arrays or nested arrays because gh -f and -F cannot encode them. const hasArrays = hasNestedArrays(bodyParameters as NestedObjectParameter) if (hasArrays || isGistEndpoint) { const jsonBody = JSON.stringify( @@ -255,7 +241,7 @@ function handleSingleParameter( ): string { let cliLine = '' const keyString = `${transformKey(key)}` - // When only a value is passed to bodyParameters we don't show the '=' since there isn't a key + // Scalar bodyParameters omit = because they have no key. let separator = '=' if (!key) { separator = '' @@ -289,6 +275,8 @@ function handleSingleParameter( return cliLine } +// handleObjectParameter rejects nested arrays because form-field encoding cannot represent them. +// It expands arrays of objects into separate -f or -F parameters. function handleObjectParameter( objectParams: NestedObjectParameter, transformKey = startTransformKey, @@ -298,16 +286,11 @@ function handleObjectParameter( if (Array.isArray(value)) { for (let i = 0; i < value.length; i++) { const param = value[i] - // This isn't valid in a REST context, our REST API should not be designed to take - // something like { "letterSegments": [["a", "b", "c"], ["d", "e", "f"]] } - // If this is a possibility, we can update the code to handle it if (Array.isArray(param)) { throw new Error('Nested arrays are not valid in the bodyParameters') } if (typeof param === 'object' && param !== null) { - // When an array of objects, we want to display the key and value as two separate parameters - // E.g. -F "properties[0][property_name]=repo" -F "properties[0][value]=docs-internal" for (const [nestedKey, nestedValue] of Object.entries(param)) { cliLine += handleSingleParameter( `${key}[${i}][${nestedKey}]`, @@ -335,7 +318,9 @@ function handleObjectParameter( return cliLine } -// Generates an octokit.js example for one code sample. +// getJSExample appends query params to mutating URL templates because Octokit only +// auto-sends them for GET and HEAD, for example: +// POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name,label} export function getJSExample( operation: Operation, codeSample: CodeSample, @@ -347,10 +332,7 @@ export function getJSExample( if (codeSample.request) { Object.assign(parameters, codeSample.request.parameters) - // Most of the time the example body parameters have a name and value - // and are included in an object. But some cases are a single scalar value - // or a top-level JSON array, both of which Octokit sends as the raw - // request body via the `data` option. + // Octokit sends scalar bodies and top-level arrays through the data option. if ( codeSample.request.bodyParameters && (typeof codeSample.request.bodyParameters !== 'object' || @@ -364,11 +346,6 @@ export function getJSExample( let queryParameters = '' - // Query parameters are set automatically for GET and HEAD requests, we - // otherwise have to handle it ourselves for other request methods by adding - // the parameters to the request path in URL template format e.g.: - // - // 'POST /repos/{owner}/{repo}/releases/{release_id}/assets{?name,label}' if ( operation.verb === 'delete' || operation.verb === 'patch' || @@ -403,7 +380,7 @@ export function getJSExample( const isBasicAuth = operation?.progAccess?.basicAuth let authString = isBasicAuth ? oauthOctokit : authOctokit - // Use unauthenticated Octokit for endpoints that allow permissionless access + // Permissionless endpoints use unauthenticated Octokit. if (omitAuth) { authString = unauthenticatedOctokit } @@ -413,31 +390,8 @@ export function getJSExample( }${queryParameters}', ${stringify(parameters, null, 2)})` } -// Every code example parameter object can be slightly different depending on the operation. For e.g. for Packages it's something like this: -// [ -// { -// "id": 197, -// "name": "hello_docker", -// "package_type": "container", -// }, -// { -// "id": 198, -// "name": "goodbye_docker", -// "package_type": "container", -// } -// ] -// But for Actions cache it's something like this: -// { -// "total_count": 1, -// "actions_caches": [ -// { -// "id": 505, -// "ref": "refs/heads/main", -// "key": "Linux-node-958aff96db2d75d67787d1e634ae70b659de937b", -// } -// ] -// } -// We need to find the matching key so this is using JSON.stringify to handle the "recursion" to search for the matching key. +// Package responses can be arrays while Actions cache responses nest items under actions_caches. +// JSON.stringify traversal finds the matching required query key in either shape. function findMatchingQueryKey(exampleObj: CodeExamples | CodeExamples[], matchKey: string) { let match: string | null = null JSON.stringify(exampleObj, (_, nestedValue) => { diff --git a/src/rest/components/useClipboard.ts b/src/rest/components/useClipboard.ts index 4d47649e1727..a2efa90f00bd 100644 --- a/src/rest/components/useClipboard.ts +++ b/src/rest/components/useClipboard.ts @@ -1,10 +1,7 @@ import { useState, useEffect } from 'react' interface IOptions { - /** - * Reset the status after a certain number of milliseconds. This is useful - * for showing a temporary success message. - */ + // Reset copied status after this many milliseconds for temporary success messages. successDuration?: number } diff --git a/src/rest/docs.ts b/src/rest/docs.ts index 756ca4ab6a52..4a4d5e3653b0 100755 --- a/src/rest/docs.ts +++ b/src/rest/docs.ts @@ -2,7 +2,7 @@ import chalk from 'chalk' import { readFile } from 'fs/promises' import { allVersions } from '@/versions/lib/all-versions' -// Translate the docs versioning nomenclature back to the OpenAPI names +// Map docs version names back to OpenAPI names. const invertedVersionMapping = JSON.parse( await readFile('src/rest/lib/config.json', 'utf8'), ).versionMapping diff --git a/src/rest/lib/code-example-utils.ts b/src/rest/lib/code-example-utils.ts index e828a55cded5..51f75c01f2d4 100644 --- a/src/rest/lib/code-example-utils.ts +++ b/src/rest/lib/code-example-utils.ts @@ -1,6 +1,5 @@ -// The part of a code example these label helpers need. RestCodeSamples copies -// `sample.request.description` up to a top-level `description` and keeps the -// original `request` object as-is. +// RestCodeSamples copies sample.request.description to description and keeps the original request. +// These helpers only require that subset of each code example. export interface CodeExample { request?: { contentType?: string @@ -30,7 +29,7 @@ export function shouldShowResponseContentType(examples: CodeExample[]): boolean ) } -// Labels each example option with whichever content types vary across the set. +// Label each example option with whichever content types vary across the set. export function generateExampleOptions(examples: CodeExample[]): ExampleOption[] { const responseContentTypesDiffer = shouldShowResponseContentType(examples) const requestContentTypesDiffer = shouldShowRequestContentType(examples) diff --git a/src/rest/lib/config.ts b/src/rest/lib/config.ts index d264723a9105..c2f55795847d 100644 --- a/src/rest/lib/config.ts +++ b/src/rest/lib/config.ts @@ -1,8 +1,7 @@ -// Separate from config.json because client-side React components need to -// import static values, while the REST sync scripts need a JSON file they can -// write to. +// Keep this separate from config.json because client React components need static imports +// while REST sync scripts need writable JSON config. -// These paths must match the paths in src/pages/[versionId]/rest +// Keep these paths matching src/pages/[versionId]/rest. export const nonAutomatedRestPaths: readonly string[] = [ '/rest/quickstart', '/rest/about-the-rest-api', @@ -11,5 +10,5 @@ export const nonAutomatedRestPaths: readonly string[] = [ '/rest/guides', ] as const -// ApiVersionPicker links here to explain what API versioning is. +// ApiVersionPicker links here to explain REST API versioning. export const apiVersionPath: string = '/rest/about-the-rest-api/api-versions' diff --git a/src/rest/lib/index.ts b/src/rest/lib/index.ts index d76b96d734f5..05dcc4e7bbf2 100644 --- a/src/rest/lib/index.ts +++ b/src/rest/lib/index.ts @@ -21,9 +21,8 @@ interface RestMiniTocData { restOperationsMiniTocItems: MiniTocItem[] } -// Caches generated mini-TOC data, keyed by language, then docs version, then -// API date, then category, then subcategory. A version with no calendar dates -// uses `not_api_versioned` in place of a date. +// Cache generated mini-TOC data by language, docs version, API date, category, and subcategory. +// Versions without calendar dates use not_api_versioned in place of a date. const NOT_API_VERSIONED = 'not_api_versioned' const brotliDecompressAsync = promisify(brotliDecompress) const restOperationData = new Map< @@ -31,13 +30,14 @@ const restOperationData = new Map< Map>>> >() -// Two-tier cache: fpt and ghec are pinned in a plain Map (never evicted) because -// they account for >90% of traffic and each version needs ~100 slots alone. -// All other versions (ghes) go into a bounded LRU cache. +// Pin fpt and ghec in a plain Map because they account for more than 90% of traffic +// and each version needs roughly 100 slots. GHES versions go into a bounded LRU cache. const PINNED_OPEN_API_VERSIONS = new Set(['fpt', 'ghec']) -export const pinnedCache = new Map() // @internal, stores deflate-compressed JSON +// Exported for tests; stores deflate-compressed JSON. +export const pinnedCache = new Map() const LRU_MAX_SIZE = Math.max(1, parseInt(process.env.REST_SCHEMA_LRU_SIZE ?? '', 10) || 96) -export const lruCache = new QuickLRU({ maxSize: LRU_MAX_SIZE }) // @internal +// Exported for tests. +export const lruCache = new QuickLRU({ maxSize: LRU_MAX_SIZE }) // In-flight deduplication: concurrent cache misses for the same key share one read. const inflight = new Map>() @@ -64,11 +64,11 @@ export const categoriesWithoutSubcategories: string[] = fs }) .map((filteredFile: string) => filteredFile.replace('.md', '')) -// version: a docs version, e.g. `enterprise-server@3.5`. -// apiVersion: a REST API calendar date. Not every version has these. -// openApiVersion: the matching OpenAPI name, e.g. `ghes-3.5`. Every docs -// version maps to one, because the two naming schemes differ. - +// getRest accepts a docs version such as enterprise-server@3.5 and an optional REST date. +// getOpenApiVersion maps every version to an OpenAPI name such as ghes-3.5. +// getRest stores pinned fpt and ghec category files as deflate-compressed JSON +// Buffers to save roughly 100 to 500 MB of heap. The bounded LRU cache stores +// parsed objects for lower-traffic GHES files. export default async function getRest( version: string, apiVersion: string | undefined, @@ -81,8 +81,6 @@ export default async function getRest( const isPinned = PINNED_OPEN_API_VERSIONS.has(openApiVersion) - // Pinned cache: store deflate-compressed JSON Buffers to save ~100–500 MB heap. - // LRU cache: store parsed objects (bounded size, low traffic). if (isPinned) { if (pinnedCache.has(lruKey)) { return JSON.parse(inflateSync(pinnedCache.get(lruKey)!).toString()) as RestOperationCategory @@ -115,16 +113,16 @@ export default async function getRest( } // Read asynchronously to avoid blocking the event loop on a cache miss. -// A synchronous read + JSON.parse of a category file (1–2 MB) would stall +// A synchronous read plus JSON.parse of a 1 to 2 MB category file would stall // all in-flight requests on this pod for the duration of the parse. -// Try the brotli-compressed variant first (used in staging), then plain JSON. +// Staging writes the brotli-compressed variant, so try .br before plain JSON. async function loadCategoryFile(basePath: string): Promise { try { const compressed = await fsPromises.readFile(`${basePath}.br`) const decompressed = await brotliDecompressAsync(compressed) return JSON.parse(decompressed.toString()) as RestOperationCategory } catch { - // .br missing, corrupt, or unreadable, so fall back to plain JSON. + // If .br is missing, corrupt, or unreadable, fall back to plain JSON. const raw = await fsPromises.readFile(basePath, 'utf-8') return JSON.parse(raw) as RestOperationCategory } @@ -140,7 +138,6 @@ export function getRestCategories(version: string, apiVersion?: string): string[ .sort() } -// Generates the miniToc for a rest reference page. export async function getRestMiniTocItems( category: string, subCategory: string, diff --git a/src/rest/pages/category.tsx b/src/rest/pages/category.tsx index 6df55716722b..b4fe3396c757 100644 --- a/src/rest/pages/category.tsx +++ b/src/rest/pages/category.tsx @@ -30,6 +30,8 @@ type Props = { restOperations: Operation[] } +// Category landing pages (index.md) render TocLanding instead of the REST reference +// sidebar because their categories have no mini-TOC items at that level. export default function Category({ mainContext, automatedPageContext, @@ -41,10 +43,6 @@ export default function Category({ return ( - {/* When the page is the rest product landing page, we don't want to - render the rest-specific sidebar because toggling open the categories - won't have the minitoc items at that level. These are pages that have - category - subcategory - and operations */} {relativePath?.endsWith('index.md') ? ( @@ -68,7 +66,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => const tocLandingContext = getTocLandingContextFromRequest( req as unknown as Parameters[0], ) - // e.g. the `activity` from `/en/rest/activity/events` const category = context.params!.category as string let subcategory = context.params!.subcategory as string const currentVersion = context.params!.versionId as string @@ -79,8 +76,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => ? queryApiVersion : allVersions[currentVersion].latestApiVersion - // For pages with category level only operations like /rest/billing, we set - // the subcategory's value to be the category for the call to getRest() + // Category-only pages like /rest/billing use the category as the getRest subcategory. if (!subcategory) { subcategory = category } @@ -88,19 +84,14 @@ export const getServerSideProps: GetServerSideProps = async (context) => const categoryData = await getRest(currentVersion, apiVersion, category) const restOperations = (categoryData && categoryData[subcategory]) || [] - // Build the TocLanding table of contents for every operation in the category. - // The operations come back grouped by subcategory, so walk the subcategories, - // take the minitoc items for each one's operations, and collect them. + // TocLanding needs one child item per operation grouped under each REST subcategory. const restCategoryOperations = categoryData || {} const restCategoryTocItems = [] for (const [subCat, subCatOperations] of Object.entries(restCategoryOperations)) { let versionPathSegment: string - // If 'free-pro-team@latest' is in the URL, after clicking the link the - // sidebar isn't expanded to whatever subcategory or operation you clicked - // and 'free-pro-team@latest' is still in the browser address bar so - // manually removing. + // Omit free-pro-team@latest; otherwise clicked links keep it and the sidebar stays collapsed. if (context.params?.versionId === nonEnterpriseDefaultVersion) { versionPathSegment = '/' } else { @@ -108,12 +99,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => } const fullSubcategoryPath = `/${context.locale}${versionPathSegment}rest/${context.params?.category}/${subCat}` - // The actual page titles are available from the tocLandingContext so we - // can use this information as we build our REST toc items. If we relied - // only on the API information, we would need to cleanup subcategory names - // (e.g. they're all lowercase and use hyphens as word separators) and we - // would also end up using words we wouldn't want to like "Repos" instead - // of "GitHub Repositories" for example. + // Use tocLandingContext titles; OpenAPI slugs turn repos into Repos, not GitHub Repositories. let fullSubcategoryTitle const pageTocItem = tocLandingContext.tocItems.find( @@ -123,9 +109,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => if (pageTocItem) { fullSubcategoryTitle = pageTocItem.title } else { - // Shouldn't happen but provide a reasonable fallback just in case. E.g. - // for Organizations, a subcategory is 'outside-collaborators' and we - // convert that to 'Outside collaborators' for a toc item title. + // Fallback titleizes slugs such as outside-collaborators for missing toc entries. fullSubcategoryTitle = `${subCat[0].toUpperCase()}${subCat.slice(1).replaceAll('-', ' ')}` } @@ -150,23 +134,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => }) } - // TocLanding expects a collection of objects that looks like this: - // - // { - // fullPath: '/en/rest/activity/events', - // title: 'Events', - // childTocItems: [ - // { - // fullPath: '/en/rest/activity/events#list-public-events', - // title: 'List public events' - // }, - // { - // fullPath: '/en/rest/activity/events#list-public-events-for-a-network-of-repositories', - // title: 'List public events for a network of repositories' - // }, - // ... - // ] - // } restCategoryTocItems.push({ fullPath: fullSubcategoryPath, title: fullSubcategoryTitle, @@ -174,13 +141,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => }) } - // Gets the miniTocItems in the article context. At this point it will only - // include miniTocItems generated from the Markdown pages in - // content/rest/* + // Article context starts with mini-TOC items from content/rest Markdown. const { miniTocItems } = getAutomatedPageContextFromRequest(req) - // Build mini-TOC items from the operation titles, using the request context - // for the language and version, and append them to the article's mini-TOC. + // Append operation title anchors to the article mini-TOC. if (restOperations) { const { restOperationsMiniTocItems } = (await getRestMiniTocItems( category, diff --git a/src/rest/pages/subcategory.tsx b/src/rest/pages/subcategory.tsx index eacb232bbb04..00d7aae56756 100644 --- a/src/rest/pages/subcategory.tsx +++ b/src/rest/pages/subcategory.tsx @@ -42,7 +42,6 @@ export const getServerSideProps: GetServerSideProps = async (context) => const req = context.req as unknown as ExtendedRequest const res = context.res as unknown as ServerResponse - // e.g. the `activity` from `/en/rest/activity/events` const category = context.params!.category as string let subCategory = context.params!.subcategory as string const currentVersion = context.params!.versionId as string @@ -52,8 +51,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => const apiVersion = allVersions[currentVersion].apiVersions.includes(queryApiVersion) ? queryApiVersion : allVersions[currentVersion].latestApiVersion - // For pages with category level only operations like /rest/billing, we set - // the subcategory's value to be the category for the call to getRest() + // Category-only pages like /rest/billing use the category as the getRest subcategory. if (!subCategory) { subCategory = category } @@ -61,13 +59,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => const categoryData = await getRest(currentVersion, apiVersion, category) const restOperations = (categoryData && categoryData[subCategory]) || [] - // Gets the miniTocItems in the article context. At this point it will only - // include miniTocItems generated from the Markdown pages in - // content/rest/* + // Article context starts with mini-TOC items from content/rest Markdown. const { miniTocItems } = getAutomatedPageContextFromRequest(req) - // Build mini-TOC items from the operation titles, using the request context - // for the language and version, and append them to the article's mini-TOC. + // Append operation title anchors to the article mini-TOC. if (restOperations) { const { restOperationsMiniTocItems } = (await getRestMiniTocItems( category, diff --git a/src/rest/tests/create-rest-examples.ts b/src/rest/tests/create-rest-examples.ts index b019c34e3010..e97051859eab 100644 --- a/src/rest/tests/create-rest-examples.ts +++ b/src/rest/tests/create-rest-examples.ts @@ -14,10 +14,7 @@ import { } from '../fixtures/create-rest-examples' describe('rest example requests and responses', () => { - // If there is a request with no request body parameters and all of - // the responses have no content, then we can create a docs - // example for just status codes below 300. All other status codes will - // be listed in the status code table in the docs. + // One request with multiple contentless responses yields examples only for statuses below 300. test('check that examples with no content are created', async () => { const examples = mergeExamples(noContent.request, noContent.response) const mergedExamples = JSON.stringify(noContent.merged) diff --git a/src/rest/tests/get-rest-code-samples-2.ts b/src/rest/tests/get-rest-code-samples-2.ts index 98e59b8b3268..ab5218103401 100644 --- a/src/rest/tests/get-rest-code-samples-2.ts +++ b/src/rest/tests/get-rest-code-samples-2.ts @@ -59,7 +59,7 @@ const standardOperation: Operation = { }, } -// Sets allowPermissionlessAccess, like the revoke-credentials endpoint. +// Matches the revoke-credentials endpoint, which allows permissionless access. const unauthenticatedOperation: Operation = { verb: 'post', title: 'Revoke a list of credentials', @@ -341,7 +341,6 @@ describe('REST code samples authentication header handling', () => { expect(result).toContain('-H "Accept: application/vnd.github+json"') expect(result).toContain('-H "X-GitHub-Api-Version: 2022-11-28"') expect(result).toContain('/credentials/revoke') - // GitHub CLI handles authentication automatically, so we don't test for auth headers }) test('returns undefined for operations with basic auth', () => { @@ -467,7 +466,7 @@ describe('REST code samples authentication header handling', () => { mockVersions, ) - // The array must be nested under `data`, not spread as numeric keys ("0", "1"). + // The array must stay under data, not spread as numeric keys. expect(result).toContain('data: [') expect(result).toContain("id: 'MVS-2026-001'") expect(result).not.toMatch(/["']0["']\s*:/) diff --git a/src/rest/tests/lib-index.ts b/src/rest/tests/lib-index.ts index 833673f90d55..b064d8f97f4d 100644 --- a/src/rest/tests/lib-index.ts +++ b/src/rest/tests/lib-index.ts @@ -1,13 +1,11 @@ import { describe, test, expect, vi, beforeEach } from 'vitest' -// These mocks are declared before any dynamic import so that vi.mock hoisting -// places them ahead of the first evaluation of the module under test. +// Declare mocks before dynamic imports so vi.mock hoisting beats module evaluation. vi.mock('fs', async (importOriginal) => { const real = await importOriginal() const readFile = vi.fn() - // Only intercept readdirSync calls for the REST content dir (used at module - // scope in index.ts). All other callers (all-products, etc.) get real fs. + // Only intercept the REST content dir; all other readdirSync callers get real fs. const readdirSync = vi.fn((...args: Parameters) => { const p = String(args[0]) if (p === 'content/rest' || p.endsWith('/content/rest')) { @@ -35,9 +33,7 @@ vi.mock('@/languages/lib/languages-server', async (importOriginal) => { } }) -// getOpenApiVersion is mocked with a spy; the rest of the module is real so -// transitive dependencies (all-products, non-enterprise-default-version, etc.) -// continue to work correctly. +// Mock getOpenApiVersion only; transitive dependencies keep their real behavior. vi.mock('@/versions/lib/all-versions', async (importOriginal) => { const real = await importOriginal() return { @@ -65,8 +61,7 @@ function enoent(path = 'fake'): NodeJS.ErrnoException { const FAKE_DATA: Record = { ops: ['GET /repos'] } const FAKE_JSON = JSON.stringify(FAKE_DATA) -// Each test re-imports a fresh module instance so that the module-level state -// (pinnedCache, lruCache, inflight) starts out empty. +// Each test re-imports a fresh module so cache state starts empty. type GetRest = ( version: string, @@ -134,7 +129,6 @@ describe('two-tier cache routing', () => { await getRest('enterprise-server@3.10', undefined, 'actions') expect(pinnedCache.size).toBe(0) - // lruCache is a QuickLRU which exposes .size expect(lruCache.size).toBe(1) }) @@ -146,8 +140,7 @@ describe('two-tier cache routing', () => { await getRest('free-pro-team@latest', undefined, 'actions') await getRest('free-pro-team@latest', undefined, 'actions') - // readFile must still have been called exactly twice (once for .br, once for .json) - // on the first call; the second call must be a cache hit. + // The first call reads .br and .json once; the second call must hit cache. expect(vi.mocked(fsMock.promises.readFile)).toHaveBeenCalledTimes(2) }) }) @@ -191,7 +184,7 @@ describe('pinned cache compression', () => { describe('in-flight deduplication', () => { test('N concurrent cold-cache requests for same key share one readFile call', async () => { - // Use a deferred to keep all three getRest() calls in flight simultaneously. + // Use a deferred to keep all three getRest calls in flight simultaneously. let resolveJson!: (v: string) => void const deferred = new Promise((r) => { resolveJson = r @@ -202,8 +195,7 @@ describe('in-flight deduplication', () => { return deferred as unknown as Promise }) - // Launch 3 concurrent calls before the deferred resolves, so all three are - // in flight at once and share the single inflight promise. + // Launch 3 calls before the deferred resolves so they share the inflight promise. const allPromise = Promise.all([ getRest('free-pro-team@latest', undefined, 'actions'), getRest('free-pro-team@latest', undefined, 'actions'), @@ -217,8 +209,7 @@ describe('in-flight deduplication', () => { expect(results[1]).toEqual(FAKE_DATA) expect(results[2]).toEqual(FAKE_DATA) - // All 3 callers share one loadCategoryFile() call, so there are exactly 2 - // readFile calls: one for .br (rejected) and one for .json, not 6. + // One shared loadCategoryFile call means 2 readFile calls, not 6. expect(vi.mocked(fsMock.promises.readFile)).toHaveBeenCalledTimes(2) }) }) @@ -235,7 +226,7 @@ describe('loadCategoryFile brotli fallback', () => { }) test('.br corrupt (bad bytes) → brotliDecompress throws → falls back to .json', async () => { - // Buffer.from('not brotli') is not valid brotli; brotliDecompressAsync will throw. + // Buffer.from('not brotli') is not valid brotli, so decompression throws. vi.mocked(fsMock.promises.readFile) .mockResolvedValueOnce(Buffer.from('not brotli') as unknown as Buffer) // .br (corrupt) .mockResolvedValueOnce(FAKE_JSON as unknown as Buffer) // .json fallback diff --git a/src/rest/tests/merge-all-of.ts b/src/rest/tests/merge-all-of.ts index 9adc92165564..45285155abb2 100644 --- a/src/rest/tests/merge-all-of.ts +++ b/src/rest/tests/merge-all-of.ts @@ -209,8 +209,7 @@ describe('mergeAllOf', () => { const before = JSON.stringify(schema) const merged = mergeAllOf(schema) as { oneOf: { properties: Record }[] } - // get-body-params merges the oneOf members in place, so this must not - // reach back into the OpenAPI operation the schema came from. + // Mutating merged oneOf members must not change the source OpenAPI operation schema. Object.assign(merged.oneOf[0].properties, merged.oneOf[1].properties) merged.oneOf[0].properties.injected = true diff --git a/src/rest/tests/openapi-schema.ts b/src/rest/tests/openapi-schema.ts index 2ae9e8caee07..f60964ce730a 100644 --- a/src/rest/tests/openapi-schema.ts +++ b/src/rest/tests/openapi-schema.ts @@ -86,10 +86,9 @@ describe('markdown for each rest version', () => { }) test('markdown file exists for every operationId prefix in all versions of the OpenAPI schema', async () => { - // List of categories derived from disk const filenames = new Set( getAutomatedMarkdownFiles('content/rest') - // Gets just category level files (paths directly under /rest) + // Extract the category segment from category and subcategory paths. .map((filename) => filename.split('/')[2]) .sort(), ) @@ -142,10 +141,8 @@ describe('rest file structure', () => { }) describe('OpenAPI schema validation', () => { - // ensure every version defined in allVersions has a correlating static - // decorated file, while allowing decorated files to exist when a version - // is not yet defined in allVersions (e.g., a GHEC static file can exist - // even though the version is not yet supported in the docs) + // Every allVersions entry needs a matching decorated data directory. + // Extra directories, such as GHEC static data, can exist before allVersions exposes them. test('every OpenAPI version must have a schema file in the docs', async () => { const versionDirs = fs .readdirSync(schemasPath, { withFileTypes: true }) diff --git a/src/rest/tests/remove-stale-data-files.ts b/src/rest/tests/remove-stale-data-files.ts index 325491306093..9e09dc5dcc21 100644 --- a/src/rest/tests/remove-stale-data-files.ts +++ b/src/rest/tests/remove-stale-data-files.ts @@ -84,7 +84,6 @@ describe('removeStaleRestDataFiles', () => { const writtenFiles = new Map>() writtenFiles.set(nonexistent, new Set(['actions.json'])) - // Should not throw await removeStaleRestDataFiles(writtenFiles) }) }) diff --git a/src/rest/tests/rendering.ts b/src/rest/tests/rendering.ts index 8e698f1b8023..5fe8a6b82578 100644 --- a/src/rest/tests/rendering.ts +++ b/src/rest/tests/rendering.ts @@ -9,9 +9,7 @@ import getRest from '@/rest/lib/index' describe('REST references docs', () => { vi.setConfig({ testTimeout: 3 * 60 * 1000 }) - // This test ensures that the page component and the Markdown file are - // in sync. It checks that every version of the /rest/checks - // page has every operation defined in the openapi schema. + // This keeps the /rest/checks/runs page, Markdown, and OpenAPI runs subcategory in sync. test('loads schema data for all versions', async () => { for (const version of Object.keys(allVersions)) { const calendarDate = allVersions[version].latestApiVersion @@ -26,7 +24,7 @@ describe('REST references docs', () => { } }) - // These tests exist because of issue #1960. + // Legacy free-pro-team@latest REST reference URLs redirect to the current REST URL shape. test('rest subcategory with fpt in URL', async () => { const categories = [ 'migrations', @@ -59,7 +57,6 @@ describe('REST references docs', () => { 'users', ] for (const category of categories) { - // Without language prefix { const res = await get(`/free-pro-team@latest/rest/reference/${category}`) expect(res.statusCode).toBe(302) @@ -68,7 +65,6 @@ describe('REST references docs', () => { res.headers.location === `/en/rest/${category}/${category}`, ) } - // With language prefix { const res = await get(`/en/free-pro-team@latest/rest/reference/${category}`) expect(res.statusCode).toBe(301) @@ -87,14 +83,11 @@ describe('REST references docs', () => { }) test('REST reference pages have DOM markers needed for extracting search content', async () => { - // Pick an arbitrary REST reference page that is build from React const $ = await getDOM('/en/rest/actions/artifacts') const rootSelector = '[data-search=article-body]' const $root = $(rootSelector) expect($root.length).toBe(1) - // Within that, should expect a "lead" text. - // Note! Not all REST references pages have a lead. The one in this - // test does. + // Not all REST references have lead text; this page does. const leadSelector = '[data-search=lead] p' const $lead = $root.find(leadSelector) expect($lead.length).toBe(1) @@ -128,7 +121,6 @@ describe('REST references docs', () => { const rawModeSection = $('#render-a-markdown-document-in-raw-mode--code-samples').parent() expect(rawModeSection.length).toBeGreaterThan(0) - // Several examples means there has to be a selector dropdown. const exampleSelector = rawModeSection.find('select[aria-labelledby], select').first() expect(exampleSelector.length).toBe(1) @@ -138,15 +130,13 @@ describe('REST references docs', () => { .get() .filter((text) => text.length > 0) - // The content types differ between examples, so they show in the labels. + // Differing content types appear in selector labels. expect(optionTexts).toEqual(['Example (text/plain)', 'Rendering markdown (text/x-markdown)']) }) + // All five /rest/meta permissionless operations support every fine-grained token type, + // so noFineGrainedAccess is false and the RestAuth null guard never fires. test('RestAuth component hides auth section for permissionless endpoints', async () => { - // This only checks that a page carrying permissionless endpoints still - // renders. It does not reach the RestAuth null path: all five permissionless - // operations under /rest/meta support every fine-grained token type, so - // `noFineGrainedAccess` is false and the guard never fires. const $ = await getDOM('/en/rest/meta') const html = $.html() expect(html.length).toBeGreaterThan(0) diff --git a/src/rest/tests/sync-changelogs.ts b/src/rest/tests/sync-changelogs.ts index e543c322a2e4..a1db2057c3d7 100644 --- a/src/rest/tests/sync-changelogs.ts +++ b/src/rest/tests/sync-changelogs.ts @@ -180,7 +180,7 @@ describe('syncChangelogs', () => { await rm(tmpDir, { recursive: true, force: true }) }) - // Helper to create a changelog file in the github repo layout: + // The github repo layout stores changelog files under: // /app/api/description/changelogs//CHANGELOG.md async function createChangelog(githubDir: string, releaseDir: string, content: string) { const changelogDir = path.join(githubDir, 'app', 'api', 'description', 'changelogs', releaseDir) @@ -245,7 +245,6 @@ No breaking changes.`, test('injects hardcoded initial version when no changelog file exists', async () => { const githubDir = path.join(tmpDir, 'github') - // Only create a changelog for fpt, not ghec or ghes await createChangelog( githubDir, 'api.github.com', @@ -260,7 +259,7 @@ No breaking changes.`, const output = await readFile(outputPath, 'utf-8') expect(output).toContain('{% ifversion fpt %}') - // ghec gets the hardcoded initial version even without a changelog file + // ghec gets the hardcoded initial version even without a changelog file. expect(output).toContain('{% ifversion ghec %}') expect(output).toContain( 'first version of the GitHub Enterprise Cloud REST API after date-based versioning', @@ -270,7 +269,6 @@ No breaking changes.`, test('injects hardcoded initial version when changelog has no version sections', async () => { const githubDir = path.join(tmpDir, 'github') - // fpt has valid sections await createChangelog( githubDir, 'api.github.com', @@ -281,8 +279,7 @@ No breaking changes.`, Content.`, ) - // ghec has a changelog but no version sections, so it still gets the - // hardcoded initial version. + // ghec still gets the hardcoded initial version when its changelog lacks sections. await createChangelog( githubDir, 'ghec', @@ -307,7 +304,7 @@ This file has no version headings yet.`, await syncChangelogs(githubDir, versionNames, outputPath) - // fpt and ghec get hardcoded initial version entries even with no changelog files + // fpt and ghec get hardcoded initial version entries even with no changelog files. const output = await readFile(outputPath, 'utf-8') expect(output).toContain('{% ifversion fpt %}') expect(output).toContain('{% ifversion ghec %}') @@ -340,7 +337,7 @@ No breaking changes.`, const output = await readFile(outputPath, 'utf-8') - // Extract only the fpt ifversion block to avoid counting the hardcoded ghec entry + // Extract only the fpt ifversion block to avoid counting the hardcoded ghec entry. const fptMatch = output.match(/\{%\s*ifversion fpt\s*%\}([\s\S]*?)\{%\s*ifversion /)?.[1] ?? '' const matches = fptMatch.match(/## Version 2022-11-28/g) expect(matches).toHaveLength(1) @@ -380,9 +377,7 @@ No breaking changes.`, expect(output).toContain('{% ifversion fpt %}') expect(output).toContain('{% ifversion ghec %}') - // FPT should have two apiVersion blocks, GHEC should have one. - // Extract the fpt block: everything between {% ifversion fpt %} and the - // next {% ifversion (which starts the ghec block). + // Split out fpt before ghec; fpt has two apiVersion blocks and ghec has one. const afterFpt = output.split('{% ifversion fpt %}')[1] const fptBlock = afterFpt.split('{% ifversion ghec %}')[0] expect(fptBlock).toContain('"2026-03-10"') @@ -415,7 +410,7 @@ Change A`, const output = await readFile(outputPath, 'utf-8') - // Versions should appear in the same order as the changelog (newest first) + // Versions appear in changelog order, newest first. const idx2026_06 = output.indexOf('"2026-06-10"') const idx2026_03 = output.indexOf('"2026-03-10"') const idx2022 = output.indexOf('"2022-11-28"') diff --git a/src/rest/tests/update-markdown.ts b/src/rest/tests/update-markdown.ts index 33b9a6bdea4a..2af73a7edc86 100644 --- a/src/rest/tests/update-markdown.ts +++ b/src/rest/tests/update-markdown.ts @@ -41,8 +41,7 @@ describe('GHES version extraction for update-markdown', () => { }) test('demonstrates the original bug scenario', () => { - // The old substring match found '3.1' inside 'ghes-3.10' and wrongly - // treated 3.10 as deprecated. + // Exact extraction prevents matching 3.1 inside ghes-3.10 and deprecating 3.10. const filePath = 'src/rest/data/ghes-3.10-2022-11-28/schema.json' const extractedVersion = getGHESVersionFromFilepath(filePath) diff --git a/src/search/components/helpers/ai-search-links-json.ts b/src/search/components/helpers/ai-search-links-json.ts index a1114ac1a0f1..9efdee228811 100644 --- a/src/search/components/helpers/ai-search-links-json.ts +++ b/src/search/components/helpers/ai-search-links-json.ts @@ -4,12 +4,8 @@ type LinksJSON = Array<{ product: string }> -// We use this to generate a JSON string that includes all of the links: -// 1. Included in the AI response (inline) -// 2. Used to generate the AI response via an embedding (reference) -// -// We include the JSON string in our analytics events so we can see the -// most popular sourced references, among other things. +// Analytics records inline AI-response links and embedding reference links in one JSON payload. +// The product field lets reports group the most popular sourced references. export function generateAISearchLinksJson( sourcesBuffer: Array<{ url: string }>, aiResponse: string, @@ -37,7 +33,7 @@ export function generateAISearchLinksJson( } function extractMarkdownLinks(markdownResponse: string) { - // Matches markdown links of the form [text](url). + // Example: [Actions](https://docs.github.com/actions) yields the URL. const regex = /\[([^\]]+)\]\(([^)]+)\)/g const urls = [] @@ -67,8 +63,7 @@ function extractProductFromDocsUrl(url: string): string { const segments = pathname.split('/').filter((segment) => segment) - // If the first segment is a language code (2 characters), then product is the next segment. - // Otherwise, assume the first segment is the product. + // This heuristic treats only two-character locale prefixes as localized paths. if (segments.length === 0) { return '' } @@ -77,7 +72,7 @@ function extractProductFromDocsUrl(url: string): string { if (segments.length < 2) { return '' } - // if second segment is a version, then product is the third segment + // Versioned paths put the product after the version segment. if (segments[1].includes('@')) { return segments[2] || '' } diff --git a/src/search/components/helpers/execute-search-actions.ts b/src/search/components/helpers/execute-search-actions.ts index 6b3bb9087d52..161ace92d5b1 100644 --- a/src/search/components/helpers/execute-search-actions.ts +++ b/src/search/components/helpers/execute-search-actions.ts @@ -6,12 +6,9 @@ import { sendEvent } from '@/events/components/events' import { SEARCH_OVERLAY_EVENT_GROUP } from '@/events/components/event-groups' import { sanitizeSearchQuery } from '@/search/lib/sanitize-search-query' -// Search context values for identifying each search event export const GENERAL_SEARCH_CONTEXT = 'general-search' export const AI_SEARCH_CONTEXT = 'ai-search' -// The logic that redirects to the /search page with the proper query params -// The query params will be consumed in the general search middleware export function executeGeneralSearch( router: NextRouter, currentVersion: string, @@ -37,7 +34,6 @@ export function executeGeneralSearch( if (debug) { params.set('debug', '1') } - // Close the search overlay if (params.has('search-overlay-open')) { params.delete('search-overlay-open') } @@ -64,8 +60,6 @@ export async function executeAISearch(version: string, query: string, debug = fa return response } -// Fetches combined search results: AI autocomplete suggestions plus general -// search suggestions. export async function executeCombinedSearch( router: NextRouter, version: string, @@ -80,10 +74,10 @@ export async function executeCombinedSearch( params.set('debug', '1') } - // Add client_name to identify requests from our frontend + // client_name identifies frontend requests to the search API. params.set('client_name', 'docs.github.com-client') - // Always fetch 4 results for autocomplete + // Autocomplete intentionally requests four results. params.set('size', '4') const response = await fetch(`/api/search/combined-search/v1?${params}`, { diff --git a/src/search/components/helpers/fix-incomplete-markdown.ts b/src/search/components/helpers/fix-incomplete-markdown.ts index 785501e9bf9a..33b38559d3fb 100644 --- a/src/search/components/helpers/fix-incomplete-markdown.ts +++ b/src/search/components/helpers/fix-incomplete-markdown.ts @@ -89,7 +89,6 @@ function fixEmphasis(content: string): string { } } - // Close any remaining tokens in reverse order while (stack.length > 0) { const { token } = stack.pop()! content += token @@ -111,7 +110,7 @@ function fixTables(content: string): string { if (i + 1 < lines.length && /^\s*\|[-\s|:]*$/.test(lines[i + 1])) { inTable = true headerPipeCount = (lines[i].match(/\|/g) || []).length - i += 1 // Move to separator line + i += 1 } else { i += 1 continue diff --git a/src/search/components/hooks/useAISearchAutocomplete.ts b/src/search/components/hooks/useAISearchAutocomplete.ts index 7c098129f203..f83cd4a96292 100644 --- a/src/search/components/hooks/useAISearchAutocomplete.ts +++ b/src/search/components/hooks/useAISearchAutocomplete.ts @@ -25,7 +25,8 @@ type UseCombinedSearchReturn = { clearAutocompleteResults: () => void } -const DEBOUNCE_TIME = 100 // In milliseconds +// Wait 100 milliseconds after typing before fetching autocomplete results. +const DEBOUNCE_TIME = 100 // Cached for the current page session only, so backspacing reuses results // instead of hitting the API again. @@ -100,7 +101,7 @@ export function useCombinedSearchResults({ currentVersion, queryValue, debug, - controller.signal, // Pass in the signal to allow the request to be aborted + controller.signal, ) const results = { @@ -114,8 +115,7 @@ export function useCombinedSearchResults({ setSearchOptions(results) setSearchLoading(false) } catch (error: unknown) { - // Aborted fetch() requests reject with a DOMException (not always an - // Error instance), so match on the name rather than the prototype. + // Aborted fetches can reject with DOMException instead of Error, so match the name. if ( typeof error === 'object' && error !== null && @@ -137,7 +137,6 @@ export function useCombinedSearchResults({ [router, currentVersion, debug], ) - // Entry function called when the user types in the search input const updateAutocompleteResults = useCallback((queryValue: string) => { // Don't debounce an empty input: show the (possibly cached) options at once. if (queryValue === '') { @@ -159,7 +158,6 @@ export function useCombinedSearchResults({ setSearchError(false) }, []) - // Cleanup function to cancel any ongoing requests when unmounting useEffect(() => { return () => { abortControllerRef.current?.abort() diff --git a/src/search/components/hooks/useAISearchLocalStorageCache.ts b/src/search/components/hooks/useAISearchLocalStorageCache.ts index 56710892276d..b7ae9dfd4c14 100644 --- a/src/search/components/hooks/useAISearchLocalStorageCache.ts +++ b/src/search/components/hooks/useAISearchLocalStorageCache.ts @@ -10,9 +10,8 @@ interface CacheIndexEntry { timestamp: number } -// AI Search responses are cached as individual localStorage entries, with a -// separate index tracking the keys. Updating the cache therefore doesn't mean -// reading and parsing one large entry every time a key is accessed. +// AI Search responses are individual localStorage entries with a separate key index. +// Cache updates avoid reading and parsing one large entry on every access. // // Entries live under a prefix and expire after a fixed number of days. export function useAISearchLocalStorageCache( @@ -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 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') }) diff --git a/src/versions/components/DeprecationBanner.tsx b/src/versions/components/DeprecationBanner.tsx index db25ee8a732e..6062966c591d 100644 --- a/src/versions/components/DeprecationBanner.tsx +++ b/src/versions/components/DeprecationBanner.tsx @@ -16,10 +16,7 @@ export const DeprecationBanner = () => { return null } - // Have to "trick" TypeScript here because by default, this is an - // optional key. But because we're confident with the JS business - // logic in MainContext.tsx, we can safely assume that this key - // is present. + // MainContext supplies enterprise_deprecation before React renders this banner. const enterpriseDeprecation = data.reusables.enterprise_deprecation as EnterpriseDeprecation const message = enterpriseServerReleases.isOldestReleaseDeprecated ? enterpriseDeprecation.version_was_deprecated diff --git a/src/versions/components/VersionPicker.module.scss b/src/versions/components/VersionPicker.module.scss index f2c2e2c6f441..8bda6b9abb02 100644 --- a/src/versions/components/VersionPicker.module.scss +++ b/src/versions/components/VersionPicker.module.scss @@ -1,8 +1,5 @@ -/* - * The header variant's styling is shared with the language picker and lives in - * @/frame/components/page-header/HeaderPicker.module.scss. Only the default - * (non-header) variant is styled here. - */ +// The header variant shares HeaderPicker.module.scss with the language picker. +// This file styles the default variant. .itemsWidth { width: 14rem; diff --git a/src/versions/components/VersionPicker.tsx b/src/versions/components/VersionPicker.tsx index aab00700fc06..3d5dc7e98d6d 100644 --- a/src/versions/components/VersionPicker.tsx +++ b/src/versions/components/VersionPicker.tsx @@ -14,8 +14,8 @@ import { DEFAULT_VERSION, useVersion } from '@/versions/components/useVersion' import { useTranslation } from '@/languages/components/useTranslation' import styles from './VersionPicker.module.scss' -// The header variant's trigger, menu surface and rows are shared with the language -// picker so the two dropdowns cannot drift apart. +// The header variant shares HeaderPicker.module.scss with the language picker, +// so the two dropdowns stay in sync. import headerStyles from '@/frame/components/page-header/HeaderPicker.module.scss' type Props = { @@ -27,8 +27,8 @@ type VersionPickerLink = { text: string selected: boolean href: string - // Brand's ActionMenu identifies the chosen row by string value, so every row needs - // one. Versions use their own version name; the two extra rows use sentinels. + // Brand's ActionMenu identifies rows by string value. Versions use their version + // name; extra rows use sentinels. value: string extra: { arrow: boolean @@ -41,31 +41,25 @@ type VersionPickerLink = { const ALL_RELEASES_VALUE = 'all-enterprise-releases' const ABOUT_VERSIONS_VALUE = 'about-versions' -// Brand clones ActionMenu.Button with its own ref, so the trigger cannot be reached -// through a React ref. A stable test id keeps both the Escape handler and the tests -// off Brand's hashed CSS class names. +// Brand clones ActionMenu.Button with its own ref, so React refs cannot reach the +// trigger. A stable test id keeps the Escape handler and tests off hashed CSS classes. const HEADER_TRIGGER_TESTID = 'version-picker-button' type PlanMenuItemProps = { item: VersionPickerLink - // Injected by ActionMenu.Overlay, which clones each of its direct children with the - // select handler and the selection type derived from `selectionVariant`. + // ActionMenu.Overlay injects handler and type into each direct child. handler?: (value: string) => void type?: 'none' | 'single' | 'link' } +// Extra rows opt out of Brand selection semantics because axe rejects aria-checked +// on menuitem, and Brand derives both role and aria-checked from type. const PlanMenuItem = ({ item, handler, type }: PlanMenuItemProps) => { const isExtra = Boolean(item.extra.arrow || item.extra.info) return ( { headerStyles.headerMenuItem, item.selected && headerStyles.headerMenuItemSelected, )} - // Only spread `role` for the extras: passing `role={undefined}` would override - // the role Brand computes and leave the version rows with no role at all. + // Only spread role for extras; role undefined overrides Brand's computed role. {...(isExtra ? { role: 'menuitem' } : {})} > @@ -82,21 +75,23 @@ const PlanMenuItem = ({ item, handler, type }: PlanMenuItemProps) => { {item.extra.arrow && } {item.extra.info && } - {/* The design marks the current plan with a trailing green dot instead of - Brand's leading check icon, which the stylesheet hides. */} + {/* HeaderPicker.module.scss hides Brand's leading check icon; design uses a trailing green dot. */} {item.selected && } ) } -// The rule between the version rows and the two navigation rows. Brand has no divider -// child, and ActionMenu.Overlay clones every direct child with `handler` and `type`, -// so this wrapper takes no props at all: the injected ones are swallowed here instead -// of landing on the DOM node. The
  • carries no tabIndex and no `data-value`, so -// Brand's focus zone and its Enter handler both skip it — and it is never the menu's -// first or last
  • , which are the two rows Brand wires its arrow-key wrap-around to. +// Brand lacks a divider child, and ActionMenu.Overlay injects handler and type into +// every direct child. This wrapper swallows those props so they do not reach the li. +// Without tabIndex or data-value, Brand's focus zone and Enter handler skip the +// separator. The caller keeps it away from the first and last li, which Brand uses +// for arrow-key wrap-around. const PlanMenuSeparator = () =>
  • +// VersionPicker uses startsWith to identify Enterprise Server because VersionItem +// omits hasNumberedReleases. The label says "version" for Enterprise Server because +// versionTitle includes the numbered release; a "plan" label would make screen +// readers announce "Select your plan: Enterprise Server 3.19". export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { const router = useRouter() const { currentVersion } = useVersion() @@ -104,19 +99,11 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { const [open, setOpen] = useState(false) const pickerId = useId() const isHeader = variant === 'header' - // Use TypeScript's "not null assertion" because mainContext.page should - // be present in mainContext if it's gotten to the stage of React - // rendering. + // React rendering only starts after MainContext adds page. const page = mainContext.page! const { allVersions, enterpriseServerVersions } = mainContext const { t } = useTranslation(['pages', 'picker']) - // The same control chooses a plan on dotcom and Enterprise Cloud but a numbered - // release on Enterprise Server, where `versionTitle` is `${planTitle} ${release}`. - // A single "Select your plan:" would announce "Select your plan: Enterprise - // Server 3.19" to screen readers. Uses the same `startsWith` predicate as - // `hasEnterpriseVersions` below: `hasNumberedReleases` is set on the runtime - // version object but is not declared on the `VersionItem` type. const pickerLabel = currentVersion.startsWith('enterprise-server') ? t('version_picker_label') : t('plan_picker_label') @@ -195,7 +182,7 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { const selectedOption = allLinks.find((item) => item.selected) const handleVersionSelect = (item: VersionPickerLink) => { - // Save the user's version preference when they actively select one + // Navigation rows leave the existing version preference alone. if (item.extra?.version) { try { Cookies.set(USER_VERSION_COOKIE_NAME, item.extra.version) @@ -204,7 +191,7 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { } } setOpen(false) - // Navigate after setting cookie + // Set the cookie before navigation so the next page can read the preference. if (item.href) { onNavigate?.() router.push(item.href) @@ -212,18 +199,12 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { } if (isHeader) { - // The Figma dropdown node draws no divider, but the rule that separated the - // versions from the two navigation rows is kept from the @primer/react menu this - // replaced. The filter keeps it from ever becoming the menu's first or last row: - // Brand focuses the first
  • and binds its arrow-key wrap-around to the first - // and the last, and neither should land on a separator. + // Keep the separator from the default picker, but not where Brand focuses or wraps rows. const headerLinks = allLinks.filter( (item, index) => !item.divider || (index > 0 && index < allLinks.length - 1), ) - // Brand reports the chosen row by value. Routing every row — the two extras - // included — back through handleVersionSelect keeps navigation client-side - // instead of letting the extras become anchors that reload the page. + // Route extra rows through handleVersionSelect so they stay client-side. const handleHeaderSelect = (value: string) => { const item = headerLinks.find((link) => link.value === value) if (item) { @@ -239,15 +220,10 @@ export const VersionPicker = ({ variant = 'default', onNavigate }: Props) => { ) if (trigger?.getAttribute('aria-expanded') !== 'true') return - // Brand's ActionMenu and SubdomainNavBar both listen for Escape on `document` - // and neither honours defaultPrevented, so a single Escape would close this - // picker *and* the surrounding narrow menu. Stopping the event here — while it - // is still in its capture phase, before it reaches either listener — leaves the - // outer menu open. Brand has no controlled `open` prop, so the picker is closed - // through its own trigger: focus it first so focus stays put, then click it to - // let ActionMenu toggle itself shut. + // Stop Escape in capture so SubdomainNavBar's document listener leaves the narrow menu open. event.preventDefault() event.stopPropagation() + // Brand has no controlled open prop, so click its focused trigger to close it. trigger.focus() trigger.click() } diff --git a/src/versions/lib/all-versions.ts b/src/versions/lib/all-versions.ts index b502f0d7932a..39007a55dda4 100644 --- a/src/versions/lib/all-versions.ts +++ b/src/versions/lib/all-versions.ts @@ -2,9 +2,7 @@ import fs from 'fs' import type { AllVersions, Version } from '@/types' import enterpriseServerReleases from './enterprise-server-releases' -// version = "plan"@"release" -// example: enterprise-server@2.21 -// where "enterprise-server" is the plan and "2.21" is the release +// Version keys combine plan and release, for example enterprise-server@2.21. const versionDelimiter = '@' const latestNonNumberedRelease = 'latest' const REST_DATA_META_FILE = 'src/rest/lib/config.json' @@ -27,26 +25,23 @@ interface RestApiConfig { } } -// !Explanation of versionless redirect fallbacks! -// This array is **in order** of the versions the site should try to fall back to if -// no version is provided in a URL. For example, if /foo refers to a page that is available -// in all versions, we should not redirect it (because /foo is the correct FPT versioned URL). -// But if /foo refers to a page that is only available in GHEC and GHES, we should redirect it -// to /enterprise-cloud@latest/foo (since GHEC comes first in the hierarchy of version fallbacks). -// The implementation lives in lib/redirects/permalinks.ts. +// Versionless redirects try these plans in order. If /foo supports every plan, it +// stays the Free, Pro, and Team URL. If it supports only Enterprise Cloud and +// Enterprise Server, src/redirects/lib/permalinks.ts redirects it to +// /enterprise-cloud@latest/foo. const plans: PlanConfig[] = [ { - // free-pro-team is **not** a user-facing version and is stripped from URLs. - // See lib/remove-fpt-from-path.ts for details. + // free-pro-team is not user-facing. + // src/versions/lib/remove-fpt-from-path.ts strips it from URLs. plan: 'free-pro-team', planTitle: 'Free, Pro, & Team', shortName: 'fpt', releases: [latestNonNumberedRelease], latestRelease: latestNonNumberedRelease, - nonEnterpriseDefault: true, // permanent way to refer to this plan if the name changes + nonEnterpriseDefault: true, // Marks the non-enterprise default independently of the plan name. hasNumberedReleases: false, - openApiBaseName: 'fpt', // used for REST - miscBaseName: 'dotcom', // used for GraphQL and webhooks + openApiBaseName: 'fpt', // REST base name. + miscBaseName: 'dotcom', // Search index version map base name. }, { plan: 'enterprise-cloud', @@ -72,8 +67,6 @@ const plans: PlanConfig[] = [ const allVersions: AllVersions = {} -// combine the plans and releases to get allVersions object -// e.g. free-pro-team@latest, enterprise-server@2.21, enterprise-server@2.20, etc. for (const planObj of plans) { for (const release of planObj.releases) { const version = `${planObj.plan}${versionDelimiter}${release}` @@ -91,8 +84,10 @@ for (const planObj of plans) { miscVersionName: planObj.hasNumberedReleases ? `${planObj.miscBaseName}${release}` : planObj.miscBaseName, - apiVersions: [], // REST Calendar Date Versions, this may be empty for non calendar date versioned products - latestApiVersion: '', // Latest REST Calendar Date Version, this may be empty for non calendar date versioned products + // REST calendar date versions; empty for products without calendar date API versions. + apiVersions: [], + // Latest REST calendar date version; empty for products without calendar date API versions. + latestApiVersion: '', plan: planObj.plan, planTitle: planObj.planTitle, shortName: planObj.shortName, @@ -108,7 +103,7 @@ for (const planObj of plans) { } } -// Adds the calendar date (or api versions) to the allVersions object +// REST config adds calendar date API versions after the version objects exist. const apiVersions: RestApiConfig['api-versions'] = JSON.parse( fs.readFileSync(REST_DATA_META_FILE, 'utf8'), )['api-versions'] @@ -116,7 +111,7 @@ const apiVersions: RestApiConfig['api-versions'] = JSON.parse( for (const key of Object.keys(apiVersions)) { const docsVersion = getDocsVersion(key) allVersions[docsVersion].apiVersions.push(...apiVersions[key].sort().reverse()) - // Create a copy of the array to avoid mutating the original when using pop() + // Copy before pop so latestApiVersion does not remove a version from apiVersions. const sortedVersions = [...apiVersions[key].sort()] allVersions[docsVersion].latestApiVersion = sortedVersions.pop() || '' } @@ -130,9 +125,7 @@ export function isApiVersioned(version: string): boolean { return allVersions[version] && allVersions[version].apiVersions.length > 0 } -// Currently the versions from the OpenAPI do not match the versions on Docs. -// There is a mapping between the version names. This gets the Docs version from -// the OpenAPI version name. +// OpenAPI names do not match Docs version names, so this maps one to its Docs version. export function getDocsVersion(openApiVersion: string): string { const matchingVersion = Object.values(allVersions).find((version) => openApiVersion.startsWith(version.openApiVersionName), diff --git a/src/versions/lib/enterprise-server-releases.d.ts b/src/versions/lib/enterprise-server-releases.d.ts index 85f2391e2272..86d4910323f9 100644 --- a/src/versions/lib/enterprise-server-releases.d.ts +++ b/src/versions/lib/enterprise-server-releases.d.ts @@ -1,11 +1,13 @@ type Dates = { [key: string]: { - releaseDate: string // For backward compatibility - will be RC date initially, then GA date once available + // Templates read releaseDate as the display date: RC date until the GA date exists. + releaseDate: string deprecationDate: string - releaseCandidateDate?: string // Release Candidate date - generalAvailabilityDate?: string // General Availability date - displayCandidateDate?: string | null // Computed: RC date if in past, null if future - displayReleaseDate?: string | null // Computed: GA date if in past, null if future + releaseCandidateDate?: string + generalAvailabilityDate?: string + // Templates hide release dates until each date has passed. + displayCandidateDate?: string | null + displayReleaseDate?: string | null } } diff --git a/src/versions/lib/enterprise-server-releases.ts b/src/versions/lib/enterprise-server-releases.ts index 49511d28cc48..224e728ad796 100644 --- a/src/versions/lib/enterprise-server-releases.ts +++ b/src/versions/lib/enterprise-server-releases.ts @@ -24,18 +24,18 @@ const rawDates: RawDatesData = JSON.parse( fs.readFileSync('src/ghes-releases/lib/enterprise-dates.json', 'utf8'), ) -// Upcoming GHES release numbers (used in frontmatter and release planning) +// Frontmatter and release planning use the next two GHES release numbers. export const next = '3.23' export const nextNext = '3.24' -// Currently supported GHES versions (in descending order, latest first) +// Keep supported GHES versions in descending order, latest first. export const supported = ['3.22', '3.21', '3.20', '3.19', '3.18', '3.17'] -// Set to version number when in RC phase, null when no RC is active +// Use the release number during an active RC; use null outside RC. export const releaseCandidate = null -// Deprecated versions with functional redirect handling (3.0+) -// When archiving a new version, add it here and update the archival process +// Deprecated releases from 3.0 onward use functional redirects. +// Add a newly archived release here and update the archival process. export const deprecatedWithFunctionalRedirects = [ '3.16', '3.15', @@ -56,7 +56,7 @@ export const deprecatedWithFunctionalRedirects = [ '3.0', ] -// All deprecated versions (combines functional + legacy redirect handling) +// The deprecated list combines functional redirects with legacy redirect handling. export const deprecated = [ ...deprecatedWithFunctionalRedirects, '2.22', @@ -85,13 +85,13 @@ export const deprecated = [ '11.10.340', ] -// Versions with legacy asset handling (stored in separate repos before blob storage) +// Legacy asset releases store assets in separate repos instead of blob storage. export const legacyAssetVersions = ['3.0', '2.22', '2.21'] export const firstReleaseStoredInBlobStorage = '3.2' export const firstVersionDeprecatedOnNewSite = '2.13' export const lastVersionWithoutArchivedRedirectsFile = '2.17' -export const lastReleaseWithLegacyFormat = '2.18' // Last to use /enterprise//... paths +export const lastReleaseWithLegacyFormat = '2.18' // Last release with /enterprise//... paths. export const firstReleaseNote = '2.20' export const firstRestoredAdminGuides = '2.21' @@ -101,7 +101,7 @@ export const latest = supported[0] export const latestStable = releaseCandidate ? supported[1] : latest export const oldestSupported = supported[supported.length - 1] -// Enhanced dates object with computed display values for templates +// Templates read these computed display dates to hide future release dates. export const dates: Record = Object.fromEntries( Object.entries(rawDates).map(([version, versionData]) => [ version, @@ -118,8 +118,7 @@ export const isOldestReleaseDeprecated = nextDeprecationDate ? new Date() > new Date(nextDeprecationDate) : false -// Find any other releases that may share the oldest deprecation date -// We'll want to display the deprecation banner on all of these releases (not just oldest) +// Show the deprecation banner on every release that shares the oldest deprecation date. export const releasesWithOldestDeprecationDate = Object.entries(dates) .filter(([, versionData]) => versionData.deprecationDate === nextDeprecationDate) .map(([version]) => version) @@ -140,8 +139,8 @@ export const deprecatedReleasesOnDeveloperSite = deprecated.filter((version) => versionSatisfiesRange(version, '<=2.16'), ) -// Returns the date only once it has passed, so we never advertise a future -// release date. An unparseable date gives NaN, which also returns null. +// Return a date only after it has passed, so templates never advertise future releases. +// Unparseable dates produce NaN, which also returns null. function processDateForDisplay(date: string | undefined): string | null { if (!date) return null const currentTimestamp = Math.floor(Date.now() / 1000) diff --git a/src/versions/lib/get-applicable-versions.ts b/src/versions/lib/get-applicable-versions.ts index cba545b0cf0b..ec6541833b2b 100644 --- a/src/versions/lib/get-applicable-versions.ts +++ b/src/versions/lib/get-applicable-versions.ts @@ -20,12 +20,14 @@ interface FeatureData { } } -// Feature data is dynamically loaded from YAML files +// Feature data loads lazily from YAML on first use. let featureData: FeatureData | null = null const allVersionKeys = Object.keys(allVersions) -// return an array of versions that an article's product versions encompasses +// Feature frontmatter can name one feature, feature: foo, or many, feature: [foo, bar]. +// Merge each feature's version rules before evaluation. Example: fpt: * with +// feature: foo can add ghes: >=2.23 to the versions object. function getApplicableVersions( versionsObj: VersionsObject | string | undefined, filepath?: string, @@ -35,7 +37,7 @@ function getApplicableVersions( throw new Error(`No \`versions\` frontmatter found in ${filepath || 'undefined'}`) } - // Catch an old frontmatter value that was used to indicate an article was available in all versions. + // versions: * is invalid legacy frontmatter; use plan keys or feature-based frontmatter. if (versionsObj === '*') { throw new Error( `${filepath || 'undefined'} contains the invalid versions frontmatter: *. Please explicitly list out all the versions that apply to this article.`, @@ -46,16 +48,6 @@ function getApplicableVersions( featureData = getDeepDataByLanguage('features', 'en') as FeatureData } - // Check for frontmatter that includes a feature name, like: - // fpt: '*' - // feature: 'foo' - // or multiple feature names, like: - // fpt: '*' - // feature: ['foo', 'bar'] - // and add the versions affiliated with the feature (e.g., foo) to the frontmatter versions object: - // fpt: '*' - // ghes: '>=2.23' - // where the feature is bringing the ghes versions into the mix. const featureVersionsObj: VersionsObject = typeof versionsObj === 'string' ? {} @@ -90,7 +82,7 @@ function getApplicableVersions( ) } - // Sort them by the order in lib/all-versions. + // Return versions in the same order as src/versions/lib/all-versions.ts. let sortedVersions = sortBy(applicableVersions, (v) => { return allVersionKeys.indexOf(v) }) @@ -104,36 +96,27 @@ function getApplicableVersions( return sortedVersions } +// evaluateVersions accepts short names such as ghes: >=2.19 and expands them to full version keys. function evaluateVersions(versionsObj: VersionsObject): string[] { - // get an array like: [ 'free-pro-team@latest', 'enterprise-server@2.21', 'enterprise-cloud@latest' ] const versions: string[] = [] - // where versions obj is something like: - // fpt: '*' - // ghes: '>=2.19' - // ghec: '*' - // ^ where each key corresponds to a plan's short name (defined in lib/all-versions.ts) for (const [plan, planValue] of Object.entries(versionsObj)) { if (typeof planValue !== 'string') continue - // For each available plan (e.g., `ghes`), get the matching versions from allVersions. - // This will be an array of one or more version objects. + // Short and full plan names both match version objects. const matchingVersionObjs: Version[] = Object.values(allVersions).filter( (relevantVersionObj: Version) => relevantVersionObj.plan === plan || relevantVersionObj.shortName === plan, ) - // For each matching version found above, compare it to the provided planValue. - // E.g., compare `enterprise-server@2.19` to `ghes: >=2.19`. for (const relevantVersionObj of matchingVersionObjs) { - // If the version doesn't require any semantic comparison, we can assume it applies. + // Non-numbered plans always match because only numbered releases use ranges. if (!relevantVersionObj.hasNumberedReleases) { versions.push(relevantVersionObj.version) continue } - // Special handling for a plan value that evaluates to the next GHES release number or a hardcoded `next`. - // Note these will not be included in the final array unless the `includeNextVersion` option is provided. + // Include future GHES releases only when includeNextVersion keeps them in the returned array. if (versionSatisfiesRange(next, planValue) || planValue === 'next') { versions.push(`${relevantVersionObj.plan}@${next}`) } @@ -141,7 +124,6 @@ function evaluateVersions(versionsObj: VersionsObject): string[] { versions.push(`${relevantVersionObj.plan}@${nextNext}`) } - // Determine which release to use for semantic comparison. const releaseToCompare: string = relevantVersionObj.currentRelease if (releaseToCompare && versionSatisfiesRange(releaseToCompare, planValue)) { diff --git a/src/versions/lib/remove-fpt-from-path.ts b/src/versions/lib/remove-fpt-from-path.ts index 32ee9ded6ff7..04f3e8f6de68 100644 --- a/src/versions/lib/remove-fpt-from-path.ts +++ b/src/versions/lib/remove-fpt-from-path.ts @@ -1,9 +1,8 @@ import slash from 'slash' import nonEnterpriseDefaultVersion from './non-enterprise-default-version' -// This is a convenience function to remove free-pro-team@latest from all -// **user-facing** aspects of the site (particularly URLs) while continuing to support -// free-pro-team@latest as a version both in the codebase and in content/data files. +// Strip free-pro-team@latest from user-facing paths while retaining it as a code and +// content version. export default function removeFPTFromPath(path: string): string { return slash(path.replace(`/${nonEnterpriseDefaultVersion}`, '')) } diff --git a/src/versions/lib/version-satisfies-range.ts b/src/versions/lib/version-satisfies-range.ts index b4abc8089e92..fe1852b822be 100644 --- a/src/versions/lib/version-satisfies-range.ts +++ b/src/versions/lib/version-satisfies-range.ts @@ -1,21 +1,15 @@ import semver from 'semver' -// Where "release" is a release number, like `3.1` for Enterprise Server, -// and "range" is a semver range operator with another number, like `<=3.2`. +// Release is a GHES release such as 3.1; range is a semver range such as <=3.2. export default function versionSatisfiesRange(release: string | undefined, range: string): boolean { - // Handle undefined release if (!release) { return false } - // workaround for Enterprise Server 11.10.340 because we can't use semver to - // compare it to 2.x like we can with 2.0+ + // Enterprise Server 11.10.340 predates semver-compatible 2.x, so only less-than ranges match. if (release === '11.10.340') return range.startsWith('<') - // If the release is '*', we want it to evaluate to false against the range 'next' - // but to true against itself ('*'). Unfortunately by default it will evaluate to - // true against 'next'. So we have to do a hack here and replace it with a - // dummy value of '1.0', which will get the results we want. + // Treat wildcard as 1.0 so it matches wildcard ranges and not next. if (release === '*') { release = '1.0' } diff --git a/src/versions/middleware/features.ts b/src/versions/middleware/features.ts index 5870e701477e..198d4d577173 100644 --- a/src/versions/middleware/features.ts +++ b/src/versions/middleware/features.ts @@ -30,16 +30,13 @@ const cache = new Map>() export function getFeaturesByVersion(currentVersion: string): Record { if (!cache.has(currentVersion)) { if (!allFeatures) { - // As of Oct 2022, the `data/features/**` reading is *not* JIT. - // The `data/features` is deliberately not ignored in nodemon.json. - // See internal issue #2389 + // data/features loads outside JIT, so nodemon watches it instead of ignoring it. allFeatures = getDeepDataByLanguage('features', 'en') as Record } const featureFlags: { [feature: string]: boolean } = {} - // Determine whether the currentVersion belongs to the list of versions the feature is available in. for (const [featureName, feature] of Object.entries(allFeatures)) { const { versions } = feature const applicableVersions = getApplicableVersions( @@ -47,8 +44,7 @@ export function getFeaturesByVersion(currentVersion: string): Record 3.0 %}, see `lib/liquid-tags/if-ver.ts`. +// Liquid conditionals use these shortcuts: {% if fpt %}, {% if ghec %}, and {% if ghes %}. +// Release comparisons use the custom ifversion tag, such as {% ifversion ghes > 3.XX %}. import type { ExtendedRequest } from '@/types' import type { Response, NextFunction } from 'express' @@ -20,10 +14,8 @@ export default async function shortVersions( return next() } - // Add the short name to context. req.context[currentVersionObj.shortName] = true - // Add convenience props. if (currentVersion) { req.context.currentRelease = currentVersion.split('@')[1] req.context.currentVersionShortName = currentVersionObj.shortName diff --git a/src/versions/scripts/update-versioning-in-files.ts b/src/versions/scripts/update-versioning-in-files.ts index 1edcfcc5e319..324ec77cdaf1 100755 --- a/src/versions/scripts/update-versioning-in-files.ts +++ b/src/versions/scripts/update-versioning-in-files.ts @@ -17,7 +17,6 @@ const dataFiles = walk(dataPath, { includeBasePath: true, directories: false }) for (const file of dataFiles) { const content = fs.readFileSync(file, 'utf8') - // Update Liquid in data files const newContent = updateLiquid(content) fs.writeFileSync(file, newContent) @@ -26,15 +25,12 @@ for (const file of dataFiles) { for (const file of contentFiles) { const { data, content } = frontmatter(fs.readFileSync(file, 'utf8')) - // Update Liquid in content files const newContent = content ? updateLiquid(content) : '' - // Update versions frontmatter if (data) { if (!data.versions && data.productVersions) { data.versions = data.productVersions for (const version of Object.keys(data.versions)) { - // update dotcom, actions, rest, etc. if (version !== 'enterprise') { data.versions['free-pro-team'] = data.versions[version] delete data.versions[version] @@ -47,9 +43,8 @@ for (const file of contentFiles) { delete data.productVersions - // Update Liquid in frontmatter props const frontmatterKeys = Object.keys(data) - // Only process a subset of props + // Rewrite Liquid only in title, intro, and product frontmatter. .filter((xkey) => xkey === 'title' || xkey === 'intro' || xkey === 'product') for (const key of frontmatterKeys) { data[key] = updateLiquid(data[key]) diff --git a/src/versions/scripts/use-short-versions.ts b/src/versions/scripts/use-short-versions.ts index f0847142db9b..36c78e35ebdf 100755 --- a/src/versions/scripts/use-short-versions.ts +++ b/src/versions/scripts/use-short-versions.ts @@ -37,47 +37,40 @@ interface OperatorsMap { } const operatorsMap: OperatorsMap = { - // old: new '==': '=', ver_gt: '>', ver_lt: '<', - '!=': '!=', // noop + '!=': '!=', // Already matches ifversion syntax. } -// [start-readme] -// -// Run this script to convert long form Liquid conditionals (e.g., {% if currentVersion == "free-pro-team" %}) to -// the new custom tag (e.g., {% ifversion fpt %}) and also use the short names in versions frontmatter. -// -// [end-readme] +// Converts long-form Liquid conditionals to ifversion tags and short version names +// in versions frontmatter. async function main() { if (dryRun) console.log('This is a dry run! The script will not write any files. Use for debugging.\n') - // 1. UPDATE MARKDOWN FILES (CONTENT AND REUSABLES) + // Markdown files need both Liquid conditionals and versions frontmatter converted. console.log('Updating Liquid conditionals and versions frontmatter in Markdown files...\n') for (const file of markdownFiles) { - // A. UPDATE LIQUID CONDITIONALS IN CONTENT - // Create an { old: new } conditionals object so we can get the replacements and - // make the replacements separately and not do both in nested loops. + // Collect replacements before editing so nested loops do not rewrite generated conditionals. const content = fs.readFileSync(file, 'utf8') const contentReplacements = getLiquidReplacements(content, file) const newContent = makeLiquidReplacements(contentReplacements, content) - // B. UPDATE FRONTMATTER VERSIONS PROPERTY + // Frontmatter versions need short plan names in addition to Liquid updates. const { data } = frontmatter(newContent) as { data: VersionData } if (data.versions && typeof data.versions !== 'string') { const versions = data.versions as Record for (const [plan, value] of Object.entries(versions)) { - // Update legacy versioning while we're here + // Normalize legacy versions before writing short plan names. const valueToUse = value .replace('2.23', '3.0') .replace(`>=${oldestSupported}`, '*') .replace(/>=?2\.20/, '*') .replace(/>=?2\.19/, '*') - // Find the relevant version from the master list so we can access the short name. + // Find the version config before replacing the plan with its short name. const versionObj = allVersionKeys.find( (version) => version.plan === plan || version.shortName === plan, ) @@ -98,19 +91,19 @@ async function main() { frontmatter.stringify( newContent, data, - // lineWidth is a js-yaml option passed through gray-matter, not in gray-matter's type definitions + // lineWidth is a js-yaml option passed through gray-matter, not in its types. { lineWidth: 10000 } as unknown as Parameters[2], ), ) } } - // 2. UPDATE LIQUID CONDITIONALS IN DATA YAML FILES + // YAML data files need Liquid conditional and versions-key rewrites. console.log('Updating Liquid conditionals in YAML files...\n') for (const file of yamlFiles) { const yamlContent = fs.readFileSync(file, 'utf8') const yamlReplacements = getLiquidReplacements(yamlContent, file) - // Update any `versions` properties in the YAML as well + // YAML versions keys use short plan names too. const newYamlContent = makeLiquidReplacements(yamlReplacements, yamlContent) .replace(/("|')?free-pro-team("|')?:/g, 'fpt:') .replace(/("|')?enterprise-server("|')?:/g, 'ghes:') @@ -131,7 +124,7 @@ try { process.exit(1) } -// Remove verbose input properties for readability in debugging output +// Remove verbose input properties for readable debugging output. function removeInputProps(arrayOfObjects: TopLevelToken[]): TopLevelToken[] { return arrayOfObjects.map((obj) => { const record = obj as unknown as Record @@ -143,29 +136,28 @@ function removeInputProps(arrayOfObjects: TopLevelToken[]): TopLevelToken[] { }) } +// makeLiquidReplacements also collapses "ghes and ghes" from old deprecation-script +// guards. Example: enterpriseServerVersions contains currentVersion plus +// currentVersion ver_gt enterprise-server@3.XX becomes ghes > 3.XX. function makeLiquidReplacements(replacementsObj: ReplacementsMap, text: string): string { let newText = text for (const [oldCond, newCond] of Object.entries(replacementsObj)) { const oldCondRegex = new RegExp(`({%-?)\\s*?${RegExp.escape(oldCond)}\\s*?(-?%})`, 'g') newText = newText .replace(oldCondRegex, `$1 ${newCond} $2`) - // Content files use an old-school hack to ensure our old regex deprecation script DTRT, for example: - // `if enterpriseServerVersions contains currentVersion and currentVersion ver_gt "enterprise-server@2.21"` - // This script will change the above to `if ghes and ghes > 2.21`. - // But we don't need the hack for the new deprecation script, because it will change `if ghes > 2.21` to `if ghes`. - // So we can update this to the simpler `{% if ghes > 2.21 %}`. + // Collapse duplicated GHES guards from old deprecation-script conditionals. .replace(/ghes and ghes/g, 'ghes') } return newText } -// Versions map: -// if currentVersion == "myVersion@myRelease" -> ifversion myVersionShort OR ifversion myVersionShort = @myRelease -// if currentVersion != "myVersion@myRelease" -> ifversion not myVersionShort OR ifversion myVersionShort != @myRelease -// if currentVersion ver_gt "myVersion@myRelease -> ifversion myVersionShort > myRelease -// if currentVersion ver_lt "myVersion@myRelease -> ifversion myVersionShort < myRelease -// if enterpriseServerVersions contains currentVersion -> ifversion ghes +// getLiquidReplacements maps long currentVersion conditionals to ifversion conditionals: +// currentVersion == enterprise-server@3.XX -> ifversion ghes = 3.XX +// currentVersion != free-pro-team@latest -> ifversion not fpt +// currentVersion ver_gt enterprise-server@3.XX -> ifversion ghes > 3.XX +// currentVersion ver_lt enterprise-server@3.XX -> ifversion ghes < 3.XX +// enterpriseServerVersions contains currentVersion -> ifversion ghes function getLiquidReplacements(content: string, file: string): ReplacementsMap { const replacements: ReplacementsMap = {} @@ -191,22 +183,18 @@ function getLiquidReplacements(content: string, file: string): ReplacementsMap { .map((xtoken) => xtoken.content) for (const token of conditionalTokens) { const newToken = token.startsWith('if') ? ['ifversion'] : ['elsif'] - // Everything from here on pushes to the `newToken` array to construct the new conditional. for (const op of token.replace(/(if|elsif) /, '').split(/ (or|and) /)) { if (op === 'or' || op === 'and') { newToken.push(op) continue } - // This string will always resolve to `ifversion ghes`. + // enterpriseServerVersions contains currentVersion maps to ifversion ghes. if (op.includes('enterpriseServerVersions contains currentVersion')) { newToken.push('ghes') continue } - // For the rest, we need to check the release string. - - // E.g., [ 'currentVersion', '==', '"enterprise-server@3.0"']. const opParts = op.split(' ') if (!(opParts.length === 3 && opParts[0] === 'currentVersion')) { @@ -215,10 +203,8 @@ function getLiquidReplacements(content: string, file: string): ReplacementsMap { } const operator = opParts[1] - // Remove quotes around the version and then split it on the at sign. const [plan, release] = opParts[2].slice(1, -1).split('@') - // Find the relevant version from the master list so we can access the short name. const versionObj = allVersionKeys.find((version) => version.plan === plan) if (!versionObj) { @@ -226,7 +212,6 @@ function getLiquidReplacements(content: string, file: string): ReplacementsMap { process.exit(1) } - // Handle numbered releases! if (versionObj.hasNumberedReleases) { const newOperator: string | undefined = operatorsMap[operator] if (!newOperator) { @@ -236,60 +221,54 @@ function getLiquidReplacements(content: string, file: string): ReplacementsMap { process.exit(1) } - // Account for this one weird version included in a couple content files + // Some content still references 1.19, so treat it as deprecated for this conversion. deprecated.push('1.19') - // E.g., ghes > 2.20 const availableInAllGhes = deprecated.includes(release) && newOperator === '>' - // We can change > deprecated releases, like ghes > 2.19, to just ghes. - // These are now available for all ghes releases. + // A greater-than check against a deprecated release matches every supported GHES release. if (availableInAllGhes) { newToken.push(versionObj.shortName) continue } - // E.g., ghes < 2.20 const lessThanDeprecated = deprecated.includes(release) && newOperator === '<' - // E.g., ghes < 2.21 const lessThanOldestSupported = release === oldestSupported && newOperator === '<' - // E.g., ghes = 2.20 const equalsDeprecated = deprecated.includes(release) && newOperator === '=' const hasDeprecatedContent = lessThanDeprecated || lessThanOldestSupported || equalsDeprecated - // Remove these by hand. + // Deprecated-only content needs manual removal instead of conversion. if (hasDeprecatedContent) { console.error(`Found content that needs to be removed! See "${token} in "${file}`) process.exit(1) } - // Override for legacy 2.23, which should be 3.0 + // Legacy 2.23 conditionals map to the first 3.0 release. const releaseToUse = release === '2.23' ? '3.0' : release newToken.push(`${versionObj.shortName} ${newOperator} ${releaseToUse}`) continue } - // Turn != into nots, now that we can assume this is not a numbered release. + // Non-numbered inequality maps to ifversion not. if (operator === '!=') { newToken.push(`not ${versionObj.shortName}`) continue } - // We should only have equality conditionals left. + // Non-numbered releases only support equality after inequality handling. if (operator !== '==') { console.error(`Expected == but found ${operator} in "${op}" in ${token}`) process.exit(1) } - // Handle `latest`! if (release === 'latest') { newToken.push(versionObj.shortName) continue } - // Handle all other non-standard releases, like github-ae@next and github-ae@issue-12345 + // Keep non-standard non-numbered releases in the condition name, such as github-ae@next. newToken.push(`${versionObj.shortName}-${release}`) } diff --git a/src/versions/tests/get-applicable-versions.ts b/src/versions/tests/get-applicable-versions.ts index ba7bba65a3bb..7ea317ce383f 100644 --- a/src/versions/tests/get-applicable-versions.ts +++ b/src/versions/tests/get-applicable-versions.ts @@ -43,7 +43,7 @@ describe('Versions frontmatter', () => { describe('general cases', () => { test('wildcard * is no longer used', () => { - // docs engineering 3110 + // versions: * shorthand is invalid; plan keys and feature-based frontmatter are explicit. expect.assertions(2) try { getApplicableVersions('*') @@ -65,7 +65,7 @@ describe('general cases', () => { const applicableVersions = getApplicableVersions(versions) expect(applicableVersions.every((v) => Object.keys(allVersions).includes(v))) } - // Same thing but as an array each time + // Feature arrays follow the same rules as a single feature name. for (const possibleFeature of possibleFeatures) { const versions: Versions = { feature: [possibleFeature] } const applicableVersions = getApplicableVersions(versions) diff --git a/src/versions/tests/version-cookie.ts b/src/versions/tests/version-cookie.ts index a691d3092f40..195f3a9bd8c1 100644 --- a/src/versions/tests/version-cookie.ts +++ b/src/versions/tests/version-cookie.ts @@ -58,8 +58,8 @@ describe('version cookie redirects', () => { }) }) -// See github/technical-content#7227. Before this, the cookie was only ever consulted on the bare -// homepage, so every deep link served Free/Pro/Team no matter what the reader preferred. +// Version preference applies to article URLs, not only the homepage. The cookie is +// the default, and an explicit path segment wins. describe('version cookie on article URLs', () => { // Exists in Free/Pro/Team and in Enterprise Cloud. const versioned = '/en/get-started/start-your-journey/what-is-github' @@ -76,8 +76,7 @@ describe('version cookie on article URLs', () => { '/en/enterprise-cloud@latest/get-started/start-your-journey/what-is-github', ) expect(res.headers.vary).toContain('x-user-version') - // Listed once, not twice. The manual append is skipped on the redirect path because - // `languageAndVersionCacheControl` already names it. + // Manual append is skipped on redirects because languageAndVersionCacheControl names it. expect(res.headers.vary!.match(/x-user-version/g)).toHaveLength(1) }) @@ -101,9 +100,8 @@ describe('version cookie on article URLs', () => { expect(res.statusCode).toBe(200) }) - // The escape hatch. `getRedirect` strips the `/free-pro-team@latest` prefix, so without - // reading the request path we would bounce this reader straight back to Enterprise Cloud - // and they could never look at the Free/Pro/Team article on purpose. + // An explicit free-pro-team URL must beat the cookie. getRedirect strips the prefix; + // without the request path, the reader would bounce back to Enterprise Cloud. test('an explicit free-pro-team URL beats the cookie', async () => { const res = await get( '/en/free-pro-team@latest/get-started/start-your-journey/what-is-github', @@ -134,9 +132,8 @@ describe('version cookie on article URLs', () => { expect(res.statusCode).toBe(200) }) - // Varying only for cookie holders would let this cached response be handed to a reader - // who should have been redirected. test('varies on the cookie even for readers who have not set one', async () => { + // Unversioned articles with alternate versions vary on x-user-version so caches keep redirects. const res = await get(versioned, { followRedirects: false }) expect(res.statusCode).toBe(200) expect(res.headers.vary).toContain('x-user-version') @@ -153,7 +150,6 @@ describe('version cookie on article URLs', () => { ) }) - // Staying in the reader's language is covered by the unit tests in - // src/redirects/tests/version-preference.ts. It cannot be covered here because this - // suite runs against real content, and only English is loaded. + // Unit tests in src/redirects/tests/version-preference.ts cover staying in the + // reader's language. This suite runs against real content, and only English is loaded. }) diff --git a/src/webhooks/components/Webhook.tsx b/src/webhooks/components/Webhook.tsx index 2f4949fdb3d0..c90cd7d83d94 100644 --- a/src/webhooks/components/Webhook.tsx +++ b/src/webhooks/components/Webhook.tsx @@ -19,7 +19,6 @@ type Props = { webhook: WebhookAction } -// fetcher passed to useSWR() to get webhook data using the given URL async function webhookFetcher(url: string) { const response = await fetch(url) if (!response.ok) { @@ -30,25 +29,16 @@ async function webhookFetcher(url: string) { } export function Webhook({ webhook }: Props) { - // Get version for requests to switch webhook action type const version = useVersion() const { t, tObject } = useTranslation('webhooks') - // Get more user friendly language for the different availability options in - // the webhook schema (we can't change it directly in the schema). Note that - // we specifically don't want to translate these strings with useTranslation() - // like we usually do with strings from data/ui.yml. + // Map schema availability values to UI copy instead of translating source values directly. const rephraseAvailability = tObject('rephrase_availability') - // The param that was clicked so we can expand its property
    element const [clickedBodyParameterName, setClickedBodyParameterName] = useState('') - // The selected webhook action type the user selects via a dropdown const [selectedWebhookActionType, setSelectedWebhookActionType] = useState('') - // The index of the selected action type so we can highlight which one is selected - // in the action type dropdown const [selectedActionTypeIndex, setSelectedActionTypeIndex] = useState(0) - // Tracks whether we need to announce once data loads (first interaction only, - // before SWR cache is populated). + // Tracks the first uncached interaction so data-load effects can announce it once. const [pendingAnnouncement, setPendingAnnouncement] = useState('') const webhookSlug = slug(webhook.data.category) @@ -57,10 +47,7 @@ export function Webhook({ webhook }: Props) { version: version.currentVersion, })}` - // fires when the webhook action type changes or someone clicks on a nested - // body param for the first time. In either case, we now have all the data - // for a webhook (i.e. all the data for each action type and all of their - // nested parameters) + // Fetch full webhook data after the action type changes or a user expands nested parameters. const { data, error } = useSWR( clickedBodyParameterName || selectedWebhookActionType ? webhookFetchUrl : null, webhookFetcher, @@ -69,13 +56,7 @@ export function Webhook({ webhook }: Props) { }, ) - // When you load the page we want to support linking to a specific webhook type - // so this effect sets the webhook type if it's provided in the URL e.g.: - // - // webhook-events-and-payloads?actionType=published#package - // - // where the webhook is set in the hash (which is equal to webhookSlug) and - // the webhook action type is passed in the actionType parameter. + // Example: webhook-events-and-payloads?actionType=published#package opens the published package payload. useEffect(() => { const url = new URL(location.href) const actionType = url.searchParams.get('actionType') @@ -86,7 +67,6 @@ export function Webhook({ webhook }: Props) { } }, []) - // Build a plain-text announcement from the webhook action data. const buildAnnouncement = useCallback( (type: string, actionData: { descriptionHtml: string }) => { const tempEl = document.createElement('div') @@ -100,26 +80,15 @@ export function Webhook({ webhook }: Props) { [t], ) - // callback for the action type dropdown -- sets the action type to the given - // type, index is the index of the selected type so we can highlight it as - // selected. - // - // Besides setting the action type state, we also want to: - // - // * clear the clicked body param so that no properties are expanded when we - // re-render the webhook - // * update the URL so people can link to a specific webhook action type + // Reset nested parameters, announce the selected action type, and keep the URL linkable. function handleActionTypeChange(type: string, index: number) { setClickedBodyParameterName('') setSelectedWebhookActionType(type) setSelectedActionTypeIndex(index) - // If SWR data is already cached, announce immediately. Otherwise, flag - // the type so the effect can announce once data arrives. + // Cached data can announce now; uncached data announces after SWR loads. if (data && data[type]) { - // Use setTimeout so the announcement fires after the ActionMenu closes - // and VoiceOver finishes reading the button. Compute message eagerly to - // avoid stale closures if data changes before the timeout fires. + // Compute the message eagerly to avoid stale data, then delay until VoiceOver finishes the menu. const message = buildAnnouncement(type, data[type]) setTimeout(() => { announce(message, { politeness: 'assertive' }) @@ -128,15 +97,13 @@ export function Webhook({ webhook }: Props) { setPendingAnnouncement(type) } - // Update the URL without triggering Next.js router navigation, which causes - // VoiceOver to re-read the page title and swallow live-region announcements. + // Replace history directly so Next.js navigation does not make VoiceOver re-read the page title. const url = new URL(location.href) url.searchParams.set('actionType', type) url.hash = webhookSlug window.history.replaceState(window.history.state, '', url.toString()) } - // callback to trigger useSWR() hook after a nested property is clicked function handleBodyParamExpansion(target: HTMLDetailsElement) { setClickedBodyParameterName(target.closest('details')?.dataset.nestedParamId) } @@ -144,8 +111,7 @@ export function Webhook({ webhook }: Props) { const currentWebhookActionType = selectedWebhookActionType || webhook.data.action const currentWebhookAction = (data && data[currentWebhookActionType]) || webhook.data - // Announce content changes when data arrives for the first time (before SWR - // cache is populated). Subsequent changes are announced directly in the handler. + // Announce the first uncached selection after SWR loads; cached selections announce in the handler. useEffect(() => { if (!pendingAnnouncement || !data || !data[pendingAnnouncement]) return const type = pendingAnnouncement diff --git a/src/webhooks/lib/index.ts b/src/webhooks/lib/index.ts index 0756bfa27417..111fa4b69fbc 100644 --- a/src/webhooks/lib/index.ts +++ b/src/webhooks/lib/index.ts @@ -33,23 +33,19 @@ interface WebhookActionData { type WebhookCategory = Record type WebhookData = Record -// Two-tier cache: fpt and ghec are pinned in a plain Map (never evicted) because -// they account for the vast majority of traffic. All other versions (ghes) go -// into a bounded LRU cache to prevent unbounded memory growth. +// Pin fpt and ghec because they receive most traffic. +// Bound all GHES versions with LRU so schema cache memory cannot grow without limit. const PINNED_OPEN_API_VERSIONS = new Set(['fpt', 'ghec']) const pinnedCache = new Map() const LRU_MAX_SIZE = Math.max(1, parseInt(process.env.WEBHOOK_SCHEMA_LRU_SIZE ?? '', 10) || 96) const lruCache = new QuickLRU({ maxSize: LRU_MAX_SIZE }) -// In-flight deduplication: concurrent cache misses for the same key share one -// file read instead of each triggering their own. +// Concurrent cache misses for the same key share one file read. const inflight = new Map>() const brotliDecompressAsync = promisify(brotliDecompress) -// cache for webhook data for when you first visit the webhooks page where we -// show all webhooks for the current version but only 1 action type per webhook -// and also no nested parameters +// Landing-page data has every webhook for a version, one action type each, and no nested params. const initialWebhooksCache = new Map() interface InitialWebhook { @@ -58,7 +54,6 @@ interface InitialWebhook { data: WebhookActionData } -// Returns the data described above for initialWebhooksCache. export async function getInitialPageWebhooks(version: string): Promise { if (initialWebhooksCache.has(version)) { return initialWebhooksCache.get(version) || [] @@ -66,9 +61,7 @@ export async function getInitialPageWebhooks(version: string): Promise 0 ? actionTypes[0] : '' @@ -79,25 +72,19 @@ export async function getInitialPageWebhooks(version: string): Promise name === webhookCategory) if (!safeCategory) return undefined @@ -131,8 +115,7 @@ export async function getWebhook( const slimData = cache.get(cacheKey) if (!slimData || !includeChildParams) return slimData - // Merge childParamsGroups from the separate file for drill-down requests. - // This data is not cached because it is large and only needed per request. + // Drill-down childParamsGroups stay uncached because they are large and needed per request. const childParamsPath = path.join( WEBHOOK_DATA_DIR, openApiVersion, @@ -144,9 +127,7 @@ export async function getWebhook( return mergeChildParams(slimData, childParams) } -// returns all the webhook data for the given version by loading each category -// file in parallel. Does NOT include childParamsGroups, because this feeds the -// landing page. Use getWebhook() for drill-down with full nested params. +// Loads landing-page data for every category in parallel, without childParamsGroups. export async function getWebhooks(version: string): Promise { const categories = getWebhookCategories(version) const entries = await Promise.all( @@ -158,11 +139,8 @@ export async function getWebhooks(version: string): Promise { return Object.fromEntries(entries) } -// returns the list of webhook category names available for the given version -// by reading the data directory. Mirrors getRestCategories() in src/rest/lib/index.ts. -// Memoized per openApiVersion: the data directory is static at runtime, and -// getWebhook() consults this on every call as a path-injection allowlist, so we -// must not pay a readdirSync on each lookup. +// Mirrors getRestCategories in src/rest/lib/index.ts. +// Cache the static data-directory listing because getWebhook uses it as a path-injection allowlist. const categoriesCache = new Map() export function getWebhookCategories(version: string): string[] { const openApiVersion = getOpenApiVersion(version) @@ -179,21 +157,15 @@ export function getWebhookCategories(version: string): string[] { type ChildParamsData = Record> -// Load the child-params file for a webhook category. Returns null if the file -// does not exist (some webhooks have no childParamsGroups). -// The filePath is built from a filesystem-derived category name (validated -// against getWebhookCategories in getWebhook), never directly from request input. +// Child-params sidecars are optional; categories without nested groups return null. +// getWebhook passes a filesystem-derived path, never raw request input. async function loadChildParamsFile(filePath: string): Promise { try { const compressed = await fsPromises.readFile(`${filePath}.br`) const decompressed = await brotliDecompressAsync(compressed) return JSON.parse(decompressed.toString()) as ChildParamsData } catch { - // The brotli variant is optional; fall back to plain JSON. A genuine - // missing-file (ENOENT) means this category simply has no childParamsGroups, - // so we return null. Any other error (malformed JSON, truncated/corrupt - // read, permission denied) is a real failure that would silently drop - // nested params on drill-down, so we log it loudly before returning null. + // Missing sidecars return null; corrupt or unreadable plain JSON logs before dropping nested params. try { const raw = await fsPromises.readFile(filePath, 'utf-8') return JSON.parse(raw) as ChildParamsData @@ -206,7 +178,6 @@ async function loadChildParamsFile(filePath: string): Promise { try { const compressed = await fsPromises.readFile(`${basePath}.br`) const decompressed = await brotliDecompressAsync(compressed) return JSON.parse(decompressed.toString()) as WebhookCategory } catch { - // .br missing or unreadable, so fall back to plain JSON. + // Missing or unreadable Brotli files fall back to plain JSON. const raw = await fsPromises.readFile(basePath, 'utf-8') return JSON.parse(raw) as WebhookCategory } diff --git a/src/webhooks/lib/tests/index.ts b/src/webhooks/lib/tests/index.ts index 4abdbe58b328..e31ef29f0f90 100644 --- a/src/webhooks/lib/tests/index.ts +++ b/src/webhooks/lib/tests/index.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { getInitialPageWebhooks, getWebhook, getWebhooks } from '../index' -// Use a version that's guaranteed to exist in the data directory. +// free-pro-team@latest always exists in the data directory. const VERSION = 'free-pro-team@latest' // Pick a webhook category that has a .child-params.json sidecar, so getWebhook @@ -23,7 +23,6 @@ describe('getInitialPageWebhooks does not corrupt the getWebhook cache', () => { }) it('preserves childParamsGroups in the getWebhook cache after getInitialPageWebhooks runs', async () => { - // Seed the cache and record original childParamsGroups lengths. const before = await getWebhook(VERSION, CATEGORY) expect(before).toBeDefined() @@ -37,11 +36,9 @@ describe('getInitialPageWebhooks does not corrupt the getWebhook cache', () => { } expect(Object.keys(originalLengths).length).toBeGreaterThan(0) - // The initial-page data has empty childParamsGroups. It must not reach back - // into the objects getWebhook already cached. + // Initial-page data must not mutate childParamsGroups already cached by getWebhook. await getInitialPageWebhooks(VERSION) - // getWebhook returns cached data, which must NOT have been mutated. const after = await getWebhook(VERSION, CATEGORY) expect(after).toBeDefined() @@ -61,7 +58,6 @@ describe('getInitialPageWebhooks does not corrupt the getWebhook cache', () => { describe('childParamsGroups deferred loading', () => { it('getWebhooks() returns data without childParamsGroups (slim)', async () => { const allWebhooks = await getWebhooks(VERSION) - // Check a category known to have child params const webhook = allWebhooks[CATEGORY] expect(webhook).toBeDefined() diff --git a/src/webhooks/middleware/webhooks.ts b/src/webhooks/middleware/webhooks.ts index 86063188feda..cc59b7f62e51 100644 --- a/src/webhooks/middleware/webhooks.ts +++ b/src/webhooks/middleware/webhooks.ts @@ -5,11 +5,7 @@ import { defaultCacheControl } from '@/frame/middleware/cache-control' const router = express.Router() -// Returns a webhook for the given category and version -// -// Example request: -// -// /api/webhooks/v1?category=check_run&version=free-pro-team%40latest +// Example: /api/webhooks/v1?category=check_run&version=free-pro-team%40latest router.get('/v1', async function webhooks(req, res) { if (!req.query.category) { res.status(400).json({ error: "Missing 'category' in query string" }) diff --git a/src/webhooks/pages/webhook-events-and-payloads.tsx b/src/webhooks/pages/webhook-events-and-payloads.tsx index d3ad7860187e..ee7f2fa4cc08 100644 --- a/src/webhooks/pages/webhook-events-and-payloads.tsx +++ b/src/webhooks/pages/webhook-events-and-payloads.tsx @@ -40,16 +40,12 @@ export default function WebhooksEventsAndPayloads({ ) }) - // When someone clicks on a minitoc hash anchor link on this page, we want to - // remove the type query parameter from the URL because the type won't make - // sense anymore (e.g. ?actionType=closed#issues and you click on the fork minitoc - // we don't want the URL to be ?actionType=closed#fork). + // Drop actionType on hash navigation, such as ?actionType=closed#issues to #fork; it no longer applies. useEffect(() => { const hashChangeHandler = () => { const { pathname, hash, search } = window.location - // carry over any other query parameters besides `actionType` for the webhook - // action type + // Preserve unrelated query parameters when removing actionType. const params = new URLSearchParams(search) params.delete('actionType') @@ -87,13 +83,10 @@ export const getServerSideProps: GetServerSideProps = async (context) => addUINamespaces(req, mainContext.data.ui, ['parameter_table', 'webhooks']) const { miniTocItems } = getAutomatedPageContextFromRequest(req) - // Get data for initial webhooks page (i.e. only 1 action type per webhook and - // no nested parameters) + // Landing-page webhooks include one action type per webhook and no nested parameters. const webhooks = (await getInitialPageWebhooks(currentVersion)) as unknown as WebhookAction[] - // Build the minitocs for the webhooks page which is based on the webhook - // categories in addition to the Markdown in the webhook-events-and-payloads.md - // content file + // Add webhook categories to the mini table of contents from webhook-events-and-payloads.md. const webhooksMiniTocs = await getAutomatedPageMiniTocItems( webhooks.map((webhook) => webhook.data.category), context, diff --git a/src/webhooks/scripts/sync.ts b/src/webhooks/scripts/sync.ts index 2a909e72635d..1ff0b958accc 100644 --- a/src/webhooks/scripts/sync.ts +++ b/src/webhooks/scripts/sync.ts @@ -22,10 +22,7 @@ export async function syncWebhookData( webhookSchemas.map(async (schemaName) => { const file = path.join(sourceDirectory, schemaName) const schema: WebhookFile = JSON.parse(await readFile(file, 'utf-8')) - // In OpenAPI version 3.1, the schema data is under the `webhooks` - // key, but in 3.0 the schema data was in `x-webhooks`. - // We just fallback to `x-webhooks` for now since there's - // currently no difference in the schema data between versions. + // OpenAPI 3.1 stores webhook data under webhooks and 3.0 under x-webhooks; both use the same shape. const webhookSchemaData = schema.webhooks ?? schema['x-webhooks'] if (!webhookSchemaData) { console.log( @@ -51,12 +48,7 @@ export async function syncWebhookData( await mkdir(targetDirectory, { recursive: true }) } - // Write one JSON file per webhook category (e.g. check_run.json) instead - // of a single monolithic schema.json. This allows the server to load only - // the requested webhook on demand rather than the entire version schema. - // - // childParamsGroups are split into a separate file ({category}.child-params.json) - // so the landing page never loads them. They are fetched on drill-down only. + // Split categories, such as check_run.json, from childParamsGroups sidecars. await Promise.all( Object.entries(data).map(async ([category, categoryData]) => { const childParams: Record> = {} @@ -97,10 +89,7 @@ export async function syncWebhookData( await writeFile(childParamsPath, JSON.stringify(childParams, null, 2)) console.log(`✅ Wrote ${childParamsPath}`) } else { - // Remove any stale sidecar from a previous sync where this category - // had child params but no longer does. getWebhook() probes for this - // file unconditionally, so leaving it would reintroduce removed - // nested params on drill-down. + // Remove stale child-param sidecars so drill-down pages do not show removed nested params. for (const stalePath of [childParamsPath, `${childParamsPath}.br`]) { if (existsSync(stalePath)) { await unlink(stalePath) @@ -126,10 +115,8 @@ async function processWebhookSchema(webhooks: Webhook[]): Promise { } } -// Create an object with all webhooks where the key is the webhook name. -// Webhooks typically have a property called `action` that describes the -// events that trigger the webhook. Some webhooks (like `ping`) don't have -// action types -- in that case we set the value of action to 'default'. +// Groups webhooks by category and action type. +// Webhooks without action types, such as ping, use default. async function formatWebhookData( webhooks: Webhook[], ): Promise>> { diff --git a/src/webhooks/scripts/webhook-schema.ts b/src/webhooks/scripts/webhook-schema.ts index be1053124491..f624ad607255 100644 --- a/src/webhooks/scripts/webhook-schema.ts +++ b/src/webhooks/scripts/webhook-schema.ts @@ -1,10 +1,8 @@ -// This schema is used to validate each generated webhook object at build time - +// Defines the build-time schema for every generated webhook object. export default { type: 'object', required: ['availability', 'bodyParameters', 'category', 'descriptionHtml', 'summaryHtml'], properties: { - // Properties from the source OpenAPI schema that this module depends on action: { description: 'The webhook action type', type: ['string', 'null'], diff --git a/src/webhooks/scripts/webhook.ts b/src/webhooks/scripts/webhook.ts index a081fbc6f67a..8d0b88a0f0a9 100644 --- a/src/webhooks/scripts/webhook.ts +++ b/src/webhooks/scripts/webhook.ts @@ -63,9 +63,7 @@ export default class Webhook implements WebhookInterface { null, ) - // for some webhook action types (like some pull-request webhook types) the - // schema properties are under a oneOf so we try and take the action from - // the first one (the action will be the same across oneOf items) + // Some pull-request webhooks put the same action enum under the first oneOf schema. if (!this.action) { this.action = get( webhook, @@ -74,9 +72,7 @@ export default class Webhook implements WebhookInterface { ) } - // The OpenAPI uses hyphens for the webhook names, but the webhooks - // are sent using underscores (e.g. `branch_protection_rule` instead - // of `branch-protection-rule`) + // OpenAPI uses branch-protection-rule, but delivered webhooks use branch_protection_rule. this.category = webhook['x-github'].subcategory.replace(/-/g, '_') } @@ -101,7 +97,7 @@ export default class Webhook implements WebhookInterface { const schema = get(this.#webhook, `requestBody.content['application/json'].schema`, {}) this.bodyParameters = isPlainObject(schema) ? await getBodyParams(schema, true) : [] - // Removes the children of the common properties + // Common top-level properties do not need expanded child parameter groups. for (const param of this.bodyParameters) { if (NO_CHILD_PROPERTIES.includes(param.name)) { param.childParamsGroups = [] diff --git a/src/webhooks/tests/api.ts b/src/webhooks/tests/api.ts index 5092943e10a0..1cb9b68b91e1 100644 --- a/src/webhooks/tests/api.ts +++ b/src/webhooks/tests/api.ts @@ -6,9 +6,7 @@ import { makeLanguageSurrogateKey } from '@/frame/middleware/set-fastly-surrogat describe('webhooks v1 middleware', () => { test('basic get webhook', async () => { const sp = new URLSearchParams() - // Based on live data which isn't ideal but it should rarely change at least. - // Just check that we find the webhook and that the result has the `category` - // field which all webhook types should have. + // Live data can change, so assert only the stable category field on an existing webhook. sp.set('category', 'branch_protection_rule') sp.set('version', 'free-pro-team@latest') const res = await get(`/api/webhooks/v1?${sp}`) @@ -18,7 +16,6 @@ describe('webhooks v1 middleware', () => { expect(actionTypes.length).toBeGreaterThan(2) expect(Object.keys(results[actionTypes[0]]).includes('category')).toBeTruthy() - // Check that it can be cached at the CDN expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -64,7 +61,7 @@ describe('webhooks v1 middleware', () => { test('drill-down endpoint returns childParamsGroups', async () => { const sp = new URLSearchParams() - // projects_v2_item is known to have non-empty childParamsGroups + // projects_v2_item is known to have non-empty childParamsGroups. sp.set('category', 'projects_v2_item') sp.set('version', 'free-pro-team@latest') const res = await get(`/api/webhooks/v1?${sp}`) diff --git a/src/webhooks/tests/oneof-handling.ts b/src/webhooks/tests/oneof-handling.ts index 174814e73e86..cff3971b363f 100644 --- a/src/webhooks/tests/oneof-handling.ts +++ b/src/webhooks/tests/oneof-handling.ts @@ -7,8 +7,7 @@ import { describe('oneOf handling in webhook parameters', () => { test('should handle oneOf fields correctly for secret_scanning_alert_location details', async () => { - // Mirrors the secret_scanning_alert_location details field in the real - // OpenAPI schema. + // The mock mirrors the real secret_scanning_alert_location details field. const mockSchema = { type: 'object', properties: { @@ -215,7 +214,7 @@ describe('oneOf handling in webhook parameters', () => { expect(detailsParam).toBeDefined() expect(detailsParam?.childParamsGroups?.length).toBe(2) - // When titles are missing, the name should be undefined or handled gracefully + // Untitled oneOf options still render object variants without names. if (detailsParam?.childParamsGroups) { for (const param of detailsParam.childParamsGroups) { expect(param.type).toBe('object') diff --git a/src/webhooks/tests/rendering.ts b/src/webhooks/tests/rendering.ts index 3240d6b5d75a..f35df5099e4b 100644 --- a/src/webhooks/tests/rendering.ts +++ b/src/webhooks/tests/rendering.ts @@ -7,8 +7,7 @@ import { getWebhooks } from '../lib/index' describe('webhooks events and payloads', () => { vi.setConfig({ testTimeout: 3 * 60 * 1000 }) - // This test ensures that the page component and the Markdown file are - // in sync. It also checks that all expected items are present. + // Keeps the page component, Markdown, and generated webhook data in sync. test('loads webhook schema data for all versions', async () => { for (const version in allVersions) { const webhooks = await getWebhooks(version) @@ -27,8 +26,6 @@ describe('webhooks events and payloads', () => { }) test('Non-GHES versions do not load GHES only webhook', async () => { - // available since 3.4, only in GHES (technically also GHAE which is based - // off of GHES) const ghesOnlyWebhook = 'cache_sync' for (const version in allVersions) { @@ -52,8 +49,7 @@ describe('webhooks events and payloads', () => { const $root = $(rootSelector) expect($root.length).toBe(1) - // on the webhooks page the lead is separate from the article body (unlike - // the REST pages for example) + // Webhooks pages render the lead outside the article body for search extraction. const leadSelector = '[data-search=lead] p' const $lead = $(leadSelector) expect($lead.length).toBe(1) @@ -64,12 +60,7 @@ describe('webhooks events and payloads', () => { test('every webhook event has at least one payload example', async () => { const versions = Object.values(allVersions).map((value) => value.version) - // For all versions, check that the webhook events and payloads page - // has at least one payload example for each event. Payload examples - // start with the id `webhook-payload-example` and have a sibling div - // with the class `height-constrained-code-block`. The sibling is - // usually but not always the next sibling element, which is why - // `nextUntil` is used. + // nextUntil finds payload code blocks in later siblings, not only the next one. for (const version of versions) { const page = `/${version}/webhooks-and-events/webhooks/webhook-events-and-payloads` const $ = await getDOM(page) diff --git a/src/webhooks/tests/webhook-generation-oneof.ts b/src/webhooks/tests/webhook-generation-oneof.ts index b7ff4534c208..82451ba9740e 100644 --- a/src/webhooks/tests/webhook-generation-oneof.ts +++ b/src/webhooks/tests/webhook-generation-oneof.ts @@ -3,8 +3,7 @@ import Webhook from '../scripts/webhook' describe('webhook generation with oneOf fields', () => { test('should properly generate webhook documentation for secret_scanning_alert_location with oneOf details', async () => { - // Mock OpenAPI schema that represents the actual structure from github/rest-api-description - // This simulates the secret_scanning_alert_location webhook with oneOf details field + // Mirrors secret_scanning_alert_location's oneOf details shape in github/rest-api-description. const mockWebhookSchema = { summary: 'This event occurs when there is activity relating to the locations of a secret in a secret scanning alert.', @@ -267,34 +266,28 @@ describe('webhook generation with oneOf fields', () => { }, } - // Create webhook instance and process it const webhook = new Webhook(mockWebhookSchema) await webhook.process() - // Verify basic webhook properties expect(webhook.category).toBe('secret_scanning_alert_location') expect(webhook.action).toBe('created') expect(webhook.availability).toEqual(['repository', 'organization', 'app']) expect(webhook.bodyParameters).toBeDefined() expect(webhook.bodyParameters.length).toBeGreaterThan(0) - // Find the location parameter const locationParam = webhook.bodyParameters.find((param) => param.name === 'location') expect(locationParam).toBeDefined() expect(locationParam?.type).toBe('object') expect(locationParam?.childParamsGroups).toBeDefined() - // Find the details parameter within location const detailsParam = locationParam?.childParamsGroups?.find((param) => param.name === 'details') expect(detailsParam).toBeDefined() expect(detailsParam?.type).toBe('object') - // Verify that oneOf handling worked correctly expect(detailsParam?.oneOfObject).toBe(true) expect(detailsParam?.childParamsGroups).toBeDefined() expect(detailsParam?.childParamsGroups?.length).toBeGreaterThan(1) - // Check that all expected oneOf variants are present const childParams = detailsParam?.childParamsGroups || [] const variantNames = childParams.map((param) => param.name) @@ -311,13 +304,11 @@ describe('webhook generation with oneOf fields', () => { expect(variantNames).toContain('pull_request_review') expect(variantNames).toContain('pull_request_review_comment') - // Verify specific variant details const commitVariant = childParams.find((param) => param.name === 'commit') expect(commitVariant).toBeDefined() expect(commitVariant?.description).toContain("commit' secret scanning location type") expect(commitVariant?.childParamsGroups?.length).toBeGreaterThan(0) - // Check commit variant has expected properties const commitProperties = commitVariant?.childParamsGroups?.map((param) => param.name) || [] expect(commitProperties).toContain('path') expect(commitProperties).toContain('start_line') @@ -334,13 +325,11 @@ describe('webhook generation with oneOf fields', () => { expect(issueUrlParam?.name).toBe('issue_title_url') expect(issueUrlParam?.description).toContain('API URL to get the associated issue resource') - // Verify that descriptions are properly rendered expect(commitVariant?.description).toContain('

    ') expect(issueTitleVariant?.description).toContain('

    ') }) test('should handle mixed oneOf types correctly', async () => { - // Test case where oneOf contains both objects and non-objects const mockMixedOneOfSchema = { summary: 'Test webhook with mixed oneOf types', description: 'A webhook for testing mixed oneOf handling', @@ -386,7 +375,6 @@ describe('webhook generation with oneOf fields', () => { const mixedParam = webhook.bodyParameters.find((param) => param.name === 'mixed_field') expect(mixedParam).toBeDefined() - // For mixed types, it should use the fallback behavior (not oneOfObject) expect(mixedParam?.oneOfObject).toBeFalsy() expect(mixedParam?.type).toContain('string') expect(mixedParam?.type).toContain('object') @@ -418,7 +406,6 @@ describe('webhook generation with oneOf fields', () => { const webhook = new Webhook(mockEmptyOneOfSchema) - // Should not throw an error await expect(webhook.process()).resolves.not.toThrow() const emptyParam = webhook.bodyParameters.find((param) => param.name === 'empty_oneof') diff --git a/src/workflows/sync-sdk-docs/convert-mermaid.ts b/src/workflows/sync-sdk-docs/convert-mermaid.ts index dbcd214ae81d..d171e66edb4d 100644 --- a/src/workflows/sync-sdk-docs/convert-mermaid.ts +++ b/src/workflows/sync-sdk-docs/convert-mermaid.ts @@ -1,9 +1,8 @@ #!/usr/bin/env node -// Renders each ```mermaid block in the SDK docs to a PNG with +// Renders each mermaid code block in the SDK docs to a PNG with // @mermaid-js/mermaid-cli (mmdc), saves it under the assets directory, and -// replaces the code block with an image reference. A block whose render fails -// is left as it is. +// replaces the source block with an image reference. Failed renders stay as source. // // Filenames come from the source file path and the block index, so re-running // produces stable results. @@ -36,7 +35,7 @@ if (!fs.existsSync(SDK_DOCS_DIR)) { process.exit(1) } -// Find the mmdc binary: global PATH first, then local node_modules. +// Prefer mmdc from PATH, then fall back to the repo dependency. let MMDC_BIN: string try { MMDC_BIN = execSync('which mmdc', { encoding: 'utf8' }).trim() @@ -50,7 +49,6 @@ try { } } -// Recursively collect all .md files. function getAllMarkdownFiles(dir: string): string[] { const results: string[] = [] for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { @@ -64,15 +62,13 @@ function getAllMarkdownFiles(dir: string): string[] { return results } -// Generates a filename from the source file's relative path and the block -// index, so it is stable across runs. +// Deterministic filenames come from the source path and mermaid block index. function generateImageName(filePath: string, blockIndex: number): string { const rel = path.relative(SDK_DOCS_DIR, filePath).replace(/\.md$/, '').replace(/\//g, '-') return `${rel}-diagram-${blockIndex}.png` } -// Builds generic alt text from the diagram type named on the first line. The -// contents of the diagram are not used. +// Generic alt text avoids inventing semantics from diagram source. function generateAltText(mermaidSource: string): string { const lines = mermaidSource.trim().split('\n') const firstLine = lines[0].trim() @@ -106,7 +102,6 @@ function generateAltText(mermaidSource: string): string { return 'Diagram illustrating the described process.' } -// Converts the mermaid blocks in one file and returns how many succeeded. function processFile(filePath: string, assetsUrlPath: string): number { const raw = fs.readFileSync(filePath, 'utf8') @@ -118,7 +113,7 @@ function processFile(filePath: string, assetsUrlPath: string): number { let converted = 0 let result = raw - // Process matches in reverse order to preserve string indices + // Process matches in reverse order to preserve string indices. for (let i = matches.length - 1; i >= 0; i--) { const match = matches[i] const mermaidSource = match[1] @@ -169,9 +164,8 @@ console.log('--- Converting Mermaid diagrams to PNG ---\n') fs.mkdirSync(ASSETS_DIR, { recursive: true }) -// Compute the URL path for image references -// The assets dir relative to the docs-internal root gives us the URL path -// e.g. assets/images/help/copilot/sdk-docs → /assets/images/help/copilot/sdk-docs +// Image references need the assets directory relative to the docs-internal root. +// Example: assets/images/help/copilot/sdk-docs becomes /assets/images/help/copilot/sdk-docs. const assetsUrlPath = `/${path.relative(REPO_ROOT, ASSETS_DIR)}` const files = getAllMarkdownFiles(SDK_DOCS_DIR) diff --git a/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts b/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts index d7cd5bf4b26c..d45efd1cf395 100644 --- a/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts +++ b/src/workflows/sync-sdk-docs/normalize-sdk-docs.ts @@ -1,11 +1,8 @@ #!/usr/bin/env node -// Normalizes Copilot SDK docs for publishing on docs.github.com. The steps are -// called at the bottom of this file, roughly but not exactly in numeric order: -// Step 0a runs before Step 0, and Step 1b after Step 1. Where the ordering -// matters, the step's own comment says why. -// -// Adapted from the spike normalization script in docs-internal#60525. +// Normalizes Copilot SDK docs for docs.github.com, including README landing +// pages, frontmatter, links, code fences, ordered lists, hidden validation +// samples, codetabs, and SDK-specific markdownlint suppressions. // // Usage: // npx tsx src/workflows/sync-sdk-docs/normalize-sdk-docs.ts --content-dir \ @@ -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 diff --git a/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts b/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts index c2fb5b0ad9f4..7e8f69ef5feb 100644 --- a/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts +++ b/src/workflows/tests/sync-sdk-docs-preserve-redirects.ts @@ -3,7 +3,7 @@ import os from 'node:os' import path from 'node:path' import { execFileSync } from 'node:child_process' -import { describe, expect, test, beforeAll, afterAll } from 'vitest' +import { describe, expect, test, vi, beforeAll, afterAll } from 'vitest' import { contentPathToUrl, @@ -258,6 +258,8 @@ describe('upsertRedirectBlock', () => { */ describe('preserve-redirects end to end', () => { let repo: string + // Each test spawns npx tsx, and a cold start on a busy CI runner can exceed the 5s default. + vi.setConfig({ testTimeout: 30 * 1000 }) const git = (...args: string[]) => execFileSync('git', args, { cwd: repo, encoding: 'utf8' }) diff --git a/src/workflows/unallowed-contribution-filters.yml b/src/workflows/unallowed-contribution-filters.yml index 9930c22b66c3..209f066a8d21 100644 --- a/src/workflows/unallowed-contribution-filters.yml +++ b/src/workflows/unallowed-contribution-filters.yml @@ -9,6 +9,7 @@ notAllowed: - 'src/**' - 'patches/**' - 'content/actions/how-tos/secure-your-work/security-harden-deployments/**' + - 'content/README.md' contentTypes: - 'content/**' # allows getting a list of just added files from the dorny/paths-filter action