and append the Copilot icon.
+// The prompt tag wraps content in code and appends Copilot links with responsive labels.
import octicons from '@primer/octicons'
import type { TagToken, TopLevelToken } from 'liquidjs'
@@ -32,9 +32,9 @@ export const Prompt: LiquidTag = {
const promptParam: string = encodeURIComponent(contentString)
const href: string = `https://github.com/copilot?prompt=${promptParam}`
- // Use murmur hash for deterministic ID (avoids hydration mismatch)
+ // Deterministic IDs prevent hydration mismatches.
const promptId: string = generatePromptId(contentString)
- // Show long text on larger screens and short text on smaller screens (set via accessibility.scss)
+ // accessibility.scss shows the long label on large screens and short label on small screens.
const promptLabelLong: string = 'Run this prompt in Copilot Chat'
const promptLabelShort: string = 'Run prompt'
return [
diff --git a/src/content-render/liquid/tool.ts b/src/content-render/liquid/tool.ts
index 922893032e37..47118cef29d7 100644
--- a/src/content-render/liquid/tool.ts
+++ b/src/content-render/liquid/tool.ts
@@ -3,53 +3,18 @@ import { allPlatforms } from '@/tools/lib/all-platforms'
export const tags: string[] = Object.keys(allTools).concat(allPlatforms).concat(['rowheaders'])
-// The trailing newline is important. Without it, the line immediately after
-// the `` will be considered part of the previous block, which means the Markdown following the `` will not be rendered to HTML correctly. For example:
-//
-// Here's some stuff
-// And *here* us also some stuff.
-//
-// Another **sentence** here.
-//
-// Will yield:
-//
-// Here's some stuff
-// And *here* us also some stuff.
-//
-// Another sentence here.
-//
-// when rendering this template with unified.
-// If you instead inject an extra newline after the ``, you
-// go from:
-//
-// Here's some stuff
-//
-// And *here* us also some stuff.
-//
-// Another **sentence** here.
-//
-// which yields:
-//
-// Here's some stuff
-//
-// And here us also some stuff.
-//
-// Another sentence here.
-//
-// The Tool Liquid tags are a little bit fragile because we hope and assume
-// that the author of the Liquid+Markdown *don't* do this:
-//
-// {% vscode %}Bla bla.{% endvscode %}Next stuff here...
-//
+// The trailing newline keeps Markdown after outside the HTML block so unified renders it.
+// Tool tags require content after the closing tag to start on a new line.
+// Example: \nText stays in the HTML block; \n\nText renders as Markdown.
const template = '{{ output }}\n'
export const Tool = {
type: 'block' as const,
tagName: '',
- // Liquid template objects don't have TypeScript definitions
+ // Liquid does not publish TypeScript definitions for template objects.
templates: [] as unknown[],
- // tagToken and remainTokens are Liquid internal types without TypeScript definitions
+ // Liquid internal types do not cover tagToken or remainTokens.
parse(tagToken: unknown, remainTokens: unknown) {
const token = tagToken as { name: string; getText: () => string }
this.tagName = token.name
@@ -58,7 +23,6 @@ export const Tool = {
const stream = this.liquid.parser.parseStream(remainTokens)
stream
.on(`tag:end${this.tagName}`, () => stream.stop())
- // tpl is a Liquid template object without TypeScript definitions
.on('template', (tpl: unknown) => this.templates.push(tpl))
.on('end', () => {
throw new Error(`tag ${token.getText()} not closed`)
@@ -66,7 +30,7 @@ export const Tool = {
stream.start()
},
- // scope is a Liquid scope object, Generator yields/returns Liquid template values - no TypeScript definitions available
+ // Liquid does not type scope or generator template values.
*render(scope: unknown): Generator {
const output = yield this.liquid.renderer.renderTemplates(this.templates, scope)
return yield this.liquid.parseAndRender(template, {
diff --git a/src/content-render/scripts/add-content-type.ts b/src/content-render/scripts/add-content-type.ts
index f6286ea7b4a2..0f5925e55768 100644
--- a/src/content-render/scripts/add-content-type.ts
+++ b/src/content-render/scripts/add-content-type.ts
@@ -1,7 +1,5 @@
-/**
- * @purpose Writer tool
- * @description Auto-populate the `contentType` frontmatter property based on the directory location of the content file
- */
+// @purpose Writer tool
+// @description Auto-populate the `contentType` frontmatter property based on the directory location of the content file
import fs from 'fs'
import path from 'path'
@@ -54,8 +52,7 @@ async function main() {
if (file.includes('early-access')) return false
if (!options.paths) return true
return options.paths.some((p: string) => {
- // Allow either a full content path like "content/foo/bar.md"
- // or a top-level directory name like "copilot"
+ // Accept full content paths like content/foo/bar.md or top-level dirs like copilot.
if (!p.startsWith('content')) {
p = path.join('content', p)
}
@@ -130,7 +127,7 @@ function processFile(filePath: string, scriptOptions: ScriptOptions) {
frontmatter.stringify(
content,
data,
- // lineWidth is a js-yaml option passed through gray-matter, not in gray-matter's type definitions
+ // gray-matter passes lineWidth to js-yaml, but its types omit it.
{ lineWidth: -1 } as unknown as Parameters[2],
),
)
@@ -144,38 +141,31 @@ function processFile(filePath: string, scriptOptions: ScriptOptions) {
}
function determineContentType(relativePath: string): string {
- // The split path array will be structured like:
- // [ 'copilot', 'how-tos', 'troubleshoot', 'index.md' ]
- // where the content type we want is in slot 1.
+ // For copilot/how-tos/troubleshoot/index.md, pathSegments[1] is the content type.
const pathSegments = relativePath.split(path.sep)
const topLevelDirectory = pathSegments[0]
const derivedContentType = pathSegments[1]
- // There is only one content/index.md, and it's the homepage.
+ // content/index.md is the only homepage.
if (topLevelDirectory === 'index.md') return 'homepage'
- // SPECIAL HANDLING FOR RAI
- // If a directory name includes a responsible-use string, assume the 'rai' type.
+ // Responsible-use directories map to the rai content type.
if (derivedContentType.includes(RESPONSIBLE_USE_STRING)) {
return RAI_TYPE
}
- // Allow 'getting-started' as an alternative directory name for 'get-started'.
+ // getting-started directories map to get-started.
if (derivedContentType === 'getting-started') {
return 'get-started'
}
- // When the content directory matches any of the allowed
- // content type values (such as 'get-started',
- // 'concepts', 'how-tos', 'reference', and 'tutorials'),
- // immediately return it. We're satisfied.
+ // Directories matching contentTypesEnum map to their content type.
if (contentTypesEnum.includes(derivedContentType)) {
return derivedContentType
}
- // There is only one content//index.md file per doc set.
- // This index.md is always a landing page.
+ // Product index.md files are landing pages.
if (derivedContentType === 'index.md') {
return LANDING_TYPE
}
diff --git a/src/content-render/scripts/all-documents/cli.ts b/src/content-render/scripts/all-documents/cli.ts
index 3e4893ef9793..5b304222c4f2 100644
--- a/src/content-render/scripts/all-documents/cli.ts
+++ b/src/content-render/scripts/all-documents/cli.ts
@@ -1,43 +1,14 @@
-/**
- * You specify one or more languages and versions, and this script
- * will output a JSON file with the metadata needed.
- * You run it with:
- *
- * npm run all-documents -- -o /tmp/all-documents.json
- *
- * By default, it will do free-pro-team, enterprise-cloud, and whatever
- * the latest enterprise-server is. You can specify versions with: --version
- * For example:
- *
- * npm run all-documents -- -v free-pro-team@latest -v ghes-3.12
- *
- * By default it will include all languages, but you can specify
- * with --language
- *
- * npm run all-documents -- -l en -l de
- *
- * For debugging purposes, because there are so *many* documents you can
- * apply a filter by URL matching, for example:
- *
- * npm run all-documents -- -f get-started/using-github
- *
- * This will only include documents whose URL contains the string
- * 'get-started/using-github'.
- *
- * If you don't specify an output file (the --output flag or -o for short),
- * it will print all the JSON to stdout.
- *
- * By default the fields set to include are: title, shortTitle, intro, url.
- * You can instead specify the fields you only want. For example
- *
- * npm run all-documents -- --field url --field title
- *
- * Now the JSON will look like this:
- *
- * ...
- * {"title": "Some title", "url": "/some-url"}
- * ...
- */
+// Generates JSON metadata for documents.
+// Run npm run all-documents -- -o /tmp/all-documents.json.
+// Defaults to all languages, free-pro-team, enterprise-cloud, latest enterprise-server,
+// fields title, shortTitle, intro, and url, and output file all-documents.json.
+// Use --version for versions such as free-pro-team@latest and ghes-3.12.
+// Use --language for languages such as en and de.
+// Use --filter to include only documents whose URL contains the given string.
+// Use --field to choose output fields, such as url and title.
+// Filter example: npm run all-documents -- -f get-started/using-github.
+// Field example: npm run all-documents -- --field url --field title.
+// Example field output: {"title":"Some title","url":"/some-url"}.
import { writeFileSync, statSync } from 'fs'
@@ -47,7 +18,7 @@ import { languageKeys } from '@/languages/lib/languages-server'
import { allVersions } from '@/versions/lib/all-versions'
import { allDocuments, POSSIBLE_FIELDS, type AllDocument } from './lib'
-// E.g. enteprise-server@3.12, free-pro-team@latest, etc
+// Version flags accept enterprise-server@3.12 and free-pro-team@latest.
const fullVersions = Object.keys(allVersions)
const defaultVersions: string[] = []
const shortAlias = new Map()
diff --git a/src/content-render/scripts/cta-builder.ts b/src/content-render/scripts/cta-builder.ts
index 96ca90b65f00..3cd26ab9597b 100644
--- a/src/content-render/scripts/cta-builder.ts
+++ b/src/content-render/scripts/cta-builder.ts
@@ -1,7 +1,5 @@
-/**
- * @purpose Writer tool
- * @description Create a properly formatted Call-to-Action URL with tracking parameters
- */
+// @purpose Writer tool
+// @description Create a properly formatted Call-to-Action URL with tracking parameters
import { Command } from 'commander'
import readline from 'readline'
import chalk from 'chalk'
@@ -92,7 +90,7 @@ program.action(() => {
interactiveBuilder()
})
-// Only run CLI when script is executed directly, not when imported
+// Avoid parsing CLI arguments when tests import this module.
if (import.meta.url === `file://${process.argv[1]}`) {
program.parse()
}
@@ -106,7 +104,7 @@ async function selectFromOptions(
console.log(chalk.yellow(`\n${message} (${paramName}):`))
for (let index = 0; index < options.length; index++) {
const option = options[index]
- const letter = String.fromCharCode(97 + index) // 97 is 'a' in ASCII
+ const letter = String.fromCharCode(97 + index) // 97 is the ASCII code for a.
console.log(chalk.white(` ${letter}. ${option}`))
}
@@ -115,7 +113,7 @@ async function selectFromOptions(
const answer = await promptFn('Enter the letter of your choice: ')
if (!answer) continue
- const letterIndex = answer.toLowerCase().charCodeAt(0) - 97 // Convert letter to index
+ const letterIndex = answer.toLowerCase().charCodeAt(0) - 97
if (letterIndex >= 0 && letterIndex < options.length && answer.length === 1) {
return options[letterIndex]
@@ -124,7 +122,7 @@ async function selectFromOptions(
const validLetters = options.map((_, index) => String.fromCharCode(97 + index)).join(', ')
console.log(chalk.red(`Invalid choice. Please enter one of: ${validLetters}`))
- // Safety: prevent infinite loops in automated scenarios
+ // Cap invalid answers for automated runs; empty answers reprompt without counting.
if (++attempts > 50) {
throw new Error('Too many invalid attempts. Please restart the tool.')
}
@@ -145,7 +143,7 @@ async function confirmChoice(
if (lower === 'n' || lower === 'no') return false
console.log(chalk.red('Please enter y or n'))
- // Safety: prevent infinite loops in automated scenarios
+ // Cap invalid answers for automated runs; empty answers reprompt without counting.
if (++attempts > 50) {
throw new Error('Too many invalid attempts. Please restart the tool.')
}
@@ -176,7 +174,6 @@ interface AjvError {
params: AjvErrorParams
}
-// Process AJV validation errors into readable messages
function formatValidationErrors(ctaParams: CTAParams, errors: AjvError[]): string[] {
const errorMessages: string[] = []
for (const error of errors) {
@@ -198,7 +195,6 @@ function formatValidationErrors(ctaParams: CTAParams, errors: AjvError[]): strin
return errorMessages
}
-// Full validation using AJV schema (consistent across all commands)
function validateCTAParams(params: CTAParams): { isValid: boolean; errors: string[] } {
const isValid = validateCTASchema(params)
const ajvErrors = validateCTASchema.errors || []
@@ -234,7 +230,7 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin
const newParams: CTAParams = {}
- // Preserve any new-style params that are already on the URL.
+ // Keep CTA params that already pass the schema.
for (const [key, value] of url.searchParams.entries()) {
for (const param of Object.keys(ctaSchema.properties)) {
if (key === param && key in ctaSchema.properties) {
@@ -277,7 +273,7 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin
}
}
- // Build new URL - preserve all existing parameters except old ref_ parameters
+ // Keep existing query parameters except ref_cta, ref_loc, and ref_page.
const newUrl = new URL(url.toString())
newUrl.searchParams.delete('ref_cta')
@@ -290,15 +286,12 @@ export function convertOldCTAUrl(oldUrl: string): { newUrl: string; notes: strin
}
}
- // The URL constructor may add a slash before the question mark in
- // "github.com?foo", but we don't want that. First, check if original
- // URL had trailing slash before query params.
+ // URL serializes github.com?foo as github.com/?foo; preserve the original slash shape.
const urlBeforeQuery = oldUrl.split('?')[0]
const hadTrailingSlash = urlBeforeQuery.endsWith('/')
let finalUrl = newUrl.toString()
- // Remove unwanted trailing slash if original didn't have one.
if (!hadTrailingSlash && finalUrl.includes('/?')) {
finalUrl = finalUrl.replace('/?', '?')
}
@@ -321,19 +314,19 @@ function inferProductFromUrl(url: string, refCta: string): string {
try {
hostname = new URL(url).hostname.toLowerCase()
} catch {
- // Fallback if url isn't valid: leave hostname empty
+ // Invalid URLs fall back to ref_cta or the default product.
}
if (hostname === 'desktop.github.com' || refCta.includes('desktop')) {
return 'desktop'
}
- // Hostname contains 'copilot' (e.g., copilot.github.com), or refCta mentions copilot
+ // GitHub subdomains containing copilot and ref_cta values containing copilot map to copilot.
if (
(hostname.includes('copilot') && hostname.endsWith('.github.com')) ||
refCta.toLowerCase().includes('copilot')
) {
return 'copilot'
}
- // Hostname contains 'enterprise' (e.g. enterprise.github.com), or refCta mentions GHEC
+ // GitHub subdomains containing enterprise and ref_cta values containing GHEC map to ghec.
if (
(hostname.includes('enterprise') && hostname.endsWith('.github.com')) ||
refCta.includes('GHEC')
@@ -344,8 +337,7 @@ function inferProductFromUrl(url: string, refCta: string): string {
}
function inferStyleFromContext(refLoc: string): string {
- // If location suggests it's in a button context, return button
- // Otherwise default to text for inline links
+ // Button-like ref_loc values map to button; everything else defaults to text.
const isButton = buttonKeywords.some((keyword) => refLoc.toLowerCase().includes(keyword))
return isButton ? 'button' : 'text'
}
@@ -393,7 +385,6 @@ async function interactiveBuilder(): Promise {
)
}
- // Optional parameters (properties not in required array)
console.log(chalk.white(`\nOptional parameters:\n`))
const allProperties = Object.keys(ctaSchema.properties)
@@ -458,7 +449,6 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise<
const result = convertOldCTAUrl(options.url)
if (options.quiet) {
- // In quiet mode, only output the new URL
console.log(result.newUrl)
return
}
@@ -469,7 +459,6 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise<
console.log(chalk.white('\nNew URL:'))
console.log(chalk.cyan(result.newUrl))
- // Validate the converted URL using shared validation function
try {
const newParams = extractCTAParams(result.newUrl)
const validation = validateCTAParams(newParams)
@@ -507,7 +496,7 @@ async function convertUrls(options: { url?: string; quiet?: boolean }): Promise<
}
}
- // The convert command doesn't use readline, so script should exit naturally
+ // The convert command opens no readline handle, so Node exits after logging.
}
async function validateUrl(options: { url?: string }): Promise {
@@ -531,7 +520,6 @@ async function validateUrl(options: { url?: string }): Promise {
return
}
- // Validate against schema using shared validation function
const validation = validateCTAParams(ctaParams)
if (validation.isValid) {
@@ -595,7 +583,6 @@ async function buildProgrammaticCTA(options: {
const validation = validateCTAParams(params)
if (!validation.isValid) {
- // Output validation errors to stderr and exit with error code
for (const error of validation.errors) {
console.error(`Validation error: ${error}`)
}
diff --git a/src/content-render/scripts/liquid-tags.ts b/src/content-render/scripts/liquid-tags.ts
index e24fdf6cfc73..5f9aaac84914 100644
--- a/src/content-render/scripts/liquid-tags.ts
+++ b/src/content-render/scripts/liquid-tags.ts
@@ -1,7 +1,5 @@
-/*
- * @purpose Writer tool
- * @description Expand and restore Liquid data references in content files
- */
+// @purpose Writer tool
+// @description Expand and restore Liquid data references in content files
// Usage: npm run liquid-tags -- expand --paths content/pull-requests/about.md
// Usage: npm run liquid-tags -- restore --paths content/pull-requests/about.md
@@ -38,23 +36,20 @@ function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
-// Regex pattern to match expanded content blocks
const EXPANDED_PATTERN = /(.+?)/gs
-// Validates and normalizes the incoming dataPath to prevent path traversal
-// and ensure the final resolved path remains within the expected root.
+// Reject absolute, traversal, empty, and unsafe data paths before resolving under data root.
function getDataFilePath(type: 'reusable' | 'variable', dataPath: string): string {
if (path.isAbsolute(dataPath)) {
throw new Error(`Invalid ${type} data path: absolute paths are not allowed: ${dataPath}`)
}
- // Disallow path traversal and empty segments
const segments = dataPath.split(/[\\/]/)
if (segments.some((segment) => segment === '..' || segment === '')) {
throw new Error(`Invalid ${type} data path: contains disallowed segments: ${dataPath}`)
}
- // Restrict allowed characters to a conservative safe set
+ // Restrict data paths to filename characters used by reusables and variables.
if (!/^[A-Za-z0-9_.\-/]+$/.test(dataPath)) {
throw new Error(`Invalid ${type} data path: contains disallowed characters: ${dataPath}`)
}
@@ -147,11 +142,11 @@ function getAllowedTypes(options: ExpandOptions): Array<'reusable' | 'variable'>
async function expandReferences(options: ExpandOptions): Promise {
const { paths, verbose, markers, shallow } = options
- // markers will be true by default, false when --no-markers is used
+ // --no-markers sets markers to false; missing flag leaves it true.
const withMarkers = markers !== false
- const recursive = !shallow // Recursive by default unless --shallow is specified
+ const recursive = !shallow // Omitting --shallow enables recursive expansion.
const allowedTypes = getAllowedTypes(options)
- const maxIterations = 10 // Safety limit for recursive expansion
+ const maxIterations = 10 // Stop recursive expansion after 10 passes to avoid circular references.
if (paths.length === 0) {
console.error(chalk.red('Error: No paths provided. Use --paths option.'))
@@ -204,7 +199,6 @@ async function expandReferences(options: ExpandOptions): Promise {
hasRemainingRefs = remainingRefs.length > 0
if (shallow) {
- // Shallow mode: show remaining references and break
if (hasRemainingRefs) {
console.log(
chalk.yellow(
@@ -296,10 +290,10 @@ async function restoreReferences(options: ExpandOptions): Promise {
console.log(chalk.dim(' Use --verbose to see details of the edits'))
}
- // Update data files with the edited content before restoring
+ // Write edited expanded blocks back to data files before restoring Liquid tags.
const updatedDataFiles = updateDataFiles(filePath, verbose, false, allowedTypes)
- // Automatically restore any updated data files back to liquid tags
+ // Restore updated data files so nested references return to Liquid tags too.
if (updatedDataFiles.length > 0) {
if (verbose)
console.log(chalk.blue(' Restoring updated data files back to liquid tags...'))
@@ -324,7 +318,7 @@ async function restoreReferences(options: ExpandOptions): Promise {
}
}
- // Always restore the main file content regardless of edits
+ // Restore the main file even when no data file changed.
const restoredContent = restoreFileContent(content, verbose, allowedTypes)
if (restoredContent !== content) {
@@ -414,12 +408,10 @@ async function detectContentEdits(
if (!allowedTypes || allowedTypes.includes(refType)) {
try {
- // Load the original content from data files
const originalContent = loadDataValue(refType, dataPath.trim())
if (originalContent !== null) {
- // Compare against the original content directly, not re-resolved
- // This avoids nested resolution issues that cause false positives
+ // Compare direct data file content to avoid false positives from nested resolution.
const currentContent = resolvedContent.trim()
if (currentContent !== originalContent.trim()) {
@@ -458,7 +450,7 @@ function loadDataValue(type: 'reusable' | 'variable', dataPath: string): string
if (type === 'reusable') {
const content = fs.readFileSync(targetPath, 'utf8')
- // Remove any frontmatter if present (same as resolveReusable)
+ // Strip reusable frontmatter before comparing content, matching resolveReusable.
const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '')
return contentWithoutFrontmatter.trim()
} else {
@@ -478,7 +470,7 @@ function loadDataValue(type: 'reusable' | 'variable', dataPath: string): string
return typeof current === 'string' ? current.trim() : String(current).trim()
}
} catch {
- // Silently return null for any errors
+ // Unreadable data returns null so callers can treat it as unverifiable.
}
return null
}
@@ -561,7 +553,7 @@ function extractDataUpdates(
const refType = type as 'reusable' | 'variable'
if (!allowedTypes || allowedTypes.includes(refType)) {
- // Check if this content was actually changed before including it
+ // Compare expanded blocks with their source before updating data files.
try {
const originalContent = loadDataValue(refType, dataPath.trim())
if (originalContent !== null && resolvedContent.trim() !== originalContent.trim()) {
@@ -572,7 +564,7 @@ function extractDataUpdates(
})
}
} catch {
- // If we can't verify, assume it was changed to be safe
+ // Keep blocks on unexpected errors; unreadable files return null from loadDataValue.
updates.push({
type: refType,
path: dataPath.trim(),
@@ -619,19 +611,18 @@ function applyDataUpdates(
} else {
console.log(chalk.green(` Updated: ${targetPath}`))
}
- return targetPath // Return path even in dry run
+ return targetPath // Dry runs return the target path so callers can report it.
}
try {
if (type === 'reusable') {
- // For reusables, replace entire file content
if (contents.length > 1) {
console.log(
chalk.yellow(` Warning: Multiple content blocks found for ${dataPath}, using first one`),
)
}
- // Preserve original file's newline behavior
+ // Preserve a trailing newline from the original reusable file.
const originalContent = fs.readFileSync(targetPath, 'utf8')
const hasTrailingNewline = originalContent.endsWith('\n')
const newContent =
@@ -642,12 +633,11 @@ function applyDataUpdates(
console.log(chalk.green(` Updated: ${type}s.${dataPath}`))
}
} else {
- // For variables, update YAML structure
const yamlContent = fs.readFileSync(targetPath, 'utf8')
const data = load(yamlContent) as Record
const pathParts = dataPath.split('.')
- const propertyPath = pathParts.slice(1) // Skip the file name
+ const propertyPath = pathParts.slice(1)
let current: Record = data
for (let i = 0; i < propertyPath.length - 1; i++) {
@@ -665,7 +655,7 @@ function applyDataUpdates(
}
current[finalKey] = contents[0]
- // Preserve original file's newline behavior for YAML
+ // Preserve a trailing newline from the original YAML file.
const hasTrailingNewline = yamlContent.endsWith('\n')
const yamlOutput = dump(data)
const finalYaml =
@@ -692,13 +682,13 @@ function findLiquidReferences(
const references: LiquidReference[] = []
const types = allowedTypes || ['reusable', 'variable']
- // Pattern to match {% data reusables.path %} and {% data variables.path %}
+ // Match data references for reusables and variables.
const liquidPattern = /{%\s*data\s+(reusables|variables)\.([^%]+)\s*%}/g
let match
while ((match = liquidPattern.exec(content)) !== null) {
const [original, type, dataPath] = match
- const refType = type.slice(0, -1) as 'reusable' | 'variable' // Remove 's' from end
+ const refType = type.slice(0, -1) as 'reusable' | 'variable'
if (types.includes(refType)) {
references.push({
@@ -745,7 +735,7 @@ async function resolveReusable(reusablePath: string, verbose?: boolean): Promise
try {
const content = fs.readFileSync(filePath, 'utf-8')
- // Remove any frontmatter if present
+ // Strip reusable frontmatter before inserting its body.
const contentWithoutFrontmatter = content.replace(/^---[\s\S]*?---\s*/, '')
return contentWithoutFrontmatter.trim()
} catch (error: unknown) {
@@ -781,8 +771,8 @@ async function resolveVariable(variablePath: string, verbose?: boolean): Promise
const yamlContent = fs.readFileSync(filePath, 'utf-8')
const data = load(yamlContent) as Record
- // Navigate through the key path to find the value
- const [, ...keyPath] = pathParts // Skip filename, get remaining path
+ // Variable paths start with the file name; remaining segments address YAML keys.
+ const [, ...keyPath] = pathParts
let value: unknown = data
for (const key of keyPath) {
if (value && typeof value === 'object' && key in value) {
diff --git a/src/content-render/scripts/move-by-content-type.ts b/src/content-render/scripts/move-by-content-type.ts
index e7773d92d881..1b4d395e5078 100644
--- a/src/content-render/scripts/move-by-content-type.ts
+++ b/src/content-render/scripts/move-by-content-type.ts
@@ -1,7 +1,5 @@
-/**
- * @purpose Writer tool
- * @description Move files to the relevant directory based on `contentType` frontmatter
- */
+// @purpose Writer tool
+// @description Move files to the relevant directory based on `contentType` frontmatter
import { program } from 'commander'
import fs from 'fs/promises'
@@ -16,8 +14,7 @@ const CONTENT_TYPES = contentTypesEnum.filter(
(type) => type !== 'homepage' && type !== 'other' && type !== 'landing',
)
-// The number of path segments at the product level (e.g., "content//...").
-// Used when determining whether a target directory is a deeper subdirectory.
+// Three segments identify content//index.md and top-level content-type directories.
const PRODUCT_LEVEL_PATH_SEGMENTS = 3
const contentTypeToDir = (contentType: string): string => {
@@ -31,10 +28,10 @@ function shouldSkipIndexFile(filePath: string): boolean {
const parts = relativePath.split(path.sep)
const contentIndex = parts.indexOf('content')
- // Skip product-level index.md: content/product/index.md
+ // Keep product-level index.md files in place.
if (parts.length === contentIndex + PRODUCT_LEVEL_PATH_SEGMENTS) return true
- // Skip content-type-level index.md that's already in place: content/product/content-type/index.md
+ // Keep content-type index.md files that already sit at content/product/content-type/index.md.
if (parts.length === contentIndex + 4) {
const parentDir = parts[parts.length - 2]
if (validContentTypeDirs.has(parentDir)) return true
@@ -52,18 +49,16 @@ function calculateTarget(filePath: string, contentType: string, productDir: stri
const targetContentType = contentTypeToDir(contentType)
if (targetContentType === 'how-tos') {
- // Preserve subdirectory structure for how-tos
+ // How-to pages keep their product subdirectory structure.
const pathAfterProduct = parts.slice(contentIndex + 2, -1)
if (pathAfterProduct[0] === 'how-tos') {
- // Already in how-tos, no change
return { targetDir: path.dirname(filePath), targetPath: filePath }
} else {
- // Move to how-tos preserving structure
const targetDir = path.join(productDir, targetContentType, ...pathAfterProduct)
return { targetDir, targetPath: path.join(targetDir, fileName) }
}
} else {
- // Flatten to content-type directory
+ // Other content types flatten into their content-type directory.
const targetDir = path.join(productDir, targetContentType)
return { targetDir, targetPath: path.join(targetDir, fileName) }
}
@@ -81,7 +76,6 @@ program
.description('Reorganize content files into subdirectories based on their contentType property')
.argument('[paths...]', 'Content paths to process')
.action(async (paths: string[]) => {
- // Gather files.
const filesToProcess: string[] = []
if (paths?.length > 0) {
for (const p of paths) {
@@ -102,8 +96,8 @@ program
const filesToMove: FileMove[] = []
const skipped: Array<{ file: string; reason: string }> = []
- const targetDirs = new Set() // Relative paths of all target directories
- const subdirTargets = new Set() // Subdirectories receiving index.md files
+ const targetDirs = new Set()
+ const subdirTargets = new Set()
const productDirs = new Set()
const productsWithRai = new Set()
@@ -111,7 +105,6 @@ program
const relativePath = path.relative(process.cwd(), filePath)
try {
- // Skip certain index.md files
if (path.basename(filePath) === 'index.md' && shouldSkipIndexFile(filePath)) {
continue
}
@@ -129,7 +122,7 @@ program
const parts = relativePath.split(path.sep)
const contentIndex = parts.indexOf('content')
- // Skip all landing pages - they should only be product-level index.md and don't move
+ // Landing pages belong at product-level index.md files; this script does not move them.
if (contentType === 'landing') {
console.log(chalk.gray(`→ Skipping ${relativePath}: landing pages don't move`))
continue
@@ -166,7 +159,7 @@ program
console.log(chalk.yellow(`⚠ Skipping ${relativePath}: Target file already exists`))
continue
} catch {
- // Good, doesn't exist
+ // Missing target means the move can proceed.
}
filesToMove.push({ filePath, targetDir, targetPath, contentType })
@@ -174,7 +167,6 @@ program
const relativeTargetDir = path.relative(process.cwd(), targetDir)
targetDirs.add(relativeTargetDir)
- // Track subdirectories that will receive index.md files
if (
path.basename(filePath) === 'index.md' &&
relativeTargetDir.split(path.sep).length > PRODUCT_LEVEL_PATH_SEGMENTS
@@ -195,7 +187,6 @@ program
console.log(chalk.white('Ensuring standard content-type directories exist...\n'))
- // Add standard content-type directories for each affected product
if (paths?.length > 0) {
for (const p of paths) {
const fullPath = path.resolve(process.cwd(), p)
@@ -237,10 +228,10 @@ program
await fs.access(indexPath)
console.log(chalk.gray(`- Skipping ${dirPath}/index.md (already exists)`))
} catch {
- // Only create placeholders for top-level content-type directories (not subdirectories)
+ // Create placeholders only for top-level content-type directories.
if (dirPath.split(path.sep).length > PRODUCT_LEVEL_PATH_SEGMENTS) continue
- // Skip if an index.md will be moved here
+ // Moved index.md files become the placeholder for their target directory.
if (subdirTargets.has(dirPath)) {
console.log(chalk.gray(`- Skipping ${dirPath}/index.md (will be moved)`))
continue
@@ -249,8 +240,6 @@ program
const contentTypeName = path.basename(dirPath)
const title = titleMap[contentTypeName] || contentTypeName
- // Determine the correct contentType for this placeholder
- // Map directory name back to contentType enum value
const placeholderContentType =
contentTypeName === 'responsible-use' ? 'rai' : contentTypeName
@@ -316,7 +305,7 @@ contentType: ${placeholderContentType}
const moved: Array<{ file: string; from: string; to: string }> = []
- // Categorize files by type for correct move order
+ // Move regular files and index.md files in separate groups to avoid path conflicts.
const regularFiles = filesToMove.filter((f) => path.basename(f.filePath) !== 'index.md')
const topLevelIndexFiles = filesToMove.filter((f) => {
if (path.basename(f.filePath) !== 'index.md') return false
@@ -333,7 +322,7 @@ contentType: ${placeholderContentType}
)
})
- // Move subdirectory index files first (copy only, delete later)
+ // Copy subdirectory index.md files first; delete sources after regular files move.
const indexFilesToDeleteLater: string[] = []
for (const file of subdirIndexFiles) {
try {
@@ -341,7 +330,7 @@ contentType: ${placeholderContentType}
const content = await fs.readFile(file.filePath, 'utf-8')
const { data, content: body } = readFrontmatter(content)
- // Clear children array because paths will be invalid in the new content-type directory structure
+ // Clear children because the new content-type directory structure invalidates child paths.
if (data?.children) data.children = []
await fs.writeFile(
@@ -526,7 +515,7 @@ contentType: ${placeholderContentType}
if (!data) continue
- // For how-tos, build children from subdirectories
+ // how-tos children point to subdirectories.
if (path.basename(dirPath) === 'how-tos') {
const entries = await fs.readdir(absoluteDirPath, { withFileTypes: true })
const subdirs = entries
@@ -544,7 +533,7 @@ contentType: ${placeholderContentType}
)
}
}
- // For others, sort with about-* first
+ // Other content types sort about-* pages first.
else if (data.children && Array.isArray(data.children) && data.children.length > 0) {
const sorted = [...data.children].sort((a, b) => {
const aBasename = path.basename(a)
diff --git a/src/content-render/scripts/move-content.ts b/src/content-render/scripts/move-content.ts
index d0f02a9e0f14..7c4b35603053 100755
--- a/src/content-render/scripts/move-content.ts
+++ b/src/content-render/scripts/move-content.ts
@@ -1,25 +1,13 @@
-/**
- * @purpose Writer tool
- * @description Move or rename a file or a folder and automatically add redirects
- */
-// [start-readme]
-//
-// Use this script to help you move or rename a single file or a folder. The script will move or rename the file or folder for you, update relevant `children` in the index.md file(s), and add a `redirect_from` to frontmatter in the renamed file(s). Note: You will still need to manually update the `title` if necessary.
-//
-// By default, the `move-content.ts` script will commit the changes it makes. If you don't want the script to run any git commands for you, run it with the `--no-git` flag. Note: In most cases it will be easier and safer to let the script run the git commands for you, since git can get confused when a file is both renamed and edited.
-//
-// To learn more about the script, you can run `npm run move-content --help`.
-//
-// To run the script for a file:
-// - `npm run move-content PATH/TO/CURRENT-FILE.md PATH/TO/DESIRED-FILE-LOCATION-OR-NAME.md`
-//
-// To run the script for a folder:
-// - `npm run move-content PATH/TO/CURRENT-FOLDER PATH/TO/DESIRED-FOLDER-LOCATION-OR-NAME`
-//
-// To undo the script, run the same command that you used to run the script, but add an `--undo` flag:
-// - `npm run move-content --undo PATH/TO/OLD PATH/TO/NEW`
-//
-// [end-readme]
+// @purpose Writer tool
+// @description Move or rename a file or a folder and automatically add redirects
+// Moves one file or folder, updates relevant children entries, and adds redirect_from.
+// It does not update title frontmatter.
+// By default, it runs git mv and git commit; pass --no-git to avoid git commands.
+// Keeping git enabled records rename and edit commits separately.
+// Run npm run move-content --help for options.
+// Run file: npm run move-content PATH/TO/CURRENT-FILE.md PATH/TO/DESIRED-FILE-LOCATION-OR-NAME.md.
+// Run folder: npm run move-content PATH/TO/CURRENT-FOLDER PATH/TO/DESIRED-FOLDER-LOCATION-OR-NAME.
+// Undo: npm run move-content --undo PATH/TO/OLD PATH/TO/NEW.
import fs from 'fs'
import path from 'path'
@@ -45,7 +33,7 @@ interface PositionInfo {
childGroupPositions: number[][]
}
-// This is so you can optionally run it again the test fixtures root.
+// ROOT lets tests run against a fixture content root.
const ROOT = process.env.ROOT || '.'
const CONTENT_ROOT = path.resolve(path.join(ROOT, 'content'))
@@ -99,7 +87,6 @@ async function main(opts: MoveOptions, nameTuple: string[]) {
newPath = new_
}
- // The file you're about to move needs to exist
if (!fs.existsSync(oldPath)) {
console.error(chalk.red(`${oldPath} does not exist.`))
process.exit(1)
@@ -107,20 +94,11 @@ async function main(opts: MoveOptions, nameTuple: string[]) {
let isFolder = fs.lstatSync(oldPath).isDirectory()
- // Before validating, see if we need to fake that the newPath should be.
- // This is to mimic how bash `mv` works where you can do:
- //
- // mv some/place/a/file.txt destin/ation/
- //
- // which is implied to mean the same as;
- //
- // mv some/place/a/file.txt destin/ation/file.txt
- //
+ // Emulate mv: moving path/file.md to an existing path/dir resolves to path/dir/file.md.
if (undo) {
if (isFolder) {
const wouldBe = path.join(oldPath, path.basename(newPath))
- // We can't know if the `newPath` is a directory or file because
- // whichever it is, it doesn't exist.
+ // For undo, infer a file move from the old folder plus the new file basename.
if (fs.existsSync(wouldBe) && !fs.lstatSync(wouldBe).isDirectory()) {
isFolder = false
oldPath = wouldBe
@@ -142,22 +120,19 @@ async function main(opts: MoveOptions, nameTuple: string[]) {
process.exit(2)
}
- // This will exit non-zero if anything is wrong with these inputs
validateFileInputs(oldPath, newPath, isFolder)
const oldHref = makeHref(CONTENT_ROOT, undo ? newPath : oldPath)
const newHref = makeHref(CONTENT_ROOT, undo ? oldPath : newPath)
if (isFolder) {
- // The folder must have an index.md file
+ // Folders can move only when they have an index.md landing file.
const indexFilePath = path.join(oldPath, 'index.md')
if (!fs.existsSync(indexFilePath)) {
throw new Error(`${oldPath} does not have an index.md file`)
}
- // Gather individual files by walking `oldPath` recursively.
const files = findFilesInFolder(oldPath, newPath, opts)
- // First take care of the `git mv` (or regular rename) part.
if (undo) {
undoFolder(oldPath, newPath, files, opts)
} else {
@@ -172,10 +147,8 @@ async function main(opts: MoveOptions, nameTuple: string[]) {
editFiles(files, false, opts)
}
} else {
- // When it's just an individual file, it's easier.
const files: FileTuple[] = [[oldPath, newPath, oldHref, newHref]]
- // First take care of the `git mv` (or regular rename) part.
moveFiles(files, opts)
if (undo) {
@@ -185,11 +158,9 @@ async function main(opts: MoveOptions, nameTuple: string[]) {
}
}
- // Updating featuredLinks front matter actually doesn't care if
- // the file is a folder or not. It just needs to know the old and new hrefs.
+ // featuredLinks updates need old and new hrefs, not whether the path is a file or folder.
changeFeaturedLinks(oldHref, newHref)
- // Update any links in ChildGroups on the homepage.
changeHomepageLinks(oldHref, newHref, verbose)
if (!undo) {
@@ -205,8 +176,7 @@ async function main(opts: MoveOptions, nameTuple: string[]) {
function validateFileInputs(oldPath: string, newPath: string, isFolder: boolean) {
if (isFolder) {
- // Make sure that only the last portion of the path is different
- // and that all preceding are equal.
+ // Directory moves can change only the last path segment unless the destination base exists.
const [oldBase, oldName] = splitDirectory(oldPath)
const [newBase] = splitDirectory(newPath)
if (oldBase !== newBase && !existsAndIsDirectory(newBase)) {
@@ -333,9 +303,7 @@ function undoFolder(oldPath: string, newPath: string, files: FileTuple[], opts:
}
function getBasename(fileOrDirectory: string) {
- // Note, can't use fs.lstatSync().isDirectory() because it's just a string
- // at this point. It might not exist.
-
+ // Infer file or directory names from path strings because the destination may not exist.
if (fileOrDirectory.endsWith('index.md')) {
return path.basename(path.dirname(fileOrDirectory))
}
@@ -444,9 +412,9 @@ function addToChildren(newPath: string, positions: PositionInfo, opts: MoveOptio
}
}
+// When git runs, commit pure renames before edits so later merges avoid complex three-way diffs.
function moveFiles(files: FileTuple[], opts: MoveOptions) {
const { verbose, git: useGit } = opts
- // Before we do anything, assert that the files are valid
for (const [oldPath] of files) {
const fileContent = fs.readFileSync(oldPath, 'utf-8')
const { errors } = fm(fileContent, { filepath: oldPath })
@@ -458,13 +426,6 @@ function moveFiles(files: FileTuple[], opts: MoveOptions) {
if (errors.length > 0) throw new Error('There were more than 0 parse errors')
}
- // In the first loop, we exclusively perform the rename. No file edits!
- // The reason is that we don't want lump renaming and edits in the same
- // git commit.
- // By having a dedicated git commit that purely renames (without changing
- // any content) is best practice to avoid complex 3-way diffs that
- // `git merge` does when you later have to merge in the latest `main`
- // into your ongoing renaming branch.
for (const [oldPath, newPath] of files) {
if (verbose) {
console.log(`Moving ${chalk.bold(oldPath)} to ${chalk.bold(newPath)}`)
@@ -493,13 +454,10 @@ function moveFiles(files: FileTuple[], opts: MoveOptions) {
}
}
+// editFiles keeps redirect_from edits in a separate commit from renames when git runs.
function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) {
const { verbose, git: useGit } = opts
- // Second loop. This time our only job is to edit the `redirects_from`
- // frontmatter key.
- // See comment in the first loop above for why we're looping over the files
- // two times.
for (const [oldPath, newPath, oldHref] of files) {
const fileContent = fs.readFileSync(newPath, 'utf-8')
const { content, data } = readFrontmatter(fileContent)
@@ -518,7 +476,7 @@ function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions)
}
}
- // Add contentType frontmatter to moved files
+ // Moved files get contentType from target paths.
if (files.length > 0) {
const filePaths = files.map(([, newPath]) => newPath)
try {
@@ -553,7 +511,6 @@ function editFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions)
function undoFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions) {
const { verbose, git: useGit } = opts
- // First undo any edits to the file
for (const [oldPath, newPath, oldHref] of files) {
const fileContent = fs.readFileSync(newPath, 'utf-8')
const { content, data } = readFrontmatter(fileContent)
@@ -580,10 +537,9 @@ function undoFiles(files: FileTuple[], updateParent: boolean, opts: MoveOptions)
}
}
+// Regex replacement preserves YAML formatting and comments that serialization would lose.
+// Homepage childGroup hrefs omit the leading slash.
function changeHomepageLinks(oldHref: string, newHref: string, verbose: boolean) {
- // Can't deserialize and serialize the Yaml because it would lose
- // formatting and comments. So regex replace it.
- // Homepage childGroup links do not have a leading '/', so we need to remove that.
const homepageOldHref = oldHref.replace('/', '')
const homepageNewHref = newHref.replace('/', '')
const escapedHomepageOldHref = RegExp.escape(homepageOldHref)
diff --git a/src/content-render/scripts/reusables-cli.ts b/src/content-render/scripts/reusables-cli.ts
index d253cd6d2a36..4c84f3496d0a 100644
--- a/src/content-render/scripts/reusables-cli.ts
+++ b/src/content-render/scripts/reusables-cli.ts
@@ -1,7 +1,5 @@
-/**
- * @purpose Writer tool
- * @description Find all content files that use a specific reusable
- */
+// @purpose Writer tool
+// @description Find all content files that use a specific reusable
// Usage: npm run reusables -- --help
// Usage: npm run reusables -- find used accounts/create-account.md
// Usage: npm run reusables -- find unused accounts/create-account.md
diff --git a/src/content-render/scripts/reusables-cli/find/potential-uses.ts b/src/content-render/scripts/reusables-cli/find/potential-uses.ts
index c0827117caa9..af2886568cbe 100644
--- a/src/content-render/scripts/reusables-cli/find/potential-uses.ts
+++ b/src/content-render/scripts/reusables-cli/find/potential-uses.ts
@@ -63,7 +63,7 @@ export function findPotentialUses({
reusableCount += 1
for (const { filePath, fileContents } of allFileContents) {
- // Skip the reusable file itself
+ // Do not report a reusable as a use of itself.
if (filePath === reusableFilePath) continue
const indices = findIndicesOfSubstringInString(reusableContents.trim(), fileContents)
diff --git a/src/content-render/scripts/reusables-cli/find/unused.ts b/src/content-render/scripts/reusables-cli/find/unused.ts
index 1f7bf29e8711..9906fb5b663e 100644
--- a/src/content-render/scripts/reusables-cli/find/unused.ts
+++ b/src/content-render/scripts/reusables-cli/find/unused.ts
@@ -33,7 +33,7 @@ export function findUnused({ absolute }: { absolute: boolean }) {
args.startsWith('reusables.')
) {
const reusableName = `${path.join('data', ...args.split(' ')[0].split('.'))}.md`
- // Special cases where we don't want them to count as reusables. It's an example in a how-to doc
+ // Ignore how-to examples that use fake reusable names.
if (
reusableName.includes('foo/bar.md') ||
reusableName.includes('foo/par.md') ||
diff --git a/src/content-render/scripts/reusables-cli/find/used.ts b/src/content-render/scripts/reusables-cli/find/used.ts
index 6f56c31512d6..23e44a37d38f 100644
--- a/src/content-render/scripts/reusables-cli/find/used.ts
+++ b/src/content-render/scripts/reusables-cli/find/used.ts
@@ -26,7 +26,7 @@ export function findUsed(reusablePath: string, { absolute }: { absolute: boolean
const filesWithReusables: FilesWithLineNumbers = []
for (const filePath of allFilePaths) {
- // Skip the reusable file itself
+ // Do not report a reusable as a use of itself.
if (filePath === reusableFilePath) continue
const fileContents = fs.readFileSync(filePath, 'utf-8')
diff --git a/src/content-render/scripts/reusables-cli/ignore-reusables.ts b/src/content-render/scripts/reusables-cli/ignore-reusables.ts
index 9c9979f80f54..2460a9878523 100644
--- a/src/content-render/scripts/reusables-cli/ignore-reusables.ts
+++ b/src/content-render/scripts/reusables-cli/ignore-reusables.ts
@@ -1,5 +1,4 @@
-// List of reusables to ignore when checking for potential uses of reusables
-// Make sure paths are relative to the root of the repo
+// List repo-relative reusables excluded from potential-use checks.
export const reusablesToIgnore = [
- 'data/reusables/copilot/trial-period.md', // Just a number, so it pops up in unrelated files
+ 'data/reusables/copilot/trial-period.md', // This numeric reusable matches unrelated files.
]
diff --git a/src/content-render/scripts/reusables-cli/shared.ts b/src/content-render/scripts/reusables-cli/shared.ts
index c04e24725d15..454e0477159e 100644
--- a/src/content-render/scripts/reusables-cli/shared.ts
+++ b/src/content-render/scripts/reusables-cli/shared.ts
@@ -73,12 +73,12 @@ export function getIndicesOfLiquidVariable(liquidVariable: string, fileContents:
}
export function resolveReusablePath(reusablePath: string): string {
- // Try .md if extension is not provided
+ // Append .md when the reusable path has no extension.
if (!reusablePath.endsWith('.md') && !reusablePath.endsWith('.yml')) {
reusablePath += '.md'
}
- // Allow user to just pass the name of the file. If it's not ambiguous, we'll find it.
+ // Resolve a path fragment only when it matches exactly one reusable file.
const allReusableFiles = getAllReusablesFilePaths()
const foundPaths = []
for (const possiblePath of allReusableFiles) {
@@ -130,13 +130,12 @@ export function findIndicesOfSubstringInString(substr: string, str: string): num
}
export function findSimilarSubStringInString(substr: string, str: string) {
- // Take every sentence in the substr, lower case it, and compare it to every sentence in the str to get a similarity score
+ // Score each substring sentence against each corpus sentence by shared words.
const substrSentences = substr.split('.').map((sentence) => sentence.toLowerCase())
const corpus = str.split('.').map((sentence) => sentence.toLowerCase())
let similarityScore = 0
- // Find how similar every two strings are based on the words they share
for (const substrSentence of substrSentences) {
for (const sentence of corpus) {
const substrTokens = substrSentence.split(' ')
diff --git a/src/content-render/scripts/update-filepaths.ts b/src/content-render/scripts/update-filepaths.ts
index 7760d5c7b441..45bc147c26d8 100755
--- a/src/content-render/scripts/update-filepaths.ts
+++ b/src/content-render/scripts/update-filepaths.ts
@@ -1,7 +1,5 @@
-/**
- * @purpose Writer tool
- * @description Update content filenames to match short titles
- */
+// @purpose Writer tool
+// @description Update content filenames to match short titles
import fs from 'fs'
import path from 'path'
@@ -53,11 +51,12 @@ const estimateScriptMinutes = (numberOfFiles: number): string => {
return estNum === 0 ? '<1' : estNum.toString()
}
+// main processes files sequentially because move-content must move files before directories,
+// and deepest directories before parents.
+// Async does not shorten this work because each path move depends on the ordered result.
async function main(): Promise {
const slugger = new GithubSlugger()
const contentDir: string = path.join(process.cwd(), 'content')
- // Filter to get all the content files we want to read in.
- // Then sort them from longest > shortest so we can do the file moves in order.
const filesToProcess: string[] = sortFiles(filterFiles(contentDir, options))
if (filesToProcess.length === 0) {
@@ -71,11 +70,6 @@ async function main(): Promise {
console.log(`Estimated time: ${estimate} min\n`)
}
- // Process files sequentially to maintain the correct order of operations.
- // Files must be moved before directories, and directories must be moved
- // from deepest to shallowest to avoid path conflicts during the move operations.
- // The result is rather slow, but an asynchronous approach that ensures
- // sequential processing would not be faster.
for (const file of filesToProcess) {
try {
slugger.reset()
@@ -110,24 +104,16 @@ async function processFile(
stringToSlugify = await renderContent(stringToSlugify, context, { textOnly: true })
}
- // Slugify the short title of each article.
- // Where: shortTitle = Foo bar
- // Returns: slug = foo-bar
- // Fall back to title if shortTitle doesn't exist.
+ // Slug shortTitle, or title when shortTitle is absent, to get the target basename.
const slug: string = slugger.slug(decode(stringToSlugify))
let basename: string
if (isDirectory) {
- // Where: content location = content/foobar/index.md
- // Returns: basename = foobar
basename = path.basename(path.dirname(file))
} else {
- // Where: content location = content/foobar.md
- // Returns: basename = foobar
basename = path.basename(file, '.md')
}
- // If slug and basename already match, all set here. Return early.
if (slug === basename) return null
const newPath = isDirectory
@@ -153,7 +139,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void {
return
}
- // Call out to well-tested move-content script for the moving and redirect adding functions.
+ // move-content handles file moves, redirects, and children updates.
const stdout = execFileSync(
'tsx',
[
@@ -166,7 +152,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void {
{ encoding: 'utf8' },
)
- // Grab just the "Moving..." and "Renamed..." output from stdout; otherwise output is too noisy.
+ // Print only Moving or Renamed lines unless verbose; full move-content output is noisy.
const moveMsg = stdout.split('\n').find((l) => l.startsWith('Moving') || l.startsWith('Renamed'))
if (moveMsg && !options.verbose) {
console.log(moveMsg, '\n')
@@ -176,11 +162,7 @@ function moveFile(result: string[], scriptOptions: ScriptOptions): void {
}
function sortFiles(filesArray: string[]): string[] {
- // The order of operations is important.
- // We need to return an array so that the moving operations happens in this order:
- // 1. Filepaths
- // 2. Deepest subdirectory path
- // 3. Shallowest subdirectory path (up to category level, e.g., content/product/category)
+ // Move files before directories, then deepest directories before parents.
return filesArray.toSorted((a, b) => {
if (!isDirectoryCheck(a) && isDirectoryCheck(b)) {
return -1
@@ -194,7 +176,7 @@ function sortFiles(filesArray: string[]): string[] {
if (isDirectoryCheck(a) && isDirectoryCheck(b)) {
const aDepth = a.split(path.sep).length
const bDepth = b.split(path.sep).length
- return bDepth - aDepth // Deeper paths first
+ return bDepth - aDepth
}
return 0
@@ -203,21 +185,19 @@ function sortFiles(filesArray: string[]): string[] {
function filterFiles(contentDir: string, scriptOptions: ScriptOptions) {
return walkFiles(contentDir, ['.md']).filter((file: string) => {
- // Never move readmes
+ // Keep README paths unchanged.
if (file.endsWith('README.md')) return false
- // Never move early access files
+ // Keep early access paths unchanged.
if (file.includes('early-access')) return false
- // Never move the homepage (content/index.md)
+ // Keep the homepage path unchanged.
if (path.relative(contentDir, file) === 'index.md') return false
- // Never move product landings (content/foo/index.md)
+ // Keep product landing paths unchanged.
if (path.relative(contentDir, file).split(path.sep)[1] === 'index.md') return false
- // If no specific paths are passed, we are done filtering.
if (!scriptOptions.paths) return true
return scriptOptions.paths.some((p: string) => {
- // Allow either a full content path like "content/foo/bar.md"
- // or a top-level directory name like "copilot"
+ // Accept full content paths like content/foo/bar.md or top-level dirs like copilot.
if (!p.startsWith('content')) {
p = path.join('content', p)
}
@@ -236,7 +216,7 @@ function determineProcessStatus(
isDirectory: boolean,
scriptOptions: ScriptOptions,
): boolean {
- // A directory is never processed when dirs are excluded, whatever else is set.
+ // exclude-dirs prevents directory moves even when force is set.
if (isDirectory && scriptOptions.excludeDirs) {
return false
}
diff --git a/src/content-render/stylesheets/accessibility.scss b/src/content-render/stylesheets/accessibility.scss
index 4f859c02cf71..2659cf3482de 100644
--- a/src/content-render/stylesheets/accessibility.scss
+++ b/src/content-render/stylesheets/accessibility.scss
@@ -1,23 +1,16 @@
-/* Accessibility fixes for tooltip text spacing and other a11y improvements */
-
-/* Fix tooltip text spacing inheritance - Issue #11442 */
+// Tooltips inherit the user's custom text-spacing preferences for accessibility.
.tooltipped {
&::before,
&::after {
- /* Allow tooltips to inherit user's custom text spacing preferences */
letter-spacing: inherit !important;
word-spacing: inherit !important;
line-height: inherit !important;
}
- /* WCAG 1.4.13: Make tooltip content hoverable with the mouse pointer.
- Primer's .tooltipped uses pointer-events:none on the ::after pseudo-
- element and a 6px margin gap between the trigger and the tooltip.
- This makes it impossible to hover the tooltip content itself.
-
- Fix: re-enable pointer-events and replace the directional margin with
- a transparent border so the hover hit-area is contiguous while the
- visual appearance is unchanged. */
+ // WCAG 1.4.13 requires tooltip content to stay hoverable with the mouse pointer.
+ // Primer's .tooltipped sets pointer-events: none on ::after and leaves a 6px
+ // margin gap between the trigger and tooltip, so replace the margin with a
+ // transparent border and keep the visual appearance unchanged.
&::after {
pointer-events: auto !important;
}
@@ -43,16 +36,14 @@
}
}
-/* Enhanced focus indicators for high contrast mode */
@media (prefers-contrast: high) {
.tooltipped {
&:focus-visible::before,
&:focus-visible::after {
- // --color-focus-outset is defined nowhere in this app, which made the whole
- // `outline` shorthand invalid at computed-value time — so the longhands reset
- // and `outline-style: none` won, leaving high-contrast users with NO focus
- // ring at all, in either colour mode. Brand's focus token, matching the same
- // repair already made in annotate.scss.
+ // --color-focus-outset is undefined here. It invalidates the outline shorthand
+ // at computed-value time, resets the longhands, and lets outline-style: none
+ // remove the focus ring in both color modes. Use Brand's focus token to match
+ // annotate.scss.
outline: var(--brand-borderWidth-thick, 2px) solid
var(--brand-color-focus, #0377ff);
outline-offset: 2px;
@@ -60,8 +51,6 @@
}
}
-/* Responsive tooltip text for Copilot prompt links */
-/* Show long tooltip text on small screens and up (544px+) */
.copilot-prompt-long {
display: none;
visibility: hidden;
@@ -72,7 +61,6 @@
}
}
-/* Show short tooltip text only on extra small screens (below 544px) */
.copilot-prompt-short {
display: inline-block;
visibility: visible;
diff --git a/src/content-render/stylesheets/alerts.scss b/src/content-render/stylesheets/alerts.scss
index 62366c9f0ca3..4d26e2de3deb 100644
--- a/src/content-render/stylesheets/alerts.scss
+++ b/src/content-render/stylesheets/alerts.scss
@@ -1,7 +1,5 @@
-// Largely identical styling from the monolith,
-// but the color names match the Primer variables
-// and we had to directly state a few props instead of using variables
-// that are only in the monolith.
+// This mirrors monolith styling, but color names match Primer variables.
+// Direct properties replace variables that exist only in the monolith.
$colors:
"default", "muted", "subtle", "accent", "success", "attention", "severe",
@@ -9,9 +7,8 @@ $colors:
.ghd-alert {
padding: var(--base-size-8, 0.5rem) var(--base-size-16, 1rem);
- // Docs 2026: brand-align the callout container — rounded corners + brand
- // border-radius token. The colored left border is set per-type below; the
- // callout *system* redesign (Note/Warning/Tip/Pro tip) is tracked separately.
+ // Brand callout work changes the container only: rounded corners and the Brand token.
+ // Per-type colors stay on the left border; Note, Warning, Tip, and Pro tip changes are separate.
border-left: 0.25em solid
var(--brand-color-border-default, var(--color-border-default));
border-radius: var(--brand-borderRadius-medium, 0.5rem);
diff --git a/src/content-render/stylesheets/annotate.scss b/src/content-render/stylesheets/annotate.scss
index bcc496501233..f00245290941 100644
--- a/src/content-render/stylesheets/annotate.scss
+++ b/src/content-render/stylesheets/annotate.scss
@@ -1,8 +1,5 @@
@import "src/frame/stylesheets/breakpoint-xxl.scss";
-/* Code annotations
-----------------------------------------------------------------------------*/
-
.annotate.beside {
.annotate-beside {
display: inherit;
@@ -31,8 +28,8 @@
.annotate-header header {
border-top-left-radius: 6px !important;
border-top-right-radius: 6px !important;
- // Brand's `subtle` border (#d2d9d4) is the match for Primer's
- // --color-border-default (#d0d7de); brand's `default` is much darker (#b6bfb8).
+ // Brand's subtle border #d2d9d4 matches Primer --color-border-default #d0d7de;
+ // Brand's default #b6bfb8 is much darker.
border-bottom: var(--brand-borderWidth-thin, 1px) solid
var(--brand-color-border-subtle, #d2d9d4);
}
@@ -98,14 +95,13 @@
border-color: var(--color-segmented-control-button-selected-border);
}
- // High contrast theme support
@media (prefers-contrast: high) {
border-color: var(--brand-color-border-subtle, #d2d9d4);
&:hover {
background: var(--brand-color-canvas-subtle, #f2f5f3);
- // --color-border-emphasis is defined nowhere in this app, so this border
- // was computing to currentColor. Brand's `default` is its strongest border.
+ // --color-border-emphasis is undefined here, so this border computes to currentColor.
+ // Brand's default border is its strongest border.
border-color: var(--brand-color-border-default, #b6bfb8);
}
@@ -116,7 +112,7 @@
}
&:focus-visible {
- // --color-focus-outset is also undefined in this app; brand's focus token.
+ // --color-focus-outset is undefined here; use Brand's focus token.
outline: var(--brand-borderWidth-thick, 2px) solid
var(--brand-color-focus, #0377ff);
outline-offset: 2px;
diff --git a/src/content-render/stylesheets/article-section-framing.scss b/src/content-render/stylesheets/article-section-framing.scss
index c6b132334f5b..f4b47059dc8a 100644
--- a/src/content-render/stylesheets/article-section-framing.scss
+++ b/src/content-render/stylesheets/article-section-framing.scss
@@ -1,27 +1,22 @@
-// Docs 2026 article section framing: the article body renders as stacked
-// sections separated by single horizontal rules (Figma node 795:41405).
+// Article body sections stack with single horizontal rules, matching Figma node 795:41405.
//
-// Scoped to `#article-contents[data-article-body]`. The id alone is NOT enough:
-// AutomatedPage renders the same `#article-contents` wrapper, and it backs the
-// GraphQL reference / changelog / breaking-changes / schema-previews pages,
-// webhook events and payloads, audit-log events and the github-apps lists — all
-// of which would pick up this framing. The attribute is set only by the pages
-// this treatment was drawn for (ArticlePage and TocLanding), so auto-generated
-// reference pages keep their own look.
+// Scope this to #article-contents[data-article-body]. The id alone also wraps
+// AutomatedPage, including GraphQL reference, changelog, breaking-changes,
+// schema-previews, webhook events and payloads, audit-log events, and github-apps
+// lists. ArticlePage and TocLanding set this attribute, so auto-generated reference
+// pages keep their own look.
#article-contents[data-article-body] {
.markdown-body {
position: relative;
- // Vertical padding gives the first/last section breathing room from the
- // top/bottom rules. There are deliberately NO vertical side rules at any
- // width — sections are separated by horizontal rules alone, and the flexible
- // gap columns either side of the content keep the text off the rails.
+ // Vertical padding separates the first and last sections from the top and bottom rules.
+ // No width draws vertical side rules; horizontal rules separate sections, and flexible
+ // gap columns keep text off the rails.
padding-top: 2rem;
padding-bottom: 2rem;
- // Closing rule below the last section — the h2 rules only draw the TOP of
- // each section, so without this the article would end without a divider.
- // Spans the body column, like those rules.
+ // The h2 rules draw only the top of each section, so this rule closes the article.
+ // It spans the body column like the h2 rules.
&::after {
content: "";
position: absolute;
@@ -33,18 +28,17 @@
pointer-events: none;
}
- // The Figma section headings have no underline — the section-box top rule is
- // the only divider. Drop the @primer/css setext border under h2/h3.
+ // Figma section headings have no underline; the section-box top rule is the only divider.
+ // Drop the @primer/css setext border under h2 and h3.
h2,
h3 {
border-bottom: 0;
}
- // Each top-level section (h2) is separated by a SINGLE horizontal rule with
- // clear space either side of it: the 3.5rem heading margin is split by the
- // rule into ~24px above and 2rem below. The rule spans the width of the
- // article body and no further — it is not run out to the rails. The first
- // h2's rule is suppressed — the hero divider already sits above it.
+ // Each top-level h2 has one horizontal rule with clear space on both sides.
+ // The 3.5rem heading margin splits into about 24px above the rule and 2rem below.
+ // The rule spans the article body only. The first h2 suppresses its rule because
+ // the hero divider already sits above it.
h2 {
position: relative;
margin-top: 3.5rem;
@@ -55,8 +49,7 @@
position: absolute;
left: 0;
right: 0;
- // Sits 2rem above the heading, leaving that gap below the rule and the
- // remainder of the heading margin above it.
+ // This leaves 2rem between the rule and heading, with the rest of the margin above.
top: -2rem;
border-bottom: var(--borderWidth-thin, 1px) solid
var(--brand-color-border-muted, #e4ebe6);
@@ -72,11 +65,9 @@
}
}
- // Journey-track pages render a full-width "Up next" band directly below the
- // grid (ArticlePage drops the 24px wrapper/band margins on those pages so the
- // band sits flush). The band carries its own full-width top border, which
- // already closes the article, so suppress our own closing rule rather than
- // stacking two lines.
+ // Journey-track pages render a full-width "Up next" band directly below the grid.
+ // ArticlePage removes the 24px wrapper and band margins there, so the band sits flush.
+ // The band's full-width top border already closes the article, so this avoids two lines.
&[data-has-upnext] .markdown-body::after {
display: none;
}
diff --git a/src/content-render/stylesheets/heading-links.scss b/src/content-render/stylesheets/heading-links.scss
index 3b46e85c5936..9d97297a703c 100644
--- a/src/content-render/stylesheets/heading-links.scss
+++ b/src/content-render/stylesheets/heading-links.scss
@@ -10,8 +10,8 @@
// https://primer.style/design/foundations/icons/link-16
mask: url('data:image/svg+xml;charset=utf8,');
mask-size: cover;
- // Brand has no `subtle` text step; `muted` is the closest analogue to
- // Primer's --color-fg-subtle (#6e7781 -> #58635b).
+ // Brand has no subtle text step; muted is closest to Primer --color-fg-subtle.
+ // Primer #6e7781 maps to Brand #58635b.
background-color: var(--brand-color-text-muted, #58635b);
@media (forced-colors: active) {
background-color: LinkText;
diff --git a/src/content-render/stylesheets/markdown-overrides.scss b/src/content-render/stylesheets/markdown-overrides.scss
index 0c4af7f5909f..7af2a92e180b 100644
--- a/src/content-render/stylesheets/markdown-overrides.scss
+++ b/src/content-render/stylesheets/markdown-overrides.scss
@@ -1,21 +1,7 @@
-// What might happens is that we have a DOM of
-//
-//
-//
-// Heading
-// ...
-//
-// When this is the case, by default, that first that is the first
-// gets the `margin-top: 0 !important` and not the first .
-// Generally, the reason this even exists is because (and ) elements
-// are given extra margin-top so as to divide the article into sections
-// with some extra whitespace. That's fine, but we don't to start the
-// top of the page with too much whitespace. That's why @primer/css
-// has a solution for that. Just the problem that it fails then first
-// element isn't actually a heading.
-// Note we're also doing it for a possible being the first element.
+// Primer's markdown-body first-child reset can hit a hidden first child instead
+// of the first h2 or h3. Those headings carry section spacing, but the page top
+// must not start with it, so reset the first h2 or h3 directly.
// See https://github.com/primer/css/issues/2303
-// See internal issue #2368
.markdown-body {
> h2:first-of-type,
> h3:first-of-type {
@@ -23,9 +9,8 @@
}
}
-// Horizontal scroll gets flagged as an accessibility violation.
-// Updates all code examples to only allow vertical scroll, and
-// break aggressively.
+// Horizontal scroll gets flagged as an accessibility violation, so code examples wrap
+// aggressively and allow only vertical scrolling.
.markdown-body {
pre {
overflow-x: hidden;
@@ -38,97 +23,71 @@
}
}
-// Fix for permissions icon collision with bulleted lists
-// When permissions/product statements contain lists that start immediately,
-// the list bullets can visually collide with the icons in the flex layout.
-// This adds proper spacing to prevent the collision while supporting RTL languages
-// and avoiding effects on nested lists.
-// See: https://github.com/github/docs-engineering/issues/5199
+// Lists that start immediately in permissions and product statements can collide with
+// the icon in the flex layout. Inline spacing preserves right-to-left layouts and avoids
+// changing nested lists.
.permissions-statement,
.product-statement {
ul {
margin-inline-start: 0;
- padding-inline-start: 1rem; // Ensure proper spacing from icon (RTL-aware)
+ padding-inline-start: 1rem;
}
ul > li {
- margin-inline-start: 0.5rem; // Additional spacing to prevent bullet collision (direct children only)
+ margin-inline-start: 0.5rem;
}
}
-// A CTA button written on its own line in markdown — `` — becomes its own , and that paragraph already carries the 16px
-// rhythm margin. The `mt-3` utility then stacks a second 16px inside it, so the
-// button ends up 32px below the preceding line but only 16px above the next one.
-// Drop the utility when the button is alone in its paragraph and let the
-// paragraph margin do the spacing, which puts the CTA on the same rhythm as
-// every other block. `!important` is required because Primer's spacing
-// utilities are themselves !important.
-//
-// `:only-child` is doing real work here — it is what keeps the two cases apart:
-// - CTA callouts (`product:`/`permissions:` frontmatter) put the button after
-// a
INSIDE the prose paragraph, so there is no paragraph margin above
-// it and `mt-3` is the only thing separating it from the text.
-// - The side-by-side Yes/No `.btn-outline` pairs are two buttons in one
-// paragraph.
-// Neither is an only child, so both keep their margin.
+// A CTA button written alone in markdown, such as ,
+// becomes its own p. The p already has a 16px rhythm margin, and mt-3 adds another
+// 16px, leaving 32px below the preceding line but 16px above the next one.
+// Drop mt-3 only when the button is alone in its p; Primer spacing utilities use
+// !important too.
+// :only-child keeps CTA callouts and side-by-side Yes/No buttons unchanged. Frontmatter
+// product: and permissions: put the CTA after a br inside the prose p, so mt-3 supplies
+// its only top spacing. Yes/No .btn-outline pairs have two buttons in one p.
.markdown-body p > a.btn:only-child {
margin-top: 0 !important;
}
-// @primer/css holds `.btn` at `white-space: nowrap`, which a button cannot
-// honour and still stay inside a narrow column. The longest CTA label — "Set up
-// a trial of GitHub Enterprise Cloud", 322px — is wider than the article column
-// below a ~420px viewport and wider than the callout's text column below ~390px,
-// so the button ran past the content edge and was clipped.
+// @primer/css sets .btn to white-space: nowrap, which makes long CTA labels overflow
+// narrow columns. The longest CTA label, "Set up a trial of GitHub Enterprise Cloud",
+// measures 322px, wider than the article column below about 420px and the callout text
+// column below about 390px, so it gets clipped.
//
-// Letting the label wrap fixes it with no breakpoint to guess at. An
-// inline-block is shrink-to-fit — min(max-content, available) — so
-// `white-space: normal` changes nothing until max-content exceeds the space
-// available: at every width where the button already fits it still renders on
-// one line, byte-identical. That also makes it self-correcting for longer
-// translated labels and for the narrower column a callout gives the same button.
+// Let labels wrap instead of guessing a breakpoint. inline-block shrink-to-fit,
+// min(max-content, available), means white-space: normal changes nothing until
+// max-content exceeds the available space. Buttons that fit still render on one line,
+// and longer translations or narrower callout columns self-correct.
.markdown-body a.btn,
.permissions-statement a.btn,
.product-statement a.btn {
white-space: normal;
- // Wrapping alone orphaned the trailing octicon on a line of its own: the
- // space between the label and the icon is a valid break point, and the
- // label filled the first line exactly. Laying the button out as a flex row
- // instead lets the label wrap within itself and keeps the icon beside it,
- // vertically centred. At widths where nothing wraps the result is within a
- // pixel of the inline-block it replaces: same 17px left inset, same 21px
- // right inset, same 32px height, still one line. The `gap` below covers the
- // one thing that does change.
+ // Wrapping alone can orphan the trailing octicon because the label/icon space can break.
+ // inline-flex lets the label wrap inside itself and keeps the icon beside it, centered.
+ // When nothing wraps, this stays within a pixel of inline-block: 17px left inset,
+ // 21px right inset, 32px height, and one line. The gap below covers the one change.
display: inline-flex;
align-items: center;
- // Flex layout eats the one thing that was separating the label from the icon.
- // The markup is `Label {% octicon "link-external" %}`, and that
- // literal space does survive Liquid and the markdown pipeline as a real text
- // node — but a whitespace-only text node between two flex items is not itself
- // a flex item, so no box is generated for it and the label ends up touching
- // the icon. `gap` puts the space back.
+ // Flex removes the literal space between the label and icon. In
+ // Label {% octicon "link-external" %}, Liquid and markdown preserve the
+ // space as a text node, but a whitespace-only text node between flex items creates no
+ // box, so the label touches the icon.
//
- // 4px rather than the measured width of that space glyph, because a space is
- // font- and locale-dependent — it measures differently on two machines here —
- // while 4px is the value Primer itself already uses between a button's icon
- // and its label. The button ends up a fraction of a pixel wider than it was
- // rather than most of a space narrower, on a number the design system owns.
+ // Use 4px instead of the measured space width because the space changes by font and
+ // locale. Primer already uses 4px between a button icon and label, so this
+ // design-system value makes the button slightly wider rather than most of a space narrower.
//
- // Only the label/icon gap is restored. Primer's `.btn .octicon` also carries
- // `margin-right: 4px`, which assumes a LEADING icon and so lands outside the
- // trailing icon on these CTAs, giving them 21px of inset on the right against
- // 17px on the left. That asymmetry is what ships today, so it stays — zeroing
- // it would restyle every CTA on the site, which is a different change from
- // keeping a long label inside its column.
+ // Restore only the label/icon gap. Primer .btn .octicon also has margin-right: 4px
+ // for leading icons, which lands outside these trailing CTA icons and gives 21px right
+ // inset against 17px left. Keep that asymmetry; zeroing it would restyle every CTA.
gap: 4px;
- // The octicon is a flex item now, and flex items shrink before their container
- // overflows. Once the label wraps, the icon is the only thing left to give, so
- // the 16px glyph was rendering at 11px in a 240px callout column. It is a
- // fixed-size icon; the label is what should absorb a narrow column.
+ // The octicon is a flex item, and flex items shrink before their container overflows.
+ // Once the label wraps, the icon is the only thing left to shrink, so the 16px glyph
+ // rendered at 11px in a 240px callout column. Keep the icon fixed and let the label absorb width.
.octicon {
flex-shrink: 0;
}
diff --git a/src/content-render/stylesheets/octicon-table-optimization.scss b/src/content-render/stylesheets/octicon-table-optimization.scss
index 23d1f9af5728..777b9d30f538 100644
--- a/src/content-render/stylesheets/octicon-table-optimization.scss
+++ b/src/content-render/stylesheets/octicon-table-optimization.scss
@@ -1,5 +1,5 @@
-// Octicon table optimization for pages with hundreds of repeated icons
-// Uses CSS background images instead of inline SVGs to dramatically reduce HTML size
+// Pages with hundreds of repeated octicons use CSS background images instead of inline SVGs
+// to reduce HTML size.
$octicon-check-path: "M13.78 4.22a.75.75 0 0 1 0 1.06l-7.25 7.25a.75.75 0 0 1-1.06 0L2.22 9.28a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018L6 10.94l6.72-6.72a.75.75 0 0 1 1.06 0Z";
$octicon-x-path: "M3.72 3.72a.75.75 0 0 1 1.06 0L8 6.94l3.22-3.22a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l3.22 3.22a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-3.22 3.22a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042L6.94 8 3.72 4.78a.75.75 0 0 1 0-1.06Z";
diff --git a/src/content-render/stylesheets/syntax-highlighting.scss b/src/content-render/stylesheets/syntax-highlighting.scss
index 30b9b9efa5ae..e7b679732af0 100644
--- a/src/content-render/stylesheets/syntax-highlighting.scss
+++ b/src/content-render/stylesheets/syntax-highlighting.scss
@@ -8,10 +8,9 @@ from https://unpkg.com/highlight.js@9.15.8/styles/github.css
.hljs {
display: block;
padding: 0.5em;
- // The block's BASE text colour — the tokens below are prettylights, which has
- // no Brand equivalent and stays on Primer deliberately, but this one is just
- // "default text" and was painting Primer's #e6edf3 inside a Brand-framed code
- // block. The `background` is inert here (markdown-overrides paints the `pre`).
+ // Use Brand's default text color for the block itself. The syntax tokens below stay
+ // on Primer prettylights because Brand has no equivalent. Inside .markdown-body, Primer
+ // paints the pre and makes pre code transparent, so this background is inert there.
color: var(--brand-color-text-default);
background: var(--color-canvas-subtle);
}
diff --git a/src/content-render/tests/annotate.ts b/src/content-render/tests/annotate.ts
index c47a78d6fb76..05e220bd62d8 100644
--- a/src/content-render/tests/annotate.ts
+++ b/src/content-render/tests/annotate.ts
@@ -124,7 +124,6 @@ on: [push]
\`\`\`
`
- // Create a mock context with pages for AUTOTITLE resolution
const mockPages: Record = {
'/get-started/start-your-journey/hello-world': {
href: '/get-started/start-your-journey/hello-world',
@@ -141,7 +140,7 @@ on: [push]
currentVersion: 'free-pro-team@latest',
pages: mockPages,
redirects: {},
- // Mock test object doesn't need all Context properties, using 'as unknown as' to bypass strict type checking
+ // AUTOTITLE resolution reads only these Context fields.
} as unknown as Context
const res = await renderContent(autotitleExample, mockContext)
diff --git a/src/content-render/tests/collect-mini-toc.ts b/src/content-render/tests/collect-mini-toc.ts
index ae8b6942ccc4..eaf110d5d833 100644
--- a/src/content-render/tests/collect-mini-toc.ts
+++ b/src/content-render/tests/collect-mini-toc.ts
@@ -62,7 +62,7 @@ describe('collect-mini-toc rehype plugin', () => {
})
test('does not collect when collectMiniToc is not provided', async () => {
- // Should not throw — plugin is a no-op without collectInto
+ // Without collectMiniToc, the plugin is a no-op.
const result = await renderContent('## Heading')
expect(result).toContain('Heading')
})
diff --git a/src/content-render/tests/data.ts b/src/content-render/tests/data.ts
index 85fbe23faa54..99365061b34e 100644
--- a/src/content-render/tests/data.ts
+++ b/src/content-render/tests/data.ts
@@ -42,9 +42,7 @@ describe('data tag', () => {
currentPath: '/en/liquid-tags/good-data-variable',
}
const rendered = await page!.render(context)
- // The test fixture contains:
- // {% data variables.stuff.foo %}
- // which we control the value of here in the test.
+ // good-data-variable.md uses {% data variables.stuff.foo %} from the test data directory.
expect(rendered.includes('Foo')).toBeTruthy()
})
test('should throw if the data tag is used with something unrecognized', async () => {
diff --git a/src/content-render/tests/link-error-line-numbers.ts b/src/content-render/tests/link-error-line-numbers.ts
index 36cd3d1f842e..734e2fa2d77c 100644
--- a/src/content-render/tests/link-error-line-numbers.ts
+++ b/src/content-render/tests/link-error-line-numbers.ts
@@ -54,9 +54,6 @@ More content here.`
} catch (error) {
expect(error).toBeInstanceOf(TitleFromAutotitleError)
- // The broken link is on line 10 in the original file
- // (3 lines of frontmatter + 1 blank line + 1 title + 1 blank + 1 content + 1 blank + 1 link line)
- // The error message should reference the correct line number
expect((error as TitleFromAutotitleError).message).toContain('/nonexistent/page')
expect((error as TitleFromAutotitleError).message).toContain('could not be resolved')
expect((error as TitleFromAutotitleError).message).toContain('(Line: 10)')
diff --git a/src/content-render/tests/liquid-tags.ts b/src/content-render/tests/liquid-tags.ts
index db28d494733b..5151423349d5 100644
--- a/src/content-render/tests/liquid-tags.ts
+++ b/src/content-render/tests/liquid-tags.ts
@@ -55,7 +55,8 @@ This uses {% data variables.product.prodname_dotcom %} in content.
const expandedContent = await fs.readFile(testFile, 'utf8')
expect(expandedContent).not.toBe(testContent)
- expect(expandedContent).toContain('GitHub') // Should expand to actual fixture value
+ // The fixture data tag expands to GitHub.
+ expect(expandedContent).toContain('GitHub')
})
test('restore command should complete successfully', async () => {
diff --git a/src/content-render/tests/liquid.ts b/src/content-render/tests/liquid.ts
index e38b32f68ba7..82e8053b10c4 100644
--- a/src/content-render/tests/liquid.ts
+++ b/src/content-render/tests/liquid.ts
@@ -8,10 +8,7 @@ import { allVersions } from '@/versions/lib/all-versions'
import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases'
import type { Context, ExtendedRequest, Page } from '@/types'
-// Setup these variables so we don't need to manually update tests as GHES
-// versions continually get deprecated. For example, if we deprecate GHES 3.0,
-// oldestSupportedGhes will be 3.1, secondOldestSupportedGhes will be 3.2, and
-// thirdOldestSupportedGhes will be 3.3.
+// Derive GHES versions from supported releases so deprecations do not require test updates.
const oldestSupportedGhes =
enterpriseServerReleases.supported[enterpriseServerReleases.supported.length - 1]
const secondOldestSupportedGhes =
@@ -50,7 +47,7 @@ describe('liquid template parser', () => {
vi.setConfig({ testTimeout: 60 * 1000 })
describe('short versions', () => {
- // Create a fake req so we can test the shortVersions middleware
+ // shortVersionsMiddleware reads and mutates a request context.
const req = { language: 'en', query: {} } as ExtendedRequest
test('FPT works as expected when it is FPT', async () => {
@@ -61,7 +58,7 @@ describe('liquid template parser', () => {
} as Context
contextualize(req)
const output = await liquid.parseAndRender(shortVersionsTemplate, req.context)
- // We should have TWO results because we are supporting two shortcuts
+ // FPT matches directly and through the fpt or ghes shortcut.
expect(output.replace(/\s\s+/g, ' ').trim()).toBe(
`I am FPT I am FTP or GHES < ${secondOldestSupportedGhes}`,
)
@@ -70,7 +67,6 @@ describe('liquid template parser', () => {
test('GHEC works as expected', async () => {
req.context = {
currentVersion: 'enterprise-cloud@latest',
- // page: {},
allVersions,
enterpriseServerReleases,
} as Context
@@ -144,13 +140,13 @@ describe('liquid template parser', () => {
})
describe('feature versions', () => {
- // Create a fake req so we can test the feature versions middleware
+ // featureVersionsMiddleware reads and mutates a request context.
const req = { language: 'en', query: {} } as ExtendedRequest
test('does not render in FPT because feature is not available in FPT', async () => {
req.context = {
currentVersion: 'free-pro-team@latest',
- page: {} as Page, // it just has to be any truthy value
+ page: {} as Page, // featureVersionsMiddleware only checks that page is truthy.
allVersions,
enterpriseServerReleases,
} as Context
@@ -162,7 +158,7 @@ describe('liquid template parser', () => {
test('renders in GHES because feature is available in GHES', async () => {
req.context = {
currentVersion: `enterprise-server@${enterpriseServerReleases.latest}`,
- page: {} as Page, // it just has to be any truthy value
+ page: {} as Page, // featureVersionsMiddleware only checks that page is truthy.
allVersions,
enterpriseServerReleases,
} as Context
@@ -174,7 +170,7 @@ describe('liquid template parser', () => {
test('renders in GHEC because feature is available in GHEC', async () => {
req.context = {
currentVersion: 'enterprise-cloud@latest',
- page: {} as Page, // it just has to be any truthy value
+ page: {} as Page, // featureVersionsMiddleware only checks that page is truthy.
allVersions,
enterpriseServerReleases,
} as Context
diff --git a/src/content-render/tests/prompt-id.ts b/src/content-render/tests/prompt-id.ts
index 71e046b0fd48..ff26162f4653 100644
--- a/src/content-render/tests/prompt-id.ts
+++ b/src/content-render/tests/prompt-id.ts
@@ -39,13 +39,13 @@ describe('generatePromptId', () => {
})
test('generates deterministic IDs (regression test)', () => {
- // These specific values ensure the hash function remains consistent
+ // Fixed hash outputs catch unintended murmurhash changes.
expect(generatePromptId('hello world')).toBe('1730621824')
expect(generatePromptId('test')).toBe('4180565944')
})
test('handles prompts with code context (ref pattern)', () => {
- // When ref= is used, the prompt includes referenced code + prompt text separated by newline
+ // ref= prompts include referenced code, a newline, then prompt text.
const codeContext =
'function logPersonAge(name, age, revealAge) {\n if (revealAge) {\n console.log(name);\n }\n}'
const promptText = 'Improve the variable names in this function'
@@ -59,15 +59,15 @@ describe('generatePromptId', () => {
})
test('handles very long prompts', () => {
- // Real-world prompts can include entire code blocks (100+ lines)
- const longCode = 'x\n'.repeat(500) // 500 lines
+ // Real prompts can include code blocks longer than 100 lines.
+ const longCode = 'x\n'.repeat(500)
const id = generatePromptId(longCode)
expect(typeof id).toBe('string')
expect(id.length).toBeGreaterThan(0)
})
test('handles prompts with backticks and template literals', () => {
- // Prompts often include inline code with backticks
+ // Prompts can include inline code delimiters.
const prompt = "In JavaScript I'd write: `The ${numCats === 1 ? 'cat is' : 'cats are'} hungry.`"
const id = generatePromptId(prompt)
expect(typeof id).toBe('string')
@@ -75,7 +75,7 @@ describe('generatePromptId', () => {
})
test('handles prompts with placeholders', () => {
- // Content uses placeholders like NEW-LANGUAGE, OWNER/REPOSITORY
+ // Content uses placeholders like NEW-LANGUAGE and OWNER/REPOSITORY.
const id1 = generatePromptId('What is NEW-LANGUAGE best suited for?')
const id2 = generatePromptId('In OWNER/REPOSITORY, create a feature request')
expect(id1).not.toBe(id2)
@@ -84,7 +84,7 @@ describe('generatePromptId', () => {
})
test('handles unicode and international characters', () => {
- // May encounter non-ASCII characters in prompts
+ // Prompts can include non-ASCII text.
const id1 = generatePromptId('Explique-moi le code en français')
const id2 = generatePromptId('コードを説明してください')
const id3 = generatePromptId('Объясните этот код')
diff --git a/src/content-render/tests/render-changed-and-deleted-files.ts b/src/content-render/tests/render-changed-and-deleted-files.ts
index 617089e59ea4..b61d0b715cc5 100644
--- a/src/content-render/tests/render-changed-and-deleted-files.ts
+++ b/src/content-render/tests/render-changed-and-deleted-files.ts
@@ -1,37 +1,13 @@
-/**
- * To "debug" this test locally, you need to set at least one of these
- * environment variables:
- *
- * - CHANGED_FILES
- * - DELETED_FILES
- * - RENAMED_FILES
- *
- * `CHANGED_FILES` and `DELETED_FILES` are whitespace-separated lists of
- * paths to content files. `RENAMED_FILES` is a whitespace-separated list
- * of `oldPath,newPath` pairs (as emitted by tj-actions/changed-files
- * `all_old_new_renamed_files` output). For example:
- *
- * export CHANGED_FILES="content/get-started/index.md content/get-started/start-your-journey/hello-world.md"
- * export RENAMED_FILES="content/old/path.md,content/new/path.md"
- *
- * If any of the paths in there, split by ' ', don't match real files, the
- * test will fail before it even starts. Meaning, it will throw an error
- * rather than failing an `expect(...)` assertion.
- *
- * Technically, the value is any whitespace. So you can actually use:
- *
- * export DELETED_FILES=`git diff --name-only main...`
- *
- * which will make the environment variable be newline-separated and that
- * works too.
- *
- * So, for example, if you've made some deletions and some edits the
- * staged files:
- *
- * export DELETED_FILES=`git diff --name-only --diff-filter=D main...`
- * export CHANGED_FILES=`git diff --name-only --diff-filter=M main...`
- * npm run test -- src/content-render/tests/render-changed-and-deleted-files.ts
- */
+// To run this test locally, set CHANGED_FILES, DELETED_FILES, or RENAMED_FILES.
+// CHANGED_FILES and DELETED_FILES contain whitespace-separated content paths.
+// RENAMED_FILES contains oldPath,newPath pairs from tj-actions/changed-files.
+// CHANGED_FILES paths must identify loaded pages or the test throws before expectations run.
+// Newline-separated git diff output works because the parser accepts all whitespace.
+// Example:
+// export CHANGED_FILES="content/get-started/index.md content/actions/index.md"
+// export RENAMED_FILES="content/old/path.md,content/new/path.md"
+// export DELETED_FILES="$(git diff --name-only --diff-filter=D main...)"
+// npm run test -- src/content-render/tests/render-changed-and-deleted-files.ts
import path from 'path'
@@ -52,10 +28,8 @@ function getDeletedContentFiles() {
return getContentFiles(process.env.DELETED_FILES)
}
-// Parse `RENAMED_FILES` from tj-actions/changed-files `all_old_new_renamed_files`
-// output. Each whitespace-separated entry is an `oldPath,newPath` pair. We return
-// the OLD paths so they can be checked the same way deleted files are: the test
-// will fail if the old URL 404s (i.e. no redirect was set up for the rename).
+// RENAMED_FILES comes from tj-actions/changed-files all_old_new_renamed_files.
+// Each oldPath,newPath entry adds the old path because old URLs must not return 404.
function getRenamedOldContentFiles() {
const raw = (process.env.RENAMED_FILES || '').split(/\s+/g).filter(Boolean)
const oldPaths = raw.map((pair) => pair.split(',')[0]).filter(Boolean)
@@ -64,7 +38,7 @@ function getRenamedOldContentFiles() {
function getContentFiles(spaceSeparatedList: string | undefined): string[] {
return (spaceSeparatedList || '').split(/\s+/g).filter((filePath) => {
- // This filters out things like '', or `data/foo.md` or `content/something/README.md`
+ // Only content Markdown pages count; data files and content README files do not render.
return (
filePath.endsWith('.md') &&
filePath.split(path.sep)[0] === 'content' &&
@@ -73,23 +47,18 @@ function getContentFiles(spaceSeparatedList: string | undefined): string[] {
})
}
-// If the list of changed pages is very large, this test can take a long time.
-// It can also happen if some of the pages involves are infamously slow.
-// For example guide pages because they involved a lot of processing
-// to gather and preview linked data.
+// Large changes and guide pages can render slowly because guides gather linked data.
vi.setConfig({ testTimeout: 60 * 1000 })
describe('changed-content', () => {
const changedContentFiles = getChangedContentFiles()
- // `test.each` will throw if the array is empty, so we need to add a dummy
- // when there are no changed files in the environment.
+ // test.each throws on an empty array, so EMPTY stands in when no files are present.
const testFiles: Array = changedContentFiles.length
? changedContentFiles
: [EMPTY]
test.each(testFiles)('changed-content: %s', async (file: string | symbol) => {
- // Necessary because `test.each` will throw if the array is empty
if (file === EMPTY) return
const page = pageList.find((p) => {
@@ -98,7 +67,7 @@ describe('changed-content', () => {
if (!page) {
throw new Error(`Could not find page for ${file as string} in all loaded English content`)
}
- // Each version of the page should successfully render
+ // Every permalink must render because changed files can affect all versions.
for (const { href } of page.permalinks) {
const res = await get(href)
if (!res.ok) {
@@ -114,19 +83,16 @@ describe('changed-content', () => {
})
describe('deleted-content', () => {
- // Renamed files (status `R` from git) don't appear in `DELETED_FILES`, but
- // the old path is just as gone from the user's perspective and needs a
- // redirect. Treat the old path of each rename the same as a deleted file.
+ // RENAMED_FILES provides old paths separately because git status R paths skip DELETED_FILES.
const deletedContentFiles = [...getDeletedContentFiles(), ...getRenamedOldContentFiles()]
- // `test.each` will throw if the array is empty, so we need to add a dummy
- // when there are no deleted files in the environment.
+ // test.each throws on an empty array, so EMPTY stands in when no files are present.
const testFiles: Array = deletedContentFiles.length
? deletedContentFiles
: [EMPTY]
+ // Deleted pages no longer have versions frontmatter, so this checks the versionless permalink.
test.each(testFiles)('deleted-content: %s', async (file: string | symbol) => {
- // Necessary because `test.each` will throw if the array is empty
if (file === EMPTY) return
const page = pageList.find((p) => {
@@ -137,9 +103,6 @@ describe('deleted-content', () => {
`The supposedly deleted file ${file as string} is still in list of loaded pages`,
)
}
- // You can't know what the possible permalinks were for a deleted page,
- // because it's deleted so we can't look at its `versions` front matter.
- // However, we always make sure all pages work in versionless.
const indexmdSuffixRegex = new RegExp(`${path.sep}index\\.md$`)
const mdSuffixRegex = /\.md$/
const relativePath = (file as string).split(path.sep).slice(1).join(path.sep)
@@ -150,9 +113,7 @@ describe('deleted-content', () => {
res.statusCode === 404
? `The deleted or renamed file ${file as string} did not set up a redirect.`
: ''
- // Certain articles that are deleted and moved under a directory with the same article name
- // should just route to the subcategory page instead of redirecting (docs content team confirmed).
- // So, in this scenario, we'd get a 200 status code.
+ // Same-name subcategory moves return 200 instead of redirecting.
expect(res.statusCode === 301 || res.statusCode === 200, error).toBe(true)
})
})
diff --git a/src/content-render/tests/render-content.ts b/src/content-render/tests/render-content.ts
index dc1cdbbf9576..939abf540582 100644
--- a/src/content-render/tests/render-content.ts
+++ b/src/content-render/tests/render-content.ts
@@ -4,8 +4,7 @@ import { describe, expect, test } from 'vitest'
import { renderContent } from '@/content-render/index'
import { EOL } from 'os'
-// Use platform-specific line endings for realistic tests when templates have
-// been loaded from disk
+// Disk-loaded templates use platform line endings, so tests do too.
const nl = (str: string): string => str.replace(/\n/g, EOL)
describe('renderContent', () => {
@@ -240,8 +239,8 @@ var a = 1
const html = await renderContent(template)
const $ = load(html)
const el = $('button.js-btn-copy')
+ // Copy buttons use a murmurhash ID that matches the paired pre element.
expect(el.data('clipboard')).toBe(2967273189)
- // Generates a murmurhash based ID that matches a
})
describe('wrap-code-terms ( in table code)', () => {
diff --git a/src/content-render/tests/render-to-hast.ts b/src/content-render/tests/render-to-hast.ts
index c50f67640d84..0bfddca3c574 100644
--- a/src/content-render/tests/render-to-hast.ts
+++ b/src/content-render/tests/render-to-hast.ts
@@ -4,11 +4,8 @@ import { renderContentToHast } from '@/content-render/index'
import { renderUnified, renderUnifiedToHast } from '@/content-render/unified/index'
import type { Context } from '@/types'
-// A corpus that exercises the parts of the pipeline most likely to differ
-// between "stringify the processed vfile" (today) and "stringify the hast tree
-// we stopped at" (the new hast path): headings (slug + anchor links), code
-// blocks (highlight + code-header), tables (several rewrite plugins), alerts,
-// raw inline HTML (rehype-raw), and images.
+// This corpus covers pipeline stages where vfile HTML and hast-derived HTML can diverge:
+// headings, highlighted code, tables, alerts, raw inline HTML, images, and blockquotes.
const fixtures: Array<{ name: string; template: string }> = [
{ name: 'paragraph', template: 'Hello **world**, this is a [link](https://github.com).' },
{
diff --git a/src/content-render/tests/table-accessibility-labels.ts b/src/content-render/tests/table-accessibility-labels.ts
index e17e246cf096..a69844db08c0 100644
--- a/src/content-render/tests/table-accessibility-labels.ts
+++ b/src/content-render/tests/table-accessibility-labels.ts
@@ -4,8 +4,7 @@ import { describe, expect, test } from 'vitest'
import { renderContent } from '@/content-render/index'
import { EOL } from 'os'
-// Use platform-specific line endings for realistic tests when templates have
-// been loaded from disk
+// Disk-loaded templates use platform line endings, so tests do too.
const nl = (str: string) => str.replace(/\n/g, EOL)
describe('table accessibility labels', () => {
@@ -170,7 +169,7 @@ Some additional context here.
const tables = $('table')
expect(tables.length).toBe(2)
expect($(tables[0]).attr('aria-labelledby')).toBe('first-heading')
- // Second table should not get the same heading since the first table is in between
+ // A prior table stops heading lookup, so the second table stays unlabeled.
expect($(tables[1]).attr('aria-labelledby')).toBeUndefined()
})
diff --git a/src/data-directory/lib/data-directory.ts b/src/data-directory/lib/data-directory.ts
index 05e7049d93a6..56da0f47a565 100644
--- a/src/data-directory/lib/data-directory.ts
+++ b/src/data-directory/lib/data-directory.ts
@@ -17,6 +17,9 @@ interface DataDirectoryResult {
[key: string]: unknown
}
+// dataDirectory uses setWith because lodash set creates arrays for numeric release-note paths.
+// Example: release-notes.enterprise-server.2-20.0 must stay an object path.
+// See https://lodash.com/docs#set.
export default function dataDirectory(
dir: string,
opts: DataDirectoryOptions = {},
@@ -38,7 +41,6 @@ export default function dataDirectory(
const data: DataDirectoryResult = {}
- // find YAML and Markdown files in the given directory, recursively
const filenames = walk(dir, { includeBasePath: true }).filter((filename: string) => {
if (mergedOpts.ignorePatterns.some((pattern) => pattern.test(filename))) return false
@@ -51,7 +53,6 @@ export default function dataDirectory(
])
for (const [filename, fileContent] of files) {
- // derive `foo.bar.baz` object key from `foo/bar/baz.yml` filename
const key = filenameToKey(path.relative(dir, filename))
const extension = path.extname(filename).toLowerCase()
@@ -60,11 +61,6 @@ export default function dataDirectory(
processedContent = mergedOpts.preprocess(fileContent)
}
- // Add this file's data to the global data object.
- // Note we want to use `setWith` instead of `set` so we can customize the type during path creation.
- // If we just use `set`, then e.g. `release-notes.enterprise-server.2-20.0` will be an Array but
- // `release-notes.enterprise-server.3-0.0` will be an Object.
- // See https://lodash.com/docs#set for an explanation.
switch (extension) {
case '.json':
setWith(data, key, JSON.parse(processedContent), Object)
@@ -74,9 +70,7 @@ export default function dataDirectory(
break
case '.md':
case '.markdown':
- // Use `matter` to drop frontmatter, since localized reusable Markdown files
- // can potentially have frontmatter, but we want to prevent the frontmatter
- // from being rendered.
+ // Localized reusable Markdown can have frontmatter; strip it so content rendering hides it.
setWith(data, key, matter(processedContent).content, Object)
break
}
diff --git a/src/data-directory/lib/data-schemas/ctas.ts b/src/data-directory/lib/data-schemas/ctas.ts
index 2f97602bed03..31c2c0238c80 100644
--- a/src/data-directory/lib/data-schemas/ctas.ts
+++ b/src/data-directory/lib/data-schemas/ctas.ts
@@ -1,13 +1,9 @@
-// This schema enforces the structure for CTA (Call-to-Action) URL parameters
-// Used to validate CTA tracking parameters in documentation links
-
export default {
type: 'object',
additionalProperties: false,
required: ['ref_product', 'ref_type', 'ref_style'],
properties: {
- // GitHub Product: The GitHub product the CTA leads users to
- // Format: ref_product=copilot
+ // Example query parameter: ref_product=copilot.
ref_product: {
type: 'string',
name: 'Product',
@@ -26,8 +22,7 @@ export default {
],
},
- // Type of CTA: The type of action the CTA encourages users to take
- // Format: ref_type=trial
+ // Example query parameter: ref_type=trial.
ref_type: {
type: 'string',
name: 'Type',
@@ -35,8 +30,7 @@ export default {
enum: ['trial', 'purchase', 'engagement'],
},
- // CTA style: The way we are formatting the CTA in the docs
- // Format: ref_style=button
+ // Example query parameter: ref_style=button.
ref_style: {
type: 'string',
name: 'Style',
@@ -44,8 +38,7 @@ export default {
enum: ['button', 'text'],
},
- // Type of plan (Optional): For links to sign up for or trial a plan, the specific plan we link to
- // Format: ref_plan=business
+ // Example query parameter: ref_plan=business.
ref_plan: {
type: 'string',
name: 'Plan',
diff --git a/src/data-directory/lib/data-schemas/features.ts b/src/data-directory/lib/data-schemas/features.ts
index 1b9aa310350b..de81e35ff109 100644
--- a/src/data-directory/lib/data-schemas/features.ts
+++ b/src/data-directory/lib/data-schemas/features.ts
@@ -15,7 +15,6 @@ interface FeatureVersionsSchema {
additionalProperties: false
}
-// Copy the properties from the frontmatter schema.
const featureVersions: FeatureVersionsSchema = {
type: 'object',
properties: {
@@ -24,8 +23,7 @@ const featureVersions: FeatureVersionsSchema = {
additionalProperties: false,
}
-// Remove the feature versions properties.
-// We don't want to allow features within features! We just want pure versioning.
+// Each data/features file allows version gates but not nested feature gates.
delete (featureVersions.properties.versions.properties as Record | undefined)
?.feature
diff --git a/src/data-directory/lib/data-schemas/glossaries-candidates.ts b/src/data-directory/lib/data-schemas/glossaries-candidates.ts
index cfe6393c32a6..ae86460d98ac 100644
--- a/src/data-directory/lib/data-schemas/glossaries-candidates.ts
+++ b/src/data-directory/lib/data-schemas/glossaries-candidates.ts
@@ -7,7 +7,8 @@ export interface TermSchema {
export const term: TermSchema = {
type: 'string',
minLength: 1,
- pattern: '^((?!\\*).)*$', // no asterisks allowed
+ // Reject asterisks in glossary terms.
+ pattern: '^((?!\\*).)*$',
}
export interface GlossaryCandidateItem {
diff --git a/src/data-directory/lib/data-schemas/index.ts b/src/data-directory/lib/data-schemas/index.ts
index bd157c2afb63..9a082301823a 100644
--- a/src/data-directory/lib/data-schemas/index.ts
+++ b/src/data-directory/lib/data-schemas/index.ts
@@ -12,16 +12,14 @@ function resolveSchemaPath(filename: string): string {
const isTest = process.env.NODE_ENV === 'test'
if (isTest) {
- // Use relative paths that work for vitest and 4.x compatibility with
- // dynamic imports in particular
+ // Vitest dynamic imports need relative schema paths.
return `../lib/data-schemas/${filename}`
} else {
- // Use absolute paths that work for content linter and other contexts
+ // Content linter and other runtime contexts need absolute schema paths.
return `@/data-directory/lib/data-schemas/${filename}`
}
}
-// Auto-discover table schemas from data/tables/ directory
function loadTableSchemas(): DataSchemas {
const tablesDir = path.join(process.cwd(), 'data/tables')
const schemasDir = path.join(__dirname, 'tables')
@@ -43,7 +41,6 @@ function loadTableSchemas(): DataSchemas {
return tableSchemas
}
-// Manual schema registrations for non-table data
const manualSchemas: DataSchemas = {
'data/features': resolveSchemaPath('features.ts'),
'data/variables': resolveSchemaPath('variables.ts'),
@@ -51,9 +48,8 @@ const manualSchemas: DataSchemas = {
'data/code-languages.yml': resolveSchemaPath('code-languages.ts'),
'data/glossaries/candidates.yml': resolveSchemaPath('glossaries-candidates.ts'),
'data/glossaries/external.yml': resolveSchemaPath('glossaries-external.ts'),
- // Tables in subdirectories of data/tables are not picked up by loadTableSchemas(),
- // which only reads the top level, so the matrix is registered explicitly here.
- // The matrix/ entry is a directory schema: every per-IDE file is validated against it.
+ // Register the matrix directory schema because loadTableSchemas reads only top-level files.
+ // The directory schema validates every per-IDE file.
'data/tables/copilot/matrix': resolveSchemaPath('tables/copilot/matrix-ide.ts'),
'data/tables/copilot/matrix-meta.yml': resolveSchemaPath('tables/copilot/matrix-meta.ts'),
}
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/auto-model-selection.ts b/src/data-directory/lib/data-schemas/tables/copilot/auto-model-selection.ts
index 9b6ff927dd95..63b81d4f8cec 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/auto-model-selection.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/auto-model-selection.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in auto-model-selection.yml
-
const autoModelSelectionSchema = {
type: 'array',
items: {
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/matrix-ide.ts b/src/data-directory/lib/data-schemas/tables/copilot/matrix-ide.ts
index e91ddcc37c4c..e2b85f6cdc9b 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/matrix-ide.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/matrix-ide.ts
@@ -1,28 +1,15 @@
-// Schema for the per-IDE files in data/tables/copilot/matrix/
-//
-// Registered as a directory schema in src/data-directory/lib/data-schemas/index.ts,
-// so every file added to that directory is validated against this shape.
+// The directory schema registration validates every data/tables/copilot/matrix/.yml file.
-// Deliberately not an enum. The vocabulary is defined once, as data, in
-// matrix-meta.yml, and is enforced against every IDE file by the
-// 'every support level used is defined in matrix-meta' invariant in
-// src/data-directory/tests/copilot-matrix.ts. Repeating the values here would
-// be a fourth copy that can drift from the data — which is exactly what the
-// schema this file replaces did: it was missing 'closing-down'.
+// supportLevel stays open because matrix-meta.yml owns the vocabulary and tests enforce it.
+// Repeating values here would create a fourth copy that can drift from data.
const supportLevel = {
type: 'string',
}
-// Every version tracked here is 3-part, and that follows from what is tracked
-// rather than from convention: four of the six files track the Copilot
-// extension (marketplace versions are required to be x.y.z) and the two that
-// track the IDE itself, VS Code and Visual Studio, version that way natively.
-// Kept strict on purpose. It catches a dropped or added segment — the mistake
-// an updater reading release notes is most likely to make, and one the
-// cross-file invariants cannot see, since they only check that a version is
-// used consistently, not that it is real. If an IDE genuinely changes
-// versioning scheme, that is a deliberate decision: change this pattern and say
-// why in the PR.
+// All six matrix files use three-part versions: four track Copilot extension marketplace versions,
+// and VS Code and Visual Studio use three-part IDE versions natively.
+// Keep the pattern strict because cross-file tests catch consistency, not malformed versions.
+// Update this pattern if an IDE adopts a different version format.
const VERSION_PATTERN = '^\\d+\\.\\d+\\.\\d+$'
const copilotMatrixIdeSchema = {
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/matrix-meta.ts b/src/data-directory/lib/data-schemas/tables/copilot/matrix-meta.ts
index 8853dc696125..873642ba58e8 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/matrix-meta.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/matrix-meta.ts
@@ -1,7 +1,5 @@
-// Schema for data/tables/copilot/matrix-meta.yml
-//
-// Shared configuration for the Copilot IDE feature matrix. Per-IDE data lives in
-// data/tables/copilot/matrix/.yml and is validated by matrix-ide.ts.
+// matrix-meta.yml owns shared Copilot IDE matrix configuration.
+// Per-IDE data lives in data/tables/copilot/matrix/.yml and matrix-ide.ts validates it.
const copilotMatrixMetaSchema = {
type: 'object',
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts
index 022eb8da25aa..6189c00e9ff6 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/model-comparison.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in model-comparison.yml
-
const modelComparisonSchema = {
type: 'object',
additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-deprecation-history.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-deprecation-history.ts
index ba31dc99efb6..84fc98abac18 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/model-deprecation-history.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/model-deprecation-history.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in model-deprecation-history.yml
-
const modelDeprecationHistorySchema = {
type: 'object',
additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-release-status.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-release-status.ts
index a00352c7735a..6918f92a8903 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/model-release-status.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/model-release-status.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in model-release-status.yml
-
const modelsReleaseStatusSchema = {
type: 'object',
additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-supported-clients.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-supported-clients.ts
index ffb28af36dc2..84475d8305aa 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/model-supported-clients.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/model-supported-clients.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in model-supported-clients.yml
-
const modelsSupportedClientsSchema = {
type: 'object',
additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/model-supported-plans.ts b/src/data-directory/lib/data-schemas/tables/copilot/model-supported-plans.ts
index 401b46254091..1476e55a4774 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/model-supported-plans.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/model-supported-plans.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in model-supported-plans.yml
-
const modelSupportedPlansSchema = {
type: 'object',
additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/copilot/models-and-pricing.ts b/src/data-directory/lib/data-schemas/tables/copilot/models-and-pricing.ts
index 96f8127cd22e..992153a91100 100644
--- a/src/data-directory/lib/data-schemas/tables/copilot/models-and-pricing.ts
+++ b/src/data-directory/lib/data-schemas/tables/copilot/models-and-pricing.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in models-and-pricing.yml
-
const modelsAndPricingSchema = {
type: 'object',
additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/repository-roles.ts b/src/data-directory/lib/data-schemas/tables/repository-roles.ts
index 6774b9739d4b..ab0a2af84e5c 100644
--- a/src/data-directory/lib/data-schemas/tables/repository-roles.ts
+++ b/src/data-directory/lib/data-schemas/tables/repository-roles.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in data/tables/repository-roles.yml
-
const row = {
type: 'object',
additionalProperties: false,
@@ -9,13 +7,11 @@ const row = {
type: 'string',
lintable: true,
},
- // Liquid that renders non-empty when the row should be shown. When omitted,
- // the row is shown on every version.
+ // Non-empty Liquid output limits the row to matching versions; omitting it renders everywhere.
versions: {
type: 'string',
},
- // Comma separated list of the roles that can perform the action. Roles left
- // out render as no. May contain Liquid, so a single role can be conditional.
+ // Comma-separated roles can contain Liquid; omitted roles render as no.
roles: {
type: 'string',
},
diff --git a/src/data-directory/lib/data-schemas/tables/rest-api-versions.ts b/src/data-directory/lib/data-schemas/tables/rest-api-versions.ts
index 046c02afe403..b0e5aef2741d 100644
--- a/src/data-directory/lib/data-schemas/tables/rest-api-versions.ts
+++ b/src/data-directory/lib/data-schemas/tables/rest-api-versions.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in data/tables/rest-api-versions.yml
-
export default {
type: 'object',
additionalProperties: false,
diff --git a/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts b/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts
index a298f709ef15..a7836014e0d5 100644
--- a/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts
+++ b/src/data-directory/lib/data-schemas/tables/supported-code-languages.ts
@@ -1,5 +1,3 @@
-// This schema enforces the structure in data/tables/supported-code-languages.yml
-
export default {
type: 'object',
additionalProperties: false,
@@ -164,7 +162,7 @@ export default {
type: 'object',
additionalProperties: false,
patternProperties: {
- // Language names like C, C++, C#, Go, Java, JavaScript, etc.
+ // Matches language names like C, C++, C#, Go, Java, and JavaScript.
'^[a-zA-Z+#]+$': {
type: 'object',
additionalProperties: false,
@@ -188,15 +186,15 @@ export default {
},
codeScanning: {
type: 'string',
- // Allow "supported", "not-supported", or custom text like "third-party [^1]"
+ // Accepts supported, not-supported, or custom text such as "third-party [^1]".
},
depGraph: {
type: 'string',
- // Allow "supported", "not-supported", or specific package managers like "npm, Yarn"
+ // Accepts supported, not-supported, or package managers such as "npm, Yarn".
},
depUpdates: {
type: 'string',
- // Allow "supported", "not-supported", or specific package managers
+ // Accepts supported, not-supported, or package managers.
},
actions: {
type: 'string',
@@ -204,7 +202,7 @@ export default {
},
packages: {
type: 'string',
- // Allow "supported", "not-supported", or specific package managers
+ // Accepts supported, not-supported, or package managers.
},
},
},
diff --git a/src/data-directory/lib/filename-to-key.ts b/src/data-directory/lib/filename-to-key.ts
index e46c27903709..b9eaf51fbaf7 100644
--- a/src/data-directory/lib/filename-to-key.ts
+++ b/src/data-directory/lib/filename-to-key.ts
@@ -3,14 +3,12 @@ import path from 'path'
const leadingPathSeparator = new RegExp(`^${RegExp.escape(path.sep)}`)
const windowsLeadingPathSeparator = new RegExp('^/')
-// all slashes in the filename. path.sep is OS agnostic (windows, mac, etc)
+// path.sep handles the current OS; the slash and backslash regexes handle paths from other systems.
const pathSeparator = new RegExp(RegExp.escape(path.sep), 'g')
const windowsPathSeparator = new RegExp('/', 'g')
-// handle MS Windows style double-backslashed filenames
const windowsDoubleSlashSeparator = new RegExp('\\\\', 'g')
-// derive `foo.bar.baz` object key from `foo/bar/baz.yml` filename
export default function filenameToKey(filename: string): string {
const extension = new RegExp(`${RegExp.escape(path.extname(filename))}$`)
const key = filename
diff --git a/src/data-directory/lib/get-data.ts b/src/data-directory/lib/get-data.ts
index 65b3d8d8bc91..85f7acf1bd09 100644
--- a/src/data-directory/lib/get-data.ts
+++ b/src/data-directory/lib/get-data.ts
@@ -20,14 +20,10 @@ interface FileSystemError extends Error {
code?: string
}
-// If you run `export DEBUG_JIT_DATA_READS=true` in your terminal,
-// next time it will mention every file it reads from disk.
+// Set DEBUG_JIT_DATA_READS=true to log every data file read from disk.
const DEBUG_JIT_DATA_READS = Boolean(JSON.parse(process.env.DEBUG_JIT_DATA_READS || 'false'))
-// This is a list of files that we should always immediately fall back to
-// English for.
-// Having this is safer than trying to wrangle the translations to NOT
-// have them translated.
+// Product and Copilot paths belong in the English-only set; translations can change fixed names.
const ALWAYS_ENGLISH_YAML_FILES = new Set([
'data/variables/product.yml',
'data/variables/copilot.yml',
@@ -37,17 +33,13 @@ const ALWAYS_ENGLISH_MD_FILES = new Set([
'data/reusables/ssh/known_hosts.md',
])
-// Returns all the things inside a directory
export const getDeepDataByLanguage = memoize(
(dottedPath: string, langCode: string, dir: string | null = null): Record => {
if (!(langCode in languages)) {
throw new Error(`langCode '${langCode}' not a recognized language code`)
}
- // The `dir` argument is only used for testing purposes.
- // For example, our unit tests that depend on using a fixtures root.
- // If we don't allow those tests to override the `dir` argument,
- // it'll be stuck from the first time `languages.ts` was imported.
+ // Tests pass a fixture root because languages-server.ts captures directories when it loads.
if (dir === null) {
dir = languages[langCode].dir
}
@@ -55,8 +47,7 @@ export const getDeepDataByLanguage = memoize(
},
)
-// Doesn't need to be memoized because it's used by getDataKeysByLanguage
-// which is already memoized.
+// getDeepDataByLanguage caches each top-level path, so recursive reads need no extra cache.
function getDeepDataByDir(dottedPath: string, dir: string): Record {
const fullPath = ['data']
const split = dottedPath.split(/\./g)
@@ -66,7 +57,8 @@ function getDeepDataByDir(dottedPath: string, dir: string): Record {
const uiEnglish = getUIData('en')
if (langCode === 'en') return uiEnglish as UIStrings
- // Got to combine. Start with the English and put the translation on top.
- // E.g.
- // english = {food: "Food", drink: "Drink"}
- // swedish = {food: "Mat"}
- // =>
- // combind = {food: "Mat", drink: "Drink"}
+ // Merge translations over English so missing localized UI keys fall back to English.
const combined: Record = {}
merge(combined, uiEnglish)
merge(combined, getUIData(langCode))
return combined as UIStrings
})
-// Doesn't need to be memoized because it's used by another function
-// that is memoized.
+// getUIDataMerged memoizes results, so this reader needs no separate cache.
const getUIData = (langCode: string): Record => {
const fullPath = ['data', 'ui.yml']
const { dir } = languages[langCode]
return getYamlContent(dir, fullPath.join(path.sep)) as Record
}
+// When translated data misses a dotted path, retry English.
+// lodash get returns undefined for the missing dotted path instead of ENOENT.
export const getDataByLanguage = memoize((dottedPath: string, langCode: string): unknown => {
if (!(langCode in languages))
throw new Error(`langCode '${langCode}' not a recognized language code`)
@@ -116,32 +104,20 @@ export const getDataByLanguage = memoize((dottedPath: string, langCode: string):
try {
const value = getDataByDir(dottedPath, dir, languages.en.dir, langCode)
- // What could happens is that a new key has only been added to
- // the English data/ui.yml but hasn't been added to Japanese, but
- // there nevertheless exists a Japanese `data/ui.yml`.
- // Since getDataByDir() uses `get(dataObject, 'dott.ed.path')` it
- // will return `undefined` if it's not present.
- // If this happens, we can't rely on `err.code === 'ENOENT'` to
- // fall back the English one. So we just start over using the English data.
if (value === undefined && langCode !== 'en') {
return getDataByDir(dottedPath, languages.en.dir)
}
return value
} catch (error) {
if (error instanceof Error && (error as YAMLException).mark && error.message) {
- // It's a load() generated error!
- // Remember, the file that we read might have been a .yml or a .md
- // file. If it was a .md file, with corrupt front-matter that too
- // would have caused a YAMLException
+ // Corrupt YAML files and Markdown frontmatter raise YAMLException, so translations fall back.
if (langCode !== 'en') {
if (DEBUG_JIT_DATA_READS) {
logger.warn('Unable to parse Yaml in translation', { langCode, dottedPath, error })
}
- // Give it one more chance, but use English this time
return getDataByDir(dottedPath, languages.en.dir)
}
- // Always throw English Yaml reading errors. Staff writers
- // need to know early and explicitly that they are corrupt.
+ // Throw English YAML errors so staff writers see corrupt source data early.
throw error
}
@@ -150,6 +126,10 @@ export const getDataByLanguage = memoize((dottedPath: string, langCode: string):
}
})
+// getSmartSplit preserves dotted path segments such as version-3.4.
+// Release notes split normally because numeric paths such as 3-7/0.yml would combine incorrectly.
+// getDataByDir keeps {% data early-access.reusables.foo.bar %} under data/early-access.
+// That data lives at data/early-access/reusables/foo/bar.md.
function getDataByDir(
dottedPath: string,
dir: string,
@@ -158,28 +138,10 @@ function getDataByDir(
): unknown {
const fullPath = ['data']
- // Using English here because it doesn't matter. We just want to
- // figure out how to turn `foo.version-3.4.deeper.key' into
- // `['foo', 'version-3.4', 'deeper', 'key']` here and we'll need
- // any directory to do that and English is always the most up-to-date.
- // We need the getSmartSplit() as long as there's a chance that a
- // directory or file inside data/ might contain a dot in the name,
- // however the exception is the file names in data/release-notes/**/*.yml
- // because it contains files that are just numbers like 3-7/0.yml and
- // that can cause problems inside getSmartSplit().
const split = dottedPath.startsWith('release-notes')
? dottedPath.split('.')
: getSmartSplit(dottedPath)
- // For early-access data stuff, they're referred to as...
- //
- // {% data early-access.reusables.foo.bar %}
- //
- // When we "merge" in the early-access data, we put the whole directory
- // within the root `data/` so it exists, on disk, as
- //
- // data/early-access/reusables/foo/bar.md
- //
if (split[0] === 'early-access') {
fullPath.push(split.shift()!)
}
@@ -233,24 +195,12 @@ function getDataByDir(
const markdown = getMarkdownContent(dir, fullPath.join(path.sep), englishRoot)
let { content } = matter(markdown)
if (dir !== englishRoot) {
- // If we're reading a translation, we need to replace the possible
- // corruptions. For example `[AUTOTITLE"을](/foo/bar)`.
- // To do this we'll need the English equivalent
+ // Translated reusables need English content to fix corruptions like [AUTOTITLE"을](/foo/bar).
let englishContent = content
try {
englishContent = getMarkdownContent(englishRoot, fullPath.join(path.sep), englishRoot)
} catch (error) {
- // In some real but rare cases a reusable doesn't exist in English.
- // At all.
- // This can happen when the translation is really out of date.
- // You might have an old `docs-internal.locale/content/**/*.md`
- // file that mentions `{% data reusables.foo.bar %}`. And it's
- // working fine, except none of that exists in English.
- // If this is the case, we still want to executed the
- // correctTranslatedContentStrings() function, but we can't
- // genuinely give it the English equivalent content, which it
- // sometimes uses to correct some Liquid tags. At least other
- // good corrections might happen.
+ // Translated pages can reference reusables missing in English; other corrections still run.
if ((error as FileSystemError).code !== 'ENOENT') {
throw error
}
@@ -263,9 +213,9 @@ function getDataByDir(
return content
}
- // E.g. {% data ui.pages.foo.bar %}
+ // UI data references such as {% data ui.pages.foo.bar %} read from data/ui.yml.
if (first === 'ui') {
- const basename = split.shift() // i.e. 'ui'
+ const basename = split.shift()
fullPath.push(`${basename}.yml`)
const allData = getYamlContent(dir, fullPath.join(path.sep), englishRoot)
return get(allData, split.join('.'))
@@ -292,7 +242,7 @@ function getSmartSplit(dottedPath: string): string[] {
const next = split[i + 1]
if (/\d$/.test(bit) && /^\d/.test(next)) {
bits.push([bit, next].join('.'))
- i++ // jump ahead one position in the loop
+ i++
} else {
bits.push(bit)
}
@@ -301,36 +251,12 @@ function getSmartSplit(dottedPath: string): string[] {
return bits
}
-// The reason this is memoized, even though the parent caller function
-// (`getDataByLanguage`) is also memoized is because we might read
-// the same file for two different keys. E.g.
-//
-// getDataByLanguage('variables.product.prodname_ghe_server', 'en')
-// getDataByLanguage('variables.product.company_short', 'en')
-//
-// ...will actually depend on reading `data/variables/product.yml`. Twice.
-// Well, actually not twice because we cache the disk reading. So the outcome
-// becomes this:
-//
-// 1. getDataByLanguage('variables.product.prodname_ghe_server', 'en')
-// -> cache MISS
-// 1.1. read and parse data/variables/product.yml
-// -> cache MISS
-// 2. getDataByLanguage('variables.product.company_short', 'en')
-// -> cache MISS
-// 2.1. read and parse data/variables/product.yml
-// -> cache HIT (Yay!)
-//
+// getDataByLanguage caches each dotted key, but different keys can read the same YAML file.
+// Cache YAML reads too, so product name variables share data/variables/product.yml.
const getYamlContent = memoize(
(root: string | undefined, relPath: string, englishRoot?: string): unknown => {
- // Certain Yaml files we know we always want the English one
- // no matter what the specified language is.
- // For example, we never want `data/variables/product.yml` translated
- // so we know to immediately fall back to the English one.
if (ALWAYS_ENGLISH_YAML_FILES.has(relPath)) {
- // This forces it to read from English. Later, when it goes
- // into `getFileContent(...)` it will note that `root !== englishRoot`
- // so it won't try to fall back.
+ // Passing englishRoot prevents getFileContent from treating this as a translation fallback.
root = englishRoot
}
const fileContent = getFileContent(root, relPath, englishRoot)
@@ -338,13 +264,10 @@ const getYamlContent = memoize(
},
)
-// The reason why this is memoized, is the same as for getYamlContent() above.
+// Cache Markdown reads too because different dotted keys can hit the same file.
const getMarkdownContent = memoize(
(root: string | undefined, relPath: string, englishRoot?: string): string => {
- // Certain reusables we never want to be pulled from the translations.
- // For example, certain reusables don't contain any English prose. Just
- // facts like numbers or hardcoded key words.
- // If this is the case, forcibly always draw from the English files.
+ // SSH fingerprints and known_hosts contain facts, not prose, so they are meant to use English.
if (ALWAYS_ENGLISH_MD_FILES.has(relPath)) {
root = englishRoot
}
@@ -364,13 +287,9 @@ const getFileContent = (
try {
return fs.readFileSync(filePath, 'utf-8')
} catch (err) {
- // It might fail because that particular data entry doesn't yet
- // exist in a translation
if ((err as FileSystemError).code === 'ENOENT') {
- // If looking it up as a file fails, give it one more chance if the
- // read was for a translation.
if (englishRoot && root !== englishRoot) {
- // We can try again but this time using the English files
+ // Missing translated data falls back to English when an English root is available.
return getFileContent(englishRoot, relPath, englishRoot)
}
}
@@ -378,24 +297,15 @@ const getFileContent = (
}
}
+// Development bypasses caching because repeated sync reads stay cheap enough for debugging.
+// A benchmark sampled 10 common data files across 100 runs, with about 80% YAML files.
+// Median sync reads took 0.5 ms per 10 files, or 2.1 ms per 10 files with YAML parsing.
function memoize(
func: (...args: Args) => Return,
): (...args: Args) => Return {
const cache = new Map()
return (...args: Args) => {
if (process.env.NODE_ENV === 'development') {
- // It is very possible that certain files, when caching is disabled,
- // are read multiple times in short succession. E.g. `product.yml`.
- // So how expensive is it to read these files excessively?
- // To answer that, we benchmarked it by sampling 10 files from the
- // most common files that are used from `data/`. In fact, we ran 100
- // runs of 10 *different* files. About 80% of them were `.yml` files.
- // As a median, it takes **0.5ms to read 10 files from disk**
- // all in a sync manner.
- // Since most files coming through here is `.yml` files (e.g.
- // product.yml and ui.yml) if you also do the `load()` of the
- // read content, that number becomes **2.1ms to read and parse 10 files**.
- // So in conclusion, not a lot of time.
return func(...args)
}
diff --git a/src/data-directory/middleware/data-tables.ts b/src/data-directory/middleware/data-tables.ts
index 8edf5262f938..bb3ed7c20a32 100644
--- a/src/data-directory/middleware/data-tables.ts
+++ b/src/data-directory/middleware/data-tables.ts
@@ -6,13 +6,12 @@ let tablesCache: Record | null = null
const getTables = () => {
if (!tablesCache) {
- // Keep product-name-heavy reference tables in English only for now
+ // Product-name-heavy reference tables stay in English to avoid localized product names.
tablesCache = getDeepDataByLanguage('tables', 'en')
}
return tablesCache
}
-// Loads the YAML files under data/tables/ into req.context.
export default async function dataTables(req: ExtendedRequest, res: Response, next: NextFunction) {
if (!req.context) throw new Error('request not contextualized')
diff --git a/src/data-directory/scripts/deleted-features-pr-comment.ts b/src/data-directory/scripts/deleted-features-pr-comment.ts
index a601190d9c16..a88cc4565b36 100644
--- a/src/data-directory/scripts/deleted-features-pr-comment.ts
+++ b/src/data-directory/scripts/deleted-features-pr-comment.ts
@@ -1,12 +1,6 @@
-/**
- * This script is supposed to be used in Actions. When it's run in Actions
- * there will be an env var called GITHUB_REPOSITORY. If it's not there,
- * you can use this script as a CLI tool. For example:
- *
- * export GITHUB_TOKEN=github_pat_blablabla
- * npm run deleted-features-pr-comment -- github docs-internal main 2ba53b6a
- *
- */
+// Produces deleted-feature Markdown as an Actions output; without GITHUB_REPOSITORY, prints it.
+// Required: GITHUB_TOKEN.
+// CLI: npm run deleted-features-pr-comment -- github docs-internal main 2ba53b6a
import { context as github_context, getOctokit } from '@actions/github'
import { setOutput } from '@actions/core'
@@ -44,7 +38,6 @@ async function main(owner: string, repo: string, baseSHA: string, headSHA: strin
throw new Error(`GITHUB_TOKEN environment variable not set`)
}
const octokit = getOctokit(GITHUB_TOKEN)
- // get the list of file changes from the PR
const response = await octokit.rest.repos.compareCommitsWithBasehead({
owner,
repo,
@@ -62,10 +55,10 @@ async function main(owner: string, repo: string, baseSHA: string, headSHA: strin
console.warn(`Feature involved in this PR: ${filename}; Status: ${status}`)
if (status === 'removed') {
- // Bad
+ // Deleted feature files can stay referenced in translated content.
oldFilenames.push(filename)
} else if (status === 'renamed') {
- // Also bad
+ // Renamed feature files can stay referenced by the old name in translated content.
const previousFilename = file.previous_filename
oldFilenames.push(previousFilename)
} else {
diff --git a/src/data-directory/scripts/find-orphaned-features/find.ts b/src/data-directory/scripts/find-orphaned-features/find.ts
index 2398738b896c..43f7b73afde0 100644
--- a/src/data-directory/scripts/find-orphaned-features/find.ts
+++ b/src/data-directory/scripts/find-orphaned-features/find.ts
@@ -1,31 +1,8 @@
-/**
- * This script will loop over all pages, in all languages, and look at
- * the following:
- *
- * 1. `title` in frontmatter
- * 2. `intro` in frontmatter
- * 3. `shortTitle` in frontmatter (if present)
- * 4. the markdown body itself
- * 5. The `versions:` frontmatter key (if the page is in English)
- *
- * Then it will search out the features mentioned based on `data/features/*.yml`
- * It will make a Set of these (e.g. `dependabot-grouped-dependencies` and
- * `ghas-enablement-webhook`) and one by one pluck them away.
- *
- * After the pages, it will loop over the reusables in English, and do the
- * same search there. Once it's done the English, it loops over the
- * reusables in the translations (if they exist) and does the same search.
- *
- * Lastly, it will output the remaining features, as relative file paths.
- * For example, `data/features/havent-been-used-in-years.yml` so now you
- * know that file can be deleted.
- *
- * NOTE: A lot of translations have corrupted Liquid. So if we can't parse
- * the Liquid we fall back to string search. A regex will try to find
- * all `{% ifversion ... %}` (and `elsif`) and search for any features
- * mentioned inside that as a string.
- *
- */
+// Finds data/features/*.yml entries that no page, reusable, or variable references.
+// It scans title, intro, shortTitle, body, and English versions frontmatter across all pages.
+// It also scans English reusables and variables, then matching translated reusables.
+// Outputs remaining features as paths such as data/features/havent-been-used-in-years.yml.
+// If translated Liquid cannot parse, regex searches feature names in ifversion and elsif tags.
import { strictEqual } from 'node:assert'
import fs from 'fs'
@@ -118,12 +95,12 @@ function formatDelta(t0: Date, t1: Date) {
return `${(ms / 1000).toFixed(1)} seconds`
}
+// searchAndRemove scans translated reusables only when English has the same relative path.
+// English content lets correctTranslatedContentStrings repair Liquid before feature matching.
function searchAndRemove(features: Set, pages: Page[], verbose = false) {
for (const page of pages) {
const content = page.markdown
- // We actually never bother looking at the `versions:` frontmatter
- // key in translations, so it doesn't matter if the translated
- // frontmatter might have `versions: some-old-feature`.
+ // Only English versions frontmatter can mark a feature used.
if (page.languageCode === 'en') {
for (const [key, value] of Object.entries(page.versions)) {
if (key === 'feature') {
@@ -144,19 +121,6 @@ function searchAndRemove(features: Set, pages: Page[], verbose = false)
checkString(combined, features, { page, verbose, languageCode: page.languageCode })
}
- // Reusables are a bit special, as they are shared between languages.
- // There'll always be a slight mismatch between files present on disk
- // in English vs. translations.
- // The translations never delete files, so there's often excess reusables
- // on disk in translations. And the English might be ahead, meaning a file
- // has been introduced in English but not yet translated.
- // The code below loops over the English reusables, and takes note of the
- // their relative paths and content. Then, we re-use the keys of that map
- // to know which files, in the translations, to check. And when we read
- // them in, we'll need the English equivalent content to be able to
- // use the correctTranslatedContentStrings function.
-
- // Check the English variable files.
for (const filePath of getVariableFiles(path.join(languages.en.dir, 'data', 'variables'))) {
const fileContent = fs.readFileSync(filePath, 'utf-8')
checkString(fileContent, features, { filePath, verbose, languageCode: 'en' })
@@ -170,7 +134,7 @@ function searchAndRemove(features: Set, pages: Page[], verbose = false)
englishReusables.set(relativePath, fileContent)
}
for (const language of Object.values(languages)) {
- if (language.code === 'en') continue // Already did that in the loop above
+ if (language.code === 'en') continue
for (const [relativePath, englishFileContent] of Array.from(englishReusables.entries())) {
const filePath = path.join(language.dir, relativePath)
@@ -192,10 +156,7 @@ function searchAndRemove(features: Set, pages: Page[], verbose = false)
})
} catch (error) {
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
- // That a reusable does *not* exist in a translation is
- // perfectly expected. It means that English reusable was
- // most likely added recently and the translation hasn't been
- // translated yet.
+ // Missing translated reusables are expected when English has newer files.
continue
}
throw error
@@ -243,10 +204,7 @@ function checkString(
}: { page?: Page; filePath?: string; languageCode?: string; verbose?: boolean } = {},
) {
try {
- // The reason for the `noCache: true` is that we're going to be sending
- // a LOT of different strings in and the cache will fill up rapidly
- // when testing every possible string in every possible language for
- // every page.
+ // Disable the Liquid token cache because scanning many different strings would fill it quickly.
const tokens = getLiquidTokens(string, { noCache: true }).filter(
(token): token is TagToken => token.kind === TokenKind.Tag,
)
@@ -264,11 +222,10 @@ function checkString(
}
} catch (error) {
if (error instanceof TokenizationError) {
- // If it happens in English, it's a serious error
+ // English Liquid parse failures are source errors.
if (languageCode === 'en') throw error
- // The translation might, currently, have corrupted liquid
- // So treat it as a string
+ // Translated Liquid can be corrupt, so regex search still catches feature references.
if (verbose)
console.log(
`TokenizationError in ${page ? page.fullPath : filePath}. Treating ${page ? page.fullPath : filePath} as a string and using regex`,
diff --git a/src/data-directory/scripts/find-orphaned-tables.ts b/src/data-directory/scripts/find-orphaned-tables.ts
index a254863b9b6a..9ce6e3e74898 100644
--- a/src/data-directory/scripts/find-orphaned-tables.ts
+++ b/src/data-directory/scripts/find-orphaned-tables.ts
@@ -1,20 +1,6 @@
-// [start-readme]
-//
-// Print a list of all the YAML-powered table files in ./data/tables/ that
-// can't be found mentioned in any source file (content, data & code), along
-// with their paired schema files. Mirrors find-orphaned-assets.ts.
-//
-// Tables are referenced from Liquid like:
-//
-// {% data tables.. %}
-// {% for entry in tables.. %}
-//
-// so a table file `data/tables//.yml` is "used" if the string
-// `tables..` appears anywhere. A deeper reference such as
-// `tables...` also counts, because the file key is a
-// prefix of it.
-//
-// [end-readme]
+// Prints unreferenced YAML-powered table files under ./data/tables/ and paired schema files.
+// Both {% data tables.copilot.matrix-meta %} and
+// {% for level in tables.copilot.matrix-meta.supportLevels %} mark the table used.
import fs from 'fs'
import path from 'path'
@@ -28,17 +14,15 @@ import languages from '@/languages/lib/languages-server'
const TABLES_DIR = 'data/tables'
const SCHEMAS_DIR = 'src/data-directory/lib/data-schemas/tables'
-// Tables that are referenced dynamically (not via Liquid) and must never be
-// flagged as orphans. Add an entry here (the dotted key, e.g. `copilot.foo`)
-// if a table is loaded by code rather than mentioned in content.
+// EXCEPTIONS protects tables loaded dynamically by code rather than mentioned in content.
const EXCEPTIONS = new Set([])
export type TableFile = {
- // Repo-relative path to the YAML file, e.g. data/tables/copilot/model-multipliers.yml
+ // Repo-relative YAML path, such as data/tables/copilot/model-multipliers.yml.
yml: string
// Repo-relative path to the paired schema, if it exists on disk.
schema?: string
- // Dotted key used in Liquid, e.g. copilot.model-multipliers
+ // Dotted Liquid key, such as copilot.model-multipliers.
key: string
}
@@ -72,9 +56,7 @@ type MainOptions = {
excludeTranslations: boolean
}
-// Given the table files and the contents of every source file, return the
-// tables whose Liquid key is never mentioned. Pulled out of main() so it can
-// be unit tested without touching the filesystem.
+// Exported for tests so orphan detection can run without filesystem reads.
export function getOrphanedTables(
tables: TableFile[],
sourceContents: Iterable,
@@ -91,8 +73,7 @@ export function getOrphanedTables(
return [...orphans.values()].sort((a, b) => a.yml.localeCompare(b.yml))
}
-// Only parse argv and run when invoked directly (e.g. via `npm run
-// find-orphaned-tables`), not when imported by a test.
+// Guard main so tests can import getOrphanedTables; npm run find-orphaned-tables invokes it.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
program.parse(process.argv)
main(program.opts())
@@ -108,10 +89,7 @@ async function main(opts: MainOptions) {
const sourceFiles: string[] = [...englishFiles]
if (!excludeTranslations) {
- // Translations are often behind English. A table can still be referenced
- // in a translation even when no English content references it, so we must
- // search translations too. We only look at files that also exist in
- // English, because translations rarely delete renamed/removed files.
+ // Search matching translations because translated content can still reference a table.
const englishRelativeFiles = new Set(
englishFiles.map((englishFile) => path.relative(languages.en.dir, englishFile)),
)
@@ -133,9 +111,7 @@ async function main(opts: MainOptions) {
}
}
- // Tables can also be referenced from code (e.g. table-rendering helpers), so
- // search src and contributing as well. Searching more files only ever marks
- // a table as used, never as an orphan, so it errs on the safe side.
+ // Search code because table-rendering helpers can reference tables without Liquid.
for (const root of ['contributing', 'src']) {
if (!fs.existsSync(root)) continue
sourceFiles.push(
@@ -165,9 +141,7 @@ async function main(opts: MainOptions) {
const orphanTables = getOrphanedTables(tables, readContents())
- // Safety net: if every table looks orphaned, the detection is almost
- // certainly broken (e.g. content wasn't checked out). Refuse to suggest
- // deleting everything.
+ // If every table looks orphaned, detection is probably broken; refuse to list deletions.
if (tables.length > 0 && orphanTables.length === tables.length) {
console.error(
'Every table was flagged as orphaned, which is almost certainly a bug. ' +
diff --git a/src/data-directory/tests/copilot-matrix.ts b/src/data-directory/tests/copilot-matrix.ts
index d12ec6ae8f8e..529344ab9c43 100644
--- a/src/data-directory/tests/copilot-matrix.ts
+++ b/src/data-directory/tests/copilot-matrix.ts
@@ -4,23 +4,14 @@ import { join } from 'path'
import { load } from 'js-yaml'
import { describe, expect, test } from 'vitest'
-// Cross-file invariants for the Copilot IDE feature matrix.
-//
-// The JSON schemas validate each file in isolation. These tests cover the
-// relationships *between* matrix-meta.yml and the per-IDE files, which is where
-// a hand edit — or, later, an automated changelog-driven update — is most
-// likely to introduce a silent error.
-//
-// "Silent" is the operative word: a missing or mistyped key does not raise an
-// error, it renders as ✗ (not supported) to customers.
+// JSON schemas validate each file in isolation. These tests cover cross-file matrix relationships.
+// Missing or mistyped keys silently render as ✗ (not supported) in customer-facing tables.
const MATRIX_DIR = join(process.cwd(), 'data/tables/copilot/matrix')
const META_PATH = join(process.cwd(), 'data/tables/copilot/matrix-meta.yml')
-// Stands for "supported since before we tracked versions". Some IDEs list it in
-// `versions` without putting it in a `versionGroup`, so it is the one version
-// allowed to have no detail table. Removing it is a customer-visible content
-// decision; until then it is excluded from the grouping invariant below.
+// Some IDEs use 0.0.0 for supported-before-tracking without a versionGroup.
+// Removing the sentinel is customer-visible, so the grouping invariant excludes it.
const SENTINEL_VERSION = '0.0.0'
type Ide = {
@@ -70,8 +61,7 @@ describe('copilot matrix meta', () => {
expect(new Set(meta.featureOrder).size).toBe(meta.featureOrder.length)
})
- // A stale featureOrder entry that no IDE uses renders as a row of ✗ across
- // every column of the summary table.
+ // A stale featureOrder entry renders as a row of ✗ across every summary-table column.
test('every featureOrder entry is used by at least one IDE', () => {
const used = new Set()
for (const ide of Object.values(ides)) {
@@ -114,13 +104,9 @@ describe.each(ideFilenames)('copilot matrix: %s', (slug) => {
).toEqual([])
})
- // Only versions listed in a versionGroup are rendered as a detail table. A
- // version in `versions` that is in no group is data customers cannot see —
- // and the summary table reads `versions | first`, so if it is the newest one
- // the page shows support data for a version with no detail table at all.
- // This is the most likely mistake for an automated updater that appends to
- // `versions` and forgets `versionGroups`, and checking only the newest
- // version would miss a backfilled older one.
+ // Only versions listed in versionGroups render detail tables. The test skips the 0.0.0 sentinel.
+ // The summary table reads versions | first, so an ungrouped newest version has no detail table.
+ // Checking every version also catches backfilled older versions that automated updates miss.
test('every version appears in at least one versionGroup', () => {
const grouped = new Set(Object.values(ide.versionGroups).flat())
const ungrouped = ide.versions.filter(
diff --git a/src/data-directory/tests/data-schemas.ts b/src/data-directory/tests/data-schemas.ts
index 7dd168ade55b..bc2b83a61f37 100644
--- a/src/data-directory/tests/data-schemas.ts
+++ b/src/data-directory/tests/data-schemas.ts
@@ -64,7 +64,6 @@ describe('YAML-powered tables', () => {
const schemaPath = join(schemasDir, `${name}.ts`)
expect(existsSync(schemaPath)).toBe(true)
- // Also verify it's registered in the dataSchemas
const dataKey = `data/tables/${yamlFile}`
expect(dataSchemas[dataKey]).toBeDefined()
}
diff --git a/src/data-directory/tests/find-orphaned-tables.ts b/src/data-directory/tests/find-orphaned-tables.ts
index b3ac92a04978..1dfc47015020 100644
--- a/src/data-directory/tests/find-orphaned-tables.ts
+++ b/src/data-directory/tests/find-orphaned-tables.ts
@@ -41,8 +41,6 @@ describe('getOrphanedTables', () => {
})
test('counts a deeper sub-key reference as using the table file', () => {
- // A reference to `tables.copilot.copilot-matrix.ides` should mark the
- // `copilot.copilot-matrix` file as used.
const orphans = getOrphanedTables(
[table('copilot.copilot-matrix')],
['{% for row in tables.copilot.copilot-matrix.ides %}'],
@@ -51,8 +49,6 @@ describe('getOrphanedTables', () => {
})
test('does not let a longer key falsely mark a shorter, unrelated table', () => {
- // `tables.copilot.annual-subscriber-model-multipliers` must NOT mark
- // `copilot.model-multipliers` as used.
const orphans = getOrphanedTables(
[table('copilot.model-multipliers')],
['{% data tables.copilot.annual-subscriber-model-multipliers %}'],
diff --git a/src/data-directory/tests/get-data.ts b/src/data-directory/tests/get-data.ts
index 7b3a0414f929..68e834bce35e 100644
--- a/src/data-directory/tests/get-data.ts
+++ b/src/data-directory/tests/get-data.ts
@@ -14,7 +14,7 @@ import { DataDirectory } from '@/tests/helpers/data-directory'
describe('get-data', () => {
let dd: DataDirectory
const enDirBefore = languages.en.dir
- // Only `en` is available in tests, so pretend we also have Japanese
+ // Only en is available in tests, so copy English metadata for Japanese fixtures.
languages.ja = Object.assign({}, languages.en, {})
beforeAll(() => {
@@ -77,12 +77,10 @@ describe('get-data', () => {
const result = getDataByLanguage('variables.stuff.foo', 'en')
expect(result).toBe('Foo')
}
- // Test that memoization doesn't go wrong
{
const result = getDataByLanguage('variables.stuff.bar', 'en')
expect(result).toBe('Bar')
}
- // Test that unrecognized keys just return `undefined`
{
const result = getDataByLanguage('variables.stuff.neverheardof', 'en')
expect(result).toBeUndefined()
@@ -94,12 +92,10 @@ describe('get-data', () => {
const result = getDataByLanguage('variables.stuff.foo', 'ja')
expect(result).toBe('フー')
}
- // Test fallback to English if not present in translation
{
const result = getDataByLanguage('variables.stuff.bar', 'ja')
expect(result).toBe('Bar')
}
- // Test that unrecognized keys just return `undefined`
{
const result = getDataByLanguage('variables.stuff.neverheardof', 'ja')
expect(result).toBeUndefined()
@@ -111,12 +107,10 @@ describe('get-data', () => {
const result = getDataByLanguage('variables.stuff.key_non_existent', 'en')
expect(result).toBeUndefined()
}
- // Test fallback to English if not present in translation
{
const result = getDataByLanguage('variables.stuff.key_non_existent', 'ja')
expect(result).toBeUndefined()
}
- // Returns undefined if not only the key is missing but the whole file too
{
const result = getDataByLanguage('variables.notpresent.whatever', 'en')
expect(result).toBeUndefined()
@@ -128,7 +122,6 @@ describe('get-data', () => {
const result = getDataByLanguage('reusables.coolness', 'en')
expect(result).toBe('This is *Markdown*')
}
- // Test that memoization doesn't go wrong
{
const result = getDataByLanguage('reusables.otherness', 'en')
expect(result).toBe('**Also** Markdown')
@@ -140,7 +133,6 @@ describe('get-data', () => {
const result = getDataByLanguage('reusables.coolness', 'ja')
expect(result).toBe('これがマークダウンです')
}
- // Test translations fall back to English if file doesn't exist
{
const result = getDataByLanguage('reusables.otherness', 'ja')
expect(result).toBe('**Also** Markdown')
@@ -152,7 +144,6 @@ describe('get-data', () => {
const result = getDataByLanguage('reusables.neverheardof', 'en')
expect(result).toBeUndefined()
}
- // Test translations will try English but fail if the fallback fails too
{
const result = getDataByLanguage('reusables.neverheardof', 'ja')
expect(result).toBeUndefined()
@@ -165,12 +156,10 @@ describe('get-data', () => {
expect(result.key).toBe('Value')
expect((result.deep as Record).er).toBe('Depth')
}
- // In a specific language
{
const result = getUIDataMerged('ja')
expect(result.key).toBe('価値')
expect((result.deep as Record).er).toBe('深さ')
- // Note how it falls back to English on that key
expect((result.deep as Record).est).toBe('Deepest')
}
})
@@ -181,7 +170,6 @@ describe('get-data', () => {
expect((result.stuff as Record).foo).toBe('Foo')
expect((result.stuff as Record).bar).toBe('Bar')
}
- // All reusables
{
const result = getDeepDataByLanguage('reusables', 'en')
expect(result['coolness.md']).toBe('This is *Markdown*')
@@ -213,7 +201,7 @@ front: >'matter
describe('get-data on corrupt translations', () => {
let dd: DataDirectory
const enDirBefore = languages.en.dir
- // Only `en` is available in vitest tests, so pretend we also have Japanese
+ // Only en is available in tests, so copy English metadata for Japanese fixtures.
languages.ja = Object.assign({}, languages.en, {})
beforeAll(() => {
@@ -263,12 +251,10 @@ describe('get-data on corrupt translations', () => {
})
test('getDataByLanguage on a corrupt .yml file', () => {
- // First make sure it works in English
{
const result = getDataByLanguage('variables.everything.is', 'en')
expect(result).toBe('Awesome')
}
- // Japanese translations would fall back due to a corrupt Yaml file
{
const result = getDataByLanguage('variables.everything.is', 'ja')
expect(result).toBe('Awesome')
@@ -276,12 +262,10 @@ describe('get-data on corrupt translations', () => {
})
test('getDataByLanguage on a corrupt .md file', () => {
- // First make sure it works in English
{
const result = getDataByLanguage('reusables.cool', 'en')
expect(result).toBe('*English* /Markdown/')
}
- // Japanese translations would fall back due to a corrupt Yaml file
{
const result = getDataByLanguage('reusables.cool', 'ja')
expect(result).toBe('*English* /Markdown/')
@@ -317,11 +301,11 @@ describe('get-data applies corrections to translated variables', () => {
data: {
variables: {
myproduct: {
- // Corrupted: `data` translated to Japanese `データ`
+ // Translation corrupts the data keyword to データ.
name: '{% データ variables.myproduct.name %}',
},
phases: {
- // Not corrupted, so it should pass through unchanged
+ // Valid ifversion stays unchanged.
preview: '{% ifversion ghes < 3.16 %}ベータ{% else %}パブリックプレビュー{% endif %}',
},
},
@@ -337,12 +321,10 @@ describe('get-data applies corrections to translated variables', () => {
})
test('corrects corrupted Liquid keywords in translated variables', () => {
- // English variable is returned as-is
{
const result = getDataByLanguage('variables.myproduct.name', 'en')
expect(result).toBe('GitHub')
}
- // Japanese translation with corrupted `データ` → `data` gets corrected
{
const result = getDataByLanguage('variables.myproduct.name', 'ja')
expect(result).toBe('{% data variables.myproduct.name %}')
@@ -350,7 +332,6 @@ describe('get-data applies corrections to translated variables', () => {
})
test('leaves valid translated variables unchanged', () => {
- // Valid ifversion in translated variable should pass through
{
const result = getDataByLanguage('variables.phases.preview', 'ja')
expect(result).toBe(
diff --git a/src/data-directory/tests/index.ts b/src/data-directory/tests/index.ts
index cdef3164a438..03e0de1a292b 100644
--- a/src/data-directory/tests/index.ts
+++ b/src/data-directory/tests/index.ts
@@ -31,16 +31,14 @@ describe('data-directory', () => {
const extensions = ['.yml', 'markdown']
const data = dataDirectory(fixturesDir, { extensions })
expect('bar' in data).toBe(true)
- expect('foo' in data).toBe(false) // JSON file should be ignored
+ expect('foo' in data).toBe(false)
})
test('option: ignorePatterns', async () => {
const ignorePatterns: RegExp[] = []
- // README is ignored by default
expect('README' in dataDirectory(fixturesDir)).toBe(false)
- // README can be included by setting empty ignorePatterns array
expect('README' in dataDirectory(fixturesDir, { ignorePatterns })).toBe(true)
})
})
diff --git a/src/data-directory/tests/orphaned-features.ts b/src/data-directory/tests/orphaned-features.ts
index ea931b6b4f21..e63c7c1f8db7 100644
--- a/src/data-directory/tests/orphaned-features.ts
+++ b/src/data-directory/tests/orphaned-features.ts
@@ -49,7 +49,6 @@ describe('orphaned features detection', () => {
})
test('helper functions handle nested directories', () => {
- // Create a temporary nested structure to test
const tempDir = path.join(__dirname, 'temp-nested-test')
const nestedVariablesDir = path.join(tempDir, 'variables', 'nested')
const nestedReusablesDir = path.join(tempDir, 'reusables', 'nested')
@@ -78,7 +77,6 @@ describe('orphaned features detection', () => {
})
test('helper functions ignore non-target files', () => {
- // Create a temporary directory with mixed file types
const tempDir = path.join(__dirname, 'temp-mixed-files')
fs.mkdirSync(tempDir, { recursive: true })
@@ -90,12 +88,10 @@ describe('orphaned features detection', () => {
fs.writeFileSync(path.join(tempDir, 'README.md'), '# README')
try {
- // getVariableFiles should only find .yml files (excluding README.yml)
const variableFiles = getVariableFiles(tempDir)
expect(variableFiles).toHaveLength(1)
expect(variableFiles[0]).toMatch(/test\.yml$/)
- // getReusableFiles should only find .md files (excluding README.md)
const reusableFiles = getReusableFiles(tempDir)
expect(reusableFiles).toHaveLength(1)
expect(reusableFiles[0]).toMatch(/test\.md$/)
@@ -105,13 +101,9 @@ describe('orphaned features detection', () => {
})
test('verify fix addresses the original issue scenario', () => {
- // This test simulates the original issue where features were used only in variables
- // but not detected by the orphaned features script
-
const variablesDir = path.join(fixturesDir, 'data', 'variables')
const featuresDir = path.join(fixturesDir, 'data', 'features')
- // Verify our test setup has the scenario described in the issue
expect(fs.existsSync(path.join(featuresDir, 'used-in-variables.yml'))).toBe(true)
expect(fs.existsSync(path.join(featuresDir, 'truly-orphaned.yml'))).toBe(true)
@@ -121,8 +113,6 @@ describe('orphaned features detection', () => {
const variableFiles = getVariableFiles(variablesDir)
expect(variableFiles.length).toBeGreaterThan(0)
- // This proves that the fix would catch features used in variables files
- // because the orphaned features script now scans these files
const foundFeatureUsage = variableFiles.some((filePath) => {
const content = fs.readFileSync(filePath, 'utf-8')
return content.includes('used-in-variables')
@@ -132,8 +122,6 @@ describe('orphaned features detection', () => {
})
test('functions correctly identify different file types in same directory', () => {
- // Create a directory with both .yml and .md files to ensure each function
- // only picks up its target file types
const tempDir = path.join(__dirname, 'temp-mixed-target-files')
fs.mkdirSync(tempDir, { recursive: true })
@@ -148,7 +136,6 @@ describe('orphaned features detection', () => {
fs.writeFileSync(path.join(tempDir, 'other.txt'), 'other content')
try {
- // Each function should only find its target file type
const variableFiles = getVariableFiles(tempDir)
const reusableFiles = getReusableFiles(tempDir)
diff --git a/src/data-directory/tests/ui-yml-structure.ts b/src/data-directory/tests/ui-yml-structure.ts
index edc8cf7e789a..91803b1c281e 100644
--- a/src/data-directory/tests/ui-yml-structure.ts
+++ b/src/data-directory/tests/ui-yml-structure.ts
@@ -13,7 +13,7 @@ describe('data/ui.yml structure', () => {
const violations: string[] = []
for (let i = 0; i < lines.length; i++) {
- // A top-level key starts at column 0 with a word followed by ':'
+ // Top-level keys start at column 0 with a word followed by colon.
if (/^[a-z_]+:/.test(lines[i]) && i > 0) {
if (lines[i - 1].trim() !== '') {
violations.push(`Line ${i + 1}: "${lines[i]}" is not preceded by a blank line`)
diff --git a/src/deployments/production/build-scripts/clone-or-use-cached-repo.sh b/src/deployments/production/build-scripts/clone-or-use-cached-repo.sh
index a908885ab652..0dad817f3ff9 100644
--- a/src/deployments/production/build-scripts/clone-or-use-cached-repo.sh
+++ b/src/deployments/production/build-scripts/clone-or-use-cached-repo.sh
@@ -1,11 +1,7 @@
set -e
-# Reuses the repo cached by a previous Dockerfile build, or clones it fresh
-# and checks out the given branch/SHA.
-# Arguments:
-# $1 - Repository name (for directory naming)
-# $2 - Repository URL
-# $3 - Branch to clone
+# Reuses a cached repo or clones it, then checks out the requested branch.
+# Arguments: $1 cache directory, $2 GitHub repo name under github, $3 branch.
clone_or_use_cached_repo() {
repo_name="$1"
repo_url="$2"
diff --git a/src/deployments/production/build-scripts/fetch-repos.sh b/src/deployments/production/build-scripts/fetch-repos.sh
index 3f2239fc448e..9f24050f0013 100644
--- a/src/deployments/production/build-scripts/fetch-repos.sh
+++ b/src/deployments/production/build-scripts/fetch-repos.sh
@@ -1,7 +1,7 @@
#!/usr/bin/env sh
-# Called from the production Dockerfile. The Dockerfile only COPYs what it
-# needs, but these scripts still run as if from the docs-internal root.
+# The production Dockerfile copies only required files, but these scripts still run from the
+# docs-internal root.
echo "Fetching and resolving early-access, and translations repos"
@@ -9,7 +9,7 @@ set -e
. ./build-scripts/clone-or-use-cached-repo.sh
-# From the --secret mounted by the Docker build.
+# Docker build mounts DOCS_BOT_PAT_BASE at /run/secrets/DOCS_BOT_PAT_BASE.
GITHUB_TOKEN=$(cat /run/secrets/DOCS_BOT_PAT_BASE)
echo "Fetching early access..."
@@ -17,11 +17,11 @@ clone_or_use_cached_repo "docs-early-access" "docs-early-access" "main"
echo "Merging early access..."
. ./build-scripts/merge-early-access.sh
-# Clone into `translations/` inside the Dockerfile's WORKDIR, the docs-internal root.
+# Clone translations under the Dockerfile WORKDIR, the docs-internal root.
mkdir -p translations
cd translations
-# Temporarily turn off exit-on-error so we can collect all PIDs
+# Disable exit-on-error so the script can collect every background clone failure.
set +e
pids=""
@@ -35,7 +35,6 @@ for pid in $pids; do
wait "$pid" || failures=$((failures+1))
done
-# Restore strict mode
set -e
if [ "$failures" -gt 0 ]; then
@@ -45,8 +44,8 @@ else
echo "✅ All translations fetched."
fi
-# Go back to the root of the docs-internal repo
+# Return to the docs-internal root after cloning translations.
cd ..
-# Don't leave the token in the environment.
+# Remove the token from the shell environment.
unset GITHUB_TOKEN
diff --git a/src/deployments/production/build-scripts/merge-early-access.sh b/src/deployments/production/build-scripts/merge-early-access.sh
index 317f81a9dc87..00a3c3bfef7a 100755
--- a/src/deployments/production/build-scripts/merge-early-access.sh
+++ b/src/deployments/production/build-scripts/merge-early-access.sh
@@ -1,7 +1,6 @@
#!/usr/bin/env sh
-# Merges docs-early-access files into docs-internal. Runs from the
-# docs-internal root.
+# Merges docs-early-access files into docs-internal from the docs-internal root.
mv docs-early-access/assets/images assets/images/early-access
mv docs-early-access/content content/early-access
diff --git a/src/dev-toc/generate.ts b/src/dev-toc/generate.ts
index f883ccbbad96..3d7c16ffc1ec 100644
--- a/src/dev-toc/generate.ts
+++ b/src/dev-toc/generate.ts
@@ -1,14 +1,10 @@
-/**
- * @purpose Writer tool
- * @description Generate a local table of contents for the GitHub Docs website
- *
- * This script creates static HTML files for each documentation version, renders page titles
- * using Liquid templating, and opens the generated TOC in your browser for easy navigation
- * during development. Supports command-line options to specify which sections should be
- * open by default.
- *
- * Usage: tsx src/dev-toc/generate.ts [-o product-ids...]
- */
+// @purpose Writer tool
+// @description Generate a local table of contents for the GitHub Docs website
+//
+// Creates static HTML for each documentation version, renders Liquid page titles, and opens the
+// generated table of contents in your browser. Use -o product-ids... to open sections by default.
+//
+// Run with: tsx src/dev-toc/generate.ts [-o product-ids...]
import fs from 'fs'
import path from 'path'
diff --git a/src/dev-toc/layout.html b/src/dev-toc/layout.html
index 24d8fab8fccd..78c08dde5811 100644
--- a/src/dev-toc/layout.html
+++ b/src/dev-toc/layout.html
@@ -38,7 +38,6 @@ TOC for {{ allVersions[currentVersion].versio
{{ productPage.renderedFullTitle }}
- {% comment %} Unified nested rendering with depth control {% endcomment %}
{% if productPage.childPages and productPage.childPages.size > 0 %}
{% for l1 in productPage.childPages %}
diff --git a/src/early-access/middleware/early-access-links.ts b/src/early-access/middleware/early-access-links.ts
index a09dfdc939f5..b9ac7d0f7cbd 100644
--- a/src/early-access/middleware/early-access-links.ts
+++ b/src/early-access/middleware/early-access-links.ts
@@ -8,13 +8,11 @@ export default function earlyAccessContext(
res: Response,
next: NextFunction,
) {
- // Use req.pagePath instead of req.path because req.path is the path
- // normalized after "converting" that `/_next/data/...` path to the
- // equivalent path if it had *not* been a client-side routing fetch.
+ // handleNextDataPath sets converted routes in req.pagePath; req.path keeps the /_next/data URL.
const url = req.pagePath!.split('/').slice(2)
if (
!(
- // Is it `/early-access` or `/enterprise-cloud@latest/early-access`?
+ // Match /early-access and versioned /early-access routes.
(
(url.length === 2 && url[1] === 'early-access') ||
(url.length === 1 && url[0] === 'early-access')
@@ -45,7 +43,7 @@ export default function earlyAccessContext(
.sort()
.map((permalink) => `- [${permalink.title}](${permalink.href})`)
- // Only read by the separate EA repo, in local development.
+ // Only the separate early access repo reads this, in local development.
req.context.earlyAccessPageLinks = earlyAccessPageLinks.length
? earlyAccessPageLinks.join('\n')
: '_None for this version!_'
diff --git a/src/early-access/scripts/clone-locally b/src/early-access/scripts/clone-locally
index ab816c586dba..4564e5f83a04 100755
--- a/src/early-access/scripts/clone-locally
+++ b/src/early-access/scripts/clone-locally
@@ -5,7 +5,6 @@
set -e
-# Go up a directory
pushd .. > /dev/null
if [ -d "docs-early-access" ]; then
@@ -14,13 +13,10 @@ if [ -d "docs-early-access" ]; then
exit 0
fi
-# Clone the repo
git clone https://github.com/github/docs-early-access.git
-# Go back to the previous working directory
popd > /dev/null
-# Symlink the local docs-early-access repo into this repo
npm run symlink-from-local-repo -- -p ../docs-early-access
echo -e '\nDone!'
diff --git a/src/early-access/scripts/create-branch b/src/early-access/scripts/create-branch
index c5f6fb6fad46..456e98db95ae 100755
--- a/src/early-access/scripts/create-branch
+++ b/src/early-access/scripts/create-branch
@@ -5,7 +5,6 @@
set -e
-# Get current branch name
currentBranch=$(git rev-parse --abbrev-ref HEAD)
if [ $currentBranch == "main" ]; then
@@ -13,7 +12,6 @@ if [ $currentBranch == "main" ]; then
exit 0
fi
-# Go up a directory
pushd .. > /dev/null
if [ ! -d "docs-early-access" ]; then
@@ -22,17 +20,13 @@ if [ ! -d "docs-early-access" ]; then
exit 0
fi
-# Navigate to docs-early-access
cd docs-early-access
-# Check out main and update
git checkout main
git pull origin main
-# Create a branch with the current docs-internal branch name
git checkout -b $currentBranch
-# Go back to the previous working directory
popd > /dev/null
echo -e "\nDone! Created a branch called ${currentBranch}. Remember to commit your work in ../docs-early-access when you're ready."
diff --git a/src/early-access/scripts/merge-early-access.sh b/src/early-access/scripts/merge-early-access.sh
index 8c70e549dc4c..00df810ca60d 100755
--- a/src/early-access/scripts/merge-early-access.sh
+++ b/src/early-access/scripts/merge-early-access.sh
@@ -1,10 +1,6 @@
#!/usr/bin/env bash
-# [start-readme]
-#
-# This script takes docs-early-access files and merges them into docs-internal
-#
-# [end-readme]
+# Merges docs-early-access files into docs-internal.
mv docs-early-access/assets/images assets/images/early-access
mv docs-early-access/content content/early-access
diff --git a/src/early-access/scripts/migrate-early-access-product.ts b/src/early-access/scripts/migrate-early-access-product.ts
index ef7e6cf2ae6f..0dbedc8430ea 100644
--- a/src/early-access/scripts/migrate-early-access-product.ts
+++ b/src/early-access/scripts/migrate-early-access-product.ts
@@ -1,8 +1,4 @@
-// [start-readme]
-//
-// Move the files from an early-access product level docs set into an existing product.
-//
-// [end-readme]
+// Moves a product-level early access docs set into an existing product.
import fs from 'fs'
import path from 'path'
@@ -54,7 +50,7 @@ if (!filesToMigrate.length) {
const migratePath: string = path.posix.join(contentDir, newPathId)
-// Update the image and data refs in the to-be-migrated early access files BEFORE moving them.
+// Rewrite early access image and data refs before moving files.
try {
execFileSync('tsx', [
'src/early-access/scripts/update-data-and-image-paths.ts',
@@ -71,7 +67,7 @@ const variablesToMove: string[] = []
const reusablesToMove: string[] = []
const imagesToMove: string[] = []
-// Add redirects to and update frontmatter in the to-be-migrated early access files BEFORE moving them.
+// Apply redirects and frontmatter changes before moving files.
for (const filepath of filesToMigrate) {
const { content, data } = frontmatter(fs.readFileSync(filepath, 'utf8'))
const redirectString: string = filepath
@@ -86,7 +82,6 @@ for (const filepath of filesToMigrate) {
fs.writeFileSync(filepath, frontmatter.stringify(content || '', data))
}
- // Find the data files and images referenced in the early access files so we can move them over.
const dataRefs: string[] = content ? content.match(patterns.dataReference) || [] : []
const variables: string[] = dataRefs.filter((ref) => ref.includes('variables'))
const reusables: string[] = dataRefs.filter((ref) => ref.includes('reusables'))
@@ -97,7 +92,6 @@ for (const filepath of filesToMigrate) {
imagesToMove.push(...images)
}
-// Move the data files and images.
for (const varRef of Array.from(new Set(variablesToMove))) {
moveVariable(varRef)
}
@@ -108,10 +102,8 @@ for (const imageRef of Array.from(new Set(imagesToMove))) {
moveImage(imageRef)
}
-// Move the content files.
execFileSync('mv', [oldPath, migratePath])
-// Update the parent product TOC with the new child path.
const parentProductTocPath: string = path.posix.join(path.dirname(newPath), 'index.md')
const parentProductToc = frontmatter(fs.readFileSync(parentProductTocPath, 'utf-8'))
if (parentProductToc.data && Array.isArray(parentProductToc.data.children)) {
@@ -123,7 +115,6 @@ fs.writeFileSync(
frontmatter.stringify(parentProductToc.content || '', parentProductToc.data || {}),
)
-// Optionally, update the new product TOC with the new title.
if (program.opts().newTitle) {
const productTocPath: string = path.posix.join(newPath, 'index.md')
const productToc = frontmatter(fs.readFileSync(productTocPath, 'utf-8'))
@@ -137,7 +128,6 @@ if (program.opts().newTitle) {
)
}
-// Update internal links now that the files have been moved.
console.log('\nRunning script to update internal links...')
execFileSync('tsx', ['src/links/scripts/update-internal-links.ts'])
@@ -153,18 +143,15 @@ Please review all the changes in docs-internal and docs-early-access, especially
`)
function moveVariable(dataRef: string): void {
- // Get the data filepath from the data reference,
- // where the data reference looks like: {% data variables.foo.bar %}
- // and the data filepath looks like: data/variables/foo.yml with key of 'bar'.
+ // Variable refs like {% data variables.foo.bar %} map to data/variables/foo.yml plus key bar.
const variablePathArray: string[] =
dataRef
.match(/{% (?:data|indented_data_reference) (.*?) %}/)?.[1]
.split('.')
- // If early access is part of the path, remove it (since the path below already includes it)
+ // Remove early-access because the path already joins under data/early-access.
.filter((n) => n !== 'early-access') || []
- // In `variables.foo.bar` the last segment is the variable key.
- // Pop it off, leaving the filepath `variables/foo.yml`.
+ // The last segment is the variable key; the remaining segments form variables/foo.yml.
const variableKey: string = last(variablePathArray) as string
variablePathArray.pop()
@@ -218,14 +205,12 @@ function moveVariable(dataRef: string): void {
}
function moveReusable(dataRef: string): void {
- // Get the data filepath from the data reference,
- // where the data reference looks like: {% data reusables.foo.bar %}
- // and the data filepath looks like: data/reusables/foo/bar.md.
+ // Reusable refs like {% data reusables.foo.bar %} map to data/reusables/foo/bar.md.
const reusablePath: string =
dataRef
.match(/{% (?:data|indented_data_reference) (\S*?) .*%}/)?.[1]
.split('.')
- // If early access is part of the path, remove it (since the path below already includes it)
+ // Remove early-access because the path already joins under data/early-access.
.filter((n) => n !== 'early-access')
.join('/') || ''
@@ -254,7 +239,7 @@ function moveReusable(dataRef: string): void {
function moveImage(imageRef: string): void {
const imagePath: string = imageRef
.replace('/assets/images/', '')
- // If early access is part of the path, remove it (since the path below already includes it)
+ // Remove early-access because the path already joins under assets/images/early-access.
.replace('early-access', '')
const oldImagePath: string = path.posix.join(
diff --git a/src/early-access/scripts/symlink-from-local-repo.ts b/src/early-access/scripts/symlink-from-local-repo.ts
index 43ef6423ec9c..59040f0a295b 100644
--- a/src/early-access/scripts/symlink-from-local-repo.ts
+++ b/src/early-access/scripts/symlink-from-local-repo.ts
@@ -1,7 +1,5 @@
-/**
- * @purpose Writer tool
- * @description Create or destroy symlinks to your local docs-early-access checkout
- */
+// @purpose Writer tool
+// @description Create or destroy symlinks to your local docs-early-access checkout
import fs from 'fs'
import path from 'path'
@@ -64,7 +62,6 @@ const destinationDirsMap: Record = destinationDirNames.reduce(
{} as Record,
)
-// Remove all existing early access directories from this repo
for (const dirName of destinationDirNames) {
const destDir = destinationDirsMap[dirName]
fs.rmSync(destDir, { recursive: true, force: true })
@@ -75,7 +72,6 @@ if (unlink) {
process.exit(0)
}
-// Symlink the latest early access source directories into this repo
for (const dirName of destinationDirNames) {
if (!earlyAccessLocalRepoDir) continue
diff --git a/src/early-access/scripts/update-data-and-image-paths.ts b/src/early-access/scripts/update-data-and-image-paths.ts
index f1823f9e1187..c7220d4f0803 100644
--- a/src/early-access/scripts/update-data-and-image-paths.ts
+++ b/src/early-access/scripts/update-data-and-image-paths.ts
@@ -1,7 +1,5 @@
-/**
- * @purpose Writer tool
- * @description Add or remove "early-access" from data and image paths
- */
+// @purpose Writer tool
+// @description Add or remove "early-access" from data and image paths
import fs from 'fs'
import path from 'path'
@@ -45,7 +43,7 @@ let selectedFiles: string[] = allEarlyAccessFiles
if (earlyAccessPath) {
const contentFiles = allEarlyAccessFiles.filter((file) => file.includes(earlyAccessPath))
- // We also need to include any reusable files that are referenced in the selected content files.
+ // Include reusable files referenced by selected content files.
const referencedDataFiles: string[] = []
for (const file of contentFiles) {
const contents = fs.readFileSync(file, 'utf8')
diff --git a/src/early-access/scripts/what-docs-early-access-branch.ts b/src/early-access/scripts/what-docs-early-access-branch.ts
index fd69e85f1c1c..41e89a0ff5f9 100644
--- a/src/early-access/scripts/what-docs-early-access-branch.ts
+++ b/src/early-access/scripts/what-docs-early-access-branch.ts
@@ -16,9 +16,7 @@ async function main(): Promise {
const OUTPUT_KEY = 'branch'
- // If being run from a PR, this becomes 'my-cool-branch'.
- // If run on main, with the `workflow_dispatch` action for
- // example, the value becomes 'main'.
+ // Use the matching docs-early-access branch when it exists; 404 falls back to main.
const github = getOctokit(GITHUB_TOKEN)
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
@@ -39,7 +37,7 @@ async function main(): Promise {
setOutput(OUTPUT_KEY, 'main')
return
}
- // Retry on network/server errors (5xx, timeouts, etc.)
+ // Retry any non-404 failure until MAX_RETRIES is reached.
if (attempt < MAX_RETRIES) {
console.warn(
`Attempt ${attempt}/${MAX_RETRIES} failed with error: ${err instanceof Error ? err.message : String(err)}. Retrying in ${RETRY_DELAY_SECONDS}s...`,
diff --git a/src/early-access/tests/early-access-unit.ts b/src/early-access/tests/early-access-unit.ts
index d133f1479a12..a5b267b1ca34 100644
--- a/src/early-access/tests/early-access-unit.ts
+++ b/src/early-access/tests/early-access-unit.ts
@@ -28,7 +28,7 @@ describeIfDocsEarlyAccess('early access rendering', () => {
test('404 if any other language than English', async () => {
for (const code of Object.keys(languages)) {
if (code === 'en') {
- // This is tested elsewhere
+ // English early access rendering has separate tests above.
continue
}
const res = await get(`/${code}${VALID_EARLY_ACCESS_URI}`)
diff --git a/src/eslint-rules/no-dangerously-set-inner-html/no-dangerously-set-inner-html.js b/src/eslint-rules/no-dangerously-set-inner-html/no-dangerously-set-inner-html.js
index 75b90dbc8b14..174e8a8856bf 100644
--- a/src/eslint-rules/no-dangerously-set-inner-html/no-dangerously-set-inner-html.js
+++ b/src/eslint-rules/no-dangerously-set-inner-html/no-dangerously-set-inner-html.js
@@ -15,17 +15,12 @@ module.exports = {
},
create(context) {
return {
- // Flag the JSX attribute form:
JSXAttribute(node) {
if (node.name && node.name.name === "dangerouslySetInnerHTML") {
context.report({ node, messageId: "noDanger" });
}
},
- // Flag the object-property form used when spreading props, e.g.
- // { dangerouslySetInnerHTML: { __html: html } }. Only object *expressions*
- // (constructing props) are unsafe; skip object *patterns* (destructuring
- // like `const { dangerouslySetInnerHTML, ...rest } = props`), which strip
- // the prop and are safe.
+ // Object expressions can build JSX-spread dangerouslySetInnerHTML; patterns only read props.
Property(node) {
if (!node.parent || node.parent.type !== "ObjectExpression") return;
const key = node.key;
@@ -38,9 +33,7 @@ module.exports = {
context.report({ node, messageId: "noDanger" });
}
},
- // Flag the assignment form, including the computed string-key bypass:
- // props.dangerouslySetInnerHTML = { __html: html }
- // props['dangerouslySetInnerHTML'] = { __html: html }
+ // Direct and computed assignments bypass JSX-attribute checks, so flag both forms.
AssignmentExpression(node) {
const left = node.left;
if (!left || left.type !== "MemberExpression") return;
diff --git a/src/eslint-rules/no-dangerously-set-inner-html/tests/no-dangerously-set-inner-html.ts b/src/eslint-rules/no-dangerously-set-inner-html/tests/no-dangerously-set-inner-html.ts
index 399ef0225838..c5a80d0189aa 100644
--- a/src/eslint-rules/no-dangerously-set-inner-html/tests/no-dangerously-set-inner-html.ts
+++ b/src/eslint-rules/no-dangerously-set-inner-html/tests/no-dangerously-set-inner-html.ts
@@ -21,7 +21,7 @@ describe('no-dangerously-set-inner-html', () => {
{ code: `const el = ` },
{ code: `const el = ` },
{ code: `const props = { className: 'x', children: nodes }` },
- // Destructuring that strips the prop is safe and must not be flagged.
+ // Destructuring strips the prop, so the rule leaves it alone.
{ code: `const { dangerouslySetInnerHTML, ...safeProps } = props` },
],
invalid: [],
@@ -64,7 +64,7 @@ describe('no-dangerously-set-inner-html', () => {
code: `props.dangerouslySetInnerHTML = { __html: html }`,
errors: [{ messageId: 'noDanger' }],
},
- // Computed string-key assignment is a trivial bypass and must be flagged.
+ // Computed string-key assignment bypasses JSX-attribute checks, so the rule flags it.
{
code: `props['dangerouslySetInnerHTML'] = { __html: html }`,
errors: [{ messageId: 'noDanger' }],
diff --git a/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts b/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts
index 37aeb287c2d4..9fd4dff0d791 100644
--- a/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts
+++ b/src/eslint-rules/use-custom-logger/tests/use-custom-logger.ts
@@ -442,7 +442,7 @@ const logger = createLogger(import.meta.url);
})
it('should handle logger variable with destructuring pattern', () => {
- // A destructured logger already exists, so the fix must not redeclare it.
+ // A destructured logger already exists, so the fixer must not redeclare it.
ruleTester.run('use-custom-logger', rule, {
valid: [],
invalid: [
@@ -456,7 +456,7 @@ const logger = createLogger(import.meta.url);
message: 'Please use our internal logger.info instead of console.log',
},
],
- // The auto-fix will add the import but not the declaration since logger exists via destructuring
+ // The fixer adds the import but skips the declaration because destructuring provides it.
output: `import { createLogger } from '@/observability/logger';
const { logger } = something;
diff --git a/src/eslint-rules/use-custom-logger/use-custom-logger.js b/src/eslint-rules/use-custom-logger/use-custom-logger.js
index 05d273fb7a5f..982b9482daef 100644
--- a/src/eslint-rules/use-custom-logger/use-custom-logger.js
+++ b/src/eslint-rules/use-custom-logger/use-custom-logger.js
@@ -13,7 +13,6 @@ module.exports = {
const sourceCode = context.getSourceCode();
let setupInserted = false;
- // Check if the logger import is already present.
function needsLoggerImport() {
return !sourceCode.ast.body.some(
(node) =>
@@ -22,18 +21,13 @@ module.exports = {
);
}
- // Check if a logger variable is already declared.
- // This checks for both direct declarations (const logger = ...) and
- // destructured patterns (const { logger } = ...).
function needsLoggerDeclaration() {
return !sourceCode.ast.body.some((node) => {
if (node.type === "VariableDeclaration") {
return node.declarations.some((decl) => {
- // Check for direct identifier: const logger = ...
if (decl.id.type === "Identifier" && decl.id.name === "logger") {
return true;
}
- // Check for destructured pattern: const { logger } = ...
if (decl.id.type === "ObjectPattern") {
return decl.id.properties.some(
(prop) =>
@@ -49,7 +43,6 @@ module.exports = {
});
}
- // Retrieve the last import statement.
function getLastImportNode() {
const imports = sourceCode.ast.body.filter(
(node) => node.type === "ImportDeclaration",
@@ -69,7 +62,6 @@ module.exports = {
["log", "error", "debug", "warn"].includes(callee.property.name)
) {
const method = callee.property.name;
- // Determine the replacement method: "log" should become "info".
const newMethod = method === "log" ? "info" : method;
context.report({
node: callee,
@@ -78,14 +70,10 @@ module.exports = {
const fixes = [];
const args = node.arguments;
- // Replace 'console' with 'logger'
fixes.push(fixer.replaceText(callee.object, "logger"));
- // Replace the property; if it's "log", change to "info"
fixes.push(fixer.replaceText(callee.property, newMethod));
- // Check if we need to transform arguments for error-level methods
- // If the first argument appears to be an error variable (common pattern: err, error, e)
- // and there's only one argument, we should add a descriptive message
+ // Add a message when error or warn receives one error variable; keep it as metadata.
if (
(newMethod === "error" || newMethod === "warn") &&
args.length === 1 &&
@@ -94,8 +82,6 @@ module.exports = {
args[0].name,
)
) {
- // Transform console.error(err) to logger.error('Error occurred', { err })
- // This makes the log message more useful and follows structured logging pattern
const errorVarName = sourceCode.getText(args[0]);
fixes.push(
fixer.replaceText(
@@ -105,7 +91,7 @@ module.exports = {
);
}
- // Insert our logger setup (import + declaration) only once per file.
+ // Insert logger setup once per file.
if (!setupInserted) {
setupInserted = true;
@@ -114,7 +100,6 @@ module.exports = {
const lastImport = getLastImportNode();
if (needsImport && needsDeclaration) {
- // Insert both import and declaration together
if (lastImport) {
fixes.push(
fixer.insertTextAfter(
@@ -123,7 +108,6 @@ module.exports = {
),
);
} else {
- // No imports – insert at the top
fixes.push(
fixer.insertTextBeforeRange(
[0, 0],
@@ -132,7 +116,6 @@ module.exports = {
);
}
} else if (needsImport) {
- // Only insert the import
if (lastImport) {
fixes.push(
fixer.insertTextAfter(
@@ -149,7 +132,6 @@ module.exports = {
);
}
} else if (needsDeclaration) {
- // Only insert the logger declaration
if (lastImport) {
fixes.push(
fixer.insertTextAfter(
diff --git a/src/fixtures/helpers/color-contrast.ts b/src/fixtures/helpers/color-contrast.ts
index 4d2e6fc8fe77..1f6463defa36 100644
--- a/src/fixtures/helpers/color-contrast.ts
+++ b/src/fixtures/helpers/color-contrast.ts
@@ -1,5 +1,5 @@
-// WCAG contrast for computed `rgb()`/`rgba()` colours. Keywords, hex and
-// translucent values throw rather than being coerced — `rgba(0, 0, 0, 0)` would
+// Computes WCAG contrast only for opaque computed rgb()/rgba() colours.
+// Reject keywords, hex, and translucent values, because rgba(0, 0, 0, 0) would
// otherwise read as opaque black and yield a confident, wrong ratio.
function parseComputedColor(color: string) {
diff --git a/src/fixtures/helpers/turn-off-experiments.ts b/src/fixtures/helpers/turn-off-experiments.ts
index cfb9547b4e2e..b26fb37bee9e 100644
--- a/src/fixtures/helpers/turn-off-experiments.ts
+++ b/src/fixtures/helpers/turn-off-experiments.ts
@@ -18,7 +18,7 @@ async function alterExperimentsInPage(
variation: typeof TREATMENT_VARIATION | typeof CONTROL_VARIATION,
) {
const experiments = getActiveExperiments('all')
- // Include a page.evaluate call to simulate the same # of events as if an experiment were active
+ // When no experiments run, page.evaluate keeps the Playwright event count matching active runs.
if (!experiments.length) {
await page.evaluate(() => {
console.log('No experiments to turn off, skipping')
@@ -28,7 +28,7 @@ async function alterExperimentsInPage(
for (const experiment of getActiveExperiments('all')) {
await page.evaluate(
({ experimentKey, variationType }) => {
- // @ts-expect-error overrideControlGroup is a custom function added to the window object
+ // @ts-expect-error -- overrideControlGroup is a custom window helper for experiment tests.
window.overrideControlGroup(experimentKey, variationType)
},
{ experimentKey: experiment.key, variationType: variation },
@@ -36,8 +36,7 @@ async function alterExperimentsInPage(
}
}
-// Place Playwright tests in control group for every active experiment
-// To write a test for an experiment, explicitly turn that experiment on in the test
+// Playwright fixtures start in the control group; tests opt into treatments explicitly.
export function turnOffExperimentsBeforeEach(test: typeof Test) {
test.beforeEach(async ({ page }) => {
await page.goto('/')
diff --git a/src/fixtures/playwright.config.ts b/src/fixtures/playwright.config.ts
index 7d4e382171aa..b0d0bbda814a 100644
--- a/src/fixtures/playwright.config.ts
+++ b/src/fixtures/playwright.config.ts
@@ -5,14 +5,8 @@ const CI = Boolean(JSON.parse(process.env.CI || 'false'))
const PLAYWRIGHT_START_SERVER_COMMAND =
process.env.PLAYWRIGHT_START_SERVER_COMMAND || 'npm run start-for-playwright'
-// All of these "patience" related settings follow a simple pattern;
-// If the env var are explicitly set, use that value, otherwise, if
-// we're in CI, be very patient, otherwise, be much less patient.
-// The reasoning is that most engineer laptops are faster than CI
-// and most importantly, if a test gets stuck it's probably not because
-// of a slow CPU, but because the test is plainly wrong. The engineer
-// working on it doesn't want to have to wait half a minute to find out
-// they have a bug in a test action or an assertion.
+// Environment variables override the retry and timeout defaults. CI gets longer waits
+// than local runs, so broken local tests fail quickly instead of waiting on CI-sized timeouts.
const RETRIES = process.env.PLAYWRIGHT_RETRIES ? Number(process.env.PLAYWRIGHT_RETRIES) : CI ? 2 : 0
const TIMEOUT = process.env.PLAYWRIGHT_TIMEOUT
? Number(process.env.PLAYWRIGHT_TIMEOUT)
@@ -25,17 +19,12 @@ const EXPECT_TIMEOUT = process.env.PLAYWRIGHT_EXPECT_TIMEOUT
? 5 * 1000
: 2 * 1000
-/**
- * See https://playwright.dev/docs/test-configuration.
- */
+// See https://playwright.dev/docs/test-configuration.
export default defineConfig({
testDir: './tests',
timeout: TIMEOUT,
expect: {
- /**
- * Maximum time expect() should wait for the condition to be met.
- * For example in `await expect(locator).toHaveText();`
- */
+ // EXPECT_TIMEOUT controls waits such as await expect(locator).toHaveText().
timeout: EXPECT_TIMEOUT,
},
fullyParallel: true,
@@ -46,61 +35,21 @@ export default defineConfig({
: CI
? 1
: undefined,
- /* Reporter to use. See https://playwright.dev/docs/test-reporters */
- // reporter: 'html',
- /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ // See https://playwright.dev/docs/api/class-testoptions for shared project options.
use: {
- /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
baseURL: 'http://localhost:4000',
- /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ // See https://playwright.dev/docs/trace-viewer for trace collection behavior.
trace: 'on-first-retry',
},
projects: [
- // {
- // name: 'chromium',
- // use: {
- // ...devices['Desktop Chrome'],
- // // need this wider width because of our slightly wider than normal xl
- // // breakpoint that helps prevent overlapping main content with the minitoc
- // viewport: {
- // width: 1400,
- // height: 720,
- // },
- // },
- // },
-
- // {
- // name: 'firefox',
- // use: { ...devices['Desktop Firefox'] },
- // },
-
- // {
- // name: 'webkit',
- // use: { ...devices['Desktop Safari'] },
- // },
-
- /* Test against mobile viewports. */
- // {
- // name: 'Mobile Chrome',
- // use: { ...devices['Pixel 5'] },
- // },
- // {
- // name: 'Mobile Safari',
- // use: { ...devices['iPhone 12'] },
- // },
-
- /* Test against branded browsers. */
- // {
- // name: 'Microsoft Edge',
- // use: { channel: 'msedge' },
- // },
{
name: 'Google Chrome',
use: {
channel: 'chromium',
+ // The 1400px width avoids overlap between main content and the mini table of contents.
viewport: {
width: 1400,
height: 720,
@@ -109,9 +58,6 @@ export default defineConfig({
},
],
- /* Folder for test artifacts such as screenshots, videos, traces, etc. */
- // outputDir: 'test-results/',
-
webServer: {
command: PLAYWRIGHT_START_SERVER_COMMAND,
port: 4000,
diff --git a/src/fixtures/tests/annotations.ts b/src/fixtures/tests/annotations.ts
index 87f35190137e..0f9b7122146e 100644
--- a/src/fixtures/tests/annotations.ts
+++ b/src/fixtures/tests/annotations.ts
@@ -8,13 +8,9 @@ describe('annotations', () => {
const $: CheerioAPI = await getDOM('/get-started/foo/code-snippet-with-hashbang')
const annotations = $('#article-contents .annotate')
- // Check http://localhost:4000/en/get-started/foo/code-snippet-with-hashbang
- // to understand the confidence in the assertions.
-
- // This fixture page has 2 bash annotations and 1 yaml
+ // The fixture page intentionally has 2 Bash annotations and 1 YAML annotation.
expect(annotations.length).toBe(2 + 1)
- // First code snippet block
{
const annotation = annotations.eq(0)
expect(annotation.find('.annotate-header').length).toBe(1)
@@ -25,7 +21,6 @@ describe('annotations', () => {
const noteTexts = notes.map((_, el) => $(el).text()).get()
expect(noteTexts).toEqual(["Let's get started", 'This is just a sample', 'End of the script'])
}
- // Second code snippet block
{
const annotation = annotations.eq(1)
expect(annotation.find('.annotate-header').length).toBe(1)
@@ -36,7 +31,7 @@ describe('annotations', () => {
const noteTexts = notes.map((_, el) => $(el).text()).get()
expect(noteTexts).toEqual(['Has to start with a comment.', 'This is the if statement'])
}
- // Yaml code snippet that starts with an empty comment
+ // The YAML snippet starts with an empty comment.
{
const annotation = annotations.eq(2)
expect(annotation.find('.annotate-header').length).toBe(1)
diff --git a/src/fixtures/tests/api-article-body.ts b/src/fixtures/tests/api-article-body.ts
index fb4d3de6d23a..d74f787a25ad 100644
--- a/src/fixtures/tests/api-article-body.ts
+++ b/src/fixtures/tests/api-article-body.ts
@@ -6,15 +6,13 @@ const makeURL = (pathname: string) => `/api/article/body?${new URLSearchParams({
describe('article body api', () => {
beforeAll(() => {
- // If you didn't set the `ROOT` variable, the tests will fail rather
- // cryptically. So as a warning for engineers running these tests,
- // alert in case it was accidentally forgotten.
+ // Missing ROOT makes local fixture failures hard to trace.
if (!process.env.ROOT) {
console.warn(
'WARNING: The articlebody tests require the ROOT environment variable to be set to the fixture root',
)
}
- // Ditto for fixture-based translations to work
+ // Missing TRANSLATIONS_FIXTURE_ROOT breaks fixture-based translations.
if (!process.env.TRANSLATIONS_FIXTURE_ROOT) {
console.warn(
'WARNING: The articlebody tests require the TRANSLATIONS_FIXTURE_ROOT environment variable to be set',
@@ -28,7 +26,7 @@ describe('article body api', () => {
expect(res.headers['content-type']).toContain('text/markdown')
expect(res.body).toContain('## About GitHub')
expect(res.body).toContain('## About Git')
- expect(res.body).toMatch(/^#+\s+\w+/m) // Check for any markdown heading pattern
+ expect(res.body).toMatch(/^#+\s+\w+/m)
expect(res.headers['set-cookie']).toBeUndefined()
expect(res.headers['cache-control']).toContain('public')
@@ -123,7 +121,7 @@ describe('article body api', () => {
})
test('codespaces content included in production markdown API', async () => {
- // Test a real production page that has codespaces content
+ // This production URL exercises real Codespaces tool content when fixtures can reach it.
const res = await get(
makeURL(
'/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/reviewing-proposed-changes-in-a-pull-request',
@@ -144,8 +142,7 @@ describe('article body api', () => {
})
test('verifies original issue #5400 is resolved', async () => {
- // This test specifically addresses the original issue where tool picker
- // content was missing from the Markdown API response
+ // This production URL verifies the Markdown API includes Codespaces tool content.
const res = await get(
makeURL(
'/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/reviewing-proposed-changes-in-a-pull-request',
@@ -162,7 +159,6 @@ describe('article body api', () => {
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toContain('text/markdown')
- // The original issue was that only webui content was returned, missing codespaces
expect(res.body).toContain('')
expect(res.body).toContain('')
diff --git a/src/fixtures/tests/breadcrumbs.ts b/src/fixtures/tests/breadcrumbs.ts
index 9559fcd43eef..d53b2fc4bd00 100644
--- a/src/fixtures/tests/breadcrumbs.ts
+++ b/src/fixtures/tests/breadcrumbs.ts
@@ -6,13 +6,11 @@ describe('breadcrumbs', () => {
test('links always prefixed with language', async () => {
const $ = await getDOM('/get-started/start-your-journey/hello-world')
const links = $('[data-testid=breadcrumbs-bar] a')
- // Home and the two ancestors are links; the current article is static text.
+ // The current article is static text, so only Home and two ancestors are links.
expect(links.length).toBe(3)
links.each((i, element) => {
const href = $(element).attr('href')!
- // The Home crumb points at the locale root (`/en` on the default version,
- // no trailing slash); every other crumb is under `/en/…`. Both are
- // language-prefixed, which is what this test guards.
+ // Home uses /en; every other crumb starts with /en/.
expect(href === '/en' || href.startsWith('/en/')).toBe(true)
})
})
@@ -45,7 +43,7 @@ describe('breadcrumbs', () => {
expect(current.text()).toBe('Hello World')
expect(current.is('a')).toBe(false)
expect(current.attr('href')).toBeUndefined()
- // The secondary-bar variant shows the full trail (no hidden last crumb).
+ // The secondary bar shows the full trail, including the last crumb.
expect(current.hasClass('d-none')).toBe(false)
})
diff --git a/src/fixtures/tests/categories-and-subcategory.ts b/src/fixtures/tests/categories-and-subcategory.ts
index 24aa47af6056..36abceb149d5 100644
--- a/src/fixtures/tests/categories-and-subcategory.ts
+++ b/src/fixtures/tests/categories-and-subcategory.ts
@@ -12,12 +12,10 @@ describe('subcategories', () => {
const links = $('[data-testid=table-of-contents] a[href]')
expect(links.length).toBeGreaterThan(0)
- // They all have the same prefix
const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get()
expect(
hrefs.every((href: string) => href.startsWith('/en/get-started/start-your-journey/')),
).toBeTruthy()
- // They all resolve to a 200 OK without redirects
const responses = await Promise.all(hrefs.map((href: string) => head(href)))
expect(responses.every((r: { statusCode: number }) => r.statusCode === 200)).toBeTruthy()
})
@@ -35,7 +33,7 @@ describe('subcategories', () => {
expect(firstArticleH2.text()).toMatch('Article title')
const firstArticleIntro = $('[data-testid=table-of-contents] p').first()
- // Its HTML in the intro is escaped and Markdown converted
+ // The intro escapes title HTML and converts Markdown.
expect(firstArticleIntro.html()).toMatch(
'This page uses < and > in the title and shortTitle',
)
@@ -50,10 +48,8 @@ describe('categories', () => {
const links = $('[data-testid=table-of-contents] a[href]')
expect(links.length).toBeGreaterThan(0)
- // They all have the same prefix
const hrefs = links.map((i: number, el: Element) => $(el).attr('href')).get()
expect(hrefs.every((href: string) => href.startsWith('/en/actions/category/'))).toBeTruthy()
- // They all resolve to a 200 OK without redirects
const responses = await Promise.all(hrefs.map((href: string) => head(href)))
expect(responses.every((r: { statusCode: number }) => r.statusCode === 200)).toBeTruthy()
})
diff --git a/src/fixtures/tests/footer.ts b/src/fixtures/tests/footer.ts
index 2df4b449b796..ac302a742c55 100644
--- a/src/fixtures/tests/footer.ts
+++ b/src/fixtures/tests/footer.ts
@@ -14,7 +14,7 @@ describe('footer', () => {
})
test('renders minimal 404 page', async () => {
- // 404 pages now render a minimal HTML response without the full layout
+ // Minimal 404 responses omit the full layout.
const $ = await getDOM('/en/delicious-snacks/donuts.php', { allow404: true })
expect($('p').text()).toContain('Page not found.')
})
diff --git a/src/fixtures/tests/glossary.ts b/src/fixtures/tests/glossary.ts
index e214b7927aa4..dab192b44eef 100644
--- a/src/fixtures/tests/glossary.ts
+++ b/src/fixtures/tests/glossary.ts
@@ -17,7 +17,7 @@ describe('glossary', () => {
const $: CheerioAPI = await getDOM('/get-started/learning-about-github/github-glossary')
const internalLink = $('#article-contents a[href="/en/get-started/foo"]')
expect(internalLink.length).toBe(1)
- // That link used AUTOTITLE so it should be "expanded"
+ // AUTOTITLE expands this fixture link to the page title.
expect(internalLink.text()).toBe('Fooing Around')
})
@@ -29,7 +29,6 @@ describe('glossary', () => {
})
test('liquid in one of the description depends on version', async () => {
- // fpt
{
const $: CheerioAPI = await getDOM('/get-started/learning-about-github/github-glossary')
const paragraphs = $('#article-contents p')
@@ -40,7 +39,6 @@ describe('glossary', () => {
expect(paragraphTexts).toContain('status check on HubGit.')
}
- // ghes
{
const $: CheerioAPI = await getDOM(
'/enterprise-server@latest/get-started/learning-about-github/github-glossary',
diff --git a/src/fixtures/tests/head.ts b/src/fixtures/tests/head.ts
index 253f43190423..6eb6ab0fd8d0 100644
--- a/src/fixtures/tests/head.ts
+++ b/src/fixtures/tests/head.ts
@@ -6,10 +6,10 @@ import { getDOM } from '@/tests/helpers/e2etest'
describe('', () => {
test('includes page intro in `description` meta tag', async () => {
const $: CheerioAPI = await getDOM('/get-started/markdown/intro')
- // The intro has Markdown syntax which becomes HTML encoded in the lead element.
+ // The lead renders Markdown syntax as HTML.
const lead = $('[data-testid="lead"] p')
expect(lead.html()).toMatch('syntax')
- // As a meta description its content is stripped of all HTML
+ // Meta descriptions strip all HTML from Markdown-rendered intros.
const description = $('head meta[name="description"]')
expect(description.attr('content')).toBe('This intro has Markdown syntax for HubGit')
})
diff --git a/src/fixtures/tests/homepage.ts b/src/fixtures/tests/homepage.ts
index 8ba9d7ece5e0..db1536eac79a 100644
--- a/src/fixtures/tests/homepage.ts
+++ b/src/fixtures/tests/homepage.ts
@@ -21,7 +21,8 @@ describe('home page', () => {
for (const href of hrefs) {
if (!href.attr('href')?.startsWith('https://')) {
const res = await get(href.attr('href')!)
- expect(res.statusCode).toBe(200) // Not needing to redirect
+ // Product group links resolve without redirects.
+ expect(res.statusCode).toBe(200)
expect(href.text().includes('{%')).toBe(false)
} else {
externalLinks++
diff --git a/src/fixtures/tests/images.ts b/src/fixtures/tests/images.ts
index a1eaaccfec5d..b9aee2601786 100644
--- a/src/fixtures/tests/images.ts
+++ b/src/fixtures/tests/images.ts
@@ -6,15 +6,16 @@ import type { Element } from 'domhandler'
import { get, head, getDOM } from '@/tests/helpers/e2etest'
import { MAX_WIDTH } from '@/content-render/unified/rewrite-asset-img-tags'
-// `getDOM` parses with `xmlMode: true`, which is case-sensitive on attribute
-// names. The legacy string render path emits a lowercase `srcset`, but the
-// React render path (hast -> JSX) emits React 19's camelCase `srcSet`. Both are
-// valid HTML (attribute names are case-insensitive in browsers), so read either.
+// getDOM parses in xmlMode, so attribute names are case-sensitive.
+// The string render path emits srcset, and the React render path emits srcSet.
+// Browsers treat both as valid HTML, so read either spelling.
function srcsetOf(el: Cheerio): string | undefined {
return el.attr('srcset') ?? el.attr('srcSet')
}
describe('render Markdown image tags', () => {
+ // _fixtures/screenshot.png is 2000x1494 and wider than MAX_WIDTH, so picture
+ // sources include mw-XXXXX resizing and preserve aspect ratio at 1076px tall.
test('page with a single image', async () => {
const $: CheerioAPI = await getDOM('/get-started/images/single-image')
@@ -41,16 +42,9 @@ describe('render Markdown image tags', () => {
expect(res.statusCode).toBe(200)
expect(res.headers['content-type']).toBe('image/webp')
- // The fixture image `_fixtures/screenshot.png` is known to be very
- // large. Larger than MAX_WIDTH pixels wide.
- // When transformed as a source in a `` tag, it's automatically
- // injected with the `mw-XXXXX` virtual indicator in the URL that
- // resizes it on-the-fly.
const image = sharp(Buffer.from(res.body as ArrayBuffer))
const { width, height } = await image.metadata()
expect(width).toBe(MAX_WIDTH)
- // The `_fixtures/screenshot.png` is 2000x1494.
- // So if 2000/1494==MAX_WIDTH/x, then x becomes 1494*MAX_WIDTH/2000=1076
expect(height).toBe(Math.round((1494 * MAX_WIDTH) / 2000))
})
@@ -63,9 +57,9 @@ describe('render Markdown image tags', () => {
const sources = $('source', pictures)
expect(sources.length).toBe(3)
- expect(srcsetOf(sources.eq(0))).toContain('1x') // 0
- expect(srcsetOf(sources.eq(1))).toContain('2x') // 1
- expect(srcsetOf(sources.eq(2))).toContain('2x') // 2
+ expect(srcsetOf(sources.eq(0))).toContain('1x')
+ expect(srcsetOf(sources.eq(1))).toContain('2x')
+ expect(srcsetOf(sources.eq(2))).toContain('2x')
})
test('image inside a list keeps its span', async () => {
@@ -77,10 +71,10 @@ describe('render Markdown image tags', () => {
test("links directly to images aren't rewritten", async () => {
const $: CheerioAPI = await getDOM('/get-started/images/link-to-image')
- // There is only 1 link inside that page
- const links = $('#article-contents a[href^="/"]') // exclude header link
+ // The fixture has one article link; header links are out of scope.
+ const links = $('#article-contents a[href^="/"]')
expect(links.length).toBe(1)
- // This proves that the link didn't get rewritten to `/en/...`
+ // Asset links must stay under /assets instead of gaining a language prefix.
expect(links.attr('href'), '/assets/images/_fixtures/screenshot.png')
const res = await head(links.attr('href')!)
expect(res.statusCode).toBe(200)
diff --git a/src/fixtures/tests/internal-links.ts b/src/fixtures/tests/internal-links.ts
index 353dc286168c..e659aebf97dc 100644
--- a/src/fixtures/tests/internal-links.ts
+++ b/src/fixtures/tests/internal-links.ts
@@ -15,13 +15,12 @@ describe('autotitle', () => {
expect($(element).text()).toBe('Hello World')
}
})
- // There are 4 links on the `autotitling.md` content.
+ // autotitling.md has 4 AUTOTITLE links.
expect.assertions(4)
})
test('typos lead to error when NODE_ENV !== production', async () => {
- // The fixture typo-autotitling.md contains two different typos
- // of the word "AUTOTITLE", separated by `{% if version ghes %}`
+ // typo-autotitling.md contains two AUTOTITLE typos split by {% if version ghes %}.
{
const res = await get('/get-started/foo/typo-autotitling', { followRedirects: true })
expect(res.statusCode).toBe(500)
@@ -48,14 +47,14 @@ describe('cross-version-links', () => {
const $: CheerioAPI = await getDOM(URL)
const links = $('#article-contents a[href]')
- // Tests that the hardcoded prefix is always removed
+ // Cross-version links drop hardcoded free-pro-team prefixes.
const firstLink = links.filter(
(i: number, element: Element) =>
$(element).text() === 'Hello world always in free-pro-team',
)
expect(firstLink.attr('href')).toBe('/en/get-started/start-your-journey/hello-world')
- // Tests that the second link always goes to enterprise-server@X.Y
+ // Cross-version links keep explicit enterprise-server targets.
const secondLink = links.filter(
(i: number, element: Element) =>
$(element).text() === 'Autotitling page always in enterprise-server latest',
@@ -79,7 +78,7 @@ describe('link-rewriting', () => {
expect(link.attr('href')).toMatch('/en/get-started/')
}
- // Some links are left untouched
+ // External, asset, public, and enterprise links keep their original prefixes.
{
const link = links.filter((i: number, element: Element) =>
@@ -120,7 +119,7 @@ describe('link-rewriting', () => {
})
test('/en and current version number is injected', async () => {
- // enterprise-server, unlike enterprise-cloud, use numbers
+ // enterprise-server URLs use numbered releases, unlike enterprise-cloud.
const $: CheerioAPI = await getDOM(
'/enterprise-server@latest/get-started/start-your-journey/link-rewriting',
)
diff --git a/src/fixtures/tests/liquid.ts b/src/fixtures/tests/liquid.ts
index af380472590f..78da5ef5b547 100644
--- a/src/fixtures/tests/liquid.ts
+++ b/src/fixtures/tests/liquid.ts
@@ -56,70 +56,44 @@ describe('post', () => {
expect(html).toMatch('- HubGit
')
expect(html).toMatch('CramFPTped')
- // Test what happens to `Cram{% ifversion fpt %}FPT{% endif %}ped.`
- // when it's not free-pro-team.
+ // Cram{% ifversion fpt %}FPT{% endif %}ped renders as Cramped outside free-pro-team.
{
const $inner: CheerioAPI = await getDOM(
'/enterprise-server@latest/get-started/liquid/whitespace',
)
const innerHtml = $inner('#article-contents').html()
- // Assures that there's not whitespace left when the `{% ifversion %}`
- // yields an empty string.
+ // Empty ifversion output must not leave extra whitespace.
expect(innerHtml).toMatch('Cramped')
}
})
})
describe('rowheaders', () => {
+ // The first fixture table rewrites the first cell in each of two tbody rows to th,
+ // leaving three td cells per row.
+ // The second fixture table has three tbody rows with three td cells each.
+ // Axe's scope-attr-valid rule requires col scope on thead th and row scope on tbody th.
+ // https://dequeuniversity.com/rules/axe/4.1/scope-attr-valid?application=RuleDescription
test('rowheaders', async () => {
const $: CheerioAPI = await getDOM('/get-started/liquid/table-row-headers')
const tables = $('#article-contents table')
expect(tables.length).toBe(2)
- // The first table should have this structure:
- //
- // table
- // tbody
- // tr
- // th
- // td
- // td
- // td
- //
- // (and there are 2 of these rows)
- //
- // That's because a Liquid + Markdown solution rewrites the
- // *first* `tbody td` to become a `th` instead.
const firstTable = tables.filter((i: number) => i === 0)
expect($('tbody tr th', firstTable).length).toBe(2)
expect($('tbody tr td', firstTable).length).toBe(2 * 3)
- // The second table should have this structure:
- //
- // table
- // tbody
- // tr
- // td
- // td
- // td
- //
- // (and there are 3 of these rows)
const secondTable = tables.filter((i: number) => i === 1)
expect($('tbody tr th', secondTable).length).toBe(0)
expect($('tbody tr td', secondTable).length).toBe(3 * 3)
- // More specifically, the tags should have the appropriate
- // `scope` attribute.
- // See "Scope attribute should be used correctly on tables"
- // https://dequeuniversity.com/rules/axe/4.1/scope-attr-valid?application=RuleDescription
$('thead th', firstTable).each((i, element) => {
expect($(element).attr('scope')).toBe('col')
})
$('tbody th', firstTable).each((i, element) => {
expect($(element).attr('scope')).toBe('row')
})
- // The 5 here is the other `expect(...)` that happens before these
- // two, just above, `expect(...)` inside the `.each(...)` loops.
+ // Start with the five fixed assertions before counting each loop assertion.
let totalAssertions = 5
totalAssertions += $('thead th', firstTable).length
totalAssertions += $('tbody th', firstTable).length
@@ -128,9 +102,7 @@ describe('rowheaders', () => {
})
describe('ifversion', () => {
- // the matchesPerVersion object contains a list of conditions that
- // should match per version tested, but we also operate against it
- // to find out versions that shouldn't match
+ // matchesPerVersion lists expected conditions and also defines the inverse set per version.
const ghesLast = `enterprise-server@${supported[supported.length - 1]}`
const ghesPenultimate = `enterprise-server@${supported[supported.length - 2]}`
const matchesPerVersion: Record = {
@@ -174,12 +146,10 @@ describe('ifversion', () => {
const allConditions = Object.values(matchesPerVersion).flat()
- // this is all conditions that should match for this rendered version
const wantedConditions = allConditions.filter((condition: string) => {
return matchesPerVersion[version].includes(condition)
})
- // this is the inverse of the above, conditions that shouldn't match for this rendered version
const unwantedConditions = allConditions.filter((condition: string) => {
return !matchesPerVersion[version].includes(condition)
})
@@ -197,7 +167,6 @@ describe('ifversion', () => {
describe('misc Liquid', () => {
test('links with liquid from data', async () => {
const $: CheerioAPI = await getDOM('/get-started/liquid/links-with-liquid')
- // The URL comes from variables.product.pricing_url
const url = getDataByLanguage('variables.product.pricing_url', 'en')
if (!url) throw new Error('variable could not be found')
const links = $(`#article-contents a[href="${url}"]`)
@@ -212,10 +181,7 @@ describe('misc Liquid', () => {
})
test('page with tool Liquid tag followed by Markdown', async () => {
- // This test tests Markdown being correctly rendered when the
- // Markdown directly follows a tool tag like `{% linux %}...{% endlinux %}`.
- // The next line immediately after the `{% endlinux %}` should not
- // leave the Markdown unrendered
+ // Markdown must render when it immediately follows a {% linux %}...{% endlinux %} tag.
const $: CheerioAPI = await getDOM('/get-started/liquid/tool-platform-switcher')
const innerHTML = $('#article-contents').html()
expect(innerHTML).not.toMatch('On *this* line is `Markdown` too.')
@@ -227,62 +193,33 @@ describe('data tag', () => {
test('injects data reusables with the right whitespace', async () => {
const $: CheerioAPI = await getDOM('/get-started/liquid/data')
- // This proves that the two injected reusables tables work.
- // CommonMark is finicky if the indentation isn't perfect, so
- // if you don't get exactly 2 tables, something is wrong, and if it's
- // wrong it's most likely because of the leading whitespaces.
+ // Incorrect reusable indentation can break CommonMark parsing, so expect exactly two tables.
expect($('#article-contents table').length).toBe(2)
- // To truly understand this test, you have to see
- // http://localhost:4000/en/get-started/liquid/data to understand it.
- // The page uses `{% data ... %}` within the bodies of bullet points.
- // If the whitespace isn't correct and working, the bullet points
- // would get confused and think the bullet point "body" is a new
- // bullet point on its own.
+ // Data tags inside ordered-list items must not split item bodies into new list items.
expect($('#article-contents ol').length).toBe(3)
expect($('#article-contents ol li').length).toBe(2 + 1 + 2)
- // In the very first bullet point we inject something that multiple
- // linebreaks in it. The source looks like this:
- //
- // 1. Bullet point
- //
- // {% data reusables.injectables.multiple_numbers %}
- //
- // (The code comment itself here has 3 spaces of manual indentation)
- // What's important is that all the expected lines of that reusables
- // stick inside this `ul li` block.
+ // The indented {% data reusables.injectables.multiple_numbers %} call keeps every line in the first list item.
const liText = $('#article-contents ol li').first().text()
expect(liText).toMatch(/Bullet point\nOne\nTwo\nThree\nFour/)
- // The code block uses `{% data ... %}` and it should be indented
- // so that it aligns perfectly with the code block itself.
- // One of the injected data reusables contains multiple lines.
- // It's important that each line from that starts at the far
- // left. No more or less whitespace.
+ // Multi-line code-block reusables start at the far left, with no extra indentation.
const codeBlock = $('#article-contents li pre').text()
expect(codeBlock).toMatch(/^One\n/)
expect(codeBlock).toMatch(/^One\nTwo\n/)
expect(codeBlock).toMatch(/^One\nTwo\nThree\n/)
- // The code block also a reusables that is just one line.
+ // The code block also receives one single-line reusable.
expect(codeBlock).toMatch(/One Two Three Four\n/)
- // On its own, if you look at
- // src/fixtures/fixtures/data/reusables/injectables/paragraphs.md, you'll
- // see each line is NOT prefixed with whitespace indentation.
- // But because `{% data reusables.injectables.paragraphs %}` is
- // inserted with some indentation, that's replicated on every line.
+ // src/fixtures/fixtures/data/reusables/injectables/paragraphs.md inherits indentation from its data call.
const li = $('#article-contents li')
.filter((_, element) => {
return $(element).text().trim().startsWith('Point 1')
})
.eq(0)
- // You can't really test the exact whitespace with cheerio,
- // of the original HTML, but it doesn't actually matter. What
- // matters is that within the bullet point, that starts with "Point 1",
- // it *contains* all the paragraphs
- // from src/fixtures/fixtures/data/reusables/injectables/paragraphs.md.
+ // Cheerio cannot test original HTML whitespace, so the bullet text checks every paragraph.
expect(li.text()).toMatch(/Paragraph one/)
expect(li.text()).toMatch(/Paragraph two/)
expect(li.text()).toMatch(/Paragraph three/)
diff --git a/src/fixtures/tests/markdown.ts b/src/fixtures/tests/markdown.ts
index b7f15b614a3a..cd2a8280532c 100644
--- a/src/fixtures/tests/markdown.ts
+++ b/src/fixtures/tests/markdown.ts
@@ -17,8 +17,7 @@ describe('alerts', () => {
test('basic rendering', async () => {
const $: CheerioAPI = await getDOM('/get-started/markdown/alerts')
const alerts = $('#article-contents .ghd-alert')
- // See src/fixtures/fixtures/content/get-started/markdown/alerts.md
- // to be this confident in the assertions.
+ // src/fixtures/fixtures/content/get-started/markdown/alerts.md defines five alert types.
expect(alerts.length).toBe(5)
const svgs = $('svg', alerts)
expect(svgs.length).toBe(5)
diff --git a/src/fixtures/tests/permissions-callout.ts b/src/fixtures/tests/permissions-callout.ts
index 93cc31cd9d87..e23b9af0f615 100644
--- a/src/fixtures/tests/permissions-callout.ts
+++ b/src/fixtures/tests/permissions-callout.ts
@@ -11,11 +11,7 @@ describe('permission statements', () => {
})
test('callout disappears depend on Liquid inside it', async () => {
- // This page has `product:` property which is a piece of Liquid
- // which makes it so that the rendered output of that becomes
- // an empty string.
- // This test tests that alert is not rendered if its output
- // "exits" but is empty.
+ // Liquid in the product: frontmatter property renders empty, so the product statement disappears.
const $: CheerioAPI = await getDOM(
'/enterprise-server@latest/get-started/foo/page-with-callout',
)
@@ -32,16 +28,13 @@ describe('permission statements', () => {
test('page with permission frontmatter', async () => {
const $: CheerioAPI = await getDOM('/get-started/markdown/permissions')
const html = $('[data-testid=permissions-statement] div').html()
- // Markdown
expect(html).toMatch('admin')
- // Liquid
expect(html).toMatch('HubGit Pages site')
})
test('page with permission frontmatter and product statement', async () => {
const $: CheerioAPI = await getDOM('/get-started/foo/page-with-permissions-and-product-callout')
const html = $('[data-testid=permissions-callout] div').html()
- // part of the UI
expect(html).toMatch('Who can use this feature')
const permission = $('[data-testid=permissions-statement] div')
diff --git a/src/fixtures/tests/playwright-a11y.spec.ts b/src/fixtures/tests/playwright-a11y.spec.ts
index 1c2017dce6ce..8cb28892669d 100644
--- a/src/fixtures/tests/playwright-a11y.spec.ts
+++ b/src/fixtures/tests/playwright-a11y.spec.ts
@@ -7,12 +7,9 @@ const SEARCH_TESTS = !!process.env.ELASTICSEARCH_URL
const pages: { [key: string]: string } = {
category: '/actions/category',
codeAnnotations: '/get-started/markdown/code-annotations',
- // The only fixture page that renders a CTA button. A `.btn-primary` anchor is the
- // one shape the brand article-link override can drive under 4.5:1 — its label sits
- // on a coloured fill rather than the page background — which is exactly what it did
- // before `:not(.btn)` was added to
- // src/frame/stylesheets/article-link-overrides.scss. Without this entry that
- // exclusion has no test at all.
+ // This CTA fixture is the only page that covers the .btn-primary article-link override.
+ // Its filled label can fall below 4.5:1 without the :not(.btn) exclusion in
+ // src/frame/stylesheets/article-link-overrides.scss.
ctaButton: '/get-started/foo/page-with-permissions-and-product-callout',
homepage: '/',
learningPath:
@@ -28,7 +25,6 @@ const pages: { [key: string]: string } = {
tableWithHeaders: '/get-started/liquid/table-row-headers',
}
-// create a test for each page, will eventually be separated into finer grain tests
for (const pageName of Object.keys(pages)) {
test.describe(`${pageName}`, () => {
test('full page axe scan without experiments', async ({ page }) => {
@@ -55,14 +51,12 @@ for (const pageName of Object.keys(pages)) {
})
}
-// The search facet filters collapse behind a "Show filters" disclosure below
-// Primer Brand's `medium` breakpoint. The scans above run at the default desktop
-// viewport, where that disclosure is display:none, so the expanded panel would
+// The search facet filters collapse behind a Show filters disclosure below
+// Primer Brand's medium breakpoint. The scans above run at the default desktop
+// viewport, where that disclosure has display: none, so the expanded panel would
// otherwise never be scanned.
test.describe('search filters (narrow viewport)', () => {
- // Without a local Elasticsearch the middleware proxies to production, so there are no
- // aggregations, the disclosure never renders, and this would time out rather than
- // skip. Matches the guard every search test in playwright-rendering.spec.ts uses.
+ // Without local Elasticsearch, the production proxy returns no aggregations, so the disclosure never renders.
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
test('expanded filter disclosure passes axe', async ({ page }) => {
@@ -76,8 +70,7 @@ test.describe('search filters (narrow viewport)', () => {
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
- // Scoped to the disclosure's own panel: a bare `fieldset` locator would hit strict
- // mode the moment anything else on the page renders one.
+ // Scope to the panel, because other fieldsets would trigger Playwright strict mode.
const panelId = await toggle.getAttribute('aria-controls')
await expect(page.locator(`#${panelId} fieldset`)).toBeVisible()
diff --git a/src/fixtures/tests/playwright-header.spec.ts b/src/fixtures/tests/playwright-header.spec.ts
index 36261f1d7ffc..8fdeb5661b79 100644
--- a/src/fixtures/tests/playwright-header.spec.ts
+++ b/src/fixtures/tests/playwright-header.spec.ts
@@ -9,35 +9,29 @@ import {
} from '../../frame/lib/constants'
const ARTICLE = '/en/get-started/foo/bar'
-// `find-page.ts` narrows `context.languages` to English alone for early-access
-// pages, which makes this the production route through the single-language
-// branch of the header's language slot.
+// find-page.ts narrows context.languages to English for early-access pages, so
+// this route exercises the single-language branch of the header's language slot.
const ENGLISH_ONLY_ARTICLE = '/en/early-access/secrets/deeper/mariana-trench'
const SEARCH_LABEL = 'Search or ask Copilot'
const LANGUAGE_LABEL = 'Select language: current language is English'
const PLAN_LABEL = 'Select your plan:'
const VERSION_LABEL = 'Select your version:'
-// The pill's line-height is the Docs design's own decision, set in
-// HeaderPicker.module.scss -- Brand's --brand-text-lineHeight-100 is 1.5 -- so
-// unlike the sizes below it is not resolved from a token.
+// The pill's line-height comes from the Docs design, not Brand's
+// --brand-text-lineHeight-100 value of 1.5.
const PILL_LINE_HEIGHT = 1.2
const PLAN_TRIGGER_TESTID = 'version-picker-button'
const LANGUAGE_TRIGGER_TESTID = 'language-picker-button'
-// Brand renders the trailing slot on `trailingComponent != null`, so the wrapper
-// survives a child that renders nothing. Its class name is CSS-module hashed, so
-// only the stable fragment can be matched -- and an absence assertion on a name
-// Brand might rename would pass vacuously, which is why the test below always
-// pairs it with a page where the same selector must still match.
+// Brand renders the trailing slot when trailingComponent != null, so the wrapper
+// survives a child that renders nothing.
+// The CSS module hash leaves only this stable fragment to match; the paired
+// presence test prevents a vacuous absence check after a Brand rename.
const BRAND_TRAILING_SLOT = '[class*="SubdomainNavBar-trailing-component"]'
-/**
- * Resolve Brand custom properties in whatever theme the page is currently in,
- * instead of hardcoding light-mode RGB values. The probe is appended inside
- * `locator` on purpose: the plan menu renders inside its own nested Brand
- * ThemeProvider, so tokens have to be read from within that subtree to reflect
- * the color mode the menu actually paints with. The hidden probe only
- * normalizes CSS color syntax into rgb(); it never styles the UI.
- */
+// Resolve Brand custom properties in the page's current theme instead of
+// hardcoding light-mode RGB values.
+// Append the probe inside locator because the plan menu has its own nested Brand
+// ThemeProvider, so tokens must come from that subtree.
+// The hidden probe normalizes CSS color syntax into rgb() without styling the UI.
async function resolveThemeTokens(locator: Locator, tokens: string[]) {
return locator.evaluate((element, tokenNames: string[]) => {
const probe = document.createElement('span')
@@ -59,12 +53,10 @@ async function resolveThemeTokens(locator: Locator, tokens: string[]) {
}, tokens)
}
-/**
- * Resolve Brand length tokens to pixels, so the pill's geometry can be checked
- * against the tokens it is built from instead of the numbers those tokens happen
- * to produce today. The probe is laid out (absolute + hidden rather than
- * `hidden`) so `width` resolves through calc()/max() to a used pixel value.
- */
+// Resolve Brand length tokens to pixels so the pill geometry stays tied to
+// tokens, not their current numeric values.
+// The absolute hidden probe stays laid out so width resolves through calc() and
+// max() to a used pixel value.
async function resolveTokenPixels(locator: Locator, tokens: string[]) {
return locator.evaluate((element, tokenNames: string[]) => {
const probe = document.createElement('div')
@@ -92,7 +84,7 @@ async function resolveTokenPixels(locator: Locator, tokens: string[]) {
}, tokens)
}
-/** Read raw custom-property values (font weights resolve to plain numbers). */
+// Font weights resolve to plain numbers, so this reads raw custom-property values.
async function resolveTokenValues(locator: Locator, tokens: string[]) {
return locator.evaluate((element, tokenNames: string[]) => {
const resolved: Record = {}
@@ -122,10 +114,7 @@ async function expectHeaderPlanPicker(page: Page) {
expect(valueId).toBeTruthy()
await expect(button).toHaveAttribute('aria-labelledby', `${labelId} ${valueId}`)
- // Every size below is arithmetic over Brand tokens, so resolve the tokens and
- // derive the expectations rather than hardcoding today's pixels: a
- // @primer/react-brand bump that moves --base-size-* then updates both sides at
- // once, instead of failing CI with no user-visible regression.
+ // Resolve Brand tokens so expected sizes move with --base-size-* changes instead of failing.
const sizes = await resolveTokenPixels(picker, [
'--brand-text-size-100',
'--base-size-2',
@@ -197,8 +186,7 @@ async function expectHeaderPlanPicker(page: Page) {
expect(buttonBox.x - (labelBox.x + labelBox.width)).toBeCloseTo(labelGap, 0)
expect(labelBox.y + labelBox.height / 2).toBeCloseTo(buttonBox.y + buttonBox.height / 2, 0)
expect(buttonBox.height).toBeCloseTo(pillHeight, 0)
- // The normal plan name must fit even with Signup visible at 1012px. Keep
- // ellipsis available for unusually long labels, not this default English one.
+ // Default English plan name must fit with Signup at 1012px; ellipsis is for longer labels.
await expect
.poll(() => value.evaluate((element) => element.scrollWidth - element.clientWidth))
.toBeLessThanOrEqual(0)
@@ -217,16 +205,12 @@ async function expectHeaderPlanPicker(page: Page) {
await expectFilledTriangleCaret(button, colors.text)
}
-/**
- * Both header triggers end in the same caret, so both are checked the same way.
- * The design's caret is a filled triangle. Brand's ActionMenu.Button hardcodes a
- * ChevronDownIcon and only loses to a caller-supplied trailingVisual because it
- * spreads rest props after that default -- a single shared cast (ActionMenuTrigger)
- * relies on that. A Brand upgrade that destructures trailingVisual would silently
- * restore the chevron on both controls at once, so assert the chevron is gone and
- * that the glyph really has the triangle's geometry: the triangle's path is
- * ~7.15 x 3.82 user units, where chevron-down's is ~9.56 x 5.31.
- */
+// Both header triggers use the same filled triangle caret, checked through one helper.
+// Brand's ActionMenu.Button defaults to ChevronDownIcon; the ActionMenuTrigger
+// cast relies on a caller-supplied trailingVisual overriding it.
+// A Brand change that destructures trailingVisual would restore chevrons on both controls.
+// Assert the chevron is gone and the triangle path is about 7.15 by 3.82 user
+// units, not chevron-down's 9.56 by 5.31.
async function expectFilledTriangleCaret(trigger: Locator, color: string) {
const caret = trigger.locator('svg.octicon-triangle-down')
await expect(caret).toBeVisible()
@@ -244,13 +228,10 @@ async function expectFilledTriangleCaret(trigger: Locator, color: string) {
expect(glyph.height).toBeLessThan(4.6)
}
-/**
- * The language trigger deliberately does *not* match the plan pill: Figma draws
- * it as a flat control -- a 16px globe, the language in muted 14px regular, then
- * the same filled caret. Only the dropdown below it is shared, so this asserts
- * the trigger keeps its own treatment and never drifts into the pill (which is
- * exactly what reusing the shared pill class would do).
- */
+// The language trigger deliberately does not match the plan pill.
+// Figma specifies a flat control: 16px globe, muted 14px regular language text,
+// then the same filled caret.
+// Only the dropdown is shared, so this catches accidental reuse of the shared pill class.
async function expectHeaderLanguageTrigger(page: Page) {
const picker = page.getByTestId('desktop-header').getByTestId('language-picker')
const trigger = picker.getByTestId(LANGUAGE_TRIGGER_TESTID)
@@ -272,10 +253,7 @@ async function expectHeaderLanguageTrigger(page: Page) {
)
expect(valueFontSize).toBeCloseTo(sizes['--brand-text-size-100'], 1)
- // Flat, not a pill: no fill at rest, no border, and a small corner rather than
- // the pill's full radius. The canvas-subtle comparison keeps this honest -- it
- // is the fill the pill carries and the fill this control only takes on hover
- // and while open.
+ // Flat trigger: no rest fill or border, a 6px corner, and canvas-subtle on hover or open.
await expect(trigger).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)')
expect(tokens['--brand-color-canvas-subtle']).not.toBe('rgba(0, 0, 0, 0)')
for (const side of ['top', 'right', 'bottom', 'left']) {
@@ -285,9 +263,7 @@ async function expectHeaderLanguageTrigger(page: Page) {
await expect(trigger).toHaveCSS(`border-${corner}-radius`, '6px')
}
const triggerBox = (await trigger.boundingBox())!
- // Brand's ActionMenu remaps --brand-borderRadius-medium to the full radius on
- // its own trigger, so a 6px corner is the difference between this control and
- // a pill rather than a cosmetic detail.
+ // Brand's ActionMenu remaps --brand-borderRadius-medium to full radius; 6px prevents a pill.
expect(triggerBox.height / 2).toBeGreaterThan(6)
const globe = trigger.locator('svg.octicon-globe')
@@ -301,29 +277,25 @@ async function expectHeaderLanguageTrigger(page: Page) {
await expectFilledTriangleCaret(trigger, tokens['--brand-color-text-muted'])
}
-/**
- * The two header dropdowns are the same control with different content: both are
- * Brand ActionMenus whose surface and rows come entirely from the shared
- * HeaderPicker.module.scss. Every design assertion below therefore runs against
- * both -- that is what proves they are identical rather than merely similar --
- * so only the content is parameterized here.
- */
+// The two header dropdowns use the same Brand ActionMenu surface and row styles
+// from HeaderPicker.module.scss.
+// Running each design assertion against both menus proves shared styling, not similar styling.
type HeaderDropdown = {
name: string
pickerTestId: string
triggerTestId: string
- /** The span each row wraps its label in. */
+ // The span each row wraps its label in.
itemTestId: string
expectTrigger: (page: Page) => Promise
- /** The row that opens already chosen: tinted, with the trailing green dot. */
+ // The row that opens already chosen: tinted, with the trailing green dot.
selectedRow: string
- /** Another selectable row: no tint, no dot. */
+ // Another selectable row: no tint, no dot.
unselectedRow: string
- /** Rows that navigate instead of selecting, so they stay plain menuitems. */
+ // Rows that navigate instead of selecting, so they stay plain menuitems.
navigationRowCount: number
- /** The plan menu keeps one rule between its versions and its navigation rows. */
+ // The plan menu keeps one rule between its versions and its navigation rows.
separatorCount: number
- /** The final row -- whatever a clipped menu loses first. */
+ // A clipped menu loses this final row first.
lastRowRole: 'menuitem' | 'menuitemradio'
lastRowName: RegExp
}
@@ -358,11 +330,8 @@ const LANGUAGE_DROPDOWN: HeaderDropdown = {
lastRowName: /日本語/,
}
-/**
- * A Docs 2026 header dropdown, rebuilt on Brand's ActionMenu. Opens the menu,
- * checks the surface, rows, selection indicator and the absence of Brand's own
- * leading check slot, then closes it and confirms focus returns to the trigger.
- */
+// Docs 2026 rebuilds header dropdowns on Brand ActionMenu, so this helper checks
+// the shared menu contract end to end.
async function expectHeaderDropdownDesign(
page: Page,
colorScheme: 'light' | 'dark',
@@ -374,7 +343,7 @@ async function expectHeaderDropdownDesign(
await trigger.click()
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
- // Brand's menu is not portalled -- it renders inside the picker wrapper.
+ // Brand's menu renders inside the picker wrapper, not a portal.
const menu = picker.getByRole('menu')
await expect(menu).toBeVisible()
@@ -385,8 +354,7 @@ async function expectHeaderDropdownDesign(
'--brand-color-text-default',
'--brand-color-success-fg',
])
- // Proves the emulated scheme reached Brand's tokens: a dark run that silently
- // stayed light would satisfy every assertion above on its own.
+ // A dark run that stays light would pass above, so verify Brand tokens changed.
const luminance = relativeLuminance(tokens['--brand-color-canvas-default'])
if (colorScheme === 'dark') {
expect(luminance).toBeLessThan(0.2)
@@ -394,8 +362,7 @@ async function expectHeaderDropdownDesign(
expect(luminance).toBeGreaterThan(0.8)
}
- // Menu surface: canvas-default fill, 1px subtle border, 6px radius, 8px pad.
- // Brand's own defaults are a border-muted border and a 16px radius.
+ // Overrides Brand's border-muted border and 16px radius; assertions also pin fill and 8px pad.
await expect(menu).toHaveCSS('background-color', tokens['--brand-color-canvas-default'])
for (const side of ['top', 'right', 'bottom', 'left']) {
await expect(menu).toHaveCSS(`border-${side}-width`, '1px')
@@ -406,13 +373,10 @@ async function expectHeaderDropdownDesign(
for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) {
await expect(menu).toHaveCSS(`border-${corner}-radius`, '6px')
}
- // The design's menu is 256px wide; a long row may grow it, never shrink it.
+ // The design sets a 256px minimum menu width; long rows can grow it, never shrink it.
const menuBox = (await menu.boundingBox())!
expect(menuBox.width).toBeGreaterThanOrEqual(256)
- // Brand anchors with `allowOutOfBounds`, so nothing clamps a menu that would
- // overhang -- which matters most for the language menu, the one control sitting
- // at the header's right edge. `menuAlignment` is what keeps it on screen, so
- // assert the result instead of trusting the prop.
+ // Brand allowOutOfBounds can overhang the right-edge menu; menuAlignment keeps it on screen.
const viewportWidth = page.viewportSize()!.width
expect(menuBox.x).toBeGreaterThanOrEqual(-1)
expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(viewportWidth + 1)
@@ -420,16 +384,10 @@ async function expectHeaderDropdownDesign(
const selectableRows = menu.getByRole('menuitemradio')
const navigationRows = menu.getByRole('menuitem')
expect(await selectableRows.count()).toBeGreaterThanOrEqual(2)
- // In the plan menu "All Enterprise Server releases" and "About versions"
- // navigate rather than select, so they stay plain menuitems. The language menu
- // has no such rows.
+ // In the plan menu, All Enterprise Server releases and About versions stay navigation menuitems.
await expect(navigationRows).toHaveCount(dropdown.navigationRowCount)
- // A single rule divides the versions from those two navigation rows. Brand has
- // no divider child, so the picker renders the separator itself; it must not be
- // focusable, and must be neither the first nor the last row, because Brand
- // focuses the first - and wires its arrow-key wrap-around to the first and
- // the last. The language menu divides nothing, so it carries no separator.
+ // Brand lacks a divider child; keep the separator unfocusable and outside arrow-key wrap ends.
const separator = menu.locator('[role="separator"]')
await expect(separator).toHaveCount(dropdown.separatorCount)
if (dropdown.separatorCount > 0) {
@@ -462,8 +420,7 @@ async function expectHeaderDropdownDesign(
expect(rule.previousRole).toBe('menuitemradio')
expect(rule.nextRole).toBe('menuitem')
expect(rule.nextText).toMatch(/All Enterprise Server releases/)
- // A plain
- is a block box, so the rule spans the menu's inner width
- // rather than sitting inside a row's own 12px insets.
+ // A block li spans the menu's inner width instead of a row's 12px insets.
expect(rule.width).toBeCloseTo(rule.innerWidth, 0)
expect(rule.marginTop).toBeCloseTo(8, 0)
expect(rule.marginBottom).toBeCloseTo(8, 0)
@@ -476,8 +433,7 @@ async function expectHeaderDropdownDesign(
const row = rows.nth(index)
expect((await row.boundingBox())!.height).toBeCloseTo(32, 0)
await expect(row).toHaveCSS('padding-left', '12px')
- // The reserved indicator column replaces Brand's 48px single-selection
- // gutter: a 12px inset, the 16px dot, then a 12px gap before the label.
+ // The indicator column reserves 12px, a 16px dot and a 12px gap, replacing Brand's 48px gutter.
await expect(row).toHaveCSS('padding-right', '40px')
for (const corner of ['top-left', 'top-right', 'bottom-left', 'bottom-right']) {
await expect(row).toHaveCSS(`border-${corner}-radius`, '6px')
@@ -509,10 +465,7 @@ async function expectHeaderDropdownDesign(
expect(selectedBox.x + selectedBox.width - (dotBox.x + dotBox.width)).toBeCloseTo(12, 0)
expect(dotBox.y + dotBox.height / 2).toBeCloseTo(selectedBox.y + selectedBox.height / 2, 0)
- // Brand renders a leading check slot on every row of a single-selection menu;
- // the design marks the current row with the trailing dot instead. Assert the
- // rendered result rather than Brand's hashed class names: the selected row's
- // only visible glyph is the dot.
+ // The selected row's only visible glyph must be the trailing dot, not Brand's leading check slot.
await expect(selectedRow.locator('svg.octicon-check')).not.toBeVisible()
const visibleGlyphs = await selectedRow
.locator('svg')
@@ -521,8 +474,7 @@ async function expectHeaderDropdownDesign(
)
expect(visibleGlyphs).toHaveLength(1)
expect(visibleGlyphs[0]).toContain('octicon-dot-fill')
- // When Brand renders that slot it must be hidden outright. Written so a future
- // Brand release that stops rendering it altogether does not fail the suite.
+ // Accept a missing leading slot so Brand can remove it without failing this suite.
const leadingSlotDisplay = await selectedRow.evaluate((row) => {
const first = row.firstElementChild
return first && row.children.length > 1 ? getComputedStyle(first).display : null
@@ -543,8 +495,7 @@ async function expectHeaderDropdownDesign(
for (let index = 0; index < dropdown.navigationRowCount; index++) {
const extra = navigationRows.nth(index)
- // axe rejects aria-checked on role=menuitem, so the extras must opt out of
- // the selection semantics ActionMenu.Overlay injects into its children.
+ // axe rejects aria-checked on menuitem, so navigation rows opt out of selection semantics.
await expect(extra).not.toHaveAttribute('aria-checked')
await expect(extra.locator('svg.octicon-dot-fill')).toHaveCount(0)
}
@@ -555,9 +506,8 @@ async function expectHeaderDropdownDesign(
await expect(trigger).toBeFocused()
}
-// The properties a shared stylesheet is supposed to fix identically for both
-// dropdowns. Content-dependent geometry (the menu's used width, a row's text) is
-// deliberately absent: only the styling has to match.
+// The shared stylesheet must fix these properties identically for both dropdowns.
+// Content-dependent geometry is absent; only styling has to match.
const SURFACE_PROPERTIES = [
'background-color',
'min-width',
@@ -593,14 +543,11 @@ const LABEL_PROPERTIES = [
]
const DOT_PROPERTIES = ['position', 'right', 'width', 'height', 'fill']
-/**
- * A style fingerprint of an open header dropdown: the surface, the selected row,
- * its label and its trailing dot. Two dropdowns whose styling really does come
- * from one shared module produce equal fingerprints -- which is a stronger claim
- * than each one separately matching the design, and it is the claim the user
- * actually made ("the language dropdown needs to look like the version
- * dropdown").
- */
+// An open header dropdown fingerprint covers the surface, selected row, label
+// and trailing dot.
+// Equal fingerprints prove the two menus share styling, not merely that each matches the design.
+// This tests the user-visible request: the language dropdown needs to look like
+// the version dropdown.
async function dropdownStyleFingerprint(menu: Locator, dropdown: HeaderDropdown) {
const selectedRow = menu.getByRole('menuitemradio', { name: dropdown.selectedRow, exact: true })
const read = (locator: Locator, properties: string[]) =>
@@ -614,8 +561,7 @@ async function dropdownStyleFingerprint(menu: Locator, dropdown: HeaderDropdown)
row: await read(selectedRow, ROW_PROPERTIES),
label: await read(selectedRow.getByTestId(dropdown.itemTestId), LABEL_PROPERTIES),
dot: await read(selectedRow.locator('svg.octicon-dot-fill'), DOT_PROPERTIES),
- // Brand's leading check slot is hidden structurally, so it has to be hidden
- // in both menus or one of them grows a check icon the other does not have.
+ // Structural hiding must match so one menu cannot grow a Brand check icon the other lacks.
leadingSlotDisplay: await selectedRow.evaluate((row) => {
const first = row.firstElementChild
return first && row.children.length > 1 ? getComputedStyle(first).display : null
@@ -644,8 +590,7 @@ async function expectDesktopHeaderSections(page: Page, signupVisible: boolean) {
const search = element.querySelector
('[data-testid="toggle-search"]')!
const language = element.querySelector('[data-testid="language-picker"]')!
const signup = element.querySelector('[data-testid="header-signup"]')
- // Find the native section wrappers from stable Docs control anchors, not
- // Brand's private CSS class names or a hardcoded number of parent hops.
+ // Find section wrappers from stable Docs anchors, not Brand CSS hashes or parent-hop counts.
let sectionRow = search.parentElement!
while (!sectionRow.contains(language)) sectionRow = sectionRow.parentElement!
const sectionFor = (control: HTMLElement) => {
@@ -720,8 +665,7 @@ async function expectDesktopHeaderSections(page: Page, signupVisible: boolean) {
expect(section.rect.top).toBeCloseTo(layout.header.top, 0)
expect(section.rect.bottom).toBeCloseTo(layout.contentBottom, 0)
}
- // Search owns the full-height divider before Language. Language must not
- // double that border; Signup owns its own separate full-height left divider.
+ // Search owns the divider before Language; Signup owns its own left divider.
expect(layout.search.borderEnd).toBe('1px')
expect(layout.search.borderEndStyle).toBe('solid')
expect(layout.search.borderEndColor).not.toBe('rgba(0, 0, 0, 0)')
@@ -744,23 +688,21 @@ async function expectDocsSearchOpen(page: Page) {
await searchInput.click()
await expect(searchInput).toBeFocused()
await expect(page.getByRole('dialog')).toHaveCount(1)
- // Brand mounts its native dialog even while closed. Only the existing Docs
- // dialog may become modal; opening both would leave competing focus traps.
+ // Only Docs search may become modal; opening Brand's closed native dialog would add a focus trap.
const brandDialog = page.getByTestId('desktop-header').locator('dialog')
await expect(brandDialog).toHaveCount(1)
await expect(brandDialog).toHaveJSProperty('open', false)
await expect(page).toHaveURL((url) => url.searchParams.get('search-overlay-open') === 'true')
}
+// expectBackgroundIsolated includes Brand's skip link because it sits outside
+// the inert wrapper as a sibling before header, yet still targets #main-content
+// while the menu is open.
+// CSS avoids getByText strict-mode matches from the wrapped label and getByRole
+// misses after aria-hidden.
async function expectBackgroundIsolated(page: Page, isolated: boolean) {
for (const locator of [
page.getByText('Skip to main content', { exact: true }),
- // Brand's own skip link sits outside the inert wrapper (it renders as a
- // sibling before ) yet still targets #main-content, which is inert
- // while the menu is open. Matched by CSS rather than text or role: Brand
- // wraps the label in a span, so getByText resolves to both the and that
- // span -- a strict mode violation -- and aria-hidden removes it from the
- // accessibility tree that getByRole searches once isolated.
page.locator('[data-container="header"] a[href="#main-content"]'),
page.locator('#main-content'),
page.getByTestId('sidebar-mobile-toggle'),
@@ -777,8 +719,7 @@ async function expectBackgroundIsolated(page: Page, isolated: boolean) {
test.describe('Brand header', () => {
test.beforeEach(async ({ page }) => {
- // These regressions cover header coordination, not remote search quality.
- // Return empty suggestions so they also run without Elasticsearch or Copilot.
+ // Empty suggestions keep header coordination tests independent of Elasticsearch and Copilot.
await page.route('**/api/search/combined-search/v1?**', (route) =>
route.fulfill({
json: {
@@ -810,32 +751,18 @@ test.describe('Brand header', () => {
await page.reload()
}
- // Wait for account detection/desktop slots before measuring the pill:
- // Signup mounting must not shrink a name that only fit before hydration.
+ // Wait for account detection; Signup can mount after hydration and shrink the plan name.
await expectDesktopHeaderSections(page, !hasAccount)
await expectHeaderPlanPicker(page)
- // 1012px is where the two triggers compete for room with Signup, so it is
- // also where the flat language control is most likely to be "fixed" by
- // giving it the pill's class.
+ // At 1012px, Signup pressure exposes accidental pill styling on the language trigger.
await expectHeaderLanguageTrigger(page)
})
}
}
- /**
- * Brand renders its trailing slot whenever `trailingComponent` is not null,
- * so a `LanguagePicker` that returned `null` from inside the slot would still
- * leave the wrapper behind: an empty divided cell at the header's right edge
- * on desktop, and a full-width 16px-padded block in the narrow menu. Header.tsx
- * therefore withholds the prop itself rather than letting the picker opt out,
- * and that decision is invisible to every other test here -- they all run on
- * multi-language pages, where the slot is supposed to be present.
- *
- * Each absence is paired with the same assertion on a multi-language page.
- * Brand's class name is hashed, so `BRAND_TRAILING_SLOT` on its own would keep
- * passing the day Brand renames it; proving the selector still matches
- * something is what stops this from becoming a test of nothing.
- */
+ // Header.tsx omits trailingComponent because Brand keeps wrapper if LanguagePicker returns null.
+
+ // The multi-language assertion keeps BRAND_TRAILING_SLOT from passing after a Brand class rename.
test('the language slot is omitted, not left empty, when only English is available', async ({
page,
}) => {
@@ -849,15 +776,12 @@ test.describe('Brand header', () => {
await page.goto(ENGLISH_ONLY_ARTICLE)
await turnOffExperimentsInPage(page)
const header = page.getByTestId('desktop-header')
- // The plan picker still renders here, so an empty header would fail this
- // rather than passing as a trivially absent language control.
+ // Assert the plan picker first so an empty header cannot pass the absence checks below.
await expect(header.getByRole('button', { name: PLAN_LABEL, exact: false })).toBeVisible()
await expect(page.getByTestId('language-picker')).toHaveCount(0)
await expect(header.locator(BRAND_TRAILING_SLOT)).toHaveCount(0)
- // Independently of Brand's class names: every divided cell in the header's
- // section row still holds a control. An empty slot is exactly a cell that
- // does not, and it would carry its own gridline and margin.
+ // Every divided header cell must hold a control; an empty slot would add a gridline and margin.
await page.evaluate(() => document.fonts.ready)
await expect(async () => {
const sections = await header.evaluate((element) => {
@@ -882,8 +806,7 @@ test.describe('Brand header', () => {
expect(sections.lastReachesEdge).toBe(true)
}).toPass()
- // The narrow menu is where the leftover wrapper would be most visible: a
- // full-width padded block above Sign up rather than a thin cell.
+ // The narrow menu exposes a leftover wrapper as a full-width padded block above Sign up.
await page.setViewportSize({ width: 390, height: 800 })
await page.getByRole('button', { name: 'Menu', exact: true }).click()
await expect(page.getByTestId('header-signup')).toBeVisible()
@@ -897,38 +820,21 @@ test.describe('Brand header', () => {
page,
}) => {
await page.setViewportSize({ width: 1440, height: 800 })
- // No color_mode cookie, so colorModeScript resolves `auto` from this
- // emulation. Set before navigating so the first paint already uses it.
+ // Emulate color before navigation so colorModeScript resolves auto without a cookie.
await page.emulateMedia({ colorScheme })
await page.goto(ARTICLE)
await turnOffExperimentsInPage(page)
- // Each trigger resolves every color through tokens, so both are worth
- // re-checking in dark mode rather than only in the light-mode loop above.
- // The two triggers are intentionally different -- a filled pill for the
- // plan, a flat control for the language -- which is why only the dropdown
- // below them is shared.
+ // Recheck both token-based triggers in dark mode; only the dropdown below them is shared.
await dropdown.expectTrigger(page)
await expectHeaderDropdownDesign(page, colorScheme, dropdown)
})
}
}
- /**
- * The sticky ladder: header > Docs 2026 secondary bar > sticky table headers.
- *
- * Brand's ActionMenu is not portalled, so the plan and language dropdowns
- * render inside the header's stacking context and hang well below it, across
- * the secondary bar. The bar is sticky at every width and sits above sticky
- * table headers, so if the header does not outrank the bar, the bar paints a
- * band straight through the open menu and eats the clicks behind it -- which
- * is invisible to every other test here, because the menu still has the right
- * geometry, styling and roles while being covered.
- *
- * Asserted by hit-testing rather than by comparing z-index values: equal
- * z-index is resolved by DOM order, so the numbers alone do not say which
- * element a reader actually reaches.
- */
+ // The unportalled ActionMenu overlaps the sticky secondary bar, so the header must outrank it.
+
+ // Hit test overlap because DOM-order z-index ties and blocked clicks do not change geometry.
test('an open dropdown stays clickable where the secondary bar crosses it', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 800 })
await page.emulateMedia({ colorScheme: 'light' })
@@ -946,7 +852,6 @@ test.describe('Brand header', () => {
const b = bar.getBoundingClientRect()
const m = menuEl.getBoundingClientRect()
const crosses = m.bottom > b.top && m.top < b.bottom
- // Sample the full height of the band the two share.
const x = m.left + m.width / 2
const top = Math.max(m.top, b.top) + 2
const bottom = Math.min(m.bottom, b.bottom) - 2
@@ -955,7 +860,7 @@ test.describe('Brand header', () => {
const el = document.elementFromPoint(x, y)
if (!el || !el.closest('[role="menu"]')) covered.push(Math.round(y))
}
- // A row the bar crosses must receive its own clicks, not just paint above.
+ // A crossed row must receive clicks, not merely paint above the bar.
const row = [
...document.querySelectorAll('[data-testid="version-picker"] [role="menuitemradio"]'),
].find((candidate) => {
@@ -979,8 +884,7 @@ test.describe('Brand header', () => {
}
})
- // If the menu stopped overlapping the bar, this test would pass while
- // asserting nothing, so require the overlap it exists to check.
+ // Require actual overlap so this cannot pass after the menu stops crossing the bar.
expect(overlap.barFound).toBe(true)
expect(overlap.crosses).toBe(true)
expect(overlap.covered).toEqual([])
@@ -994,8 +898,7 @@ test.describe('Brand header', () => {
await page.goto(ARTICLE)
await turnOffExperimentsInPage(page)
- // Opened one at a time: Brand closes a menu as soon as the other trigger is
- // clicked, and both menus read their tokens from the same page and theme.
+ // Open one menu at a time because Brand closes the first; both read the same page theme.
const fingerprints: Record = {}
for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) {
const picker = page.getByTestId('desktop-header').getByTestId(dropdown.pickerTestId)
@@ -1009,13 +912,11 @@ test.describe('Brand header', () => {
expect(fingerprints[LANGUAGE_DROPDOWN.name]).toEqual(fingerprints[PLAN_DROPDOWN.name])
})
- // Below 1012px both pickers move inside SubdomainNavBar's narrow menu, which is a
- // scrolling panel. Brand's ActionMenu is absolutely positioned and — unlike the
- // @primer/react menu it replaced — is not portalled, so it regresses easily into
- // rendering outside that panel: cut off mid-list, or running past the viewport's
- // right edge. Both of those still satisfy toBeVisible(), so assert geometry. The
- // inline-flow rule that fixes it now lives in the shared module, so a change to it
- // moves both dropdowns at once and both are covered here.
+ // Below 1012px, SubdomainNavBar's scrolling narrow menu contains both pickers.
+
+ // Geometry catches an unportalled ActionMenu outside the panel while toBeVisible still passes.
+
+ // The shared module owns the inline-flow rule, so both dropdowns must prove the geometry.
for (const dropdown of [PLAN_DROPDOWN, LANGUAGE_DROPDOWN]) {
for (const width of [390, 1000]) {
test(`the ${dropdown.name} dropdown stays inside the narrow menu at ${width}px`, async ({
@@ -1033,7 +934,7 @@ test.describe('Brand header', () => {
await expect(page.getByRole('menu')).toBeVisible()
const layout = await page.getByRole('menu').evaluate((element) => {
- // The panel is found by its scrolling, not by Brand's hashed class name.
+ // Find the panel by scrolling behavior, not Brand's hashed class name.
let panel = element.parentElement
while (panel) {
const { overflowX, overflowY } = getComputedStyle(panel)
@@ -1053,11 +954,11 @@ test.describe('Brand header', () => {
})
expect(layout.panel).not.toBeNull()
- // Inside the panel, so no row is cut off...
+ // The menu stays inside the panel so no row gets cut off.
expect(layout.menu.bottom).toBeLessThanOrEqual(layout.panel.bottom + 1)
expect(layout.menu.right).toBeLessThanOrEqual(layout.panel.right + 1)
expect(layout.lastRowBottom).toBeLessThanOrEqual(layout.panel.bottom + 1)
- // ...and inside the viewport, so no row is sliced by the screen edge.
+ // The menu stays inside the viewport so no row is sliced by the screen edge.
expect(layout.menu.left).toBeGreaterThanOrEqual(-1)
expect(layout.menu.right).toBeLessThanOrEqual(layout.viewportWidth + 1)
expect(layout.scrollsHorizontally).toBe(false)
@@ -1075,13 +976,9 @@ test.describe('Brand header', () => {
}
}
- // Brand staggers the narrow menu's items in at 80ms per slot and hardcodes the
- // signup CTA's wrapper to slot 10 -- the moment ten `SubdomainNavBar.Link`
- // children would have finished cascading in. Docs passes zero links, so the
- // shipped 800ms is a dead second: the pickers ride the panel's fade and
- // "Sign up" trails them. Header.module.scss cuts it to a single slot, so assert
- // the computed delay rather than a wall clock, and assert that only the delay
- // moved -- duration and fill mode still have to be Brand's.
+ // Brand assigns signup to stagger slot 10, but Docs passes zero SubdomainNavBar.Link children.
+
+ // Header.module.scss cuts the 800ms delay to one 80ms slot; duration and fill mode stay Brand's.
test('signup follows the narrow menu pickers by one stagger step, not ten', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto(ARTICLE)
@@ -1090,9 +987,7 @@ test.describe('Brand header', () => {
const signup = page.getByTestId('header-signup')
await expect(signup).toBeVisible()
const animation = await signup.evaluate((element) => {
- // Brand hashes this class and exposes no test id for it, so match the
- // stable part of the name -- the same anchor the override in
- // Header.module.scss uses.
+ // Brand hashes class names, so match the stable SubdomainNavBar-button-area--visible part.
const area = element.closest('[class*="SubdomainNavBar-button-area--visible"]')
if (!area) throw new Error('Signup is not inside the narrow-menu button area')
const { animationDelay, animationDuration, animationFillMode } = getComputedStyle(area)
@@ -1103,7 +998,7 @@ test.describe('Brand header', () => {
}
})
- // Brand's untouched default is calc(10 * 80ms).
+ // Brand's untouched default delay equals 10 * 80ms.
expect(animation.delay).not.toBeCloseTo(0.8, 3)
// Still staggered after the pickers, but by one 80ms slot rather than ten.
expect(animation.delay).toBeGreaterThan(0)
@@ -1186,8 +1081,7 @@ test.describe('Brand header', () => {
'open',
false,
)
- // PRC restores focus during mousedown capture; the browser then transfers it
- // to the clicked backdrop. Persistent return focus is an Escape contract only.
+ // PRC restores focus on mousedown, but backdrop click moves it; Escape owns return focus.
await expect(searchTrigger).toBeVisible()
await expect(searchTrigger).toBeEnabled()
})
@@ -1197,7 +1091,7 @@ test.describe('Brand header', () => {
}) => {
await page.goto(ARTICLE)
await expect(page.getByTestId('toggle-search')).toBeVisible()
- // Use real DOM fields without depending on survey or search results data.
+ // Real DOM fields avoid survey or search-results data dependencies.
await page.locator('#main-content').evaluate((main) => {
const fields = document.createElement('div')
fields.innerHTML = `
@@ -1471,8 +1365,7 @@ test.describe('Brand header', () => {
const picker = page.getByTestId('desktop-header').getByTestId('version-picker')
const button = picker.getByRole('button')
const value = (await button.getByTestId('field').textContent())!
- // versionTitle is `${planTitle} ${release}` for a numbered release, so the
- // plan label would announce "Select your plan: Enterprise Server 3.19".
+ // A numbered release uses the version label instead of the plan label.
expect(value).toMatch(/^Enterprise Server [\d.]+$/)
await expect(picker.getByText(VERSION_LABEL, { exact: true })).toBeVisible()
await expect(button).toHaveAccessibleName(`${VERSION_LABEL} ${value}`)
diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts
index c005042e32df..e27c4cd06cc2 100644
--- a/src/fixtures/tests/playwright-rendering.spec.ts
+++ b/src/fixtures/tests/playwright-rendering.spec.ts
@@ -8,13 +8,7 @@ import {
COLOR_MODE_COOKIE_NAME,
} from '../../frame/lib/constants'
-// This exists for the benefit of local testing.
-// In GitHub Actions, we rely on setting the environment variable directly
-// but for convenience, for local development, engineers might have a
-// .env file that can set environment variable. E.g. ELASTICSEARCH_URL.
-// The `src/frame/start-server.ts` script uses dotenv too, but since Playwright
-// tests only interface with the server via HTTP, we too need to find
-// this out.
+// Local Playwright loads .env so tests read ELASTICSEARCH_URL independently of start-server.ts.
dotenv.config({ quiet: true })
const SEARCH_TESTS = !!process.env.ELASTICSEARCH_URL
@@ -28,10 +22,9 @@ test.describe('Brand document canvas', () => {
test('follows system color scheme changes in auto mode without a cookie', async ({ page }) => {
await page.emulateMedia({ colorScheme: 'dark' })
await page.goto('/get-started/foo/bar')
- // `auto` is resolved before first paint, so the raw preference gets its own attribute.
+ // Preserve auto because data-color-mode resolves to light or dark before first paint.
await expect(page.locator('html')).toHaveAttribute('data-color-mode-preference', 'auto')
- // Check both the initial dark paint and live preference changes without reloading.
for (const colorScheme of ['dark', 'light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
const backgroundColor = colorScheme === 'dark' ? 'rgb(0, 0, 0)' : 'rgb(255, 255, 255)'
@@ -71,14 +64,12 @@ test.describe('Brand document canvas', () => {
})
}
- // A concrete [data-color-mode] below re-declares brand's whole palette
- // for that subtree.
+ // A data-color-mode below html re-declares Brand's whole palette for that subtree.
const MISMATCHES = [
{ name: 'OS dark, explicit light mode', colorScheme: 'dark', cookie: { color_mode: 'light' } },
{ name: 'OS light, explicit dark mode', colorScheme: 'light', cookie: { color_mode: 'dark' } },
{
- // Day and night themes are picked independently on github.com, so `light`
- // mode can itself resolve to a dark theme.
+ // GitHub.com picks day and night themes separately, so light can resolve to a dark theme.
name: 'light mode whose day theme is itself dark',
colorScheme: 'light',
cookie: {
@@ -95,8 +86,7 @@ test.describe('Brand document canvas', () => {
context,
baseURL,
}) => {
- // A settled assertion cannot catch a wrapper that self-corrects within a
- // macrotask, so record every data-color-mode below from first paint on.
+ // Record modes from first paint to catch wrappers that self-correct within a macrotask.
await page.addInitScript(() => {
const seen: string[] = []
;(window as unknown as { __modes: string[] }).__modes = seen
@@ -135,13 +125,11 @@ test.describe('Brand document canvas', () => {
const rootMode = await page.locator('html').getAttribute('data-color-mode')
expect(rootMode).toMatch(/^(light|dark)$/)
- // Brand's ActionMenu.Overlay wraps an open menu in its own ThemeProvider,
- // which emits a data-color-mode from brand's context, and only while open.
+ // ActionMenu.Overlay emits data-color-mode from its own ThemeProvider only while open.
await page.getByTestId('version-picker-button').first().click()
await expect(page.getByRole('menu').first()).toBeVisible()
- // `auto` is exempt: brand has no `auto` block, so such a wrapper declares
- // nothing and inherits.
+ // Brand has no auto color block, so auto wrappers declare nothing and inherit.
await expect(async () => {
const offenders = await page
.locator('body [data-color-mode]')
@@ -160,9 +148,7 @@ test.describe('Brand document canvas', () => {
)
expect(everSeen.filter((value) => value !== 'auto' && value !== rootMode)).toEqual([])
- // heading-links.ts wraps every heading's text in an ``
- // held at heading color, so a bare `a[href]` here picks a heading. The
- // exclusions mirror article-link-overrides.scss.
+ // Exclude a.heading-link and .btn; they match article-link-overrides.scss.
const link = page
.locator('#article-contents .markdown-body a[href]:not(.heading-link):not(.btn)')
.first()
@@ -181,8 +167,7 @@ test.describe('Brand document canvas', () => {
probe.remove()
}
})
- // Equality alone passes if is wrong; contrast alone passes if the
- // selector drifts off brand links.
+ // Equality alone can pass with wrong html vars; contrast alone can pass with selector drift.
expect(linkColor).toBe(expectedLinkColor)
expect(contrastRatio(linkColor, canvas)).toBeGreaterThanOrEqual(4.5)
})
@@ -192,7 +177,6 @@ test.describe('Brand document canvas', () => {
test('logo link keeps current version', async ({ page }) => {
await page.goto('/enterprise-cloud@latest')
await turnOffExperimentsInPage(page)
- // Basically clicking into any page that isn't the home page for this version.
await page.getByTestId('product').getByRole('link', { name: 'Get started' }).click()
await expect(page).toHaveURL(/\/en\/enterprise-cloud@latest\/get-started/)
await page
@@ -206,7 +190,6 @@ test('view the for-playwright article', async ({ page }) => {
await page.goto('/get-started/foo/for-playwright')
await expect(page).toHaveTitle(/For Playwright - GitHub Docs/)
- // This is the right-hand sidebar mini-toc link
await page
.getByTestId('minitoc')
.getByRole('link', { name: 'Second heading', exact: true })
@@ -237,11 +220,7 @@ test('use sidebar to go to Hello World page', async ({ page }) => {
test('sidebar highlights the clicked item optimistically while navigation is pending', async ({
page,
}) => {
- // Article pages are getServerSideProps routes, so router.asPath (and thus the real
- // aria-current) only updates after the destination loads. The sidebar marks the
- // clicked link with a visual-only `data-pending` accent so the click is acknowledged
- // immediately. Throttle the client-side data fetch so the navigation stays pending
- // long enough to observe that intermediate state.
+ // getServerSideProps delays router.asPath and aria-current; throttle _next/data for data-pending.
await page.goto('/get-started')
await page.getByTestId('product-sidebar').getByText('Start your journey').click()
@@ -249,7 +228,6 @@ test('sidebar highlights the clicked item optimistically while navigation is pen
const helloWorld = sidebar.getByRole('link', { name: 'Hello World' })
const linkRewriting = sidebar.getByRole('link', { name: 'Link rewriting' })
- // Hold the next data request open until we release it, so navigation stays pending.
let releaseNavigation = () => {}
const navigationHeld = new Promise((resolve) => {
releaseNavigation = resolve
@@ -261,24 +239,16 @@ test('sidebar highlights the clicked item optimistically while navigation is pen
await helloWorld.click()
- // While pending: the clicked link carries the optimistic visual marker, but the URL
- // and the semantic aria-current still reflect the (still-loaded) get-started page.
await expect(helloWorld).toHaveAttribute('data-pending', '')
await expect(helloWorld).not.toHaveAttribute('aria-current', 'page')
await expect(page).not.toHaveURL(/hello-world/)
- // Let the navigation finish: the marker gives way to a real aria-current.
releaseNavigation()
await expect(page).toHaveURL(/\/en\/get-started\/start-your-journey\/hello-world/)
await expect(helloWorld).toHaveAttribute('aria-current', 'page')
await expect(helloWorld).not.toHaveAttribute('data-pending', '')
- // A modifier-click (open in new tab) must NOT move the optimistic selection.
- // handleNavClick bails on modifier clicks, so pendingHref is never set: the current
- // page keeps its URL, its aria-current, and the clicked link gets no data-pending.
- // Use ControlOrMeta so the real "open in new tab" modifier is sent per-platform
- // (Ctrl on Linux/Windows CI, Meta on macOS). The click opens a background tab we
- // don't need to assert on; catch any popup so it doesn't leak.
+ // handleNavClick skips modifier clicks; ControlOrMeta must not set pendingHref. Close the popup.
page.on('popup', (popup) => popup.close())
await linkRewriting.click({ modifiers: ['ControlOrMeta'] })
await expect(linkRewriting).not.toHaveAttribute('data-pending', '')
@@ -290,18 +260,15 @@ test('press "/" to open the search overlay', async ({ page }) => {
await page.goto('/')
await turnOffExperimentsInPage(page)
- // Wait for the header search button to render, so the keydown listener is attached.
+ // The keydown listener attaches when the header search button renders.
await page.getByTestId('toggle-search').waitFor()
const searchInput = page.getByTestId('overlay-search-input')
- // The overlay (and its input) is not in the DOM until it's opened.
await expect(searchInput).toHaveCount(0)
- // Pressing "/" anywhere on the page opens the overlay and focuses the input.
await page.keyboard.press('/')
await expect(searchInput).toBeFocused()
- // Escape closes it again and returns focus to the same responsive trigger.
await page.keyboard.press('Escape')
await expect(searchInput).toHaveCount(0)
await expect(page.getByTestId('toggle-search')).toBeFocused()
@@ -317,7 +284,7 @@ test('"/" typed inside the search input is a literal slash', async ({ page }) =>
const searchInput = page.getByTestId('overlay-search-input')
await expect(searchInput).toBeFocused()
- // The "/" shortcut must not fire while typing in a field, so it is not swallowed.
+ // The slash shortcut must not fire while typing in a field.
await page.keyboard.type('a/b')
await expect(searchInput).toHaveValue('a/b')
})
@@ -352,11 +319,8 @@ test('open search, and perform a general search', async ({ page }) => {
await page.getByTestId('toggle-search').click()
await page.getByTestId('overlay-search-input').fill('serve playwright')
- // Wait for the results to load
- // NOTE: In the UI we wait for results to load before allowing "enter", because we don't want
- // to allow an unnecessary request when there are no search results. Easier to wait 1 second
+ // Wait 1 second for results to load, because the UI blocks submitting the query until then.
await page.waitForTimeout(1000)
- // Scroll down to "View all results" then press enter
await page.getByText('View more results').click()
await expect(page).toHaveURL(
@@ -364,7 +328,6 @@ test('open search, and perform a general search', async ({ page }) => {
)
await expect(page).toHaveTitle(/\d Search results for "serve playwright"/)
- // The first result should be "For Playwright"
await page.getByRole('link', { name: 'For Playwright' }).click()
await expect(page).toHaveURL(/\/get-started\/foo\/for-playwright$/)
@@ -379,13 +342,11 @@ test('open search, and select a general search article', async ({ page }) => {
await page.getByTestId('toggle-search').click()
await page.getByTestId('overlay-search-input').fill('serve playwright')
- // Let new suggestions load
const searchOverlay = page.getByTestId('general-autocomplete-suggestions')
await expect(searchOverlay.getByText('For Playwright')).toBeVisible()
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
- // We should now be on the page for "For Playwright"
await expect(page).toHaveURL(/\/get-started\/foo\/for-playwright$/)
await expect(page).toHaveTitle(/For Playwright/)
})
@@ -403,7 +364,7 @@ test('open search, and get auto-complete results', async ({ page }) => {
let listItems = listGroup.locator('li')
await expect(listItems).toHaveCount(4)
- // Top queries from queries.json fixture's 'topQueries'
+ // The first list mirrors queries.json fixture topQueries.
let expectedTexts = [
'What is GitHub and how do I get started?',
'What is GitHub Copilot and how do I get started?',
@@ -422,7 +383,6 @@ test('open search, and get auto-complete results', async ({ page }) => {
await searchInput.fill('rest')
await page.waitForTimeout(1000)
- // Ask AI suggestions
listGroup = page.getByTestId('ai-autocomplete-suggestions')
listItems = listGroup.locator('li')
await expect(listItems).toHaveCount(3)
@@ -448,20 +408,14 @@ test('search from enterprise-cloud and filter by top-level Fooing', async ({ pag
await page.waitForTimeout(1000)
await page.getByText('View more results').click()
- // Now we're on the search results page, apply the filter
await page.getByText('Fooing (1)').click()
await page.getByRole('link', { name: 'Clear' }).click()
-
- // At the moment this test isn't great because it's not proving that
- // certain things cease to be visible, that was visible before. Room
- // for improvement!
})
test('404 page renders correctly', async ({ page }) => {
const response = await page.goto('/this-definitely-does-not-exist')
expect(response?.status()).toBe(404)
- // 404 pages now render a minimal HTML response
await expect(page.getByText('Page not found.')).toBeVisible()
})
@@ -507,7 +461,6 @@ test.describe('platform picker', () => {
await turnOffExperimentsInPage(page)
await page.getByTestId('platform-picker').getByRole('link', { name: 'Windows' }).click()
- // Return and now the cookie should start us off on Windows again
await page.goto('/get-started/liquid/platform-specific')
await expect(page.getByRole('heading', { name: /Windows 95/ })).toBeVisible()
await expect(page.getByRole('heading', { name: /Macintosh/ })).not.toBeVisible()
@@ -534,7 +487,7 @@ test.describe('tool picker', () => {
test('prefer default tool', async ({ page }) => {
await page.goto('/get-started/liquid/tool-specific')
- // defaultTool is set in the fixture frontmatter to webui
+ // The fixture frontmatter defaults defaultTool to webui.
await expect(page.getByText('This is webui content')).toBeVisible()
await expect(page.getByText('This is desktop content')).not.toBeVisible()
await expect(page.getByText('This is cli content')).not.toBeVisible()
@@ -545,7 +498,6 @@ test.describe('tool picker', () => {
await turnOffExperimentsInPage(page)
await page.getByTestId('tool-picker').getByRole('link', { name: 'Web browser' }).click()
- // Return and now the cookie should start us off with Web UI content again
await page.goto('/get-started/liquid/tool-specific')
await expect(page.getByText('This is cli content')).not.toBeVisible()
await expect(page.getByText('This is desktop content')).not.toBeVisible()
@@ -553,10 +505,9 @@ test.describe('tool picker', () => {
})
test('minitoc matches picker', async ({ page }) => {
- // See the note on the platform-specific version of this test: don't sit on
- // the drawer's exact reveal breakpoint.
+ // Avoid the drawer's exact reveal breakpoint.
await page.setViewportSize({ width: 1440, height: 900 })
- // default tool set to webui in fixture frontmatter
+ // The fixture frontmatter defaults defaultTool to webui.
await page.goto('/get-started/liquid/tool-specific')
await turnOffExperimentsInPage(page)
await expect(
@@ -616,9 +567,7 @@ test.describe('code tabs', () => {
test('navigate with side bar into article inside a subcategory inside a category', async ({
page,
}) => {
- // Our TreeView sidebar only shows "2 levels". If you click and expand
- // the category, you'll be able to see the subcategory and the article
- // within.
+ // The TreeView sidebar shows two levels until the category expands.
await page.goto('/actions')
await page.getByTestId('sidebar').getByText('Category', { exact: true }).click()
await page.getByTestId('sidebar').getByText('Subcategory').click()
@@ -645,7 +594,6 @@ test.describe('hover cards', () => {
await page.goto('/pages/quickstart')
await turnOffExperimentsInPage(page)
- // hover over a link and check for intro content from hovercard
await page
.locator('#article-contents')
.getByRole('link', { name: 'Start your journey' })
@@ -656,8 +604,6 @@ test.describe('hover cards', () => {
),
).toBeVisible()
- // now move the mouse away from hovering over the link, the hovercard should
- // no longer be visible
await page.mouse.move(0, 0)
await expect(
page.getByText(
@@ -665,38 +611,31 @@ test.describe('hover cards', () => {
),
).not.toBeVisible()
- // external links don't have a hovercard
await page.getByRole('link', { name: 'github.com/github/docs' }).hover()
await expect(page.getByTestId('popover')).not.toBeVisible()
- // links in the main navigation sidebar don't have a hovercard
await page.getByTestId('sidebar').getByRole('link', { name: 'Quickstart' }).hover()
await expect(page.getByTestId('popover')).not.toBeVisible()
- // links in the secondary minitoc sidebar don't have a hovercard
await page
.getByTestId('minitoc')
.getByRole('link', { name: 'Regular internal link', exact: true })
.hover()
await expect(page.getByTestId('popover')).not.toBeVisible()
- // links in the article intro have a hovercard
await page.locator('#article-intro').getByRole('link', { name: 'article intro link' }).hover()
await expect(page.getByText('You can use HubGit Pages to showcase')).toBeVisible()
- // this page's intro has two links; one in-page and one internal
await page.locator('#article-intro').getByRole('link', { name: 'another link' }).hover()
await expect(
page.getByText('Follow this Hello World exercise to get started with HubGit.'),
).toBeVisible()
- // same page anchor links have a hovercard
await page
.locator('#article-contents')
.getByRole('link', { name: 'introduction', exact: true })
.hover()
await expect(page.getByText('You can use HubGit Pages to showcase')).toBeVisible()
- // links with formatted text need to work too
await page.locator('#article-contents').getByRole('link', { name: 'Bold is strong' }).hover()
await expect(page.getByText('The most basic of fixture data for HubGit')).toBeVisible()
await page.locator('#article-contents').getByRole('link', { name: 'bar' }).hover()
@@ -707,7 +646,6 @@ test.describe('hover cards', () => {
await page.goto('/pages/quickstart')
await turnOffExperimentsInPage(page)
- // Simply putting focus on the link should not open the hovercard
await page
.locator('#article-contents')
.getByRole('link', { name: 'Start your journey' })
@@ -718,7 +656,6 @@ test.describe('hover cards', () => {
),
).not.toBeVisible()
- // Once a link has got focus, you can use Alt+ArrowUp to open the hovercard
await page.keyboard.press('Alt+ArrowUp')
await expect(
page.getByText(
@@ -738,7 +675,6 @@ test.describe('hover cards', () => {
await page.goto('/pages/quickstart')
await turnOffExperimentsInPage(page)
- // hover over a link and check for intro content from hovercard
await page
.locator('#article-contents')
.getByRole('link', { name: 'Start your journey' })
@@ -766,9 +702,7 @@ test.describe('test nav at different viewports', () => {
})
await page.goto('/get-started/foo/bar')
- // The Docs 2026 secondary bar leads with a Home crumb, then the full trail
- // 'Get started / Foo / Bar' (no hidden last crumb). The current page is
- // static text rather than a link, so only the three ancestors are links.
+ // Breadcrumbs include Home and the full trail; only the three ancestors are links.
expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(3)
await expect(page.getByTestId('breadcrumbs-bar').locator('[aria-current="page"]')).toHaveText(
'Bar',
@@ -776,64 +710,51 @@ test.describe('test nav at different viewports', () => {
await expect(page.getByTestId('breadcrumbs-bar').getByText('Foo')).toBeVisible()
await expect(page.getByTestId('breadcrumbs-bar').getByText('Bar')).toBeVisible()
- // breadcrumbs show up in rest reference pages
await page.goto('/rest/actions/artifacts')
await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible()
- // breadcrumbs show up in one of the pages that use the AutomatedPage
- // component (e.g. graphql, audit log). This one uses the webhooks
- // reference page here
+ // Webhooks renders through an AutomatedPage reference page, which shows breadcrumbs.
await page.goto('/webhooks/webhook-events-and-payloads')
await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible()
})
test('mobile nav opens even when the desktop rail was collapsed', async ({ page }) => {
- // Collapse the desktop rail with both drawers out (xxl) so the persisted
- // `collapsed` state is set via the secondary-bar collapse toggle.
+ // At xxl with both drawers out, the secondary-bar collapse toggle persists collapsed.
page.setViewportSize({
width: 1400,
height: 700,
})
await page.goto('/get-started/foo/bar')
await page.getByTestId('sidebar-collapse-toggle').click()
- // With the rail collapsed the sidebar is not rendered on desktop.
await expect(page.getByTestId('sidebar')).toHaveCount(0)
- // Drop below lg (1012) where the inline mobile nav toggle lives (Docs 2026:
- // the lg–xxl range keeps the desktop collapse toggle instead). `collapsed`
- // persists across the resize.
+ // Below lg 1012px, the inline mobile nav toggle replaces the desktop collapse toggle.
page.setViewportSize({
width: 1000,
height: 700,
})
- // Opening the mobile nav must still render the doc-tree drawer. Before the
- // fix, `collapsed` short-circuited the sidebar to null while the open state
- // hid the content column, leaving a blank area with no drawer.
+ // Mobile nav must render the doc-tree drawer even with persisted collapsed state.
await page.getByTestId('sidebar-mobile-toggle').click()
await expect(page.getByTestId('sidebar')).toBeVisible()
- // Closing it restores the content column (main content visible again).
await page.getByTestId('sidebar-mobile-toggle').click()
await expect(page.locator('#main-content')).toBeVisible()
})
test('resizing from mobile to desktop closes the inline nav', async ({ page }) => {
- // Start below the lg (1012px) breakpoint where the inline mobile nav lives.
+ // Below lg 1012px, the inline mobile nav lives in the secondary bar.
await page.setViewportSize({
width: 1000,
height: 700,
})
await page.goto('/get-started/foo/bar')
- // Open the inline doc-tree nav from the secondary bar.
await page.getByTestId('sidebar-mobile-toggle').click()
const nav = page.locator('[data-container="nav"]')
await expect(nav).toHaveAttribute('data-mobile-open', 'true')
- // Resize up to the desktop breakpoint. The inline nav should close and the
- // fixed desktop rail (326px) should take over rather than the full-width
- // mobile markup persisting over the page.
+ // At 1400px, the desktop rail is 326px and replaces full-width mobile markup.
await page.setViewportSize({
width: 1400,
height: 700,
@@ -849,7 +770,6 @@ test.describe('test nav at different viewports', () => {
})
await page.goto('/get-started/foo/bar')
- // Both complete pickers are visible directly in the wide header.
await expect(
page.getByTestId('version-picker').getByText('Select your plan:', { exact: true }),
).toBeVisible()
@@ -863,7 +783,6 @@ test.describe('test nav at different viewports', () => {
await page.keyboard.press('Escape')
await expect(planMenu).not.toBeVisible()
- // The language picker is the same kind of nested dropdown as the plan one.
const languageButton = page.getByRole('button', {
name: 'Select language: current language is English',
})
@@ -887,15 +806,10 @@ test.describe('test nav at different viewports', () => {
})
await page.goto('/get-started/foo/bar')
- // breadcrumbs show up in the secondary bar; for this page we should have
- // a Home crumb plus 'Get started / Foo / Bar' — the last of which is the
- // current page, rendered as static text rather than a link.
await expect(page.getByTestId('breadcrumbs-bar')).toBeVisible()
expect(await page.getByTestId('breadcrumbs-bar').getByRole('link').all()).toHaveLength(3)
- // At lg+ (Docs 2026) the doc-tree rail is shown by default with the desktop
- // collapse toggle; the mobile inline-nav toggle is hidden. Clicking the
- // collapse toggle hides the rail.
+ // At lg+, the desktop rail shows and the inline-nav toggle hides.
await expect(page.getByTestId('sidebar')).toBeVisible()
await expect(page.getByTestId('sidebar-collapse-toggle')).toBeVisible()
await expect(page.getByTestId('sidebar-mobile-toggle')).toBeHidden()
@@ -945,8 +859,7 @@ test.describe('test nav at different viewports', () => {
await expect(languageMenu).not.toBeVisible()
await expect(page.getByTestId('header-signup')).toBeVisible()
- // The independent secondary-bar navigation is intentionally inert until the
- // modal header menu closes, then still expands the doc tree inline.
+ // The secondary-bar nav stays inert until the modal header menu closes.
await page.getByRole('button', { name: 'Close menu', exact: true }).click()
await expect(page.getByTestId('sidebar-mobile-toggle')).toBeVisible()
await page.getByTestId('sidebar-mobile-toggle').click()
@@ -998,13 +911,9 @@ test.describe('test nav at different viewports', () => {
})
test.describe('secondary-bar breadcrumb scroller', () => {
- // The secondary bar (and its breadcrumb scroller) only renders at wide
- // viewports, and the fixture trail is short enough to fit there, so we cap the
- // scroller width to force a deterministic overflow independent of title
- // lengths, then exercise the chevrons.
+ // Cap breadcrumb scroller width to force deterministic overflow.
test('chevrons scroll one crumb at a time instead of jumping to the ends', async ({ page }) => {
- // Smooth-scroll settle waits across several chevron clicks add up past the
- // default 5s cap.
+ // Several smooth-scroll waits can exceed the default 5s test cap.
test.setTimeout(20000)
page.setViewportSize({ width: 1300, height: 700 })
await page.goto('/get-started/foo/bar')
@@ -1015,11 +924,7 @@ test.describe('secondary-bar breadcrumb scroller', () => {
const scrollArea = page.locator('[data-search="breadcrumbs"]')
await expect(scrollArea).toBeVisible()
- // Force a deterministic overflow independent of title lengths: cap the
- // scroll region, drop the nav's min-width:100% (which otherwise stretches the
- // short fixture trail to fill the container so it never overflows), and pad
- // the crumbs so several are hidden at once — enough that a per-crumb nudge is
- // distinguishable from a jump to the end.
+ // Cap scroll width, remove nav min-width:100%, and pad crumbs to detect nudges.
await page.addStyleTag({
content: `
[data-search="breadcrumbs"] { max-width: 360px; }
@@ -1032,37 +937,28 @@ test.describe('secondary-bar breadcrumb scroller', () => {
const maxScrollOf = () => scrollArea.evaluate((el) => el.scrollWidth - el.clientWidth)
await expect.poll(maxScrollOf).toBeGreaterThan(0)
- // Anchor to the right end explicitly so we start from a known state: fully
- // scrolled right (current page visible), only the left chevron active.
+ // Start fully scrolled right so only the left chevron is active.
await scrollArea.evaluate((el) => el.scrollTo({ left: el.scrollWidth, behavior: 'instant' }))
const maxScroll = await maxScrollOf()
await expect.poll(scrollLeftOf).toBe(maxScroll)
const leftChevron = page.getByRole('button', { name: 'Scroll breadcrumbs left' })
const rightChevron = page.getByRole('button', { name: 'Scroll breadcrumbs right' })
- // At the right extreme the left chevron is active and the right one is hidden.
await expect(leftChevron).toBeVisible()
await expect(rightChevron).toBeHidden()
- // One left click nudges toward the start by a single crumb — it must move,
- // but must NOT jump all the way to 0 (the old behavior) while more than one
- // crumb is still hidden to the left.
+ // One left click must move without jumping to 0 while crumbs stay hidden left.
await leftChevron.click()
await expect.poll(scrollLeftOf).toBeLessThan(maxScroll)
const afterOneLeft = await scrollLeftOf()
expect(afterOneLeft).toBeGreaterThan(0)
- // The right chevron appears once we're no longer at the right extreme.
await expect(rightChevron).toBeVisible()
- // A right click walks back toward the current page by one crumb, not a full
- // jump back to the right extreme.
+ // Right click returns by one crumb, not a jump to the right extreme.
await rightChevron.click()
await expect.poll(scrollLeftOf).toBeGreaterThan(afterOneLeft)
- // Repeated left clicks eventually reach the start, which hides the left
- // chevron (canScrollLeft flips false). Drive off the chevron's own visibility
- // rather than an exact scrollLeft, since smooth scrolling can leave a
- // sub-pixel remainder.
+ // Drive off chevron visibility because smooth scrolling can leave sub-pixel scrollLeft.
for (let i = 0; i < 6 && (await leftChevron.isVisible()); i++) {
await leftChevron.click()
await page.waitForTimeout(200)
@@ -1073,16 +969,10 @@ test.describe('secondary-bar breadcrumb scroller', () => {
})
test.describe('anchor link scrolling', () => {
- // The doc-tree rail only renders at the xxl breakpoint (1400px) and up. Its
- // "centre the active item" effect used to call scrollIntoView, which scrolls
- // every scrollable ancestor including the document, so it undid the browser's
- // scroll to the #anchor and dumped the reader at the top of the article.
- // These tests only mean anything with the rail on screen.
+ // At xxl, centering the active doc-tree item can undo browser anchor scrolling.
const WIDE = { width: 1400, height: 720 }
- // The heading is offset from the top of the viewport by `scroll-margin-top`
- // (109px at xxl, see src/frame/stylesheets/scroll-top.scss). Allow slack for
- // rounding and sticky-header tweaks, but stay well clear of "not scrolled".
+ // scroll-margin-top is 109px at xxl; allow rounding and sticky-header slack, but reject an unscrolled page.
const expectScrolledToTarget = async (page: import('@playwright/test').Page) => {
const heading = page.locator('#target-heading')
await expect(heading).toBeVisible()
@@ -1096,10 +986,7 @@ test.describe('anchor link scrolling', () => {
await expect(page.getByTestId('sidebar')).toBeVisible()
await expectScrolledToTarget(page)
- // Guard the setup: the regression only shows when the rail has actually
- // scrolled its own container to centre the active item. If a fixture change
- // ever makes the rail short enough that it doesn't need to scroll, these
- // tests would keep passing while covering nothing — fail loudly instead.
+ // Fail if the fixture rail stops scrolling, because the test would cover nothing.
const railScrollTop = await page
.getByTestId('sidebar')
.evaluate((el) => el.closest('[role="region"]')!.scrollTop)
@@ -1135,8 +1022,7 @@ test.describe('survey', () => {
const surveyComment = 'This is a comment'
- // Important to set this up *before* interacting with the page
- // in case of possible race conditions.
+ // Install the route before interacting with the page to avoid event races.
await page.route('**/api/events', (route, request) => {
const postData = request.postData()
if (postData) {
@@ -1160,10 +1046,7 @@ test.describe('survey', () => {
}
}
}
- // At the time of writing you can't get the posted payload
- // when you use `navigator.sendBeacon(url, data)`.
- // So we can't make assertions about the payload.
- // See https://github.com/microsoft/playwright/issues/12231
+ // Chromium hides sendBeacon payloads from Playwright: https://github.com/microsoft/playwright/issues/12231
})
await page.addInitScript(() => {
@@ -1172,7 +1055,7 @@ test.describe('survey', () => {
await page.goto('/get-started/foo/for-playwright')
- // The label is visually an SVG. Finding it by its `for` value feels easier.
+ // The label renders as an SVG, so locate it by for=survey-yes.
await page.locator('[for=survey-yes]').click()
await expect(page.getByRole('button', { name: 'Cancel' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Send' })).toBeVisible()
@@ -1181,7 +1064,6 @@ test.describe('survey', () => {
await page.locator('[name=survey-email]').click()
await page.locator('[name=survey-email]').fill('test@example.com')
await page.getByRole('button', { name: 'Send' }).click()
- // simulate sending an exit event to trigger sending all queued events
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
@@ -1193,11 +1075,7 @@ test.describe('survey', () => {
return new Promise((resolve) => setTimeout(resolve, 100))
})
- // Events:
- // 1. page view event when navigating to the page
- // 2. Survey thumbs up event
- // 3. Survey submit event
- // 4. Exit event
+ // fulfilled counts page view, survey thumbs up, survey submit, and exit events.
expect(fulfilled).toBe(1 + 1 + 1 + 1)
expect(hasSurveyPressedEvent).toBe(true)
expect(hasSurveySubmittedEvent).toBe(true)
@@ -1208,8 +1086,7 @@ test.describe('survey', () => {
let fulfilled = 0
let hasSurveyEvent = false
- // Important to set this up *before* interacting with the page
- // in case of possible race conditions.
+ // Install the route before interacting with the page to avoid event races.
await page.route('**/api/events', (route, request) => {
const postData = request.postData()
if (postData) {
@@ -1223,10 +1100,7 @@ test.describe('survey', () => {
}
}
}
- // At the time of writing you can't get the posted payload
- // when you use `navigator.sendBeacon(url, data)`.
- // So we can't make assertions about the payload.
- // See https://github.com/microsoft/playwright/issues/12231
+ // Chromium hides sendBeacon payloads from Playwright: https://github.com/microsoft/playwright/issues/12231
})
await page.addInitScript(() => {
@@ -1236,7 +1110,6 @@ test.describe('survey', () => {
await page.goto('/get-started/foo/for-playwright')
await page.locator('[for=survey-yes]').click()
- // simulate sending an exit event to trigger sending all queued events
await page.evaluate(() => {
Object.defineProperty(document, 'visibilityState', {
configurable: true,
@@ -1247,10 +1120,7 @@ test.describe('survey', () => {
document.dispatchEvent(new Event('visibilitychange'))
return new Promise((resolve) => setTimeout(resolve, 100))
})
- // Events:
- // 1. page view event when navigating to the page
- // 2. the thumbs up click
- // 3. the exit event
+ // fulfilled counts page view, thumbs up, and exit events.
expect(fulfilled).toBe(1 + 1 + 1)
expect(hasSurveyEvent).toBe(true)
@@ -1259,8 +1129,7 @@ test.describe('survey', () => {
})
test('vote on one page, then go to another and it should reset', async ({ page }) => {
- // Important to set this up *before* interacting with the page
- // in case of possible race conditions.
+ // Install the route before interacting with the page to avoid event races.
await page.route('**/api/events', (route) => {
route.fulfill({})
})
@@ -1284,12 +1153,10 @@ test.describe('survey', () => {
test.describe('rest API reference pages', () => {
test('REST actions', async ({ page }) => {
await page.goto('/rest')
- // Before using the sidebar, make sure the page has redirected to a
- // URL that has that `?apiVersion=` query parameter.
+ // Redirect must add the apiVersion query before sidebar navigation.
await expect(page).toHaveURL(/\/en\/rest\?apiVersion=/)
await page.getByTestId('sidebar').getByText('Actions').click()
- // Brand NavList renders leaf articles as links (not the label-associated
- // controls Primer used), so locate them by link role rather than getByLabel.
+ // Brand NavList renders leaf articles as links, not Primer's label-associated controls.
await page.getByTestId('sidebar').getByRole('link', { name: 'Artifacts' }).click()
await page
.getByTestId('sidebar')
@@ -1313,7 +1180,6 @@ test.describe('translations', () => {
await expect(page).toHaveURL('/ja')
await expect(page.getByRole('heading', { name: '日本 GitHub Docs' })).toBeVisible()
- // Having done this once, should now use a cookie to redirect back to Japanese
await page.goto('/')
await expect(page).toHaveURL('/ja')
})
@@ -1326,16 +1192,11 @@ test.describe('translations', () => {
await expect(page).toHaveURL('/ja/get-started/start-your-journey/hello-world')
await expect(page.getByRole('heading', { name: 'こんにちは World' })).toBeVisible()
- // Having done this once, should now use a cookie to redirect
- // back to Japanese.
- // Playwright will cache this redirect, so we need to add something
- // to "cache bust" the URL
+ // Bust the URL because Playwright caches the redirect back to Japanese.
const cb = `?cb=${Math.random()}`
await page.goto(`/get-started/start-your-journey/hello-world${cb}`)
await expect(page).toHaveURL(`/ja/get-started/start-your-journey/hello-world${cb}`)
- // If you go, with the Japanese cookie, to the English page directly,
- // it will offer a link to the Japanese URL in a banner.
await page.goto('/en/get-started/start-your-journey/hello-world')
await expect(page).toHaveURL('/ja/get-started/start-your-journey/hello-world')
})
@@ -1344,9 +1205,7 @@ test.describe('translations', () => {
test('open search, and ask Copilot (Ask AI) a question', async ({ page }) => {
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
- // Mock the CSE Copilot endpoint
await page.route('**/api/ai-search/v1', async (route) => {
- // Simulate the streaming response from CSE Copilot
const mockResponse = `{"chunkType":"SOURCES","sources":[{"title":"Creating a new repository","index":"/en/get-started","url":"http://localhost:4000/en/get-started"}]}
{"chunkType":"MESSAGE_CHUNK","text":"Creating "}
@@ -1380,31 +1239,23 @@ test('open search, and ask Copilot (Ask AI) a question', async ({ page }) => {
await page.getByTestId('toggle-search').click()
await page.getByTestId('overlay-search-input').fill('How do I create a Repository?')
- // Pressing enter should ask AI the question
await page.keyboard.press('Enter')
- // Wait for the AI response to appear
await expect(page.getByText('Creating a repository on GitHub')).toBeVisible()
- // Verify that sources are displayed
await expect(page.getByText('Creating a new repository')).toBeVisible()
- // Verify the full response appears
await expect(page.getByText('something you should already know how to do')).toBeVisible()
- // Open the "Creating new repository" source link list item
- // Find the references section first
const aiReferencesSection = page.getByTestId('ai-references')
await expect(aiReferencesSection).toBeVisible()
- // Wait for the reference list to be populated
await expect(page.getByText('Creating a new repository')).toBeVisible()
})
test('open search, Ask AI returns 400 error and shows general search results', async ({ page }) => {
test.skip(!SEARCH_TESTS, 'No local Elasticsearch, no tests involving search')
- // Mock the CSE Copilot endpoint to return a 400 error
await page.route('**/api/ai-search/v1', async (route) => {
await route.fulfill({
status: 400,
@@ -1422,21 +1273,16 @@ test('open search, Ask AI returns 400 error and shows general search results', a
await page.getByTestId('toggle-search').click()
await page.getByTestId('overlay-search-input').fill('foo')
- // Pressing enter should trigger Ask AI, get 400 error, and show general search results
await page.keyboard.press('Enter')
- // Wait for the general search results to appear inside the overlay's suggestions
- // group. These render as ActionList items (buttons), so scope the lookup to the
- // group rather than matching page-level links of the same name.
+ // Search suggestions render as ActionList buttons, so scope Foo and Bar to the suggestion group.
const generalSuggestions = page.getByTestId('general-autocomplete-suggestions')
await expect(generalSuggestions.getByRole('button', { name: 'Foo' })).toBeVisible()
await expect(generalSuggestions.getByRole('button', { name: 'Bar' })).toBeVisible()
- // Wait for the AI error message to appear
- // This is a canned response for the 400 error
- await page.waitForTimeout(1000) // Wait for the AI error message to appear
+ // Wait for the canned 400 response before checking the paragraph.
+ await page.waitForTimeout(1000)
- // Verify the AI error message appears (canned response for 400 error)
await expect(
page
.getByRole('paragraph')
@@ -1445,7 +1291,6 @@ test('open search, Ask AI returns 400 error and shows general search results', a
),
).toBeVisible()
- // Verify general search results appear above the AI section
const searchResults = page.getByTestId('general-autocomplete-suggestions')
const aiSection = page.locator('#ask-ai-result-container')
@@ -1460,13 +1305,11 @@ test.describe('LandingCarousel component', () => {
const carousel = page.locator('[data-testid="landing-carousel"]')
await expect(carousel).toBeVisible()
- // Check that article cards are present. Brand Card renders each card's title
- // as an (Card.Heading) wrapping a stretched , so target the heading.
+ // Brand Card renders each card title as h3 Card.Heading around a stretched link.
const items = page.locator('[data-testid="carousel-items"]')
const cardHeadings = items.locator('h3')
await expect(cardHeadings.first()).toBeVisible()
- // Verify cards have real titles (not "Unknown Article" when article not found)
await expect(cardHeadings.first()).not.toHaveText('Unknown Article')
})
@@ -1477,15 +1320,13 @@ test.describe('LandingCarousel component', () => {
const carousel = page.locator('[data-testid="landing-carousel"]')
await expect(carousel).toBeVisible()
- // Should show 3 cards on desktop
const cards = carousel.locator('a')
await expect(cards).toHaveCount(3)
- // Check for navigation buttons if there are more than 3 articles
const nextButton = carousel.getByRole('button', { name: 'Next articles' })
if (await nextButton.isVisible()) {
const prevButton = carousel.getByRole('button', { name: 'Previous articles' })
- await expect(prevButton).toBeDisabled() // Should be disabled on first page
+ await expect(prevButton).toBeDisabled()
await expect(nextButton).toBeEnabled()
}
})
@@ -1497,7 +1338,6 @@ test.describe('LandingCarousel component', () => {
const carousel = page.locator('[data-testid="landing-carousel"]')
await expect(carousel).toBeVisible()
- // Should show 1 card on mobile
const cards = carousel.locator('a')
await expect(cards).toHaveCount(1)
})
@@ -1507,36 +1347,32 @@ test.describe('Multi-carousel support', () => {
test('displays multiple carousels from carousels frontmatter', async ({ page }) => {
await page.goto('/get-started/multi-carousel')
- // Should have multiple carousels rendered
const carousels = page.locator('[data-testid="landing-carousel"]')
const carouselCount = await carousels.count()
- // We defined exactly 2 carousels in the frontmatter
+ // Frontmatter defines exactly two carousels.
expect(carouselCount).toBe(2)
})
test('carousel with matching ui.yml key displays translated title', async ({ page }) => {
await page.goto('/get-started/multi-carousel')
- // The "recommended" carousel should show "Recommended" title from ui.yml
+ // The recommended carousel title comes from ui.yml.
const carouselHeadings = page.locator('[data-testid="landing-carousel"] h2')
const headingTexts = await carouselHeadings.allTextContents()
- // Check that at least one heading has "Recommended"
expect(headingTexts.some((text) => text.includes('Recommended'))).toBe(true)
})
test('carousel without matching ui.yml key renders without title', async ({ page }) => {
await page.goto('/get-started/multi-carousel')
- // The "titleTwoNoMatchingUiYml" carousel should not have a visible heading
- // or the heading element should be empty/not exist for that carousel
+ // A carousel without a matching ui.yml key has no heading element.
const carouselHeadings = page.locator('[data-testid="landing-carousel"] h2')
const headingTexts = await carouselHeadings.allTextContents()
- // The raw key "titleTwoNoMatchingUiYml" should NOT appear as a heading
- // (the component should not show the key as fallback)
+ // titleTwoNoMatchingUiYml must not render as a fallback heading.
expect(headingTexts.some((text) => text === 'titleTwoNoMatchingUiYml')).toBe(false)
})
@@ -1546,10 +1382,8 @@ test.describe('Multi-carousel support', () => {
const carousels = page.locator('[data-testid="landing-carousel"]')
const count = await carousels.count()
- // We have 2 carousels: "recommended" and "titleTwoNoMatchingUiYml"
expect(count).toBe(2)
- // Count carousels that have h2 elements
let carouselsWithHeadings = 0
for (let i = 0; i < count; i++) {
const carousel = carousels.nth(i)
@@ -1559,11 +1393,9 @@ test.describe('Multi-carousel support', () => {
}
}
- // Only 1 carousel should have a heading (recommended has ui.yml entry)
- // titleTwoNoMatchingUiYml should NOT have an h2 element at all
+ // Only recommended has a ui.yml entry, so titleTwoNoMatchingUiYml must not render an h2.
expect(carouselsWithHeadings).toBe(1)
- // Verify the specific titles that should be visible
const visibleHeadings = await carousels.locator('h2').allTextContents()
expect(visibleHeadings).toContain('Recommended')
expect(visibleHeadings).not.toContain('titleTwoNoMatchingUiYml')
@@ -1575,7 +1407,6 @@ test.describe('Multi-carousel support', () => {
const carousels = page.locator('[data-testid="landing-carousel"]')
const count = await carousels.count()
- // Each carousel should have at least one article
for (let i = 0; i < count; i++) {
const carousel = carousels.nth(i)
const articles = carousel.locator('[data-testid="carousel-items"] a')
@@ -1592,14 +1423,12 @@ test.describe('Journey Tracks', () => {
const journeyTracks = page.locator('[data-testid="journey-tracks"]')
await expect(journeyTracks).toBeVisible()
- // Check that at least one track is displayed
const tracks = page.locator('[data-testid="journey-track"]')
await expect(tracks.first()).toBeVisible()
- // Verify track has proper structure
const firstTrack = tracks.first()
- await expect(firstTrack.locator('h2')).toBeVisible() // Track title
- await expect(firstTrack.locator('p')).toBeVisible() // Track description
+ await expect(firstTrack.locator('h2')).toBeVisible()
+ await expect(firstTrack.locator('p')).toBeVisible()
})
test('track expansion and collapse functionality', async ({ page }) => {
@@ -1608,7 +1437,6 @@ test.describe('Journey Tracks', () => {
const firstTrack = page.locator('[data-testid="journey-track"]').first()
const expandButton = firstTrack.locator('summary')
- // Initially collapsed
const articlesList = firstTrack.locator('[data-testid="journey-articles"]')
await expect(articlesList).not.toBeVisible()
@@ -1630,7 +1458,6 @@ test.describe('Journey Tracks', () => {
await expandButton.click()
- // Click on first article
const firstArticle = firstTrack.locator('[data-testid="journey-articles"] li a').first()
await expect(firstArticle).toBeVisible()
@@ -1646,7 +1473,7 @@ test.describe('Journey Tracks', () => {
const expandButton = firstTrack.locator('summary')
await expandButton.click()
- // article links should preserve the language and version
+ // Article links preserve language and version.
const firstArticle = firstTrack.locator('[data-testid="journey-articles"] li a').first()
const href = await firstArticle.getAttribute('href')
@@ -1659,7 +1486,6 @@ test.describe('Journey Tracks', () => {
const tracks = page.locator('[data-testid="journey-track"]')
- // Check that liquid templates are rendered (no raw template syntax visible)
const trackContent = await tracks.first().textContent()
expect(trackContent).not.toContain('{{')
expect(trackContent).not.toContain('}}')
@@ -1670,21 +1496,18 @@ test.describe('Journey Tracks', () => {
test('renders the single-track journey landing path', async ({ page }) => {
await page.goto('/get-started/test-journey-single')
- // single-track pages use the simplified heading + guide list, not the numbered cards
+ // Single-track pages render the simplified heading and guide list instead of numbered cards.
const singleTrack = page.locator('[data-testid="journey-single-track"]')
await expect(singleTrack).toBeVisible()
await expect(page.locator('[data-testid="journey-tracks"]')).toHaveCount(0)
- // heading is present
await expect(singleTrack.locator('h2')).toBeVisible()
- // guide list renders its article links
const guides = singleTrack.locator('[data-testid="journey-articles"] li a')
await expect(guides.first()).toBeVisible()
expect(await guides.count()).toBeGreaterThan(0)
- // without a surrounding card, the list must sit flush with the heading
- // rather than picking up the card's inset
+ // Without a card, the guide list sits flush with the heading instead of inheriting inset.
const listPaddingLeft = await singleTrack
.locator('[data-testid="journey-articles"]')
.evaluate((el) => getComputedStyle(el).paddingLeft)
@@ -1692,21 +1515,14 @@ test.describe('Journey Tracks', () => {
})
test('journey navigation components show on article pages', async ({ page }) => {
- // go to an article that's part of a journey track
await page.goto('/get-started/start-your-journey/hello-world')
- // The journey footer "Up next" nav should be visible. (The Docs 2026 redesign
- // removed the sidebar journey card; next-step info now lives in the bottom
- // pager + the in-panel "Up next" section.)
+ // Journey next-step info shows in the bottom pager and the right-rail Up next section.
const journeyNav = page.locator('[data-testid="journey-track-nav"]')
await expect(journeyNav).toBeVisible()
})
- // Restores the coverage the Docs 2026 migration dropped along with the sidebar
- // journey card: `alternativeNextStep` and its AUTOTITLE resolution now render
- // in the right-rail "Up next" section instead. That section rides the drawer's
- // reveal breakpoint, so it needs a viewport inside the drawer range and a
- // fixture long enough to keep the bottom pager outside the viewport.
+ // A drawer-range viewport shows alternativeNextStep and AUTOTITLE in Up next; the long fixture hides the pager.
test('up next displays branching text when present', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await page.goto('/get-started/foo/journey-test-article')
@@ -1716,7 +1532,7 @@ test.describe('Journey Tracks', () => {
const upNext = page.getByTestId('up-next')
await expect(upNext).toBeVisible()
- // Branching text should be rendered with its markdown link resolved
+ // Branching text renders after resolving its markdown link.
await expect(upNext).toContainText('Want to skip ahead?')
await expect(upNext).not.toContainText('AUTOTITLE')
@@ -1758,7 +1574,6 @@ test.describe('Journey Tracks', () => {
const journeyNav = page.locator('[data-testid="journey-track-nav"]')
await expect(journeyNav).toBeVisible()
- // Link should display the next track's title and go to its first article
const nextTrackLink = journeyNav.locator('a').filter({ hasText: 'Advanced topics' })
await expect(nextTrackLink).toBeVisible()
@@ -1768,9 +1583,7 @@ test.describe('Journey Tracks', () => {
})
test.describe('Docs 2026 in-article navigation', () => {
- // Below the drawer's reveal breakpoint the right-rail "In this article" panel
- // is hidden and the collapsed control in the secondary bar is the ONLY
- // mini-TOC — the common case for most readers — so it needs its own coverage.
+ // Below the drawer breakpoint, the secondary-bar mini-TOC is the only in-page control.
test('the collapsed "In this article" menu navigates below the drawer breakpoint', async ({
page,
}) => {
@@ -1780,7 +1593,6 @@ test.describe('Docs 2026 in-article navigation', () => {
const subBar = page.getByTestId('overview-subbar')
await expect(subBar).toBeVisible()
- // The full drawer must not also be showing at this width.
await expect(page.getByTestId('minitoc')).toBeHidden()
await subBar.getByRole('button').click()
@@ -1794,11 +1606,7 @@ test.describe('Docs 2026 in-article navigation', () => {
expect(page.url()).toContain(href)
})
- // Regression guard. Platform/tool-gated headings stay in the DOM with the
- // `hidden` attribute, so they measure as an all-zero rect. Before
- // useActiveSection filtered by the selection, such a heading always satisfied
- // the "scrolled past" threshold, so the collapsed control could end up
- // labelled with a section belonging to a platform the reader had not chosen.
+ // useActiveSection filters hidden platform/tool headings because they have zero rects.
test('the collapsed menu is never labelled with a hidden platform section', async ({ page }) => {
await page.setViewportSize({ width: 1100, height: 900 })
await page.goto('/get-started/liquid/platform-specific?platform=windows')
@@ -1808,7 +1616,6 @@ test.describe('Docs 2026 in-article navigation', () => {
await expect(trigger).toBeVisible()
await expect(trigger).not.toContainText('Macintosh')
- // Scroll past the first heading so an active section is actually resolved.
await page.mouse.wheel(0, 2000)
await expect(trigger).not.toContainText('Macintosh')
})
@@ -1818,8 +1625,6 @@ test.describe('LandingArticleGridWithFilter component', () => {
test('displays article grid with filter controls', async ({ page }) => {
await page.goto('/get-started/article-grid-discovery')
- // Check that the main components are visible, title, categories drop
- // down, search input.
const articleGrid = page.getByTestId('article-grid')
await expect(articleGrid).toBeVisible()
@@ -1842,14 +1647,11 @@ test.describe('LandingArticleGridWithFilter component', () => {
const articleGrid = page.getByTestId('article-grid')
await expect(articleGrid).toBeVisible()
- // Check that article cards are present and they have expected structure
- // by checking the first card.
const articleCards = articleGrid.getByTestId('article-card')
await expect(articleCards.first()).toBeVisible()
const firstCard = articleCards.first()
- // Brand Card renders the title as an (Card.Heading) wrapping a
- // stretched , and the intro as a Card.Description .
+ // Brand Card renders titles as h3 Card.Heading links and intros as Card.Description paragraphs.
const titleLink = firstCard.locator('h3 a')
await expect(titleLink).toBeVisible()
@@ -1858,8 +1660,6 @@ test.describe('LandingArticleGridWithFilter component', () => {
const introText = await intro.textContent()
expect(introText).toBeTruthy()
- // Card should have categories, title, and intro, just check the card has
- // some text
const cardText = await firstCard.textContent()
expect(cardText).toBeTruthy()
expect(cardText!.length).toBeGreaterThan(0)
@@ -1868,27 +1668,23 @@ test.describe('LandingArticleGridWithFilter component', () => {
test('category filtering works correctly', async ({ page }) => {
await page.goto('/get-started/article-grid-discovery')
- // Check that category dropdown button exists and is clickable
const categoryDropdown = page.getByRole('button').filter({ hasText: 'All categories' })
await expect(categoryDropdown).toBeVisible()
- // Initially should show all articles (4 total in our fixtures)
+ // The fixture starts with four articles.
const articleGrid = page.getByTestId('article-grid')
await expect(articleGrid).toBeVisible()
const allArticleCards = articleGrid.getByTestId('article-card')
await expect(allArticleCards).toHaveCount(4)
- // Click the dropdown and the 'Testing' category
await categoryDropdown.click()
const testingOption = page.getByText('Testing', { exact: true }).last()
await expect(testingOption).toBeVisible()
await testingOption.click()
- // After filtering by Testing category, should show only 1 article based
- // on our fixtures.
+ // Filtering by Testing leaves one fixture article.
await expect(allArticleCards).toHaveCount(1)
- // Verify the filtered article contains "Testing" somewhere in its markup
const remainingCard = allArticleCards.first()
await expect(remainingCard).toContainText('Testing')
})
@@ -1899,18 +1695,17 @@ test.describe('LandingArticleGridWithFilter component', () => {
const searchInput = page.getByPlaceholder('Search articles')
await expect(searchInput).toBeVisible()
- // Initially should show all articles (4 total in our fixtures)
+ // The fixture starts with four articles.
const articleGrid = page.getByTestId('article-grid')
await expect(articleGrid).toBeVisible()
const articleCards = articleGrid.getByTestId('article-card')
await expect(articleCards).toHaveCount(4)
- // Search for "Grid" - based on our fixtures, multiple articles should have "Grid" in their names
+ // Multiple fixture article names contain Grid.
await searchInput.fill('Grid')
await expect(articleCards.first()).toBeVisible()
- // Verify that the remaining articles contain "Grid" in their content
const remainingCount = await articleCards.count()
expect(remainingCount).toBeGreaterThan(0)
for (let i = 0; i < remainingCount; i++) {
@@ -1925,44 +1720,33 @@ test.describe('LandingArticleGridWithFilter component', () => {
const searchInput = page.getByPlaceholder('Search articles')
await expect(searchInput).toBeVisible()
- // Search for a term that definitely won't match any articles, should show
- // no article cards
await searchInput.fill('noSuchArticles')
const articleGrid = page.getByTestId('article-grid')
await expect(articleGrid).toBeVisible()
const articleCards = articleGrid.getByTestId('article-card')
await expect(articleCards).toHaveCount(0)
- // Should show "no articles found" message as well
const noResultsMessage = page.getByTestId('no-articles-message')
await expect(noResultsMessage).toBeVisible()
await expect(noResultsMessage).toHaveText('No articles found matching your criteria.')
})
test('responsive behavior on different screen sizes', async ({ page }) => {
- // Super basic test, just make sure the article grid is visible on
- // different viewports sizes
-
- // Test desktop view (3 columns)
await page.setViewportSize({ width: 1200, height: 800 })
await page.goto('/get-started/article-grid-discovery')
const articleGrid = page.getByTestId('article-grid')
await expect(articleGrid).toBeVisible()
- // Test tablet view (2 columns)
await page.setViewportSize({ width: 768, height: 1024 })
- await page.waitForTimeout(100) // Brief wait for responsive changes
+ await page.waitForTimeout(100)
await expect(articleGrid).toBeVisible()
- // Test mobile view (1 column)
await page.setViewportSize({ width: 375, height: 667 })
- await page.waitForTimeout(100) // Brief wait for responsive changes
+ await page.waitForTimeout(100)
await expect(articleGrid).toBeVisible()
})
test('works with bespoke landing page', async ({ page }) => {
- // Other grid tests use the discovery landing page, bespoke pages are
- // similar so just do a quick check.
await page.goto('/get-started/article-grid-bespoke')
const articleGrid = page.getByTestId('article-grid')
@@ -1970,10 +1754,7 @@ test.describe('LandingArticleGridWithFilter component', () => {
})
test('card is keyboard-navigable via Enter (client-side)', async ({ page }) => {
- // The brand Card renders a native stretched anchor; a synthetic click from
- // pressing Enter on that anchor must bubble to the card's onClick handler so
- // keyboard users get the same client-side SPA navigation as mouse users.
- // Guards against a regression if the click-intercept logic is refactored.
+ // Brand Card's stretched anchor must bubble keyboard clicks for client-side navigation.
await page.goto('/get-started/article-grid-discovery')
const articleGrid = page.getByTestId('article-grid')
@@ -1983,8 +1764,7 @@ test.describe('LandingArticleGridWithFilter component', () => {
const href = await firstCardLink.getAttribute('href')
expect(href).toBeTruthy()
- // Mark the current document so we can prove navigation was client-side
- // (no full page reload): a hard navigation would wipe this window property.
+ // A hard navigation would clear this window marker; client-side navigation preserves it.
await page.evaluate(() => {
;(window as unknown as { __spaMarker?: boolean }).__spaMarker = true
})
@@ -2000,20 +1780,16 @@ test.describe('LandingArticleGridWithFilter component', () => {
})
test('bespoke landing page does not show duplicate articles', async ({ page }) => {
- // The bespoke fixture lists individual articles AND their parent group
- // as children, which would cause duplicates without deduplication.
+ // Bespoke fixtures list articles and their parent group, so deduplication prevents duplicates.
await page.goto('/get-started/article-grid-bespoke')
const articleGrid = page.getByTestId('article-grid')
await expect(articleGrid).toBeVisible()
const articleCards = articleGrid.getByTestId('article-card')
- // There are 4 unique articles across grid-category-one (2) and grid-category-two (2).
- // Even though grid-article-one and grid-article-two are listed both individually
- // and as children of grid-category-one, they should appear only once each.
+ // Four unique articles remain after deduplicating grid-article-one and grid-article-two.
await expect(articleCards).toHaveCount(4)
- // Verify no duplicate titles by collecting all card titles
const titles: string[] = []
const count = await articleCards.count()
for (let i = 0; i < count; i++) {
@@ -2027,19 +1803,16 @@ test.describe('LandingArticleGridWithFilter component', () => {
test.describe('Non-child page resolution', () => {
test('category page with local children renders properly', async ({ page }) => {
- // The local-category has local children (local-article-one, local-article-two)
- // and an external article reference via children frontmatter
+ // local-category mixes local-article-one, local-article-two, and an external frontmatter child.
await page.goto('/get-started/non-child-resolution/local-category')
- // Should have a title
await expect(page).toHaveTitle(/Local category test/)
- // The page should load without errors and have main content
await expect(page.locator('main')).toBeVisible()
})
test('cross-product children page loads correctly', async ({ page }) => {
- // The articles-only fixture now uses /content/ prefix in children for cross-product paths
+ // The articles-only fixture prefixes cross-product children with /content/.
await page.goto('/get-started/non-child-resolution/articles-only')
await expect(page).toHaveTitle(/Cross-product children test/)
@@ -2047,7 +1820,7 @@ test.describe('Non-child page resolution', () => {
})
test('children-only page with /content/ path loads correctly', async ({ page }) => {
- // The children-only fixture uses /content/ prefix for cross-product paths
+ // The children-only fixture prefixes cross-product children with /content/.
await page.goto('/get-started/non-child-resolution/children-only')
await expect(page).toHaveTitle(/Children only test/)
@@ -2062,20 +1835,19 @@ test.describe('Non-child page resolution', () => {
})
test('versioned cross-product children - fpt shows only fpt article', async ({ page }) => {
- // In fpt version, only the only-fpt article should be available
+ // In fpt, only only-fpt is available.
await page.goto('/get-started/non-child-resolution/versioned-cross-product')
await expect(page).toHaveTitle(/Versioned cross-product test/)
await expect(page.locator('main')).toBeVisible()
- // Check TOC has the fpt-only article
const tocLinks = page.locator('[data-testid="table-of-contents"] a')
await expect(tocLinks).toHaveCount(1)
await expect(tocLinks.first()).toHaveAttribute('href', /only-fpt/)
})
test('versioned cross-product children - ghec shows ghec articles', async ({ page }) => {
- // In ghec version, only-ghec and only-ghec-and-ghes should be available
+ // In ghec, only-ghec and only-ghec-and-ghes are available.
await page.goto(
'/enterprise-cloud@latest/get-started/non-child-resolution/versioned-cross-product',
)
@@ -2083,58 +1855,46 @@ test.describe('Non-child page resolution', () => {
await expect(page).toHaveTitle(/Versioned cross-product test/)
await expect(page.locator('main')).toBeVisible()
- // Check TOC has ghec articles (only-ghec and only-ghec-and-ghes)
const tocLinks = page.locator('[data-testid="table-of-contents"] a')
await expect(tocLinks).toHaveCount(2)
})
test('cross-product children excluded from sidebar in Japanese translation', async ({ page }) => {
- // The Japanese translation should work with cross-product children
+ // Japanese translations work with cross-product children.
await page.goto('/ja/get-started/non-child-resolution')
- // Verify page loads correctly with Japanese site context
- // Note: The title may not be fully translated in test fixtures, but the page should render
+ // Fixture titles can be partly untranslated, but Japanese site context must render.
await expect(page).toHaveTitle(/GitHub Docs/)
await expect(page.locator('main')).toBeVisible()
-
- // Verify page loads correctly - the cross-product children don't prevent the page from working
- // The detailed sidebar filtering is tested by the survey test which verifies no duplicate entries
})
})
test.describe('copy as markdown button', () => {
- // The article-body fetch backing this button is served for this fixture page
- // (see src/fixtures/tests/api-article-body.ts), so the copy path succeeds.
+ // api-article-body.ts serves this fixture's article-body fetch, so the copy path succeeds.
const articlePath = '/en/get-started/start-your-journey/api-article-body-test-page'
test('shows a checkmark after a successful copy', async ({ page, context }) => {
- // The click handler writes the article markdown to the clipboard.
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await page.goto(articlePath)
await turnOffExperimentsInPage(page)
- // `exact` matters: accessible-name matching is substring-based, so a bare
- // 'Copy markdown' also matches the code-block copy buttons that articles
- // with a ```markdown fence render ('Copy Markdown code to clipboard').
+ // Accessible-name matching treats names as substrings, so exact avoids code-block copy buttons.
const copyButton = page.getByRole('button', { name: 'Copy markdown', exact: true })
await expect(copyButton).toHaveCount(1)
await expect(copyButton).toBeVisible()
- // At rest the button is text-only — no icon at all. The checkmark below is
- // purely the success state.
+ // At rest the button is text-only; the checkmark is only the success state.
await expect(copyButton.locator('svg')).toHaveCount(0)
await copyButton.click()
- // After a successful copy, a checkmark appears...
await expect(copyButton.locator('.octicon-check')).toBeVisible()
- // ...and the article markdown lands on the clipboard.
const clipboardText = await page.evaluate(() => navigator.clipboard.readText())
expect(clipboardText).toContain('About GitHub')
- // The checkmark is temporary and clears again (2s timeout).
+ // The success checkmark clears after the 2s timeout.
await expect(copyButton.locator('.octicon-check')).toHaveCount(0, { timeout: 5000 })
})
})
diff --git a/src/fixtures/tests/playwright-secret-scanning.spec.ts b/src/fixtures/tests/playwright-secret-scanning.spec.ts
index a7a190190f32..60df4fa6096c 100644
--- a/src/fixtures/tests/playwright-secret-scanning.spec.ts
+++ b/src/fixtures/tests/playwright-secret-scanning.spec.ts
@@ -9,11 +9,9 @@ test.describe('Secret scanning DataTable accessibility', () => {
const table = page.getByRole('table')
await expect(table).toBeVisible()
- // The table should be labelled by the Table.Title heading
const labelledBy = await table.getAttribute('aria-labelledby')
expect(labelledBy).toBeTruthy()
- // The referenced element should exist and contain text
const titleEl = page.locator(`#${labelledBy}`)
await expect(titleEl).toBeVisible()
await expect(titleEl).not.toBeEmpty()
@@ -22,7 +20,7 @@ test.describe('Secret scanning DataTable accessibility', () => {
test('heading hierarchy does not skip levels within main content', async ({ page }) => {
await page.goto(PAGE_PATH)
- // Scope to main content area — nav/sidebar/footer may have their own heading structure
+ // Scope to main content because nav, sidebar, and footer have their own heading structure.
const main = page.locator('main, article, [role="main"]').first()
const headings = await main.locator('h1, h2, h3, h4, h5, h6').all()
expect(headings.length).toBeGreaterThan(0)
@@ -31,8 +29,7 @@ test.describe('Secret scanning DataTable accessibility', () => {
for (const heading of headings) {
const tagName = await heading.evaluate((el) => el.tagName.toLowerCase())
const level = parseInt(tagName.replace('h', ''), 10)
- // Level can go up (same or smaller number) freely, but going deeper
- // should never skip more than one level
+ // Heading levels may go up freely, but going deeper must not skip a level.
if (level > previousLevel) {
expect(level - previousLevel).toBeLessThanOrEqual(1)
}
@@ -43,7 +40,7 @@ test.describe('Secret scanning DataTable accessibility', () => {
test('all interactive controls have accessible names', async ({ page }) => {
await page.goto(PAGE_PATH)
- // Search input — Primer TextInput renders as input[type="text"] with role "textbox"
+ // Primer TextInput renders the search input as input[type="text"] with role=textbox.
const searchInput = page.locator('[role="search"] input')
await expect(searchInput).toBeVisible()
const searchLabel =
@@ -51,7 +48,6 @@ test.describe('Secret scanning DataTable accessibility', () => {
(await searchInput.getAttribute('placeholder'))
expect(searchLabel).toBeTruthy()
- // Filter buttons (ActionMenu triggers)
const buttons = page.locator('[role="search"] button')
const buttonCount = await buttons.count()
expect(buttonCount).toBeGreaterThan(0)
@@ -61,7 +57,6 @@ test.describe('Secret scanning DataTable accessibility', () => {
expect(name.length).toBeGreaterThan(0)
}
- // Pagination (if present)
const pagination = page.getByRole('navigation', { name: /pagination/i })
if ((await pagination.count()) > 0) {
await expect(pagination).toHaveAttribute('aria-label', /.+/)
@@ -71,8 +66,7 @@ test.describe('Secret scanning DataTable accessibility', () => {
test('provider column cells are row headers', async ({ page }) => {
await page.goto(PAGE_PATH)
- // Primer DataTable uses CSS grid layout — row headers are rendered as
- // elements with role="rowheader" (via scope="row" on the cell)
+ // Primer DataTable uses CSS grid, and scope=row cells render as role=rowheader.
const rowHeaders = page.locator('[role="rowheader"]')
const count = await rowHeaders.count()
expect(count).toBeGreaterThan(0)
@@ -87,16 +81,13 @@ test.describe('Secret scanning DataTable accessibility', () => {
const table = page.getByRole('table')
await expect(table).toBeVisible()
- // At narrow viewports, the table should not be hidden or clipped.
- // Content must remain reachable even if it overflows horizontally.
- // Verify the table itself is not display:none or visibility:hidden
+ // At narrow viewports, the table must stay visible even when it overflows horizontally.
await expect(table).toBeVisible()
- // Verify data cells are present and accessible
const cells = page.locator('[role="rowheader"], [role="cell"]')
expect(await cells.count()).toBeGreaterThan(0)
- // The table's container should allow horizontal scrolling (overflow not hidden)
+ // The overflow wrapper must allow horizontal scrolling.
const overflowX = await table.evaluate((el) => {
const wrapper = el.closest('[class*="OverflowWrapper"]') || el.parentElement
return wrapper ? getComputedStyle(wrapper).overflowX : 'visible'
@@ -106,8 +97,7 @@ test.describe('Secret scanning DataTable accessibility', () => {
})
test('color contrast meets 4.5:1 minimum', async ({ page }) => {
- // This is primarily covered by the axe scan in playwright-a11y.spec.ts,
- // but we include a targeted check here for the table specifically
+ // Axe covers this broadly; this test isolates table color contrast.
const { default: AxeBuilder } = await import('@axe-core/playwright')
await page.goto(PAGE_PATH)
diff --git a/src/fixtures/tests/sidebar.ts b/src/fixtures/tests/sidebar.ts
index 8607f0ed57ee..5d5eadccc5b5 100644
--- a/src/fixtures/tests/sidebar.ts
+++ b/src/fixtures/tests/sidebar.ts
@@ -6,11 +6,10 @@ import { getDOMCached as getDOM } from '@/tests/helpers/e2etest'
describe('sidebar', () => {
test('top level product mentioned at top of sidebar', async () => {
const $: CheerioAPI = await getDOM('/get-started')
- // Desktop
const sidebarProduct = $('[data-testid="sidebar-product-xl"]')
expect(sidebarProduct.text()).toBe('Get started')
expect(sidebarProduct.attr('href')).toBe('/en/get-started')
- // Docs 2026 secondary bar (breadcrumbs + nav toggle) replaces the old subnav
+ // Docs 2026 uses the secondary bar for breadcrumbs and the nav toggle.
expect($('[data-testid="docs-secondary-bar"]').length).toBe(1)
expect($('[data-testid="sidebar-mobile-toggle"]').length).toBe(1)
})
@@ -31,8 +30,7 @@ describe('sidebar', () => {
test('sidebar should always use the shortTitle', async () => {
const $: CheerioAPI = await getDOM('/get-started/foo/bar')
- // The page /get-started/foo/bar has a short title that is different
- // from its regular title.
+ // /get-started/foo/bar has a short title that differs from its regular title.
expect(
$(
'[data-testid=sidebar] [data-testid=product-sidebar] a[href*="/get-started/foo/bar"] span span',
@@ -49,19 +47,16 @@ describe('sidebar', () => {
})
test('Liquid is rendered in short title used at top of sidebar', async () => {
- // Free, pro, team
{
const $: CheerioAPI = await getDOM('/pages')
const link = $('#allproducts-menu a')
expect(link.text()).toBe('Pages (HubGit)')
}
- // Enterprise Server
{
const $: CheerioAPI = await getDOM('/enterprise-server@latest/pages')
const link = $('#allproducts-menu a')
expect(link.text()).toBe('Pages (HubGit Enterprise Server)')
}
- // Enterprise Cloud
{
const $: CheerioAPI = await getDOM('/enterprise-cloud@latest/pages')
const link = $('#allproducts-menu a')
@@ -71,45 +66,35 @@ describe('sidebar', () => {
test('no docset link for early-access', async () => {
const $: CheerioAPI = await getDOM('/early-access/secrets/deeper/mariana-trench')
- // Deskop
expect($('[data-testid="sidebar-product-xl"]').length).toBe(0)
- // The secondary bar renders, but early-access has no nav toggle
+ // Early access renders the secondary bar without a nav toggle.
expect($('[data-testid="docs-secondary-bar"]').length).toBe(1)
expect($('[data-testid="sidebar-mobile-toggle"]').length).toBe(0)
})
test('category-landing pages show title entry in sidebar', async () => {
const $ = await getDOM('/get-started')
- // Check that page loads and has proper sidebar structure
- // This tests the core functionality using a guaranteed stable page
const sidebarLinks = $('[data-testid="sidebar"] a')
expect(sidebarLinks.length).toBeGreaterThan(0)
- // Verify sidebar has proper structure indicating layout changes are in place
const sidebar = $('[data-testid="sidebar"]')
expect(sidebar.length).toBe(1)
})
test('non-category-landing pages do not show specific copilot entries', async () => {
- // Test a page from a different product that should have different sidebar content
const $ = await getDOM('/rest')
const sidebarLinks = $('[data-testid="sidebar"] a')
expect(sidebarLinks.length).toBeGreaterThan(0)
- // Verify this page has REST-specific sidebar structure
expect($('[data-testid=rest-sidebar-reference]').length).toBe(1)
})
test('layout property implementation exists in codebase', async () => {
- // This test verifies the layout property changes are in place
- // by testing a stable page and checking sidebar structure
const $ = await getDOM('/pages')
- // Verify basic sidebar functionality works
const sidebar = $('[data-testid="sidebar"]')
expect(sidebar.length).toBe(1)
- // Check that sidebar has proper structure for testing the layout changes
const sidebarLinks = $('[data-testid="sidebar"] a')
expect(sidebarLinks.length).toBeGreaterThan(0)
})
diff --git a/src/fixtures/tests/spotlight-processing.ts b/src/fixtures/tests/spotlight-processing.ts
index 1716a402eb84..32b0b160088b 100644
--- a/src/fixtures/tests/spotlight-processing.ts
+++ b/src/fixtures/tests/spotlight-processing.ts
@@ -19,7 +19,6 @@ interface ProcessedSpotlightItem {
image: string
}
-// Mock data to simulate tocItems and spotlight configurations
const mockTocItems: TocItem[] = [
{
title: 'Test Debug Article',
@@ -38,7 +37,6 @@ const mockTocItems: TocItem[] = [
},
]
-// Helper function to simulate the spotlight processing logic from CategoryLanding
function processSpotlight(
spotlight: SpotlightItem[] | undefined,
tocItems: TocItem[],
diff --git a/src/fixtures/tests/translations.ts b/src/fixtures/tests/translations.ts
index a35fa3e346c5..2f7a8316c3d0 100644
--- a/src/fixtures/tests/translations.ts
+++ b/src/fixtures/tests/translations.ts
@@ -15,7 +15,7 @@ describe('translations', () => {
test('home page', async () => {
const $: CheerioAPI = await getDOM('/ja')
const h1 = $('h1').text()
- // You gotta know your src/fixtures/fixtures/translations/ja-jp/data/ui.yml
+ // src/fixtures/fixtures/translations/ja-jp/data/ui.yml localizes the home-page h1.
expect(h1).toBe('日本 GitHub Docs')
const links = $('[data-testid=product] a[href]')
@@ -61,12 +61,11 @@ describe('translations', () => {
expect($(element).text()).toBe('こんにちは World')
}
})
- // There are 4 links on the `autotitling.md` content.
+ // autotitling.md has 4 AUTOTITLE links.
expect.assertions(4)
})
test('correction of linebreaks in translations', async () => {
- // free-pro-team
{
const $: CheerioAPI = await getDOM('/ja/get-started/foo/table-with-ifversions')
@@ -79,7 +78,6 @@ describe('translations', () => {
expect(tds.length).toBe(2)
expect(tds[1]).toBe('Not')
}
- // enterprise-server
{
const $: CheerioAPI = await getDOM(
'/ja/enterprise-server@latest/get-started/foo/table-with-ifversions',
@@ -96,37 +94,21 @@ describe('translations', () => {
}
})
+ // Japanese translation fixtures include malformed AUTOTITLE links in content and reusables.
+ // Input: ["AUTOTITLE](/get-started/start-your-journey/hello-world)."
+ // Bad output: "AUTOTITLE
+ // Runtime correction must remove AUTOTITLE because translation CI does not catch this Markdown.
test('automatic correction of bad AUTOTITLE in reusables', async () => {
const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/hello-world')
const links = $('#article-contents a[href]')
const texts = links.map((i: number, element: Element) => $(element).text()).get()
- // That Japanese page uses AUTOTITLE links. Both in the main `.md` file
- // but also inside a reusable.
- // E.g. `["AUTOTITLE](/get-started/start-your-journey/hello-world)."`
- // If we didn't do the necessary string corrections on translations'
- // content and reusables what *would* remain is a HTML link that
- // would look like this:
- //
- // "AUTOTITLE
- //
- // This test makes sure no such string is left in any of the article
- // content links.
- // Note that, in English, it's not acceptable to have such a piece of
- // Markdown. It would not be let into `main` by our CI checks. But
- // by their nature, translations are not checked by CI in the same way.
- // Its "flaws" have to be corrected at runtime.
const stillAutotitle = texts.filter((text: string) => /autotitle/i.test(text))
expect(stillAutotitle.length).toBe(0)
})
+ // Translators wrote [[Bar](バー)](/get-started/foo/bar), which must render as
+ // [Bar](バー).
test('markdown link looking constructs inside links', async () => {
- // On this page, the translators had written:
- //
- // [[Bar](バー)](/get-started/foo/bar)
- //
- // which needs to become:
- //
- // [Bar](バー)
const $: CheerioAPI = await getDOM('/ja/get-started/start-your-journey/hello-world')
const links = $('#article-contents a[href]')
const texts = links
@@ -136,7 +118,6 @@ describe('translations', () => {
})
.map((i: number, element: Element) => $(element).text())
.get()
- // Check that the text contains the essential parts rather than exact spacing
const foundBarLink = texts.find(
(text: string) => text.includes('[Bar]') && text.includes('(バー)'),
)
@@ -146,18 +127,16 @@ describe('translations', () => {
describe('localized category versioning', () => {
test('category page works in all children versions', async () => {
{
- // for translated content, we expect this to be OK
const res = await head('/ja/get-started')
expect(res.statusCode).toBe(200)
}
{
- // The actual versioning for get-started/empty-categories
- // does not specify ghes, so it should 404.
+ // The category allows ghes, but its only child is ghec-only, so enterprise-server 404s.
const res = await head('/ja/enterprise-server@latest/get-started/empty-categories')
expect(res.statusCode).toBe(404)
}
{
- // Yet this nested page shoudl work.
+ // The ghec-only child renders under enterprise-cloud.
const res = await head('/ja/enterprise-cloud@latest/get-started/empty-categories/only-ghec')
expect(res.statusCode).toBe(200)
}
diff --git a/src/fixtures/tests/versioning.ts b/src/fixtures/tests/versioning.ts
index 5fe70db14127..33f7549f4d83 100644
--- a/src/fixtures/tests/versioning.ts
+++ b/src/fixtures/tests/versioning.ts
@@ -8,7 +8,7 @@ describe('article versioning', () => {
test('only links to articles for fpt', async () => {
const $: CheerioAPI = await getDOM('/get-started/versioning')
const links = $('[data-testid="table-of-contents"] a')
- // Only 1 link because there's only 1 article available in fpt
+ // /get-started/versioning has one free-pro-team article.
expect(links.length).toBe(1)
expect(links.attr('href')).toBe('/en/get-started/versioning/only-fpt')
})
@@ -23,7 +23,7 @@ describe('article versioning', () => {
expect(second.attr('href')).toBe(
'/en/enterprise-cloud@latest/get-started/versioning/only-ghec-and-ghes',
)
- // Both links should 200 if you go to them
+ // Both linked enterprise-cloud articles must resolve without redirects.
expect((await head(first.attr('href')!)).statusCode).toBe(200)
expect((await head(second.attr('href')!)).statusCode).toBe(200)
})
@@ -38,7 +38,7 @@ describe('article versioning', () => {
expect(res.statusCode).toBe(404)
})
test('going to non-fpt article with fpt prefix will redirect', async () => {
- // Viewing a ghec only article without ghec prefix
+ // Without the ghec prefix, a ghec-only article redirects to enterprise-cloud.
const res = await head('/get-started/versioning/only-ghec', {
followRedirects: false,
})
@@ -52,15 +52,12 @@ describe('article versioning', () => {
describe('category versioning', () => {
test('category page work in all children versions', async () => {
{
- // Note that in the `versions:` of get-started/versioning/index.md
- // it *lacks* fpt. It's a deliberate pretend omission/mistake.
- // But clearly the page works.
+ // get-started/versioning/index.md deliberately omits fpt, but the category resolves.
const res = await head('/en/get-started/versioning')
expect(res.statusCode).toBe(200)
}
{
- // The actual version number of get-started/versioning/index.md
- // does not specify this version of ghes, it still works.
+ // get-started/versioning/index.md omits latest ghes, but it redirects to a number.
const res = await head('/en/enterprise-server@latest/get-started/versioning')
expect(res.statusCode).toBe(302)
expect(res.headers.location).toMatch(
@@ -68,8 +65,7 @@ describe('category versioning', () => {
)
}
{
- // The actual version number of get-started/versioning/index.md
- // does not specify this version of ghec, it still works.
+ // get-started/versioning/index.md omits latest ghec, but enterprise-cloud resolves.
const res = await head('/en/enterprise-cloud@latest/get-started/versioning')
expect(res.statusCode).toBe(200)
}
@@ -78,8 +74,7 @@ describe('category versioning', () => {
describe('home page versioning', () => {
test('invalid language and valid version', async () => {
- // Don't use 'latest' here because that will trigger a redirect
- // first to the latest actual number.
+ // Use a numbered release so the invalid language returns 404 before any version redirect.
const res = await head(`/ennnnn/enterprise-server@${supported[0]}`)
expect(res.statusCode).toBe(404)
})
diff --git a/src/frame/middleware/README.md b/src/frame/middleware/README.md
index e3f8c2b279f3..63e5e757e193 100644
--- a/src/frame/middleware/README.md
+++ b/src/frame/middleware/README.md
@@ -3,3 +3,15 @@
Each file in this directory exports an Express Middleware function.
For more info, see https://expressjs.com/en/guide/using-middleware.html
+
+## Mock Virtual Assistant portal
+
+`mock-va-portal.ts` lets you test the Virtual Assistant integration without access to a staging portal. The production portal rejects `localhost:4000` because it is hardened to `https://docs.github.com`.
+
+To test locally:
+
+1. Add `SUPPORT_PORTAL_URL=http://localhost:4000` to your `.env` file.
+2. Run `npm run dev`.
+3. Navigate to a page listed in the `PagePathToVaFlowMapping` object in `ArticleContext`.
+
+This mock is not secure. Use it only for local development.
diff --git a/src/frame/middleware/abort.ts b/src/frame/middleware/abort.ts
index e9f9496578ed..0e98af463e1a 100644
--- a/src/frame/middleware/abort.ts
+++ b/src/frame/middleware/abort.ts
@@ -14,18 +14,14 @@ class AbortError extends Error {
}
export default function abort(req: ExtendedRequest, res: Response, next: NextFunction) {
- // If the client aborts the connection, send an error
req.once('aborted', () => {
- // ignore aborts from next, usually has to do with webpack-hmr
+ // Ignore _next aborts, which usually come from webpack HMR.
if (req.path.startsWith('/_next')) {
return
}
- // NOTE: Node.js will also automatically set `req.aborted = true`
const incrementTags = []
- // Be careful with depending on attributes set on the `req` because
- // under certain conditions the contextualizers might not yet have
- // had a chance to run.
+ // Request contextualizers might not run before an abort, so guard optional request fields.
if (req.pagePath) {
incrementTags.push(`path:${req.pagePath}`)
}
diff --git a/src/frame/middleware/api.ts b/src/frame/middleware/api.ts
index a7c3f1acb7ac..4eef5c90a193 100644
--- a/src/frame/middleware/api.ts
+++ b/src/frame/middleware/api.ts
@@ -23,12 +23,8 @@ router.use('/anchor-redirect', anchorRedirect)
router.use('/pagelist', pageList)
router.use('/article', article)
-// The purpose of this is for convenience to everyone who runs this code
-// base locally but don't have an Elasticsearch server locally.
-// In production, this env var is always set but perhaps in a writer's
-// local laptop, they don't have an Elasticsearch. Neither a running local
-// server or the known credentials to a remote Elasticsearch. Whenever
-// that's the case, they can just HTTP proxy to the production server.
+// Local development proxies AI Search to docs.github.com when CSE_COPILOT_ENDPOINT
+// is unset, so writers do not need a local AI Search service.
if (process.env.CSE_COPILOT_ENDPOINT || process.env.NODE_ENV === 'test') {
router.use('/ai-search', aiSearch)
} else {
@@ -52,9 +48,8 @@ if (process.env.ELASTICSEARCH_URL) {
)
}
-// We need access to specific httpOnly cookies set on github.com from the client
-// The only way to access these on the client is to fetch them from the server
-// Limit this endpoint to 1req/min because a client should only call this route once
+// Browser JavaScript cannot read github.com httpOnly cookies.
+// The server endpoint returns the staff flag that client code needs.
router.get('/cookies', (req, res) => {
noCacheControl(res)
const cookies = {
diff --git a/src/frame/middleware/block-robots.ts b/src/frame/middleware/block-robots.ts
index 298575ec9f0c..c49e8f602f98 100644
--- a/src/frame/middleware/block-robots.ts
+++ b/src/frame/middleware/block-robots.ts
@@ -4,7 +4,7 @@ import { productMap } from '@/products/lib/all-products'
import { deprecated } from '@/versions/lib/enterprise-server-releases'
const pathRegExps: RegExp[] = [
- // Disallow indexing of WIP products
+ // WIP and hidden products stay out of search indexes.
...Object.values(productMap)
.filter((product) => product.wip || product.hidden)
.map((product) => [
@@ -12,7 +12,7 @@ const pathRegExps: RegExp[] = [
...product.versions!.map((version) => new RegExp(`^/.*?${version}/${product.id}`, 'i')),
]),
- // Disallow indexing of deprecated enterprise versions
+ // Deprecated enterprise versions stay out of search indexes.
...deprecated.map((version) => [
new RegExp(`^/.*?/enterprise-server@${version}/.*?`, 'i'),
new RegExp(`^/.*?/enterprise/${version}/.*?`, 'i'),
diff --git a/src/frame/middleware/cache-control.ts b/src/frame/middleware/cache-control.ts
index b53e5ca98364..c529f64f7918 100644
--- a/src/frame/middleware/cache-control.ts
+++ b/src/frame/middleware/cache-control.ts
@@ -19,9 +19,8 @@ const ONE_DAY = 24 * ONE_HOUR
const ONE_WEEK = 7 * ONE_DAY
const ONE_YEAR = 365 * ONE_DAY
-// Return a function you can pass a Response object to and it will set the `Cache-Control` header.
-// Max age is in seconds.
-// Max age should not be greater than 31536000, per .
+// maxAge is seconds. Keep it at or below 31536000.
+// https://www.ietf.org/rfc/rfc2616.txt
function cacheControlFactory(
maxAge: number = 0,
{
@@ -53,16 +52,14 @@ function cacheControlFactory(
}
}
-// The rest of this file is roughly in order from shortest max age to longest.
-
-// If you do not want caching.
export const noCacheControl = cacheControlFactory(0)
-// Short cache for 4xx errors.
+// 4xx errors get a short cache.
export const errorCacheControl = cacheControlFactory(ONE_MINUTE)
-// For default cache control, up to one week in cache but much shorter in browser.
-// Most responses are under the default cache control policy.
+// Default responses cache for 1 minute in browsers and 10 minutes in the CDN.
+// The CDN can serve stale responses for 1 week while revalidating or on errors.
+// Most responses use this policy.
const browserCacheControl = cacheControlFactory(ONE_MINUTE)
const defaultCDNCacheControl = cacheControlFactory(TEN_MINUTES, {
key: 'surrogate-control',
@@ -75,30 +72,29 @@ export function defaultCacheControl(res: Response): void {
}
export const searchCacheControl = defaultCacheControl
-// For requests where the response can vary between a HTML and Markdown response
-// using the accept header.
+// The Accept header can switch content responses between HTML and Markdown.
export function contentTypeCacheControl(res: Response): void {
defaultCacheControl(res)
res.append('vary', 'accept')
}
-// Vary on language when needed.
-// `x-user-language` is a custom request header derived from `req.cookie:user_language`.
-// `accept-language` is truncated to one of our available languages.
+// Vary by accept-language and x-user-language.
+// x-user-language comes from req.cookie:user_language.
+// Upstream code truncates accept-language to available languages.
// https://bit.ly/3u5UeRN
export function languageCacheControl(res: Response): void {
defaultCacheControl(res)
res.append('vary', 'accept-language, x-user-language')
}
-// Vary on both language and version for homepage redirects.
-// `x-user-version` is a custom request header derived from `req.cookie:user_version`.
+// Homepage redirects also vary by x-user-version.
+// x-user-version comes from req.cookie:user_version.
export function languageAndVersionCacheControl(res: Response): void {
defaultCacheControl(res)
res.append('vary', 'accept-language, x-user-language, x-user-version')
}
-// Long cache control for versioned assets: such as images, CSS, prebuilt JS.
+// Versioned images, CSS, and prebuilt JS use long browser and CDN caches.
const assetBrowserCacheControl = cacheControlFactory(TEN_MINUTES)
const assetCDNCacheControl = cacheControlFactory(ONE_WEEK, {
key: 'surrogate-control',
@@ -111,7 +107,7 @@ export function assetCacheControl(res: Response): void {
assetCDNCacheControl(res)
}
-// Long caching for archived pages and assets.
+// Archived pages and assets use long browser and CDN caches.
const archivedBrowserCacheControl = cacheControlFactory(TEN_MINUTES)
const archivedCDNCacheControl = cacheControlFactory(ONE_YEAR, {
key: 'surrogate-control',
diff --git a/src/frame/middleware/categories-for-support.ts b/src/frame/middleware/categories-for-support.ts
index ad8bae698a57..bf1bac8f945f 100644
--- a/src/frame/middleware/categories-for-support.ts
+++ b/src/frame/middleware/categories-for-support.ts
@@ -14,8 +14,7 @@ type Category = {
published_articles: Article[]
}
-// This middleware exposes a list of all categories and child articles at /categories.json.
-// GitHub Support uses this for internal ZenDesk search functionality.
+// /categories.json gives GitHub Support categories and child articles for Zendesk search.
export default async function categoriesForSupport(req: ExtendedRequest, res: Response) {
const englishSiteTree = req.context!.siteTree!.en
const allCategories: Category[] = []
@@ -26,9 +25,7 @@ export default async function categoriesForSupport(req: ExtendedRequest, res: Re
if (!productPage.childPages || !productPage.childPages.length) continue
await Promise.all(
productPage.childPages.map(async (categoryPage) => {
- // We can't get the rendered titles from middleware/render-tree-titles
- // here because that middleware only runs on the current version, and this
- // middleware processes all versions.
+ // Site-tree titles are raw, so render any that contain Liquid.
if (!req.context) return
const name = categoryPage.page.title.includes('{')
? await categoryPage.page.renderProp('title', req.context, renderOpts)
@@ -42,8 +39,7 @@ export default async function categoriesForSupport(req: ExtendedRequest, res: Re
)
}
- // Cache somewhat aggressively but note that it will be soft-purged
- // in every prod deployment.
+ // Use the default browser and CDN cache policy.
defaultCacheControl(res)
return res.json(allCategories)
@@ -67,7 +63,6 @@ async function findArticlesPerCategory(
if (!currentPage.childPages) return articlesArray
- // Run recursively to find any articles deeper in the tree.
await Promise.all(
currentPage.childPages.map(async (childPage) => {
await findArticlesPerCategory(childPage, articlesArray, context)
diff --git a/src/frame/middleware/context/breadcrumbs.ts b/src/frame/middleware/context/breadcrumbs.ts
index 9f5beba950dd..b6cbca12c224 100644
--- a/src/frame/middleware/context/breadcrumbs.ts
+++ b/src/frame/middleware/context/breadcrumbs.ts
@@ -10,7 +10,6 @@ export default function breadcrumbs(req: ExtendedRequest, res: Response, next: N
req.context.breadcrumbs = []
- // Return an empty array on the landing page.
if (req.context.page.documentType === 'homepage') {
return next()
}
@@ -22,21 +21,15 @@ export default function breadcrumbs(req: ExtendedRequest, res: Response, next: N
const earlyAccessExceptions = ['insights', 'enterprise-importer']
+// For Early Access pages, getBreadcrumbs omits /early-access and the product segment.
+// For example, /en/early-access/github/migrating starts at /migrating.
function getBreadcrumbs(req: ExtendedRequest, isEarlyAccess: boolean) {
if (!req.context || !req.context.currentPath || !req.context.currentProductTreeTitles)
throw new Error('request is not contextualized')
let cutoff = 0
- // When in Early access docs consider the "root" be much higher.
- // E.g. /en/early-access/github/migrating/understanding/about
- // we only want it start at /migrating/understanding/about
- // Essentially, we're skipping "/early-access" and its first
- // top-level like "/github"
if (isEarlyAccess) {
const split = req.context.currentPath!.split('/')
- // There are a few exceptions to this rule for the
- // /{version}/early-access//... URLs because they're a
- // bit different.
- // If there are more known exceptions, add them to the array above.
+ // insights and enterprise-importer Early Access URLs keep their product segment.
if (earlyAccessExceptions.some((product) => split.includes(product))) {
cutoff = 1
} else {
@@ -55,22 +48,7 @@ function getBreadcrumbs(req: ExtendedRequest, isEarlyAccess: boolean) {
return breadcrumbsResult
}
-// Return an array as if you'd traverse down a tree. Imagine a tree like
-//
-// (root /)
-// / \
-// (/foo) (/bar)
-// / \
-// (/foo/bar) (/foo/buzz)
-//
-// If the "currentPath" is `/foo/buzz` what you want to return is:
-//
-// [
-// {href: /, title: TITLE},
-// {href: /foo, title: TITLE}
-// {href: /foo/buzz, title: TITLE}
-// ]
-//
+// Example: /en/actions/learn-github-actions returns each matching ancestor and that page.
function traverseTreeTitles(currentPath: string | string[], tree: TitlesTree) {
const { href, title, shortTitle } = tree
const crumbs = [
@@ -85,17 +63,13 @@ function traverseTreeTitles(currentPath: string | string[], tree: TitlesTree) {
for (const child of tree.childPages) {
if (isParentOrEqualArray(child.href.split('/'), currentPathSplit)) {
crumbs.push(...traverseTreeTitles(currentPathSplit, child))
- // Only ever going down 1 of the children
break
}
}
return crumbs
}
-// Return true if an array is part of another array or equal.
-// Like `/foo/bar` is part of `/foo/bar/buzz`.
-// But also include `/foo/bar/buzz`.
-// Don't include `/foo/ba` if the final path is `/foo/baring`.
+// Compare split paths so /foo/ba does not match /foo/baring.
function isParentOrEqualArray(base: string[], final: string[]) {
return base.every((part, i) => part === final[i])
}
diff --git a/src/frame/middleware/context/context.ts b/src/frame/middleware/context/context.ts
index 7d0e0390b1da..b3dd70c75c50 100644
--- a/src/frame/middleware/context/context.ts
+++ b/src/frame/middleware/context/context.ts
@@ -19,19 +19,20 @@ import nonEnterpriseDefaultVersion from '@/versions/lib/non-enterprise-default-v
import { getDataByLanguage, getUIDataMerged } from '@/data-directory/lib/get-data'
import { updateLoggerContext } from '@/observability/logger/lib/logger-context'
-// This doesn't change just because the request changes, so compute it once.
+// Enterprise Server version keys do not depend on each request, so compute them once.
const enterpriseServerVersions = Object.keys(allVersions).filter((version) =>
version.startsWith('enterprise-server@'),
)
-// Supply all route handlers with a baseline `req.context` object
-// Note that additional middleware in middleware/index.ts adds to this context object
+// middleware/index.ts depends on contextualize setting baseline req.context.
+// For non-English pages, contextualize adds getEnglishPage for renderContentWithFallback.
+// It handles fallback-eligible Liquid, autotitle, and empty-title errors.
export default async function contextualize(
req: ExtendedRequest,
res: Response,
next: NextFunction,
) {
- // Ensure that we load some data only once on first request
+ // warmServer caches this data after the first request.
const { redirects, siteTree, pages: pageMap } = await warmServer([])
const context: Context = {}
@@ -40,17 +41,14 @@ export default async function contextualize(
req.context.process = { env: {} }
if (req.pagePath && req.pagePath.endsWith('.md')) {
- // req.pagePath is used later in the rendering pipeline to
- // locate the file in the tree so it cannot have .md
+ // The rendering pipeline resolves req.pagePath in the tree without the .md suffix.
req.pagePath = req.pagePath.replace(/\/index\.md$/, '').replace(/\.md$/, '')
req.context.markdownRequested = true
- // Track that markdown was requested via URL suffix, not Accept header.
- // This avoids adding a misleading Vary: accept cache header.
+ // markdownViaUrl avoids a misleading Vary: accept header for URL suffix requests.
req.context.markdownViaUrl = true
}
- // define each context property explicitly for code-search friendliness
- // e.g. searches for "req.context.page" will include results from this file
+ // Explicit req.context property assignments keep code search results discoverable.
req.context.currentLanguage = req.language
req.context.userLanguage = req.userLanguage
req.context.currentVersion = getVersionStringFromPath(req.pagePath) as string
@@ -62,8 +60,7 @@ export default async function contextualize(
req.context.allVersions = allVersions
req.context.currentPathWithoutLanguage = getPathWithoutLanguage(req.pagePath)
- // define property for writers to link to the current page in a different version
- // includes any type of rendered page not just "articles"
+ // currentArticle lets writers link any rendered page, not only articles, in another version.
req.context.currentArticle = getPathWithoutVersion(req.context.currentPathWithoutLanguage)
req.context.currentPath = req.pagePath
req.context.query = req.query
@@ -83,20 +80,15 @@ export default async function contextualize(
req.context.nonEnterpriseDefaultVersion = nonEnterpriseDefaultVersion
req.context.initialRestVersioningReleaseDate =
allVersions[nonEnterpriseDefaultVersion].apiVersions[0]
- // The default REST API version that requests use when no X-GitHub-Api-Version header is specified
- // This is the oldest supported version (last in the sorted descending array)
+ // apiVersions sorts newest first, so the last item is the default without X-GitHub-Api-Version.
const apiVersions = allVersions[nonEnterpriseDefaultVersion].apiVersions
req.context.defaultRestApiVersion = apiVersions[apiVersions.length - 1]
const restDate = new Date(req.context.initialRestVersioningReleaseDate)
req.context.initialRestVersioningReleaseDateLong = restDate.toUTCString().split(' 00:')[0]
- // Non-English pages need this so that `Page.render`, when it calls
- // `renderContentWithFallback`, can fall back to the English content when the
- // translation hits a fallback-eligible error (Liquid, autotitle, empty title).
if (req.language !== 'en') {
- // This is a function so the lookup only happens when a translated page
- // actually needs to fall back. Most requests never need it.
+ // getEnglishPage delays the lookup until a translated page needs fallback content.
req.context.getEnglishPage = (ctx) => {
if (!ctx.enPage) {
const { page } = ctx
diff --git a/src/frame/middleware/context/current-product-tree.ts b/src/frame/middleware/context/current-product-tree.ts
index 1a34c3015c83..c99b741c3b23 100644
--- a/src/frame/middleware/context/current-product-tree.ts
+++ b/src/frame/middleware/context/current-product-tree.ts
@@ -8,7 +8,6 @@ import findPageInSiteTree from '@/frame/lib/find-page-in-site-tree'
import removeFPTFromPath from '@/versions/lib/remove-fpt-from-path'
import { executeWithFallback } from '@/languages/lib/render-with-fallback'
-// This module adds currentProductTree to the context object for use in layouts.
export default async function currentProductTree(
req: ExtendedRequest,
res: Response,
@@ -18,7 +17,7 @@ export default async function currentProductTree(
if (!req.context.page) return next()
if (req.context.page.documentType === 'homepage') return next()
- // We need this so we can fall back to English if localized pages are out of sync.
+ // Keep the English tree available because localized pages can lag behind it.
if (!req.context.siteTree) throw new Error('siteTree is required')
if (!req.context.currentVersion) throw new Error('currentVersion is required')
req.context.currentEnglishTree = req.context.siteTree.en[req.context.currentVersion]
@@ -41,22 +40,17 @@ export default async function currentProductTree(
currentProductPath,
)
- // First make a slim tree of just the 'href', 'title', 'shortTitle'
- // 'documentType' and 'childPages' (which is recursive).
- // This gets used for subcategory and category pages.
+ // currentProductTreeTitles keeps href, title, shortTitle, documentType, and childPages.
req.context.currentProductTreeTitles = await getCurrentProductTreeTitles(
req.context.currentProductTree,
req.context,
)
- // Now make an even slimmer version that excludes all hidden pages.
- // This is used for sidebars.
+ // Sidebar data excludes hidden pages.
req.context.currentProductTreeTitlesExcludeHidden = excludeHidden(
req.context.currentProductTreeTitles,
)
- // Some pages, like hidden pages, don't have a tree. For example,
- // the search page. That one uses the same items as the homepage
- // for its sidebar.
+ // Hidden pages leave sidebarTree unset because excludeHidden returns null for the root.
if (req.context.currentProductTreeTitlesExcludeHidden) {
req.context.sidebarTree = sidebarTree(req.context.currentProductTreeTitlesExcludeHidden)
}
@@ -64,35 +58,18 @@ export default async function currentProductTree(
return next()
}
-// Return a nested object that contains the bits and pieces we need
-// for the tree which is used for sidebars and listing
async function getCurrentProductTreeTitles(input: Tree, context: Context): Promise {
const { page, href } = input
const childPages = await Promise.all(
(input.childPages || []).map((child) => getCurrentProductTreeTitles(child, context)),
)
- // If the current page is a translation we're going to need the English
- // equivalent for multiple things later in this function.
+ // Translated pages need their English page for fallback rendering and short-title comparison.
const enPage =
page.languageCode !== 'en' ? context.pages![href.replace(`/${page.languageCode}`, '/en')] : null
- let rawShortTitle = page.rawShortTitle // might change our minds about this
- // A lot of translations have a short title that is identical to the
- // English equivalent. E.g.
- //
- // content/foo.md:
- //
- // title: Something Something Bla
- // shortTitle: Something
- //
- // translations/docs-internal.se-sv/content/foo.md:
- //
- // title: Nånting Nånting Blä
- // shortTitle: Something
- //
- // I.e. the translations `shortTitle` hasn't been translated.
- // If this is the case, use the long title instead.
+ let rawShortTitle = page.rawShortTitle
+ // Swaps in rawTitle when shortTitle matches English, but the render below reads page.rawShortTitle.
if (page.languageCode !== 'en' && page.rawShortTitle) {
if (page.rawShortTitle === enPage!.shortTitle) {
rawShortTitle = page.rawTitle
@@ -112,8 +89,7 @@ async function getCurrentProductTreeTitles(input: Tree, context: Context): Promi
)
}
- // If the short title was present but "useless" (same as the title),
- // force it to be an empty string to not waste space.
+ // Empty duplicate short titles to avoid wasting sidebar space.
const shortTitle =
renderedShortTitle && (renderedShortTitle || '') !== renderedFullTitle ? renderedShortTitle : ''
@@ -148,12 +124,10 @@ function excludeHidden(tree: TitlesTree) {
function sidebarTree(tree: TitlesTree) {
const { href, title, shortTitle, childPages, sidebarLink } = tree
- // Filter out cross-product children from the sidebar
+ // Sidebars show only children from the current product.
const filteredChildPages = childPages.filter((child) => !child.crossProductChild)
- // Filter out children that are descendants of another sibling.
- // When a page lists both a subdirectory and individual articles from it,
- // the articles should only appear nested under the subdirectory in the sidebar.
+ // If siblings include a subdirectory and its articles, nest the articles under the subdirectory.
const siblingHrefs = filteredChildPages.map((c) => c.href)
const dedupedChildPages = filteredChildPages.filter(
(child) => !siblingHrefs.some((sh) => sh !== child.href && child.href.startsWith(`${sh}/`)),
diff --git a/src/frame/middleware/context/generic-toc.ts b/src/frame/middleware/context/generic-toc.ts
index 93a57bf99f91..67012fe6c363 100644
--- a/src/frame/middleware/context/generic-toc.ts
+++ b/src/frame/middleware/context/generic-toc.ts
@@ -12,9 +12,7 @@ function isNewLandingPage(currentLayoutName: string): boolean {
)
}
-// This module adds either flatTocItems or nestedTocItems to the context object for
-// product, category, and subcategory TOCs that don't have other layouts specified.
-// They are rendered by includes/generic-toc-flat.html or includes/generic-toc-nested.html.
+// genericToc assigns genericTocFlat or genericTocNested for React landing contexts.
export default async function genericToc(req: ExtendedRequest, res: Response, next: NextFunction) {
if (!req.context) throw new Error('request not contextualized')
if (!req.context.page) return next()
@@ -23,7 +21,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne
!isNewLandingPage(req.context.currentLayoutName || '')
)
return next()
- // This middleware can only run on product, category, and subcategories.
+ // TOC layouts skip homepages, articles, and search.
if (
req.context.page.documentType === 'homepage' ||
req.context.page.documentType === 'article' ||
@@ -39,8 +37,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne
subcategory: 'flat',
}
- // Frontmatter can optionally be set on an Early Access product to show hidden child items.
- // If so, this is a special case where we want to override the flat tocType and use a nested type.
+ // earlyAccessToc frontmatter exposes hidden child items by switching products to nested TOCs.
const earlyAccessToc = req.context.page.earlyAccessToc
if (!req.context.currentProductTree) throw new Error('currentProductTree not in context')
@@ -53,10 +50,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne
req.pagePath,
)
- // The intent is that a category whose children have no children of their own
- // should render like a subcategory. It doesn't work: `child.children` is `[]`
- // for leaf pages and `[]` is truthy, so `hasGrandchildren` is true for any
- // category with children at all. This probably wants `child.children?.length`.
+ // fauxSubcategory is meant to flatten categories without grandchildren, but [] is truthy.
let fauxSubcategory = false
if (req.context.page.documentType === 'category' && req.context.page.autogenerated !== 'rest') {
const hasGrandchildren = (treePage.childPages || []).some((child) => child.children)
@@ -69,10 +63,7 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne
? 'flat'
: tocTypes[req.context.page.documentType]
- // By default, only include hidden child items on a TOC page if it's an Early Access category or
- // subcategory page, not a product or 'articles' fake category page (e.g., /early-access/github/articles).
- // This is because we don't want entire EA product TOCs to be publicly browseable, but anything at the category
- // or below level is fair game because that content is scoped to specific features.
+ // By default, Early Access category and subcategory TOCs expose hidden children except /articles.
const isCategoryOrSubcategory =
req.context.page.documentType === 'category' || req.context.page.documentType === 'subcategory'
if (!req.context.currentPath) throw new Error('currentPath not in context')
@@ -85,7 +76,6 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne
let isRecursive
let renderIntros
- // Get an array of child links with intros and add it to the context object.
if (currentTocType === 'flat' && !isOneOffProductToc) {
isRecursive = false
renderIntros = true
@@ -97,7 +87,6 @@ export default async function genericToc(req: ExtendedRequest, res: Response, ne
})
}
- // Get an array of child subcategories and their child articles and add it to the context object.
if (currentTocType === 'nested' || isOneOffProductToc) {
isRecursive = !isOneOffProductToc
renderIntros = false
@@ -119,8 +108,9 @@ type Options = {
textOnly: boolean
}
+// rawIntro can contain Markdown without Liquid, so renderProp still needs to process it.
+// Generic TOC components keep intro HTML unless a landing-page layout needs text only.
async function getTocItems(node: Tree, context: Context, opts: Options): Promise {
- // Cleaner than trying to be too terse inside the `.filter()` inline callback.
function filterHidden(child: Tree): boolean {
return opts.includeHidden || !child.page.hidden
}
@@ -138,11 +128,6 @@ async function getTocItems(node: Tree, context: Context, opts: Options): Promise
if (opts.renderIntros) {
intro = ''
if (page.rawIntro) {
- // The intro can contain Markdown even though it might not
- // contain any Liquid.
- // Use textOnly for new landing pages to strip HTML tags.
- // For other pages, we intend to display the intro in a table of contents
- // component with the HTML (dangerouslySetInnerHTML).
intro = await page.renderProp(
'rawIntro',
context,
diff --git a/src/frame/middleware/context/glossaries.ts b/src/frame/middleware/context/glossaries.ts
index 8d49f6a815e2..8b7c81cef50c 100644
--- a/src/frame/middleware/context/glossaries.ts
+++ b/src/frame/middleware/context/glossaries.ts
@@ -12,18 +12,11 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne
if (!req.context) throw new Error('request is not contextualized')
- // If the current version (which is found as part of the URL), does not
- // correspond to a supported version, the Liquid rendering will fail
- // (if there's uses of `ifversion` in any the Liquid).
- // So we'll skip this contextualizer and let the 404 error take over later.
+ // Skip unsupported versions so ifversion Liquid errors do not replace the later 404.
if (!req.context.currentVersionObj) return next()
- // When the current language is *not* English, we'll need to get the English
- // glossary based on the term. We'll use this to render the translated
- // glossaries. For example, if the Korean translation has a corruption
- // in its description we need to know the English equivalent.
+ // Translated glossaries need English descriptions to repair corrupted Liquid before rendering.
const enGlossaryMap = new Map()
- // But we don't need to bother if the current language is English.
if (req.context.currentLanguage !== 'en') {
const enGlossariesRaw: Glossary[] = getDataByLanguage('glossaries.external', 'en') as Glossary[]
@@ -32,11 +25,7 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne
}
}
- // The glossaries Yaml file contains descriptions that might contain
- // Liquid. They need to be rendered out.
- // The github-glossary.md file uses Liquid to generate the Markdown.
- // It uses Liquid to say `{{ glossary.description }}` but once that's
- // injected there it needs to have its own possible Liquid rendered out.
+ // github-glossary.md injects glossary descriptions before their Liquid renders.
const glossariesRaw: Glossary[] = getDataByLanguage(
'glossaries.external',
req.context.currentLanguage!,
@@ -48,13 +37,7 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne
if (req.context!.currentLanguage !== 'en') {
description = correctTranslatedContentStrings(
description,
- // The function needs the English equivalent of the translated
- // Markdown. It's to make possible corrections to the
- // translation's Liquid which might have lost important
- // linebreaks.
- // But because the terms themselves are often translated,
- // in this mapping we often don't have an English equivalent.
- // So that's why we fall back on the empty string.
+ // English Markdown repairs Liquid line breaks; some translated terms lack matches.
enGlossaryMap.get(glossary.term) || '',
{ code: req.context!.currentLanguage },
)
@@ -64,17 +47,13 @@ export default async function glossaries(req: ExtendedRequest, res: Response, ne
() => liquid.parseAndRender(description, req.context),
(enContext: Context) => {
const { term } = glossary
- // It *could* be that the translation is referring to a term
- // that no longer exists in the English glossary. In that case,
- // simply skip this term.
+ // Skip translated terms missing from the English glossary.
if (!enGlossaryMap.has(term)) return
const enDescription = enGlossaryMap.get(term)
return liquid.parseAndRender(enDescription, enContext)
},
)
- // It's important to use `Object.assign` here to avoid mutating the
- // original object because from `getDataByLanguage`, reads from an
- // in-memory cache so if we mutated it, it would be mutated for all.
+ // Object.assign preserves the getDataByLanguage cache object shared across requests.
return Object.assign({}, glossary, { description })
}),
)
diff --git a/src/frame/middleware/context/layout.ts b/src/frame/middleware/context/layout.ts
index 447955cc8917..45f3a5342c84 100644
--- a/src/frame/middleware/context/layout.ts
+++ b/src/frame/middleware/context/layout.ts
@@ -9,7 +9,7 @@ export default function layoutContext(req: ExtendedRequest, res: Response, next:
let layoutName = 'default'
if (req.context.page.layout) {
if (typeof req.context.page.layout === 'boolean') {
- // A `layout: false` value means use no layout.
+ // Only layout: true reaches here and clears the layout name. layout: false gets the default.
layoutName = ''
} else if (typeof req.context.page.layout === 'string') {
layoutName = req.context.page.layout
diff --git a/src/frame/middleware/context/product-groups.ts b/src/frame/middleware/context/product-groups.ts
index 6a9427277394..b875c711d5b4 100644
--- a/src/frame/middleware/context/product-groups.ts
+++ b/src/frame/middleware/context/product-groups.ts
@@ -8,18 +8,20 @@ import { allVersionKeys } from '@/versions/lib/all-versions'
const isHomepage = (path: string) => {
const split = path.split('/')
- // E.g. `/foo` but not `foo/bar` or `foo/`
+ // Matches /en but not en/foo or /en/.
if (split.length === 2 && split[1] && !split[0]) {
return languageKeys.includes(split[1])
}
- // E.g. `/foo/possiblyproductname` but not `foo/possiblyproductname` or
- // `/foo/something/`
+ // Matches /en/free-pro-team@latest but not en/free-pro-team@latest or /en/actions/.
if (split.length === 3 && !split[0] && split[2]) {
return allVersionKeys.includes(split[2])
}
return false
}
+// handleNextDataPath maps Next data URLs, such as /_next/data/development/en/actions.json,
+// to normal page paths, so productGroups reads req.pagePath.
+// It requires a valid currentVersionObj because ifversion Liquid in getProductGroups throws otherwise.
export default async function productGroups(
req: ExtendedRequest,
res: Response,
@@ -28,18 +30,6 @@ export default async function productGroups(
if (!req.context) throw new Error('request is not contextualized')
if (!req.pagePath) throw new Error('pagePath is not set on request')
if (!req.language) throw new Error('language is not set on request')
- // It's important to use `req.pagePath` instead of `req.path` because
- // the request could be the client-side routing from Next where the URL
- // might be something like `/_next/data/foo/bar.json` which is translated,
- // in another middleware, to what it would equate to if it wasn't
- // client-side routing.
- // Before executing getProductGroups, which might need to do some
- // Liquid parsing & executing, we want to make sure the request
- // does have a valid version.
- // The `currentVersion` is taken from the `req.path` but
- // `currentVersionObj` is looking up `currentVersion` with all
- // known versions. Because if it's not valid, any possible
- // use of `{% ifversion ... %}` in Liquid, will throw an error.
if (isHomepage(req.pagePath) && req.context.currentVersionObj) {
const { pages } = await warmServer([])
req.context.productGroups = await getProductGroups(pages, req.language, req.context)
diff --git a/src/frame/middleware/context/render-product-name.ts b/src/frame/middleware/context/render-product-name.ts
index 151afab5cc68..2ca7b2f85c50 100644
--- a/src/frame/middleware/context/render-product-name.ts
+++ b/src/frame/middleware/context/render-product-name.ts
@@ -12,13 +12,12 @@ export default async function renderProductName(
const { productMap, currentProduct } = req.context
if (!productMap) throw new Error('request is not contextualized')
- // `currentProduct` might be an empty string, which is a valid value.
+ // Empty currentProduct is valid.
if (currentProduct === undefined) throw new Error('currentProduct is not contextualized')
const productObject = productMap[currentProduct]
if (!productObject) {
- // If the "currentProduct" isn't recognized, there's no point trying
- // to render its name. Skip this middleware.
+ // Skip unrecognized currentProduct values because renderContent needs a product object.
return next()
}
req.context.currentProductName = await renderContent(productObject.name, req.context, {
diff --git a/src/frame/middleware/cookie-parser.ts b/src/frame/middleware/cookie-parser.ts
index ffe82e348ba0..f9bc1e2e43f2 100644
--- a/src/frame/middleware/cookie-parser.ts
+++ b/src/frame/middleware/cookie-parser.ts
@@ -5,8 +5,7 @@ import { cookieSettings } from '@/frame/lib/cookie-settings'
export default cookieParser(
process.env.COOKIE_SECRET,
- // `cookie-settings.ts` declares these as `CookieSerializeOptions` because
- // that is the right type for the places that set cookies. cookie-parser
- // wants `CookieParseOptions`, so bridge the two here.
+ // cookie-settings.ts exports CookieSerializeOptions for cookie writers.
+ // cookie-parser expects CookieParseOptions, so bridge the two here.
cookieSettings as CookieParseOptions,
)
diff --git a/src/frame/middleware/fast-head.ts b/src/frame/middleware/fast-head.ts
index 2930aeda248f..cd6475d0df32 100644
--- a/src/frame/middleware/fast-head.ts
+++ b/src/frame/middleware/fast-head.ts
@@ -8,8 +8,7 @@ export default function fastHead(req: ExtendedRequest, res: Response, next: Next
const { context } = req
const { page } = context
if (page) {
- // Since the *presence* is not affected by the request, we can cache
- // this and allow the CDN to hold on to it.
+ // Cache by URL because request headers do not change this empty HEAD response.
defaultCacheControl(res)
res.status(200).send('')
diff --git a/src/frame/middleware/fastly-cache-test.ts b/src/frame/middleware/fastly-cache-test.ts
index 4970fc9fa9be..b1908a3dbf82 100644
--- a/src/frame/middleware/fastly-cache-test.ts
+++ b/src/frame/middleware/fastly-cache-test.ts
@@ -1,19 +1,13 @@
-//
-// This middleware function is intended to be used for testing caching behavior with Fastly.
-// It will intercept ALL URLs that are routed to it and respond with a simple HTML body
-// containing a timestamp.
-// The logic will detect certain values in the path and set the HTTP status and/or the
-// Surrogate-Control header value.
-//
-// NOTE: This middleware is intended to be removed once testing is complete!
-//
+// Tests Fastly caching by returning timestamped HTML for any routed URL.
+// Path tokens set the HTTP status and cache directives.
+// X-CacheTest-CCMode chooses Surrogate-Control, Cache-Control, or both.
import express from 'express'
import crypto from 'crypto'
const router = express.Router()
router.get('/*path', function (req, res) {
- // If X-CacheTest-Error is set, simulate the site being down (regardless of URL)
+ // X-CacheTest-Error simulates a site outage for any URL.
if (req.get('X-CacheTest-Error')) {
res.status(parseInt(req.get('X-CacheTest-Error') as string)).end()
return
diff --git a/src/frame/middleware/favicons.ts b/src/frame/middleware/favicons.ts
index 44993db15454..de496bdb6245 100644
--- a/src/frame/middleware/favicons.ts
+++ b/src/frame/middleware/favicons.ts
@@ -1,8 +1,5 @@
-// We actually don't rely and use /favicon.ico but it's nevertheless a
-// very common request. Same with /apple-touch-icon.png.
-// Because we store our images, including those not for the Markdown text,
-// in the `assets/images/site` directory, we will use a custom
-// solution to serve this directly.
+// Browsers request /favicon.ico and Apple touch icons even though pages do not link them.
+// Serve these root icon URLs directly from assets/images/site.
import fs from 'fs'
import type { Response, NextFunction } from 'express'
@@ -36,10 +33,7 @@ const MAP: {
},
}
-// It's the same image but it's fine. By default, when Safari tries to
-// to figure out which apple touch icons are available it will
-// try to load this by default. For example, if you in desktop Safari
-// click share icon, it will load this to serve as a preview icon.
+// Safari probes precomposed Apple touch icon names for desktop share previews.
MAP['/apple-touch-icon-precomposed.png'] = MAP['/apple-touch-icon.png']
MAP['/apple-touch-icon-120x120-precomposed.png'] = MAP['/apple-touch-icon-120x120.png']
MAP['/apple-touch-icon-152x152-precomposed.png'] = MAP['/apple-touch-icon-152x152.png']
@@ -51,9 +45,7 @@ function getBuffer(filePath: string) {
}
return () => {
if (!buffer) {
- // Yes, sync and a bit slow, but the headers we send will
- // make sure these requests are rare because the payload
- // will be sticky in the CDN and stickly in the browser too.
+ // Sync reads are rare because assetCacheControl keeps icons in the CDN and browser cache.
buffer = fs.readFileSync(filePath)
}
return buffer
@@ -63,11 +55,10 @@ function getBuffer(filePath: string) {
export default function favicons(req: ExtendedRequest, res: Response, next: NextFunction) {
if (!MAP[req.path]) return next()
- // This makes sure the CDN caching survives each production deployment.
+ // The manual surrogate key keeps CDN caching through production deploys.
setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL)
- // Manually settings a Cache-Control because no other middleware
- // will get a chance to do this later since we terminate here.
+ // Set asset caching here because this middleware sends the response.
assetCacheControl(res)
const { contentType, buffer } = MAP[req.path]
diff --git a/src/frame/middleware/find-page.ts b/src/frame/middleware/find-page.ts
index 7f606fc4abb4..12ea98233c80 100644
--- a/src/frame/middleware/find-page.ts
+++ b/src/frame/middleware/find-page.ts
@@ -15,18 +15,19 @@ interface FindPageOptions {
const englishPrefixRegex = /^\/en(\/|$)/
const CONTENT_ROOT = path.join(ROOT, 'content')
+// Development rereads of index pages keep startup versions and permalinks.
+// Tree construction mutates category pages from child versions, but rereads use only file data.
export default async function findPage(
req: ExtendedRequest,
res: Response,
next: NextFunction,
- // Express won't execute these but it makes it easier to unit test
- // the middleware.
+ // Express ignores these options, but tests can pass them directly.
{
isDev = process.env.NODE_ENV === 'development',
contentRoot = CONTENT_ROOT,
}: FindPageOptions = {},
): Promise {
- // Filter out things like `/will/redirect` or `/_next/data/...`
+ // Only language-prefixed content paths can map to pages; /will/redirect continues.
if (!req.pagePath || !languagePrefixPathRegex.test(req.pagePath)) {
return next()
}
@@ -37,11 +38,6 @@ export default async function findPage(
let page = req.context.pages[req.pagePath] as Page | undefined
if (page && isDev && englishPrefixRegex.test(req.pagePath)) {
- // The .applicableVersions and .permalinks properties are computed
- // when the page is read in from disk. But when the initial tree
- // was created at startup, the pages in the tree were mutated
- // based on their context. For example, a category page's versions
- // is based on looping through all its children's versions.
const reuseOldVersions = page.relativePath.endsWith('index.md')
const oldApplicableVersions = page.applicableVersions
const oldPermalinks = page.permalinks
@@ -59,9 +55,7 @@ export default async function findPage(
page.permalinks = oldPermalinks
}
- // This can happen if the page we just re-read has changed which
- // versions it's available in (the `versions` frontmatter) meaning
- // it might no longer be available on the current URL.
+ // A reread page can drop the requested version from applicableVersions.
if (
req.context?.currentVersion &&
!page.applicableVersions.includes(req.context.currentVersion)
@@ -80,9 +74,7 @@ export default async function findPage(
req.context.page = page
;(req.context.page as Page & { version: string }).version = req.context.currentVersion || ''
- // We can't depend on `page.hidden` because the dedicated search
- // results page is a hidden page but it needs to offer all possible
- // languages.
+ // page.hidden also hides search, which needs every language; restrict only early-access pages.
if (page.relativePath.startsWith('early-access') && req.context?.languages?.en) {
req.context.languages = {
en: req.context.languages.en,
@@ -93,6 +85,7 @@ export default async function findPage(
return next()
}
+// rereadByPath handles only English content because translations load at build time.
async function rereadByPath(
uri: string,
contentRoot: string,
@@ -103,18 +96,12 @@ async function rereadByPath(
const languageCode = match[1]
const withoutLanguage = uri.replace(languagePrefixPathRegex, '/')
const withoutVersion = withoutLanguage.replace(`/${currentVersion}`, '')
- // Note: We don't support loading translations at runtime. All translations
- // are loaded at build time. This function only handles English content reloading
- // during development.
const possible = path.join(contentRoot, withoutVersion)
const filePath = existsSync(possible) ? path.join(possible, 'index.md') : `${possible}.md`
const relativePath = path.relative(contentRoot, filePath)
const basePath = contentRoot
- // Remember, the Page.init() can return a Promise that resolves to falsy
- // if it can't read the file in from disk. E.g. a request for /en/non/existent.
- // In other words, it's fine if it can't be read from disk. It'll get
- // handled and turned into a nice 404 message.
+ // When a reread fails, the caller keeps the already-found page.
const page = await Page.init({
basePath,
relativePath,
diff --git a/src/frame/middleware/handle-next-data-path.ts b/src/frame/middleware/handle-next-data-path.ts
index 882720748ce0..78eb3e6748b9 100644
--- a/src/frame/middleware/handle-next-data-path.ts
+++ b/src/frame/middleware/handle-next-data-path.ts
@@ -5,16 +5,15 @@ import type { ExtendedRequest } from '@/types'
const STATSD_KEY = 'middleware.handle_next_data_path'
+// Client route transitions request _next/data JSON paths; map them back to page paths.
+// Example: /_next/data/development/en/actions/foo.json becomes
+// /en/actions/foo.
export default function handleNextDataPath(
req: ExtendedRequest,
res: Response,
next: NextFunction,
) {
if (req.path.startsWith('/_next/data/') && req.path.endsWith('.json')) {
- // translate a nextjs data request to a page path that the server can use on context
- // this is triggered via client-side route transitions
- // example path:
- // /_next/data/development/en/free-pro-team%40latest/github/setting-up-and-managing-your-github-user-account.json
let decodedPath = ''
try {
decodedPath = decodeURIComponent(req.path)
@@ -26,7 +25,7 @@ export default function handleNextDataPath(
}
const parts = decodedPath.split('/').slice(4)
- // free-pro-team@latest should not be included in the page path
+ // Drop free-pro-team@latest because page paths omit that default version.
if (parts[1] === 'free-pro-team@latest') {
parts.splice(1, 1)
}
diff --git a/src/frame/middleware/healthcheck.ts b/src/frame/middleware/healthcheck.ts
index 68e3089b17d5..514e1688ee53 100644
--- a/src/frame/middleware/healthcheck.ts
+++ b/src/frame/middleware/healthcheck.ts
@@ -4,11 +4,8 @@ import statsd from '@/observability/lib/statsd'
const router = express.Router()
-// Returns the healthiness of the service.
-// Moda may use this to decide whether this instance stays in the pool.
-// Today it checks nothing and always returns 200. If we ever needed to drain
-// an instance, for example on a failing dependency, this is where a 500
-// would go.
+// Moda may use this endpoint to decide whether an instance stays in the pool.
+// It always returns 200 and sends memory gauges to StatsD, without testing service health.
router.get('/', function healthcheck(req, res) {
noCacheControl(res)
diff --git a/src/frame/middleware/helmet.ts b/src/frame/middleware/helmet.ts
index ddbd4b90e8a1..7814bcde5975 100644
--- a/src/frame/middleware/helmet.ts
+++ b/src/frame/middleware/helmet.ts
@@ -9,11 +9,8 @@ import { colorModeScript } from '@/color-schemes/lib/color-mode-script'
const isDev = process.env.NODE_ENV === 'development'
-// The pre-paint theme script in `_document.tsx` is inlined, so it needs an
-// explicit CSP `script-src` allowance. We hash the exact script string rather
-// than using a nonce, because a nonce would have to vary per response and would
-// break the shared CDN cache. The script is identical for every request, so its
-// hash is stable and the HTML stays cacheable.
+// The pre-paint theme script from _document.tsx is inline, so CSP needs a script-src hash.
+// A nonce would vary per response and break shared CDN caching.
const colorModeScriptHash = `'sha256-${createHash('sha256').update(colorModeScript).digest('base64')}'`
const GITHUB_DOMAINS = [
"'self'",
@@ -29,22 +26,19 @@ const DEFAULT_OPTIONS = {
referrerPolicy: {
policy: 'no-referrer-when-downgrade' as const,
},
- // This module defines a Content Security Policy (CSP) to disallow
- // inline scripts and content from untrusted sources.
+ // The default CSP blocks untrusted origins and limits inline scripts to approved hashes.
contentSecurityPolicy: {
directives: {
defaultSrc: ["'none'"],
prefetchSrc: ["'self'"],
- // When doing local dev, especially in Safari, you need to add `ws:`
- // which NextJS uses for the hot module reloading.
+ // Safari local development needs ws: for Next.js hot module reloading.
connectSrc: ["'self'", 'https://collector.githubapp.com', isDev && 'ws:'].filter(
Boolean,
) as string[],
fontSrc: ["'self'", 'data:'],
imgSrc: [...GITHUB_DOMAINS, 'data:', 'placehold.it'],
objectSrc: ["'self'"],
- // For use during development only!
- // `unsafe-eval` allows us to use a performant webpack devtool setting (eval)
+ // Development webpack eval devtool needs unsafe-eval.
// https://webpack.js.org/configuration/devtool/#devtool
scriptSrc: [
...GITHUB_DOMAINS,
@@ -57,16 +51,17 @@ const DEFAULT_OPTIONS = {
frameSrc: [
...GITHUB_DOMAINS,
isDev && 'http://localhost:3000',
- // ArticleContext.tsx sets this URL too. We don't import a shared
- // constant because the env var may not be set yet at import time.
+ // src/frame/components/context/ArticleContext.tsx sets this URL too.
+ // A shared constant could capture SUPPORT_PORTAL_URL before it is set.
process.env.NODE_ENV === 'production'
? 'https://support.github.com'
- : // Assume that a developer is not testing the VA iframe locally if this env var is not set
+ : // Missing SUPPORT_PORTAL_URL means local development is not testing the VA iframe.
process.env.SUPPORT_PORTAL_URL || '',
].filter(Boolean) as string[],
frameAncestors: isDev ? ['*'] : [...GITHUB_DOMAINS],
styleSrc: [...GITHUB_DOMAINS, "'self'", "'unsafe-inline'", 'data:'],
- childSrc: ["'self'"], // exception for search in deprecated GHE versions
+ // Deprecated GitHub Enterprise search still needs child-src.
+ childSrc: ["'self'"],
manifestSrc: ["'self'"],
upgradeInsecureRequests: isDev ? null : [],
},
@@ -100,23 +95,21 @@ const staticDeprecatedHelmet = helmet(STATIC_DEPRECATED_OPTIONS)
const developerDeprecatedHelmet = helmet(DEVELOPER_DEPRECATED_OPTIONS)
export default function helmetMiddleware(req: Request, res: Response, next: NextFunction) {
- // Enable CORS
if (['GET', 'OPTIONS'].includes(req.method)) {
res.set('access-control-allow-origin', '*')
}
- // Determine version for exceptions
const { requestedVersion } = isArchivedVersion(req)
- // Check if this is a legacy developer.github.com path
const isDeveloper = req.path
.replace(languagePrefixPathRegex, '/')
.startsWith(`/enterprise/${requestedVersion}/developer`)
if (versionSatisfiesRange(requestedVersion, '<=2.18') && isDeveloper) {
+ // Deprecated developer.github.com paths need Google font and inline script exceptions.
return developerDeprecatedHelmet(req, res, next)
}
- // Exception for deprecated Enterprise docs (Node.js era)
+ // Node.js-era deprecated Enterprise docs need relaxed CSP directives.
if (
versionSatisfiesRange(requestedVersion, '<=2.19') &&
versionSatisfiesRange(requestedVersion, '>2.12')
@@ -124,7 +117,7 @@ export default function helmetMiddleware(req: Request, res: Response, next: Next
return nodeDeprecatedHelmet(req, res, next)
}
- // Exception for search in deprecated Enterprise docs <=2.12 (static site era)
+ // Static-site-era Enterprise search needs inline scripts.
if (versionSatisfiesRange(requestedVersion, '<=2.12')) {
return staticDeprecatedHelmet(req, res, next)
}
diff --git a/src/frame/middleware/index.ts b/src/frame/middleware/index.ts
index d4070063dd82..41aa501ce6a8 100644
--- a/src/frame/middleware/index.ts
+++ b/src/frame/middleware/index.ts
@@ -69,7 +69,7 @@ import urlDecode from './url-decode'
const ENABLE_FASTLY_TESTING = JSON.parse(process.env.ENABLE_FASTLY_TESTING || 'false')
-// Catch unhandled promise rejections and passing them to Express's error handler
+// asyncMiddleware passes unhandled promise rejections to Express's error handler.
// https://medium.com/@Abazhenov/using-async-await-in-express-with-node-8-b8af872c0016
const asyncMiddleware =
(
@@ -83,63 +83,38 @@ const asyncMiddleware =
}
}
+// trust proxy makes req.ip read the left-most X-Forwarded-For value for rate limits and logs.
+// https://expressjs.com/en/guide/behind-proxies.html
export default function index(app: Express) {
app.use(abort)
- // Don't use the proxy's IP, use the requester's for rate limiting or
- // logging.
- // See https://expressjs.com/en/guide/behind-proxies.html
- // Essentially, setting this means it believe that the IP is the
- // first of the `X-Forwarded-For` header values.
- // If it was 0 (or false), the value would be that
- // of `req.socket.remoteAddress`.
- // Now, the `req.ip` becomes the first entry from x-forwarded-for
- // and falls back on `req.socket.remoteAddress` in all other cases.
- // Their documentation says:
- //
- // If true, the client's IP address is understood as the
- // left-most entry in the X-Forwarded-For header.
- //
app.set('trust proxy', true)
- // *** Logging ***
- app.use(initLoggerContext) // Context for both inline logs (e.g. logger.info) and automatic logs
- app.use(getAutomaticRequestLogger()) // Automatic logging for all requests e.g. "GET /path 200"
- app.use(expressMetrics) // StatsD metrics for response time and status codes
+ app.use(initLoggerContext)
+ app.use(getAutomaticRequestLogger())
+ app.use(expressMetrics)
- // Put this early to make it as fast as possible because it's used
- // to check the health of each cluster.
+ // Keep healthcheck early so cluster probes skip slower middleware.
app.use('/healthcheck', healthcheck)
- // Must appear before static assets and all other requests
- // otherwise we won't be able to benefit from that functionality
- // for static assets as well.
+ // Default surrogate keys must run before static assets, so static responses can inherit them.
app.use(setDefaultFastlySurrogateKey)
- // Attaches res.safeRedirect() to every response. Must appear before
- // any middleware that redirects.
+ // safeRedirect must run before middleware that redirects.
app.use(safeRedirect)
- // archivedEnterpriseVersionsAssets must come before static/assets
+ // archivedEnterpriseVersionsAssets must run before static asset middleware.
app.use(asyncMiddleware(archivedEnterpriseVersionsAssets))
app.use(favicons)
- // Any static URL that contains some sort of checksum that makes it
- // unique gets the "manual" surrogate key. If it's checksummed,
- // it's bound to change when it needs to change. Otherwise,
- // we want to make sure it doesn't need to be purged just because
- // there's a production deploy.
- // Note, for `/assets/cb-*...` requests,
- // this needs to come before `assetPreprocessing` because
- // the `assetPreprocessing` middleware will rewrite `req.url` if
- // it applies.
+ // Checksummed assets keep manual keys; assetPreprocessing later rewrites /assets/cb-* URLs.
app.use(setStaticAssetCaching)
- // Must come before any other middleware for assets
+ // archivedAssetRedirects must run before other asset middleware.
app.use(archivedAssetRedirects)
- // This must come before the express.static('assets') middleware.
+ // assetPreprocessing must run before express.static assets.
app.use(assetPreprocessing)
app.use(
@@ -147,11 +122,10 @@ export default function index(app: Express) {
express.static('assets', {
index: false,
etag: false,
- // Can be aggressive because images inside the content get unique
- // URLs with a cache busting prefix.
+ // Content image URLs have cache-busting prefixes, so assets can cache aggressively.
maxAge: '7 days',
immutable: process.env.NODE_ENV !== 'development',
- // The next middleware will try its luck and send the 404 if must.
+ // Let later middleware send the asset 404.
fallthrough: true,
}),
)
@@ -161,15 +135,13 @@ export default function index(app: Express) {
express.static('src/graphql/data', {
index: false,
etag: false,
- maxAge: '7 days', // A bit longer since releases are more sparse
- // See note about the use of 'fallthrough'
+ maxAge: '7 days', // Sparse releases tolerate longer caching.
+ // Missing release assets 404 here.
fallthrough: false,
}),
)
- // In development, let NextJS on-the-fly serve the static assets.
- // But in production, don't let NextJS handle any static assets
- // because they are costly to generate (the 404 HTML page).
+ // In production, skip Next static handling because generated 404 HTML is expensive.
if (process.env.NODE_ENV !== 'development') {
const assetDir = path.join('.next', 'static')
if (!fs.existsSync(assetDir))
@@ -182,60 +154,52 @@ export default function index(app: Express) {
etag: false,
maxAge: '365 days',
immutable: true,
- // See note about the use of 'fallthrough'
+ // Missing Next assets 404 here.
fallthrough: false,
}),
)
}
- // *** Early exits ***
app.use(shielding)
app.use(handleNextDataPath)
- // *** Security ***
app.use(helmet)
app.use(cookieParser)
app.use(express.json())
if (process.env.NODE_ENV === 'development') {
- app.use(mockVaPortal) // FOR TESTING.
+ app.use(mockVaPortal)
}
- // *** Headers ***
- app.set('etag', false) // We will manage our own ETags if desired
+ app.set('etag', false) // Disable Express ETags so middleware can set them explicitly when needed.
- // *** Config and context for redirects ***
- app.use(urlDecode) // Must come before detectLanguage to decode @ symbols in version segments
- app.use(detectLanguage) // Must come before context, breadcrumbs, find-page, handle-errors, homepages
- app.use(detectVersion) // Must come before handle-redirects for version cookie support
- app.use(asyncMiddleware(reloadTree)) // Must come before context
- app.use(asyncMiddleware(context)) // Must come before early-access-*, handle-redirects
- app.use(shortVersions) // Support version shorthands
- app.use(asyncMiddleware(renderProductName)) // Must come after shortVersions
+ app.use(urlDecode) // Must run before detectLanguage to decode @ symbols in version segments.
+ // Must run before context, breadcrumbs, findPage, handleErrors, and homepages.
+ app.use(detectLanguage)
+ app.use(detectVersion) // Must run before handleRedirects for version cookie support.
+ app.use(asyncMiddleware(reloadTree)) // Must run before context.
+ app.use(asyncMiddleware(context)) // Must run before earlyAccessLinks and handleRedirects.
+ app.use(shortVersions)
+ app.use(asyncMiddleware(renderProductName)) // Must run after shortVersions.
- // Must come before handleRedirects.
- // This middleware might either redirect or serve something.
+ // archivedEnterpriseVersions must run before handleRedirects because it can redirect or serve.
app.use(asyncMiddleware(archivedEnterpriseVersions))
- // *** Redirects, 3xx responses ***
- // I ordered these by use frequency
app.use(trailingSlashes)
- app.use(languageCodeRedirects) // Must come before contextualizers
- app.use(handleRedirects) // Must come before contextualizers
+ app.use(languageCodeRedirects) // Must run before contextualizers.
+ app.use(handleRedirects) // Must run before contextualizers.
- // *** Config and context for rendering ***
- app.use(asyncMiddleware(findPage)) // Must come before archived-enterprise-versions, breadcrumbs, featured-links, products, render-page
+ // Must run before breadcrumbs, featuredLinks, productGroups, and renderPage.
+ app.use(asyncMiddleware(findPage))
app.use(blockRobots)
- // *** Rendering, 2xx responses ***
app.use('/api', api)
app.use('/llms.txt', llmsTxt)
app.get('/_build', buildInfo)
app.get('/_req-headers', reqHeaders)
app.use(asyncMiddleware(manifestJson))
- // Things like `/api` sets their own Fastly surrogate keys.
- // Now that the `req.language` is known, set it for the remaining endpoints
+ // After req.language exists, remaining endpoints get language keys; /api keeps its own.
app.use(setLanguageFastlySurrogateKey)
app.use(robots)
@@ -243,16 +207,14 @@ export default function index(app: Express) {
app.use('/categories.json', asyncMiddleware(categoriesForSupport))
app.get('/_500', asyncMiddleware(triggerError))
- // Specifically deal with HEAD requests before doing the slower
- // full page rendering.
+ // HEAD requests skip slower full page rendering.
app.head('/*path', fastHead)
- // *** Preparation for render-page: contextualizers ***
app.use(asyncMiddleware(dataTables))
app.use(asyncMiddleware(secretScanning))
app.use(asyncMiddleware(ghesReleaseNotes))
app.use(layout)
- app.use(features) // needs to come before product tree
+ app.use(features) // Must run before currentProductTree.
app.use(asyncMiddleware(currentProductTree))
app.use(asyncMiddleware(genericToc))
app.use(breadcrumbs)
@@ -264,18 +226,15 @@ export default function index(app: Express) {
app.use(asyncMiddleware(journeyTrack))
if (ENABLE_FASTLY_TESTING) {
- // The fastlyCacheTest middleware is intended to be used with Fastly to test caching behavior.
- // This middleware will intercept ALL requests routed to it, so be careful if you need to
- // make any changes to the following line:
+ // fastlyCacheTest intercepts all routed requests, so keep the route narrow.
app.use('/fastly-cache-test', fastlyCacheTest)
}
- // handle serving NextJS bundled code (/_next/*)
app.use(next)
- // *** Rendering, must go almost last ***
+ // renderPage must run after specialized routes.
app.get('/*path', asyncMiddleware(renderPage))
- // *** Error handling, must go last ***
+ // handleErrors must run last to catch middleware errors.
app.use(handleErrors)
}
diff --git a/src/frame/middleware/manifest-json.ts b/src/frame/middleware/manifest-json.ts
index 851ca6ce5f11..1e40440f39b1 100644
--- a/src/frame/middleware/manifest-json.ts
+++ b/src/frame/middleware/manifest-json.ts
@@ -30,21 +30,16 @@ export default async function manifestJson(req: Request, res: Response, next: Ne
}
if (req.url !== '/manifest.json') {
- // E.g. `/manifest.json/anything` or `/manifest.json?foo=bar`
+ // Examples: /manifest.json/anything and /manifest.json?foo=bar.
defaultCacheControl(res)
return res.safeRedirect(302, '/manifest.json')
}
const icons: Icon[] = []
- // This is modelled after https://github.com/manifest.json
+ // The manifest mirrors https://github.com/manifest.json.
const manifest = {
- // In the future we might want to have a different manifest for each
- // language. Particularly, the `name` property.
- // But as of May 2023, this is overkill because all translations's
- // home page refer to the name of the site as "GitHub Docs".
- // For example, on https://docs.github.com/ja the ``
- // is "GitHub Docs".
+ // Localized home pages title the site GitHub Docs, so one manifest covers every language.
name: 'GitHub Docs',
short_name: 'GitHub Docs',
start_url: '/',
diff --git a/src/frame/middleware/mock-va-portal.ts b/src/frame/middleware/mock-va-portal.ts
index cafc8781dc4d..be2647716ba9 100644
--- a/src/frame/middleware/mock-va-portal.ts
+++ b/src/frame/middleware/mock-va-portal.ts
@@ -1,15 +1,4 @@
-// Mocks the VA portal so you can test the VA integration without access to a
-// staging VA portal. You can't point at the production one either, because it
-// is hardened to https://docs.github.com and will reject your localhost:4000.
-//
-// To test locally:
-//
-// 1. Add `SUPPORT_PORTAL_URL=http://localhost:4000` to your `.env` file
-// 2. `npm run dev`
-// 3. Navigate to a page listed in the `PagePathToVaFlowMapping` object in
-// the `ArticleContext`.
-//
-// This mocking is not secure. It is only for local development.
+// Local-only Virtual Assistant portal mock; the production portal rejects localhost:4000.
import type { Response, NextFunction } from 'express'
diff --git a/src/frame/middleware/next.ts b/src/frame/middleware/next.ts
index bc50942ffeae..a467ddffc588 100644
--- a/src/frame/middleware/next.ts
+++ b/src/frame/middleware/next.ts
@@ -12,8 +12,7 @@ export const nextHandleRequest = nextApp.getRequestHandler()
await nextApp.prepare()
function renderPageWithNext(req: ExtendedRequest, res: Response, nextFn: NextFunction) {
- // This catches URLs like `/_next/webpack-hmr` and
- // `/_next/static/webpack/64e44ef62e261d3a.webpack.hot-update.json`.
+ // _next asset and HMR requests, like /_next/webpack-hmr, bypass docs routing.
if (req.path.startsWith('/_next') && !req.path.startsWith('/_next/data')) {
return nextHandleRequest(req, res)
}
diff --git a/src/frame/middleware/reload-tree.ts b/src/frame/middleware/reload-tree.ts
index 3007311e64c4..4432194a54dc 100644
--- a/src/frame/middleware/reload-tree.ts
+++ b/src/frame/middleware/reload-tree.ts
@@ -1,13 +1,7 @@
-// This exists for local reviewing only.
-//
-// We load the entire tree on startup and use it for sidebars, breadcrumbs,
-// landing pages, and ToC pages. In development, an individual English page is
-// reread from disk on each request in case it changed, but doing that for all
-// 1k+ pages is not feasible.
-//
-// So this middleware calls `createTree()` with the previous tree, letting
-// `createTree` reuse the pages that haven't changed on disk. That way things
-// like sidebars refresh without restarting the server.
+// The app loads the full tree at startup for sidebars, breadcrumbs, landing pages, and ToC pages.
+// This development-only middleware rereads individual English pages per request.
+// Rereading all 1k+ pages per request would be too slow.
+// createTree receives the previous tree, so navigation refreshes without restarting the server.
import path from 'path'
@@ -27,15 +21,14 @@ const isDev = process.env.NODE_ENV === 'development'
export default async function reloadTree(req: ExtendedRequest, res: Response, next: NextFunction) {
if (!isDev) return next()
- // Filter out things like `/will/redirect` or `/_next/data/...`
+ // Only language-prefixed content paths can refresh the tree; /will/redirect continues.
if (!req.pagePath || !languagePrefixRegex.test(req.pagePath)) return next()
- // We only bother if the loaded URL is something `/en/...`
+ // Only English content can refresh the development tree.
if (!englishPrefixRegex.test(req.pagePath)) return next()
const warmed = await warmServer([])
- // For all the real English content, this usually takes about 30-60ms on
- // an Intel MacBook Pro.
+ // createTree below usually takes 30-60ms for real English content on an Intel MacBook Pro.
const before = getMtimes(warmed.unversionedTree.en)
warmed.unversionedTree.en = (await createTree(
path.join(languages.en.dir, 'content'),
@@ -43,11 +36,7 @@ export default async function reloadTree(req: ExtendedRequest, res: Response, ne
warmed.unversionedTree.en,
)) as UnversionedTree
const after = getMtimes(warmed.unversionedTree.en)
- // The next couple of operations are much slower (in total) than
- // refreshing the tree. So we want to know if the tree changed before
- // bothering.
- // If refreshing of the `.en` part of the `unversionedTree` takes 40ms
- // then the following operations takes about 140ms.
+ // Dependent maps take about 140ms after a 40ms tree refresh, so skip them when mtimes match.
if (before !== after) {
warmed.siteTree = (await loadSiteTree(warmed.unversionedTree)) as SiteTree
warmed.pageList = await loadPages(warmed.unversionedTree)
@@ -58,10 +47,7 @@ export default async function reloadTree(req: ExtendedRequest, res: Response, ne
return next()
}
-// Given a tree, return a number that represents the mtimes for all pages
-// in the tree.
-// You can use this to compute it before and after the tree is (maybe)
-// mutated and if the numbers *change* you can know the tree changed.
+// Summing mtimes lets reloadTree detect page changes before rebuilding slower maps.
function getMtimes(tree: UnversionedTree) {
let mtimes = tree.page.mtime
for (const child of tree.childPages || []) {
diff --git a/src/frame/middleware/render-page.ts b/src/frame/middleware/render-page.ts
index 0aed59fe722e..1d3e0b4b7d2a 100644
--- a/src/frame/middleware/render-page.ts
+++ b/src/frame/middleware/render-page.ts
@@ -27,8 +27,7 @@ async function buildRenderedPage(req: ExtendedRequest): Promise {
if (!page) throw new Error('page not set in context')
const path = req.pagePath || req.path
- // Set up collection array for the collect-mini-toc rehype plugin only when
- // the page actually needs a mini-TOC, avoiding unnecessary work.
+ // Collect mini-TOC headings only for pages that show one, so other renders avoid plugin work.
if (page.showMiniToc) {
const collectMiniToc: CollectedHeading[] = []
context.collectMiniToc = collectMiniToc
@@ -41,16 +40,10 @@ async function buildRenderedPage(req: ExtendedRequest): Promise {
return (await pageRenderTimed(context)) as string
}
-// Spike for #6619: produce the article body as a serializable hast (HTML AST)
-// tree alongside the legacy HTML string.
-//
-// Must run AFTER buildRenderedPage, which calls page.render and populates the
-// context fields the pipeline reads (englishHeadings, alertTitles). We render
-// the same raw `page.markdown`, but with a context clone that omits
-// `collectMiniToc` so the mini-TOC isn't collected a second time.
-//
-// Wrapped so a hast failure can never break the page. The React layer falls
-// back to the string path when this is undefined.
+// buildRenderedPageHast returns a serializable HTML AST alongside renderedPage.
+// It runs after buildRenderedPage because page.render populates englishHeadings and alertTitles.
+// It disables collectMiniToc so mini-TOC collection does not repeat.
+// Failures return undefined, and the React layer falls back to renderedPage.
async function buildRenderedPageHast(req: ExtendedRequest) {
const { context } = req
if (!context) throw new Error('request not contextualized')
@@ -76,12 +69,11 @@ function buildMiniTocItems(req: ExtendedRequest) {
if (!context) throw new Error('request not contextualized')
const { page } = context
- // get mini TOC items on articles
if (!page || !page.showMiniToc) {
return
}
- // Use headings collected during rendering via the collect-mini-toc rehype plugin.
+ // Collected headings avoid rendering article content a second time.
const collected = context.collectMiniToc as CollectedHeading[] | undefined
if (collected) {
return buildMiniTocFromCollected(collected, 2)
@@ -91,15 +83,13 @@ function buildMiniTocItems(req: ExtendedRequest) {
export default async function renderPage(req: ExtendedRequest, res: Response) {
const { context } = req
- // `Error.getInitialProps`, which NextJS runs on errors, reads this off the
- // request so it can send the error to Failbot.
+ // Next.js Error.getInitialProps reads req.FailBot so it can report errors to Failbot.
req.FailBot = FailBot as Failbot
if (!context) throw new Error('request not contextualized')
const { page } = context
const path = req.pagePath || req.path
- // render a 404 page
if (!page) {
if (process.env.NODE_ENV !== 'test' && context.redirectNotFound) {
logger.error('Tried to redirect to a page that was not found', {
@@ -107,27 +97,23 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
})
}
- // send minimal 404 at this point since we ran into hydration issues trying to pass
- // these along to AppRouter 404 handling
+ // Passing this context to App Router 404 handling causes hydration failures.
defaultCacheControl(res)
return res.status(404).type('html').send(minimumNotFoundHtml)
}
- // Just finish fast without all the details like Content-Length
+ // HEAD skips page rendering but still lets Express send Content-Length: 0.
if (req.method === 'HEAD') {
return res.status(200).send('')
}
- // Updating the Last-Modified header for substantive changes on a page for engineering
- // Docs Engineering Issue #945
+ // effectiveDate marks substantive page changes for clients that watch Last-Modified.
if (page.effectiveDate) {
- // The frontmatter schema only checks that this is a string. An unparseable
- // date gets caught later, in ArticleContext, and ends up as a 500.
+ // ArticleContext turns unparseable effectiveDate strings into a 500.
res.setHeader('Last-Modified', new Date(page.effectiveDate).toUTCString())
}
- // Content negotiation: serve markdown when the client prefers it over HTML.
- // Agents like Claude Code send Accept headers that omit text/html.
+ // Serve markdown when the client prefers it over HTML; agents can omit text/html.
if (req.accepts(['text/html', 'text/markdown']) === 'text/markdown') {
context.markdownRequested = true
}
@@ -137,10 +123,7 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
if (context.markdownRequested) {
const transformer = transformerRegistry.findTransformer(page)
if (!transformer) throw new Error(`No transformer found for page: ${req.pagePath}`)
- // Pass context without markdownRequested, because transformers set it
- // themselves when rendering templates. Having it set during prepareTemplateData()
- // causes renderTitle/renderProp to output markdown instead of HTML,
- // which breaks the cheerio-based unwrap logic.
+ // Clear markdownRequested so renderTitle and renderProp output HTML for stripOuterTag.
const transformerContext = { ...context, markdownRequested: false }
req.context.renderedPage = normalizeRenderedMarkdown(
await transformer.transform(page, path, transformerContext),
@@ -153,7 +136,6 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
page.fullTitle = page.title
- // add localized ` - GitHub Docs` suffix to tag (except for the homepage)
if (!patterns.homepagePath.test(path)) {
if (
req.context.currentVersion === 'free-pro-team@latest' ||
@@ -163,9 +145,7 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
} else {
const { versionTitle } = allVersions[req.context.currentVersion!]
page.fullTitle += ' - '
- // Some plans don't have the word "GitHub" in them.
- // E.g. "Enterprise Server 3.5"
- // In those cases manually prefix the word "GitHub" before it.
+ // Prefix version titles that omit GitHub.
if (!versionTitle.includes('GitHub')) {
page.fullTitle += 'GitHub '
}
@@ -178,15 +158,15 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
if (isRequestingJsonForDebugging) {
const json = req.query.json
if (Array.isArray(json)) {
- // e.g. ?json=page.permalinks&json=currentPath
+ // Example: ?json=page.permalinks&json=currentPath.
throw new Error("'json' query string can only be 1")
}
if (json) {
- // deep reference: ?json=page.permalinks
+ // Example deep reference: ?json=page.permalinks.
return res.json(get(context, req.query.json as string))
} else {
- // dump all the keys: ?json
+ // Example full key dump: ?json.
return res.json({
message:
'The full context object is too big to display! Try one of the individual keys below, e.g. ?json=page. You can also access nested props like ?json=site.data.reusables',
diff --git a/src/frame/middleware/resolve-carousels.ts b/src/frame/middleware/resolve-carousels.ts
index 2aa68876923b..a69c60253eff 100644
--- a/src/frame/middleware/resolve-carousels.ts
+++ b/src/frame/middleware/resolve-carousels.ts
@@ -6,7 +6,7 @@ import Permalink from '@/frame/lib/permalink'
import { createLogger } from '@/observability/logger/index'
-// The Page class has rawCarousels and carousels properties that aren't on the Page type
+// Page adds rawCarousels and carousels at runtime, but the Page type omits them.
interface PageCarouselProps {
rawCarousels?: Record
carousels?: Record
@@ -20,6 +20,7 @@ function buildArticlePath(currentLanguage: string, articlePath: string, basePath
return `${pathPrefix}${separator}${articlePath}`
}
+// Resolve carousel paths as content-relative, then page-relative, then retry both with .md.
function tryResolveArticlePath(
rawPath: string,
pageRelativePath: string | undefined,
@@ -32,7 +33,6 @@ function tryResolveArticlePath(
return undefined
}
- // Strategy 1: Try content-relative path (add language prefix to raw path)
const contentRelativePath = buildArticlePath(currentLanguage, rawPath)
let foundPage = findPage(contentRelativePath, pages, redirects)
@@ -40,7 +40,6 @@ function tryResolveArticlePath(
return foundPage
}
- // Strategy 2: Try page-relative path if page context is available
if (pageRelativePath) {
const pageDirPath = pageRelativePath.split('/').slice(0, -1).join('/')
const pageRelativeFullPath = buildArticlePath(currentLanguage, rawPath, pageDirPath)
@@ -51,11 +50,9 @@ function tryResolveArticlePath(
}
}
- // Strategy 3: Try with .md extension if not already present
if (!rawPath.endsWith('.md')) {
const pathWithExtension = `${rawPath}.md`
- // Try Strategy 1 with .md extension
const contentRelativePathWithExt = buildArticlePath(currentLanguage, pathWithExtension)
foundPage = findPage(contentRelativePathWithExt, pages, redirects)
@@ -63,7 +60,6 @@ function tryResolveArticlePath(
return foundPage
}
- // Try Strategy 2 with .md extension
if (pageRelativePath) {
const pageDirPath = pageRelativePath.split('/').slice(0, -1).join('/')
const pageRelativeFullPathWithExt = buildArticlePath(
@@ -82,7 +78,6 @@ function tryResolveArticlePath(
return foundPage
}
-// Returns a page's path without the language or version prefix.
function getPageHref(page: Page): string {
if (page.relativePath) {
return Permalink.relativePathToSuffix(page.relativePath)
@@ -137,7 +132,7 @@ async function resolveCarousels(
}
if (resolved.length > 0) {
- // Prevent prototype pollution by rejecting __proto__ keys
+ // Reject unsafe object keys to prevent prototype pollution.
if (
carouselKey !== '__proto__' &&
carouselKey !== 'constructor' &&
diff --git a/src/frame/middleware/robots.ts b/src/frame/middleware/robots.ts
index 11d17680d6f3..137b9ef7d8c9 100644
--- a/src/frame/middleware/robots.ts
+++ b/src/frame/middleware/robots.ts
@@ -17,7 +17,7 @@ export default function robots(req: ExtendedRequest, res: Response, next: NextFu
const host = req.get('x-host') || req.get('x-forwarded-host') || req.get('host')
- // only include robots.txt when it's our production domain and adding localhost for robots-txt.ts test
+ // Allow indexing only on docs.github.com and 127.0.0.1 for tests.
if (
host === 'docs.github.com' ||
req.hostname === 'docs.github.com' ||
diff --git a/src/frame/middleware/safe-redirect.ts b/src/frame/middleware/safe-redirect.ts
index 0c875bfe9ed7..128b0418ec47 100644
--- a/src/frame/middleware/safe-redirect.ts
+++ b/src/frame/middleware/safe-redirect.ts
@@ -2,20 +2,19 @@ import type { Response, NextFunction } from 'express'
import type { ExtendedRequest } from '@/types'
-// Normalizes a redirect URL to prevent open redirects via protocol-relative
-// URLs (e.g. "//evil.com" which browsers interpret as "https://evil.com").
+// Strip protocol-relative prefixes so browsers cannot turn redirects into external URLs.
+// Example: //evil.com becomes /evil.com.
export function safeRedirectUrl(url: string): string {
return url.replace(/^\/\/+/, '/')
}
-// Matches the overloaded signature of Express's res.redirect().
+// SafeRedirect matches the overloaded signature of Express res.redirect.
export type SafeRedirect = {
(url: string): void
(status: number, url: string): void
}
-// Attaches res.safeRedirect() to the response for all downstream middleware.
-// Same signature as res.redirect() but normalizes the URL first.
+// Downstream middleware calls res.safeRedirect with the Express redirect signature.
export default function safeRedirect(req: ExtendedRequest, res: Response, next: NextFunction) {
res.safeRedirect = function (statusOrUrl: number | string, url?: string) {
if (typeof statusOrUrl === 'number' && url !== undefined) {
diff --git a/src/frame/middleware/set-fastly-surrogate-key.ts b/src/frame/middleware/set-fastly-surrogate-key.ts
index a455d661a4f3..23394048441e 100644
--- a/src/frame/middleware/set-fastly-surrogate-key.ts
+++ b/src/frame/middleware/set-fastly-surrogate-key.ts
@@ -3,15 +3,12 @@ import type { Request, Response, NextFunction } from 'express'
import { ExtendedRequest } from '@/types'
import type { Page, Version } from '@/types'
-// Fastly provides a Soft Purge feature that allows you to mark content as outdated (stale) instead of permanently
-// purging and thereby deleting it from Fastly's caches. Objects invalidated with Soft Purge will be treated as
-// outdated (stale) while Fastly fetches a new version from origin.
-//
-// Use of a surrogate key is required for soft purging
+// Fastly soft purges mark cached objects stale while origin fetches a fresh copy.
+// Soft purges require surrogate keys.
// https://docs.fastly.com/en/guides/soft-purges
// https://docs.fastly.com/en/guides/getting-started-with-surrogate-keys
-// What the header needs to be called for Fastly to recognize it.
+// Fastly reads surrogate keys from this response header.
const KEY = 'surrogate-key'
export const SURROGATE_ENUMS = {
@@ -59,16 +56,14 @@ export function makeLanguageSurrogateKey(langCode?: string) {
return `language:${langCode}`
}
-// Build the fine-grained surrogate keys for a content response.
-// A content page is exactly one of each axis, so ~5 keys per page, well under
-// Fastly's 16 KB Surrogate-Key header limit:
-//
-// language: (also emitted for non-content responses)
-// product: e.g. product:actions (~36)
-// version: e.g. version:ghes-3.14 (~7-8)
-// product:,language: compound, for targeted translation purges
-// language:,path: compound, one key per source page, all versions
-//
+// Content responses get about five keys, one per purge axis,
+// below Fastly's 16 KB header limit.
+// Shapes include language:, product:, version:,
+// product:,language:, and language:,path:.
+// language: also appears on non-content responses.
+// product:,language: targets translation purges.
+// language:,path: covers one source page across all versions.
+// Each response emits at most one product key and at most one version key.
export function makeContentSurrogateKeys({
langCode,
productId,
@@ -97,20 +92,19 @@ export function makeContentSurrogateKeys({
return keys
}
-// One surrogate key per source page, e.g. `language:en,path:actions/foo.md`,
-// covering every version-URL of the page. A pure function of language and
-// relativePath so the purge job can rebuild the same key from a changed file's
-// path. Language-scoped so an English deploy doesn't evict translations. Returns
-// undefined for non-content responses.
+// Page surrogate keys cover every version URL for one source page.
+// Example: language:en,path:actions/foo.md.
+// The purge job rebuilds them from changed file paths.
+// Language scoping avoids evicting translations on English deploys.
+// Missing language or path returns undefined for non-content responses.
export function makePageSurrogateKey(langCode?: string, relativePath?: string): string | undefined {
if (!langCode || !relativePath) return undefined
return `language:${langCode},path:${relativePath}`
}
-// Derive the product id for the `product:` surrogate key from a content page's
-// path. The top-level content directory is the product id (mirrors
-// Page.parentProductId), e.g. `actions`. Returns undefined for non-content
-// responses and the top-level homepage (`content/index.md`).
+// Product surrogate keys use the top-level content directory, mirroring Page.parentProductId.
+// Example: actions.
+// Non-content responses and the top-level homepage return undefined.
function productSurrogateId(page?: Page): string | undefined {
const relativePath = page?.relativePath
if (!relativePath) return undefined
@@ -119,10 +113,9 @@ function productSurrogateId(page?: Page): string | undefined {
return id
}
-// Derive the short release slug for the `version:` surrogate key, e.g. `fpt`,
-// `ghec`, `ghes-3.14`. Numbered releases (GHES) get the release appended so a
-// version-scoped purge can target a single release; unnumbered plans use the
-// plain short name.
+// Version surrogate keys use the short name.
+// Numbered GitHub Enterprise Server releases append currentRelease for single-release purges.
+// Unnumbered plans use the short name alone.
function versionSurrogateKey(versionObj?: Version): string | undefined {
if (!versionObj) return undefined
return versionObj.hasNumberedReleases
diff --git a/src/frame/middleware/trailing-slashes.ts b/src/frame/middleware/trailing-slashes.ts
index 53e34ac1856d..cd80572031b9 100644
--- a/src/frame/middleware/trailing-slashes.ts
+++ b/src/frame/middleware/trailing-slashes.ts
@@ -15,7 +15,8 @@ export default function trailingSlashes(req: ExtendedRequest, res: Response, nex
if (split.length) {
url += `?${split.join('?')}`
}
- url = url.replace(/\/+/g, '/') // Prevent multiple slashes
+ // Collapse repeated slashes so the redirect points to one canonical URL.
+ url = url.replace(/\/+/g, '/')
defaultCacheControl(res)
return res.safeRedirect(301, url)
}
diff --git a/src/frame/middleware/url-decode.ts b/src/frame/middleware/url-decode.ts
index 7e49a5e3f86f..ae76a8774b6d 100644
--- a/src/frame/middleware/url-decode.ts
+++ b/src/frame/middleware/url-decode.ts
@@ -1,7 +1,7 @@
import type { NextFunction, Response } from 'express'
import type { ExtendedRequest } from '@/types'
-// Decodes URL-encoded @ symbols anywhere in the URL.
+// Decode URL-encoded @ symbols anywhere in the URL.
// SharePoint and other systems encode @ as %40, which breaks our versioned
// URLs like /en/enterprise-cloud@latest.
export default function urlDecode(req: ExtendedRequest, res: Response, next: NextFunction) {
@@ -16,7 +16,6 @@ export default function urlDecode(req: ExtendedRequest, res: Response, next: Nex
req.url = decodedUrl
return next()
} catch {
- // If decoding fails for any reason, continue with original URL
return next()
}
}
diff --git a/src/frame/pages/app.tsx b/src/frame/pages/app.tsx
index 386c5c496705..4ee1f221fcfe 100644
--- a/src/frame/pages/app.tsx
+++ b/src/frame/pages/app.tsx
@@ -45,11 +45,9 @@ const stagingNames = new Set([
'yew',
])
+// Cache-busting prefixes need any cb-number so Fastly assigns the manual surrogate key.
+// Change the cb number when the image changes, so browsers and the CDN miss the old URL.
function getFaviconHref(stagingName?: string) {
- // The number in these "/cb-xxxxx" prefixes does not matter, it just has to be
- // present. It marks the URL as checksummed, which gets it a manual Fastly
- // surrogate key so a production deploy does not purge it.
- // If you edit these images on disk, change the numbers.
if (stagingName) {
return `/assets/cb-346/images/site/evergreens/${stagingName}.png`
}
@@ -110,12 +108,7 @@ const MyApp = ({ Component, pageProps, languagesContext, stagingName }: MyAppPro
dayScheme={theme.component.dayScheme}
nightScheme={theme.component.nightScheme}
>
- {/*
- Primer Brand ThemeProvider, nested so migrated @primer/react-brand
- components receive brand theme context during the Docs 2026 migration
- (github/docs-engineering#5879). Runs alongside the @primer/react
- ThemeProvider above while the component-by-component swap is in progress.
- */}
+ {/* @primer/react-brand context lets Brand components coexist with @primer/react. */}
@@ -131,30 +124,22 @@ const MyApp = ({ Component, pageProps, languagesContext, stagingName }: MyAppPro
MyApp.getInitialProps = async (appContext: AppContext) => {
const { ctx } = appContext
- // calls page's `getInitialProps` and fills `appProps.pageProps`
const appProps = await App.getInitialProps(appContext)
const req = ctx.req as unknown as ExtendedRequest
- // Have to define the type manually here because `req.context.languages`
- // comes from Node JS and is not type-aware.
const languagesContext: LanguagesContextT = {
languages: {},
}
- // If we're rendering certain 404 error pages, the middleware might not
- // yet have contextualized the `context.languages`. So omit this
- // context mutation and live without it.
- // Note, `req` will be undefined if this is the client-side rendering
- // of a 500 page ("Ooops! It looks like something went wrong.")
+ // Some 404 renders lack req.context.languages.
if (req?.context?.languages) {
const languageEntries = Object.entries(req.context.languages as Record)
for (const [langCode, langObj] of languageEntries) {
- // Only pick out the keys we actually need
languagesContext.languages[langCode] = {
name: langObj.name,
code: langObj.code,
}
- // The `hreflang` is used for the `` tags.
+ // hreflang drives alternate-language link tags.
if (langObj.hreflang && langObj.hreflang !== langObj.code) {
languagesContext.languages[langCode].hreflang = langObj.hreflang
}
diff --git a/src/frame/start-server.ts b/src/frame/start-server.ts
index aa7021dd454a..ec5f6620269e 100644
--- a/src/frame/start-server.ts
+++ b/src/frame/start-server.ts
@@ -1,6 +1,5 @@
-// IMPORTANT: OTel tracing MUST be the first import. It patches Node.js
-// built-ins (http, etc.) at load time via auto-instrumentation.
-// Moving this after any framework import will silently break tracing.
+// Import tracing before framework code so auto-instrumentation can patch Node.js built-ins.
+// Moving it later silently breaks OTel tracing.
import '@/observability/lib/tracing'
import http from 'http'
@@ -44,17 +43,13 @@ async function checkPortAvailability() {
}
}
+// startServer warms the idempotent server cache before listen, so development restarts do not
+// block the first page refresh.
+// The SIGTERM timer forces exit after 25s because the preStop hook sleeps 5s and Kubernetes
+// SIGKILLs at 60s, while the deploy controller can time out on old terminating pods.
async function startServer() {
const app = createApp()
- // Warm up as soon as possible.
- // The `warmServer()` function is idempotent and it will soon be used
- // by some middleware, but there's no point in having a started server
- // without this warmed up. Besides, by starting this slow thing now,
- // it can start immediately instead of waiting for the first request
- // to trigger it to warm up. That way, when in development and triggering
- // a `nodemon` restart, there's a good chance the warm up has come some
- // way before you manage to reach for your browser to do a page refresh.
await warmServer([])
// Workaround for https://github.com/expressjs/express/issues/1101
@@ -65,8 +60,7 @@ async function startServer() {
process.once('SIGTERM', () => {
logger.info('Received SIGTERM, beginning graceful shutdown', { pid: process.pid, port })
- // Force-close idle keep-alive sockets so server.close() doesn't hang
- // waiting for them to disconnect naturally.
+ // Force-close idle keep-alive sockets so server.close() does not wait for natural disconnects.
try {
server.closeIdleConnections()
} catch (err) {
@@ -77,11 +71,6 @@ async function startServer() {
logger.info('HTTP server closed')
})
- // If in-flight requests haven't drained within 25s, force exit.
- // Kubernetes sends SIGKILL at terminationGracePeriodSeconds (60s),
- // but the deploy controller may time out before that if an old pod
- // stays in "Terminating" state too long. The preStop hook sleeps 5s,
- // so 25s here keeps total shutdown well under the 60s grace period.
setTimeout(() => {
logger.warn('Graceful shutdown timed out, forcing exit')
try {
diff --git a/src/frame/stylesheets/article-link-overrides.scss b/src/frame/stylesheets/article-link-overrides.scss
index 47ea20966506..d3519c2ee5dc 100644
--- a/src/frame/stylesheets/article-link-overrides.scss
+++ b/src/frame/stylesheets/article-link-overrides.scss
@@ -1,49 +1,29 @@
-// Docs 2026 article-body link colour, overriding @primer/css's base `a` rule.
+// Docs 2026 article-body links need Brand blue instead of @primer/css accent blue.
//
-// @primer/css/base/base.scss sets
-// a { color: var(--fgColor-accent, var(--color-accent-fg)); }
-// `--fgColor-accent` is defined nowhere in this app except inside a
-// `forced-colors` block, so every link falls through to `--color-accent-fg` and
-// paints Primer's accent blue — #0969da light / #58a6ff dark. That is still the
-// OLD design system's accent; brand ships its own link blue, and the article
-// body is the most visible place the difference shows.
+// @primer/css/base/base.scss sets a to Primer accent blue through --fgColor-accent:
+// #0969da light and #4493f8 dark. Article body links need Brand link blue instead.
//
-// Scoped to `#article-contents[data-article-body] .markdown-body`, matching the
-// scope src/content-render/stylesheets/article-section-framing.scss uses for the
-// article body. All three parts are load-bearing: this stylesheet is global, so
-// a bare `a` rule would repaint the header, sidebar, landings, search and footer
-// as well; `.markdown-body` on its own would still catch the REST / GraphQL /
-// webhook pages that reuse the class; and the id on its own is not enough
-// either, because AutomatedPage renders the same `#article-contents` wrapper for
-// the GraphQL, webhook, audit-log and github-apps pages. The data attribute is
-// set only by ArticlePage and TocLanding.
+// The selector must match src/content-render/stylesheets/article-section-framing.scss:
+// #article-contents[data-article-body] .markdown-body. A bare a rule would repaint the header,
+// sidebar, landings, search, and footer. .markdown-body would also catch REST, GraphQL, and
+// webhook pages. #article-contents would also catch AutomatedPage GraphQL, webhook, audit-log,
+// and github-apps pages. ArticlePage and TocLanding set data-article-body.
#article-contents[data-article-body] .markdown-body {
- // `--brand-color-text-link-*` are the semantic tokens that brand's own
- // InlineLink component aliases into `--brand-InlineLink-color-*`. Using the
- // semantic pair keeps this independent of any ancestor that re-maps the
- // component token — breadcrumbs-overrides.scss does exactly that.
+ // Use --brand-color-text-link-* semantic tokens because breadcrumbs-overrides.scss remaps
+ // --brand-InlineLink-color-* for breadcrumbs.
//
- // Three exclusions, all because this selector outweighs the rules that
- // currently keep those anchors uncoloured:
- // - `[href]` — @primer/css holds `.markdown-body a:not([href])` at
- // `color: inherit`, for the bare named anchors markdown emits.
- // - `:not(.heading-link)` — heading anchors wrap the entire heading text,
- // and headings.scss holds them at `color: unset`.
- // - `:not(.btn)` — CTA buttons in content are plain anchors carrying
- // @primer/css's button classes (``), so they
- // match this rule too. `.btn-primary`'s own `color` is only (0,1,0)
- // against this selector's (1,4,1), so without the exclusion the label
- // painted brand link-blue on the green button — #005dd5 on #1f883d is
- // 1.31:1, well under the 4.5:1 WCAG AA floor
- // (github/technical-content#7679). Excluding `.btn`
- // hands every button variant back to its own Primer colour tokens.
+ // Exclude anchors that this selector would otherwise override:
+ // a[href] preserves @primer/css .markdown-body a:not([href]) named anchors at color: inherit.
+ // :not(.heading-link) preserves headings.scss, which keeps heading text at color: unset.
+ // :not(.btn) preserves CTA button labels. A .btn-primary label would be #005dd5 on #1f883d,
+ // 1.31:1 contrast, below the 4.5:1 WCAG AA floor.
a[href]:not(.heading-link):not(.btn) {
- // Fallback literals are brand's LIGHT values, not Primer's.
+ // Fallback literals apply only when Brand tokens are undefined.
color: var(--brand-color-text-link-rest, #005dd5);
- // Pressed is brand's only other link colour state. It defines no `:visited`
- // colour, and InlineLink's hover thickens the underline rather than changing
- // colour, so @primer/css's hover underline is left alone.
+ // Pressed is Brand's only other link color state.
+ // Brand has no visited color, and InlineLink only thickens hover underlines.
+ // Leave @primer/css hover behavior unchanged.
&:active {
color: var(--brand-color-text-link-pressed, #002f7a);
}
diff --git a/src/frame/stylesheets/breadcrumbs-overrides.scss b/src/frame/stylesheets/breadcrumbs-overrides.scss
index 9f04809032d7..743bce5fdb65 100644
--- a/src/frame/stylesheets/breadcrumbs-overrides.scss
+++ b/src/frame/stylesheets/breadcrumbs-overrides.scss
@@ -1,31 +1,21 @@
-// Docs 2026 breadcrumb treatment, overriding @primer/react-brand's Breadcrumbs.
+// Docs 2026 breadcrumbs invert @primer/react-brand default emphasis.
//
-// Brand's `default` variant renders the ancestor crumbs at full strength and
-// MUTES the current page. The design wants the inverse: the pages you are not
-// viewing — and the "/" separators between them — recede, while the page you're
-// on reads at full strength.
+// Brand's default variant gives ancestor crumbs full strength and mutes the current page. The
+// design needs ancestor pages and "/" separators to recede while the current page stays full
+// strength.
//
-// Keyed off the `data-container` attribute rather than a class because brand's
-// Breadcrumbs