From 90fcb58e636da515c6c8e1ddfca2781e1fa93329 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 17:07:54 +0000 Subject: [PATCH 01/18] Tighten code comments in src/assets and src/tools (#63463) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/assets/middleware/asset-preprocessing.ts | 32 ++--- src/assets/middleware/dynamic-assets.ts | 122 +++--------------- src/assets/middleware/static-asset-caching.ts | 3 +- .../scripts/deleted-assets-pr-comment.ts | 8 +- src/assets/scripts/find-orphaned-assets.ts | 24 +--- src/assets/scripts/list-image-sizes.ts | 6 +- src/assets/scripts/validate-asset-images.ts | 28 +--- src/assets/tests/dynamic-assets.ts | 6 +- src/assets/tests/static-assets.ts | 23 +--- src/tools/components/Fields.tsx | 6 +- .../components/InArticlePicker.module.scss | 8 +- src/tools/components/InArticlePicker.tsx | 54 ++------ src/tools/components/PlatformPicker.tsx | 10 +- src/tools/components/SelectionContext.tsx | 30 ++--- src/tools/components/ToggleableContent.tsx | 8 +- src/tools/components/ToolPicker.tsx | 14 +- src/tools/lib/all-platforms.ts | 1 - src/tools/lib/all-tools.ts | 28 ++-- .../scripts/liquid-markdown-tables/convert.ts | 2 +- .../scripts/liquid-markdown-tables/index.ts | 54 ++------ .../scripts/liquid-markdown-tables/lib.ts | 6 +- 21 files changed, 108 insertions(+), 365 deletions(-) diff --git a/src/assets/middleware/asset-preprocessing.ts b/src/assets/middleware/asset-preprocessing.ts index a61be8044422..07493abf8ed4 100644 --- a/src/assets/middleware/asset-preprocessing.ts +++ b/src/assets/middleware/asset-preprocessing.ts @@ -2,15 +2,10 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest } from '@/types' -// This middleware rewrites the URL of requests that contain the -// portion of `/cb-\d+/`. -// "cb" stands for "cache bust". -// There's a Markdown plugin that rewrites all values -// from `` to -// `` for example. -// We're doing this so that we can set a much more aggressive -// Cache-Control for assets and a CDN surrogate-key that doesn't -// soft-purge on every deployment. +// Markdown image URLs include a cache-busting path part, for example +// /assets/foo/bar.png becomes /assets/cb-123467/foo/bar.png. +// That path lets assets use aggressive Cache-Control and a Fastly surrogate key +// that avoids soft purges on every deployment. const regex = /\/cb-\d+\// @@ -20,27 +15,16 @@ export default function assetPreprocessing( next: NextFunction, ) { if (req.path.startsWith('/assets/')) { - // We didn't use to have a rule about all image assets must be - // lower case. So we've exposed things like: - // which means they could - // get a 404 if the file is actually named `foobar.png`. + // Mixed-case asset URLs can 404 when the file on disk is lowercase. if (req.url !== req.url.toLowerCase()) { - // The reason for doing a redirect instead rewriting the - // `req.url` attribute is that we don't want encourage this. - // By forcing this to be a redirect, it means we only serve - // 1 single file. All other requests will be redirects. - // Otherwise someone might trigger too much bypassing of the CDN. + // Redirecting instead of rewriting req.url serves one canonical file and protects CDN hit rates. return res.safeRedirect(req.url.toLowerCase()) } - // We're only confident enough to set the *manual* surrogate key if the - // asset contains the cache-busting piece. + // Only cache-busted assets can use the manual surrogate key safely. if (regex.test(req.url)) { - // We remove it so that when `express.static()` runs, it can - // find the file on disk by its original name. + // express.static() needs the original file path on disk. req.url = req.url.replace(regex, '/') - // The Cache-Control is managed by the configuration - // for express.static() later in the middleware. } } return next() diff --git a/src/assets/middleware/dynamic-assets.ts b/src/assets/middleware/dynamic-assets.ts index c95d53f7f7aa..f41d500387c6 100644 --- a/src/assets/middleware/dynamic-assets.ts +++ b/src/assets/middleware/dynamic-assets.ts @@ -13,35 +13,20 @@ import { createLogger } from '@/observability/logger' const logger = createLogger(import.meta.url) -/** - * This is the indicator that is a virtual part of the URL. - * Similar to `/cb-1234/` in asset URLs, it's just there to tell the - * middleware that the image can be aggressively cached. It's not - * part of the actual file-on-disk path. - * Similarly, `/mw-1000/` is virtual and will be observed and removed from - * the pathname before trying to look it up as disk-on-file. - * The exact pattern needs to match how it's set in whatever Markdown - * processing code that might make dynamic asset URLs. - * So if you change this, make sure you change the code that expects - * to be able to inject this into the URL. - */ +// Markdown processing injects a max-width segment such as /mw-1440/ into dynamic asset URLs. +// Like /cb-1234/, it marks cacheable work and is not part of the file path on disk. +// Keep this pattern in sync with the Markdown code that builds dynamic asset URLs. const maxWidthPathPartRegex = /\/mw-(\d+)\// -/** - * - * Why not any free number? If we allowed it to be any integer number - * someone would put our backend servers at risk by doing something like: - * - * const makeURL = () => `${BASE}/assets/mw-${Math.floor(Math.random()*1000)}/foo.png` - * await Promise.all([...Array(10000).keys()].map(makeURL)) - * - * Which would be lots of distinctly different and valid URLs that the - * CDN can never really "protect us" on because they're too often distinct. - * - * At the moment, the only business need is for 1,000 pixels, so the array - * only has one. But can change in the future and make this sentence moot. - */ +// Restrict widths to product-supported sizes, so attackers cannot create many +// distinct resize URLs that bypass CDN reuse. const VALID_MAX_WIDTHS = [1440, 1000] +// WebP effort 5 keeps output smaller without using sharp's slowest CPU setting. +// CDN caching lets production pay the conversion cost once per image. +// https://www.peterbe.com/plog/comparing-different-efforts-with-webp-in-sharp +// Lossy WebP is acceptable because these images are rendered for viewing, not source editing. +// Lossless output is slightly crisper but averages 1.8x larger. +// Sharp's default 80% quality and lossy mode make our images 2.8x smaller than PNGs on average. export default async function dynamicAssets( req: ExtendedRequest, res: Response, @@ -53,39 +38,16 @@ export default async function dynamicAssets( return res.status(405).type('text/plain').send('Method Not Allowed') } - // To protect from possible denial of service, we never allow what - // we're going to do (the image file operation), if the whole thing - // won't be aggressively cached. - // If we didn't do this, someone making 2 requests, ... - // - // > GET /assets/images/site/logo.web?random=10476583 - // > GET /assets/images/site/logo.web?random=20196996 - // - // ...would be treated as 2 distinct backend requests. Sure, each one - // would be cached in the CDN, but that's not helping if someone does... - // - // while (true) { - // startFetchThread(`/assets/images/site/logo.web?whatever=${rand()}`) - // } - // - // So we "force" any deviation of the URL to a redirect to the canonical - // URL (which, again, is heavily cached). + // Query strings create distinct dynamic asset URLs, so redirect them to the canonical cached URL. if (Object.keys(req.query).length > 0) { - // Cache the 404 so it won't be re-attempted over and over + // Cache the redirect so repeated noncanonical URLs do not keep reaching the backend. defaultCacheControl(res) - // This redirects to the same URL we're currently on, but with the - // query string part omitted. - // For example: - // - // > GET /assets/images/site/logo.web?foo=bar - // < 302 - // < location: /assets/images/site/logo.web - // + // /assets/images/site/logo.webp?foo=bar redirects to /assets/images/site/logo.webp. return res.safeRedirect(302, req.path) } - // From PNG to WEBP, if the PNG exists + // Dynamic WebP files are generated from PNG sources on demand. if (req.path.endsWith('.webp')) { const { url, maxWidth, error } = deconstructImageURL(req.path) if (error) { @@ -104,49 +66,15 @@ export default async function dynamicAssets( } } - // The default in sharp.webp() for effort is 4. It's a sensible - // balance between time and compression. - // If you make it low, it makes the webp conversion faster. - // If you make it high, the webp conversion is slower but the - // resulting WEBP file are smaller. - // Given that our App Service containers aren't very strong in - // terms of CPU, we avoid the highest effort. But given how - // well our CDN protects repeated requests for the same image, - // we can pay this cost once and reap it for a very long time. - // Be mindful at the highest (6), it can be extremely slow so - // let's avoid that for now. - // - // For more information about the effort option, see: - // https://www.peterbe.com/plog/comparing-different-efforts-with-webp-in-sharp - // let effort = 5 if (process.env.NODE_ENV === 'test') { - // When running tests, we want to make the conversion as fast - // as possible because the resulting WEBP buffer will most - // likely never be enjoyed by network or human eyes. + // Tests need fast conversion because the WebP buffer is not user-visible. effort = 1 } else if (process.env.NODE_ENV === 'development') { - // If you're doing local development (or review), the - // network is not precious (localhost:4000) and you have no - // CDN to cache it for you. Make it low but not too unrealistically - // low. + // Development has no CDN reuse, so reduce conversion CPU cost. effort = 1 } - // Note that by default, sharp will use a lossy compression. - // (i.e. `{lossless: false}` in the options) - // The difference is that a lossless image is slightly crisper - // but becomes on average 1.8x larger. - // Given how we serve images, no human would be able to tell the - // difference simply by looking at the image as it appears as an - // image tag in the web page. - // Also given that rendering-for-viewing is the "end of the line" - // for the image meaning it just ends up being viewed and not - // resaved as a source file. If we had intention to overwrite all - // original PNG source files to WEBP, we should consider lossless - // to preserve as much quality as possible at the source level. - // The default quality is 80% which, combined with `lossless:false` - // makes our images 2.8x smaller than the average PNG. const buffer = await image.webp({ effort }).toBuffer() assetCacheControl(res) return res.type('image/webp').send(buffer) @@ -162,23 +90,13 @@ export default async function dynamicAssets( } } - // Cache the 404 so it won't be re-attempted over and over + // Cache the 404 so repeated missing assets do not keep reaching the backend. defaultCacheControl(res) - // There's a preceeding middleware that sets the Surrogate-Key to - // "manual-purge" based on the URL possibly having the `/cb-xxxxx/` - // checksum in it. But, if it failed, we don't want that. So - // undo that if it was set. - // It's handy too to not overly cache 404s in the CDN because - // it could be that the next prod deployment fixes the missing image. - // For example, a PR landed that introduced the *reference* to the image - // but forgot to check in the new image, then a follow-up PR adds the image. + // Missing dynamic assets use the language surrogate key, not manual-purge, so a later deploy can add the image. setFastlySurrogateKey(res, makeLanguageSurrogateKey(), true) - // Don't use something like `next(404)` because we don't want a fancy - // HTML "Page not found" page response because a failed asset lookup - // is impossibly a typo in the browser address bar or an accidentally - // broken link, like it might be to a regular HTML page. + // Keep missing asset responses plain text instead of rendering the HTML page-not-found response. res.status(404).type('text/plain').send('Asset not found') } diff --git a/src/assets/middleware/static-asset-caching.ts b/src/assets/middleware/static-asset-caching.ts index 250aa2b85d00..75fb6b03f1e0 100644 --- a/src/assets/middleware/static-asset-caching.ts +++ b/src/assets/middleware/static-asset-caching.ts @@ -14,8 +14,7 @@ export default function setStaticAssetCaching( return next() } -// True if the URL is known to contain some pattern of a checksum that -// would make it intelligently different if its content has changed. +// Checksummed URLs can keep manual surrogate keys because content changes produce new URLs. function isChecksummed(path: string) { if (path.startsWith('/assets/cb-')) return true if (path.startsWith('/_next/static')) { diff --git a/src/assets/scripts/deleted-assets-pr-comment.ts b/src/assets/scripts/deleted-assets-pr-comment.ts index 55f129b40ca1..88626e24875b 100755 --- a/src/assets/scripts/deleted-assets-pr-comment.ts +++ b/src/assets/scripts/deleted-assets-pr-comment.ts @@ -8,7 +8,7 @@ if (!GITHUB_TOKEN) { throw new Error(`GITHUB_TOKEN environment variable not set`) } -// When this file is invoked directly from action as opposed to being imported +// Direct workflow execution writes the PR comment body to an action output. if (import.meta.url.endsWith(process.argv[1])) { const owner = context.repo.owner const repo = context.payload.repository?.name || '' @@ -27,7 +27,6 @@ type MainArgs = { } async function main({ owner, repo, baseSHA, headSHA }: MainArgs) { const octokit = getOctokit(GITHUB_TOKEN as string) - // get the list of file changes from the PR const response = await octokit.rest.repos.compareCommitsWithBasehead({ owner, repo, @@ -40,8 +39,7 @@ async function main({ owner, repo, baseSHA, headSHA }: MainArgs) { throw new Error('No files found in the PR') } - // Auto-generated asset directories managed by sync pipelines. - // These are deleted and recreated on each sync, so deletions are expected. + // Sync pipelines delete and recreate these auto-generated asset directories. const AUTO_GENERATED_ASSET_DIRS = ['assets/images/help/copilot/copilot-sdk/'] const oldFilenames = [] @@ -50,10 +48,8 @@ async function main({ owner, repo, baseSHA, headSHA }: MainArgs) { if (!filename.startsWith('assets')) continue if (AUTO_GENERATED_ASSET_DIRS.some((dir) => filename.startsWith(dir))) continue if (status === 'removed') { - // Bad oldFilenames.push(filename) } else if (status === 'renamed') { - // Also bad const previousFilename = file.previous_filename oldFilenames.push(previousFilename) } diff --git a/src/assets/scripts/find-orphaned-assets.ts b/src/assets/scripts/find-orphaned-assets.ts index 15143e4d7fc2..e85c54e87aaa 100755 --- a/src/assets/scripts/find-orphaned-assets.ts +++ b/src/assets/scripts/find-orphaned-assets.ts @@ -1,9 +1,4 @@ -// [start-readme] -// -// Print a list of all the asset files that can't be found mentioned -// in any of the source files (content & code). -// -// [end-readme] +// Prints assets that no content or code file mentions. import fs from 'fs' import path from 'path' @@ -79,7 +74,7 @@ const EXCEPTIONS = new Set([ 'assets/images/social-cards/subscriptions-and-notifications.png', 'assets/images/social-cards/support.png', 'assets/images/social-cards/webhooks.png', - // Hero images may not be used, but we keep them around for future use + // Hero images may be reused even when no source file mentions them. 'assets/images/banner-images/hero-1.png', 'assets/images/banner-images/hero-2.png', 'assets/images/banner-images/hero-3.png', @@ -89,11 +84,7 @@ const EXCEPTIONS = new Set([ ]) function isExceptionPath(imagePath: string) { - // We also check for .DS_Store because any macOS user that has opened - // a folder with images will have this on disk. It won't get added - // to git anyway thanks to our .DS_Store. - // But if we don't make it a valid exception, it can become inconvenient - // to run this script locally. + // Local macOS image folders can contain .DS_Store files that are not tracked. return ( EXCEPTIONS.has(imagePath) || path.basename(imagePath) === '.DS_Store' || @@ -131,10 +122,7 @@ async function main(opts: MainOptions) { sourceFiles.push(...englishFiles) if (!excludeTranslations) { - // Need to have this so we can filter the translations files and avoid - // including orphans. Because translations generally don't delete files. - // When the English content renames something, you later end up with - // 2 files in each translation repo. + // Translations keep files after English renames, so only search translated files that match English. const englishRelativeFiles = new Set( englishFiles.map((englishFile) => path.relative(languages.en.dir, englishFile)), ) @@ -173,7 +161,6 @@ async function main(opts: MainOptions) { ), ) } - // Add exceptions sourceFiles.push('.github/CONTRIBUTING.md') sourceFiles.push('README.md') if (verbose) { @@ -213,8 +200,7 @@ async function main(opts: MainOptions) { console.log(JSON.stringify([...allImages], undefined, 2)) } else { for (const imagePath of [...allImages].sort((a, b) => a.localeCompare(b))) { - // It's important to escape spaces if we're ever going to pipe this - // to xargs. + // Quotes preserve paths with spaces when piped to xargs. console.log(`"${imagePath}"`) } } diff --git a/src/assets/scripts/list-image-sizes.ts b/src/assets/scripts/list-image-sizes.ts index 34a4f8fcdd3f..8c37b9c6308e 100755 --- a/src/assets/scripts/list-image-sizes.ts +++ b/src/assets/scripts/list-image-sizes.ts @@ -1,8 +1,4 @@ -// [start-readme] -// -// This script lists all local image files, sorted by their dimensions. -// -// [end-readme] +// Lists local image files by pixel area, largest first. import { fileURLToPath } from 'url' import path from 'path' diff --git a/src/assets/scripts/validate-asset-images.ts b/src/assets/scripts/validate-asset-images.ts index f799bee711c1..56269406427a 100755 --- a/src/assets/scripts/validate-asset-images.ts +++ b/src/assets/scripts/validate-asset-images.ts @@ -1,15 +1,5 @@ -// [start-readme] -// -// Makes sure that all the image assets in `assets/` are safe. -// -// Generally writers don't check in bogus/corrupt images but mistakes -// can happen and it's ideally spotted in other processes such as -// reviewing PR review environment. -// This script also makes sure that all images really are what they're -// called. For example, an image might be named `screenshot.png` but -// it might actually be something mischievous. -// -// [end-readme] +// Validates asset files for corrupt images, unsafe SVG content, and mismatched file types. +// For example, screenshot.png must contain image/png data. import fs from 'fs/promises' import path from 'path' @@ -24,12 +14,11 @@ import isSVG from 'is-svg' const ASSETS_ROOT = path.resolve('assets') const ROOT = path.dirname(ASSETS_ROOT) -// We put images that are used by the React components in with the assets -// directory. These aren't really content-contibuted. +// React component images live under assets but are not content-contributed. const EXCLUDE_DIR = path.join(ASSETS_ROOT, 'images', 'site') const IGNORE_EXTENSIONS = new Set([ - // Currently has no known test for these + // CSV assets have no validator. '.csv', ]) @@ -105,7 +94,7 @@ async function checkFile(filePath: string) { } if (ext === '.svg') { - // Can't use `fileTypeFromFile` so have to check "manually" + // fileTypeFromFile cannot validate SVG, so parse the text content. const content = await fs.readFile(filePath, 'utf-8') if (!content.trim()) { return [CRITICAL, filePath, 'file is empty'] @@ -133,8 +122,6 @@ async function checkFile(filePath: string) { } else { return [WARNING, filePath, `Don't know how to validate '${ext}'`] } - - // All is well. Nothing to complain about. } function checkSVGContent(content: string) { @@ -148,10 +135,7 @@ function checkSVGContent(content: string) { throw new Error(`contains a <${tagName}> tag`) } for (const key in 'attribs' in el ? el.attribs : {}) { - // Looks for suspicious event handlers on tags. - // For example ` { const raw = query[queryStringKey] let value = '' @@ -52,8 +49,7 @@ export const InArticlePicker = ({ if (Array.isArray(raw)) value = raw[0] else value = raw } - // Only pick it up from the possible query string if its value - // is a valid option. + // Ignore query string values outside this picker's options. const possibleValues = options.map((option) => option.value) if (!value || !possibleValues.includes(value)) { const cookieValue = Cookies.get(cookieKey) @@ -70,49 +66,24 @@ export const InArticlePicker = ({ const [asPathRoot, asPathQuery = ''] = router.asPath.split('#')[0].split('?') - // Use a layout effect so the DOM mutation (hiding non-matching .ghd-tool - // content) happens before the browser paints. With React 19's stricter - // effect timing, a regular useEffect could leave non-matching content - // visible on initial page load until after first paint. + // Apply the selection before paint so non-matching .ghd-tool content does not flash. useIsomorphicLayoutEffect(() => { - // This will make the hook run this callback on mount and on change. - // That's important because even though the user hasn't interacted - // and made an overriding choice, we still want to run this callback - // because the page might need to be corrected based on *a* choice - // independent of whether it's a change. + // Initial values still need to update the page before the user interacts. if (currentValue) { onValue(currentValue) } }, [ currentValue, - // This is important because we can't otherwise rely on the firing - // of this effect on initial mount. It also needs to fire when the - // URL (i.e. route) changes. - // Don't use `router.asPath` because that contains the query string - // which we handle in the other useEffect above. + // Query string changes are handled separately, so depend on the route path only. asPathRoot, ]) - // This is exclusively for local development. - // If you're in local development, you have the - // causing a XHR refresh of the content triggered by the Page Visibility - // API (implemented in the uswSWR hook). That means that on the pages that - // contain these `.ghd-tool` classes, any DOM changes we might - // have previously made are lost and started over. + // Local ClientSideRefresh replaces article HTML on visibility changes, so reapply selection. useEffect(() => { let mounted = true const toggleVisibility = () => { if (document.visibilityState === 'visible') { - // We don't need to track this timer, and possibly cancel it on - // dismount, because within the callback we use the `mounted` - // boolean which means we can know to do nothing if the parent - // component has been dismounted. - // The reason this is wrapped in a short timeout is because the - // React rendering might not actually have fully updated the DOM - // (from the XHR HTML it receives) so allow the DOM to refresh - // first before asking it to change. The number can be quite low - // (which is sufficient for human eyes) but must be at least - // in the lower hundreds of milliseconds. + // Keep at least a 100 ms delay so refreshed HTML reaches the DOM before selection changes it. setTimeout(() => { if (mounted) { onValue(currentValue) @@ -148,10 +119,7 @@ export const InArticlePicker = ({ Cookies.set(cookieKey, value) } - // After a user clicks a tab, the shallow route change updates `currentValue`. - // Once the DOM reflects the new selection (aria-current="page" is on the new - // tab), move keyboard focus there so the user's context is preserved. - // WCAG 2.4.3 Focus Order: focus must land on the triggered control. + // WCAG 2.4.3 requires focus to remain on the triggered tab after shallow routing. useEffect(() => { if (!focusAfterNavRef.current || !currentValue) return focusAfterNavRef.current = false @@ -171,7 +139,7 @@ export const InArticlePicker = ({ return (
- {/* The key attribute is required for a bug in UnderlineNav that doesn't render the component when there are changes to the items. */} + {/* UnderlineNav can miss item changes without a changing key. */} {options.map((option) => { params.set(queryStringKey, option.value) diff --git a/src/tools/components/PlatformPicker.tsx b/src/tools/components/PlatformPicker.tsx index 7d78b2e48e6c..e113fdd96003 100644 --- a/src/tools/components/PlatformPicker.tsx +++ b/src/tools/components/PlatformPicker.tsx @@ -13,7 +13,7 @@ const platforms = [ { value: 'linux', label: 'Linux' }, ] -// Note: platform === os +// Content calls this preference platform, but the stored preference name is os. export const PlatformPicker = () => { const { defaultPlatform, detectedPlatforms } = useArticleContext() @@ -28,9 +28,7 @@ export const PlatformPicker = () => { setDefaultUA(userAgent) }, []) - // Defensively, just in case some article happens to have an array - // but for some reasons, it might be empty, let's not have a picker - // at all. + // Articles with no detected platforms do not need a picker. if (!detectedPlatforms.length) return null const options = platforms.filter((platform) => detectedPlatforms.includes(platform.value)) @@ -46,9 +44,7 @@ export const PlatformPicker = () => { cookieKey={OS_PREFERRED_COOKIE_NAME} queryStringKey={platformQueryKey} onValue={(value: string) => { - // Visibility is driven by React state via ToggleableContent/MiniTocs - // (#6619); the article body is React-owned on both the hast and string - // paths, so no imperative DOM mutation is needed. + // React state drives visibility because the article body is React-owned. setPlatform(value) }} preferenceName="os" diff --git a/src/tools/components/SelectionContext.tsx b/src/tools/components/SelectionContext.tsx index 0c09457855b6..5032ab8d1477 100644 --- a/src/tools/components/SelectionContext.tsx +++ b/src/tools/components/SelectionContext.tsx @@ -4,17 +4,10 @@ import type { ReactNode } from 'react' import { allPlatforms } from '@/tools/lib/all-platforms' import { allTools } from '@/tools/lib/all-tools' -// React-native replacement for the imperative platform/tool visibility toggling -// that PlatformPicker/ToolPicker used to do by walking the DOM and setting -// `style.display` on `.ghd-tool`/`.platform-*`/`.tool-*` elements (#6619). The -// selected platform + tool live in this context; the article body (rendered from -// hast) maps the relevant elements to , which reads the -// selection and hides non-matching content instead of mutating React-owned nodes. -// -// Selections start empty so that the server render and the first client render -// both show ALL variants (matching the pre-JS markup), which keeps hydration -// stable. The pickers set the real selection in an effect after hydration, the -// same moment the old imperative code used to run. +// PlatformPicker and ToolPicker store selection here so ToggleableContent can +// hide React-owned article elements without imperative style.display mutations. +// Empty initial selections keep server and first client renders showing all variants, +// which matches pre-JS markup and keeps hydration stable. export type SelectionContextT = { platform: string @@ -62,14 +55,10 @@ function toClassList(className: unknown): string[] { return [] } -// Determine whether an element is platform/tool-scoped and which value gates it. -// `.ghd-tool ` is a block (the {% mac %}/{% webui %} liquid tags); the -// extra class is the platform or tool value. `platform-`/`tool-` -// are author-written inline spans. We classify strictly against the canonical -// platform/tool vocabularies and return null on anything outside them, so an -// unrecognized class never makes content disappear. When several recognized -// markers are present the first match wins; in practice an element carries -// exactly one platform/tool marker. +// .ghd-tool uses a separate class for the platform or tool value. +// platform- and tool- are author-written inline spans. +// Strict vocabulary checks prevent unknown classes from hiding content. +// .ghd-tool prefers a recognized platform; inline spans use the first recognized marker. export function classifyToggleClass(className: unknown): ToggleClassification | null { const classes = toClassList(className) if (!classes.length) return null @@ -100,8 +89,7 @@ export function isToggleClass(className: unknown): boolean { return classifyToggleClass(className) !== null } -// Visible when no selection has been made yet (initial render shows everything, -// matching the pre-JS markup) or when the element's value is the selected one. +// Empty initial selections show everything, matching the pre-JS markup. export function isContentVisible( classification: ToggleClassification, selection: { platform: string; tool: string }, diff --git a/src/tools/components/ToggleableContent.tsx b/src/tools/components/ToggleableContent.tsx index c1e114897032..0659ace2a6a6 100644 --- a/src/tools/components/ToggleableContent.tsx +++ b/src/tools/components/ToggleableContent.tsx @@ -7,12 +7,8 @@ import { useSelection, } from '@/tools/components/SelectionContext' -// Wraps a platform/tool-scoped element from the article body hast and toggles -// its visibility from SelectionContext instead of the old imperative -// `style.display` mutation (#6619). Renders the same element/props/children, but -// sets `hidden` when the current platform/tool selection doesn't match. We keep -// the node in the DOM (hidden) rather than returning null so anchors, IDs, and -// screen-reader traversal behave like the previous `display:none` approach. +// ToggleableContent keeps hidden article nodes in the DOM so anchors, IDs, and +// screen-reader traversal match the previous display:none behavior. type ToggleableContentProps = { tag: 'div' | 'span' className?: string diff --git a/src/tools/components/ToolPicker.tsx b/src/tools/components/ToolPicker.tsx index 94748f6b1523..e0565ff98f0b 100644 --- a/src/tools/components/ToolPicker.tsx +++ b/src/tools/components/ToolPicker.tsx @@ -3,18 +3,16 @@ import { InArticlePicker } from './InArticlePicker' import { useSelection } from './SelectionContext' import { TOOL_PREFERRED_COOKIE_NAME } from '@/frame/lib/constants' -// Example page with a tool picker: -// http://localhost:4000/en/codespaces/developing-in-codespaces/creating-a-codespace - -// Note: tool === application, and picker === switcher +// Example tool picker page: /en/codespaces/developing-in-codespaces/creating-a-codespace +// Content calls this preference tool, but the stored preference name is application. function getDefaultTool(defaultTool: string | undefined, detectedTools: Array): string { if (defaultTool && detectedTools.includes(defaultTool)) return defaultTool - // Default to webui if present (this is generally the case where we show UI/CLI/Desktop info) + // UI, CLI, and Desktop articles default to webui. if (detectedTools.includes('webui')) return 'webui' - // Default to cli if present (this is generally the case where we show curl/CLI info) + // Curl and CLI articles default to cli. if (detectedTools.includes('cli')) return 'cli' return detectedTools[0] @@ -37,9 +35,7 @@ export const ToolPicker = () => { cookieKey={TOOL_PREFERRED_COOKIE_NAME} queryStringKey={toolQueryKey} onValue={(value: string) => { - // Visibility is driven by React state via ToggleableContent/MiniTocs - // (#6619); the article body is React-owned on both the hast and string - // paths, so no imperative DOM mutation is needed. + // React state drives visibility because the article body is React-owned. setTool(value) }} preferenceName="application" diff --git a/src/tools/lib/all-platforms.ts b/src/tools/lib/all-platforms.ts index 13845752f6a4..62636fde0b0f 100644 --- a/src/tools/lib/all-platforms.ts +++ b/src/tools/lib/all-platforms.ts @@ -1,4 +1,3 @@ -// All platforms available for the platform picker. export type Platform = 'mac' | 'windows' | 'linux' export const allPlatforms: Platform[] = ['mac', 'windows', 'linux'] diff --git a/src/tools/lib/all-tools.ts b/src/tools/lib/all-tools.ts index db932c91334c..e6dc50beebea 100644 --- a/src/tools/lib/all-tools.ts +++ b/src/tools/lib/all-tools.ts @@ -1,26 +1,16 @@ -// Maps each tool identifier to its display name. export interface ToolsMapping { [key: string]: string } -/* - All the tools available for the Tool Picker - - Ordered by usage analytics to prioritize most-used tools in the tool switcher. - This ensures popular tools appear before the "More" menu in the UnderlineNav component. - - Analytics Query (KQL): - ``` - docs_v0_preference_event - | where timestamp between (ago(180d) .. now()) - | where context.hostname == 'docs.github.com' - | where abs(totimespan(context.created - timestamp)) < 1h // bot filter - | summarize Count=count() by Name=preference_name, Value=preference_value - | order by Count desc - ``` - - Data as of 2025-11-04 (180-day window) -*/ +// Tools are ordered by usage analytics so common options appear before the More menu. +// Retune with this Kusto Query Language (KQL): +// docs_v0_preference_event +// | where timestamp between (ago(180d) .. now()) +// | where context.hostname == 'docs.github.com' +// | where abs(totimespan(context.created - timestamp)) < 1h // bot filter +// | summarize Count=count() by Name=preference_name, Value=preference_value +// | order by Count desc +// The trailing comments show counts from a 180-day window ending 2025-11-04. export const allTools: ToolsMapping = { vscode: 'Visual Studio Code', // 310,824 jetbrains: 'JetBrains IDEs', // 306,982 diff --git a/src/tools/scripts/liquid-markdown-tables/convert.ts b/src/tools/scripts/liquid-markdown-tables/convert.ts index eaf95e25050e..5bc6a2373a32 100644 --- a/src/tools/scripts/liquid-markdown-tables/convert.ts +++ b/src/tools/scripts/liquid-markdown-tables/convert.ts @@ -1,4 +1,4 @@ -// See the comment at the top of index.ts for how to use this script. +// Run with npm run liquid-markdown-tables -- convert content/path/to/article.md. import fs from 'fs' import chalk from 'chalk' diff --git a/src/tools/scripts/liquid-markdown-tables/index.ts b/src/tools/scripts/liquid-markdown-tables/index.ts index 04b093a4125b..7c4883a45953 100644 --- a/src/tools/scripts/liquid-markdown-tables/index.ts +++ b/src/tools/scripts/liquid-markdown-tables/index.ts @@ -1,47 +1,13 @@ -/** - * This script helps you rewrite Markdown files that might contain - * tables with Liquid `ifversion` tags the old/wrong way. - * For example: - * - * | Header | Header 2 | - * |--------|----------| - * | bla | bla |{% ifversion dependency-review-action-licenses %} - * | foo | foo |{% endif %}{% ifversion dependency-review-action-fail-on-scopes %} - * | bar | bar |{% endif %} - * | baz | baz | - * {%- ifversion dependency-review-action-licenses %} - * | qux | qux |{% endif %} - * - * Will become: - * - * | Header | Header 2 | - * |--------|----------| - * | bla | bla | - * | {% ifversion dependency-review-action-licenses %} | - * | foo | foo | - * | {% endif %} | - * | {% ifversion dependency-review-action-fail-on-scopes %} | - * | bar | bar | - * | {% endif %} | - * | baz | baz | - * | {% ifversion dependency-review-action-licenses %} | - * | qux | qux | - * | {% endif %} | - * - * Run the script like this: - * - * npm run liquid-markdown-tables -- convert content/path/to/article.md - * git diff - * - * To *find* files that you *can* convert, use: - * - * npm run liquid-markdown-tables -- find - * # or - * npm run liquid-markdown-tables -- find --filter content/mydocset - * - * This will print out paths to files that most likely contain the old/wrong Liquid `ifversion` tags. - * - */ +// Finds and converts Markdown tables that place Liquid ifversion tags inside table rows. +// Example input: | foo | bar |{% ifversion dependency-review-action-licenses %} +// Example output: +// | foo | bar | +// | {% ifversion dependency-review-action-licenses %} | +// Run convert: npm run liquid-markdown-tables -- convert content/path/to/article.md +// Then run git diff to inspect changes. +// Run find: npm run liquid-markdown-tables -- find +// Run filtered find: npm run liquid-markdown-tables -- find --filter content/mydocset +// Find prints paths that likely contain misplaced Liquid ifversion tags. import { program } from 'commander' diff --git a/src/tools/scripts/liquid-markdown-tables/lib.ts b/src/tools/scripts/liquid-markdown-tables/lib.ts index ec15b03812d0..d98ace258ef6 100644 --- a/src/tools/scripts/liquid-markdown-tables/lib.ts +++ b/src/tools/scripts/liquid-markdown-tables/lib.ts @@ -1,7 +1,7 @@ -// E.g. `{%- ifversion dependency-review-action-licenses %}\n` +// Matches a standalone ifversion line, such as {%- ifversion dependency-review-action-licenses %}. const ifVersionRegex = /^{%-?\s*ifversion\s+([\w- ]+)\s*-?%}\n/ const ifVersionEndRegex = /\|({%-?\s*ifversion\s+([\w- ]+)\s*-?%})\n/ -// E.g. `... |{% endif %}{% ifversion dependency-review-action-fail-on-scopes %}\n` +// Matches a row ending in endif plus ifversion, such as |{% endif %}{% ifversion foo %}. const endifIfVersionRegex = /\|({%-?\s*endif\s*%})({%-?\sifversion\s+([\w- ]+)\s*-?%})\n/ const endifRegex = /\|({%-?\s*endif\s*%})\n/ const endifAloneRegex = /^({%-?\s*endif\s*%})\n/ @@ -40,7 +40,7 @@ export async function processFile(content: string) { inTable = false } if (inTable) { - // E.g. `{%- ifversion dependency-review-action-licenses %}\n` + // Standalone ifversion tags become their own table rows. if (ifVersionRegex.test(line)) { const better = line.replace('{%-', '{%').replace('-%}', '%}').trim() line = `| ${better} |\n` From 0ce4f3e83d89270556e7ff8d3ec169150e2f8d18 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 17:07:59 +0000 Subject: [PATCH 02/18] Tighten code comments in src/ghes-releases (#63464) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/ghes-releases/lib/parse-release-notes.ts | 43 ++----- src/ghes-releases/lib/release-issues.ts | 7 +- .../scripts/create-enterprise-issue.ts | 35 +----- .../scripts/deprecate/archive-version.ts | 19 +-- .../scripts/deprecate/collapse-blank-lines.ts | 13 +- .../create-docs-ghes-version-repo.sh | 10 +- .../scripts/deprecate/rewrite-asset-paths.ts | 12 +- .../deprecate/update-automated-pipelines.ts | 63 +++------- .../scripts/deprecate/update-content.ts | 38 ++---- .../scripts/deprecate/update-data.ts | 13 +- .../scripts/generate-release-notes.ts | 111 +++++------------- .../scripts/notify-release-pms.ts | 65 +++------- src/ghes-releases/scripts/release-banner.ts | 11 +- .../scripts/update-enterprise-dates.ts | 25 ++-- src/ghes-releases/scripts/version-utils.ts | 10 +- .../tests/generate-release-notes.ts | 6 +- src/ghes-releases/tests/notify-release-pms.ts | 5 +- 17 files changed, 122 insertions(+), 364 deletions(-) diff --git a/src/ghes-releases/lib/parse-release-notes.ts b/src/ghes-releases/lib/parse-release-notes.ts index e235430cd314..b27fe866693f 100644 --- a/src/ghes-releases/lib/parse-release-notes.ts +++ b/src/ghes-releases/lib/parse-release-notes.ts @@ -1,7 +1,5 @@ -/** - * Pure parsing/extraction functions used by generate-release-notes.ts. - * Extracted here so they can be unit-tested without triggering the CLI. - */ +// generate-release-notes.ts uses side-effect-free parsers. +// Tests can import them without starting the CLI. import fs from 'fs' import { load } from 'js-yaml' @@ -11,14 +9,11 @@ export interface NoteEntry { sourceUrl: string } -/** - * Looks for ```yaml ... ``` blocks, or falls back to lines starting with "- heading:" - */ +// Agent output can omit fences, so fall back to the first line starting with "- heading:". export function extractYaml(agentOutput: string): string | null { const fenced = agentOutput.match(/```ya?ml\s*\n([\s\S]*?)```/) if (fenced) return fenced[1].trim() - // Fall back: look for lines that look like YAML note entries const lines = agentOutput.split('\n') const yamlLines: string[] = [] let inYaml = false @@ -27,7 +22,7 @@ export function extractYaml(agentOutput: string): string | null { inYaml = true } if (inYaml) { - // Stop if we hit a non-YAML line (not indented, not a list item, not a comment, not empty) + // End fallback YAML when the response resumes prose. if (line.trim() && !line.match(/^[\s#-]/) && !line.match(/^\s+\w+:/)) { break } @@ -66,17 +61,12 @@ export function parseNoteEntries(yamlStr: string, sourceUrl: string): NoteEntry[ } } } catch { - // Malformed YAML returns no entries rather than throwing. + // Bad agent YAML produces no entries so one issue cannot stop the run. } return entries } -/** - * Parse an existing release notes YAML file and extract NoteEntry[] from it, - * along with a set of source issue URLs already covered. - * Returns { entries, coveredUrls } or null if the file doesn't exist. - */ export function loadExistingEntries(yamlPath: string): { entries: NoteEntry[] coveredUrls: Set @@ -87,16 +77,8 @@ export function loadExistingEntries(yamlPath: string): { return loadExistingEntriesFromString(content) } -/** - * Parse release notes YAML content (as a string) and extract NoteEntry[] from it. - * This is the testable core, with no file I/O. - * - * Note: This uses manual line-by-line parsing instead of js-yaml because we need - * to preserve the `# https://github.com/.../issues/NNN` source URL comments that - * precede each note. YAML comments are stripped by `load()` and aren't part - * of the YAML data model, so a standard parser can't track the comment-to-note - * relationship we rely on for incremental mode and deduplication. - */ +// Parse release notes YAML manually so source issue URL comments stay attached to notes. +// js-yaml strips comments, which would break incremental mode and deduplication. export function loadExistingEntriesFromString(content: string): { entries: NoteEntry[] coveredUrls: Set @@ -150,7 +132,7 @@ export function loadExistingEntriesFromString(content: string): { if (currentSection === 'changes') currentHeading = 'Changes' else if (currentSection === 'closing_down') currentHeading = 'Closing down' else if (currentSection === 'retired') currentHeading = 'Retired' - else currentHeading = null // known_issues: skip + else currentHeading = null // Known issues stay in the placeholder template. continue } @@ -213,10 +195,6 @@ export function loadExistingEntriesFromString(content: string): { return { entries, coveredUrls } } -/** - * Append YAML lines for a list of note entries at a given indentation level. - * Handles the `# sourceUrl`, `- |`, and multi-line note content pattern. - */ export function appendNoteLines(lines: string[], noteEntries: NoteEntry[], indent: string): void { for (const entry of noteEntries) { lines.push(`${indent}# ${entry.sourceUrl}`) @@ -263,7 +241,6 @@ export function buildReleaseNotesYaml( lines.push('sections:') - // Features (grouped by heading). const featureEntries = noteEntries.filter((e) => featureHeadings.includes(e.heading)) const otherEntries = noteEntries.filter((e) => !featureHeadings.includes(e.heading)) @@ -291,7 +268,6 @@ export function buildReleaseNotesYaml( lines.push(' # TODO: Add feature notes') } - // Changes. const changeEntries = otherEntries.filter((e) => !['Closing down', 'Retired'].includes(e.heading)) if (changeEntries.length > 0) { lines.push('') @@ -299,14 +275,12 @@ export function buildReleaseNotesYaml( appendNoteLines(lines, changeEntries, ' ') } - // Known issues. lines.push('') lines.push(' known_issues:') lines.push(' # TODO: Add known issues from "GHES Release Note Tracking" project') lines.push(' - |') lines.push(' ...') - // Closing down. const closingEntries = otherEntries.filter((e) => e.heading === 'Closing down') if (closingEntries.length > 0) { lines.push('') @@ -314,7 +288,6 @@ export function buildReleaseNotesYaml( appendNoteLines(lines, closingEntries, ' ') } - // Retired. const retiredEntries = otherEntries.filter((e) => e.heading === 'Retired') if (retiredEntries.length > 0) { lines.push('') diff --git a/src/ghes-releases/lib/release-issues.ts b/src/ghes-releases/lib/release-issues.ts index 91e0d89cb3bd..f56c37f01956 100644 --- a/src/ghes-releases/lib/release-issues.ts +++ b/src/ghes-releases/lib/release-issues.ts @@ -7,9 +7,6 @@ interface IssueLike { labels: { name: string }[] } -/** - * Parse and validate the issue state filter. Defaults to "all". - */ export function parseIssueState(value?: string): IssueState { if (!value) return 'all' @@ -41,9 +38,7 @@ export function buildReleaseIssueListArgs(version: string, issueState: IssueStat ] } -/** - * Excludes release issues that should not produce GHES release notes. - */ +// "public roadmap" and "not planned" issues never produce GHES release notes. export function isExcludedReleaseIssue(issue: IssueLike): boolean { return issue.labels.some((l) => EXCLUDED_RELEASE_LABELS.has(l.name.toLowerCase())) } diff --git a/src/ghes-releases/scripts/create-enterprise-issue.ts b/src/ghes-releases/scripts/create-enterprise-issue.ts index b19db2e4366c..2d3ef022f680 100644 --- a/src/ghes-releases/scripts/create-enterprise-issue.ts +++ b/src/ghes-releases/scripts/create-enterprise-issue.ts @@ -1,7 +1,8 @@ -/** - * @purpose Writer tool - * @description Create release tracking issues for a new GHES version - */ +// @purpose Writer tool +// @description Create release tracking issues for a new GHES version +// +// Creates release and deprecation issues in github/docs-content and github/technical-content. +// Skips a release or deprecation when its issue already exists. import { readFileSync } from 'fs' import { basename } from 'path' import { Liquid } from 'liquidjs' @@ -45,25 +46,15 @@ interface IssueSearchOpts { titleMatch?: string } -// Required by github() to authenticate if (!process.env.GITHUB_TOKEN) { throw new Error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.') } const octokit = github() const liquid = new Liquid() -// [start-readme] -// -// This script creates enterprise release and deprecation issues in the -// github/docs-content and github/technical-content repositories. -// The script checks if an issue already exists for the release or deprecation. -// -// [end-readme] run() async function run() { - // This script requires one parameters with the value - // of either 'release' or 'deprecation' const releaseType = process.argv[2] if (releaseType !== 'release' && releaseType !== 'deprecation') { throw new Error( @@ -82,7 +73,6 @@ async function run() { async function createDeprecationIssue() { const repo = 'github/technical-content' console.log('Next deprecation number: ', oldestSupported) - // If an issue already exists for this release, do nothing const issueExists = await isExistingIssue(repo, { titleMatch: `Enterprise Server ${oldestSupported} deprecation steps`, labels: ['enterprise deprecation'], @@ -124,7 +114,6 @@ async function createReleaseIssue() { const releaseNumber = getNextReleaseNumber(releaseDates) console.log('Next release number: ', releaseNumber) - // If an issue already exists for this release, do nothing if ( await isExistingIssue(repo, { labels: ['ghes-release-automation', `GHES ${releaseNumber}`], @@ -136,8 +125,6 @@ async function createReleaseIssue() { const releaseInfo = releaseDates[releaseNumber] const rcDate = releaseInfo.release_candidate - // Only open an issue if today is within 30 days before - // the release candidate date if (getNumberDaysUntilMilestone(rcDate || '') > 30) { console.log( `The ${releaseNumber} release candidate is not until ${rcDate}! An issue will be opened 30 days prior to the release candidate date.`, @@ -147,8 +134,7 @@ async function createReleaseIssue() { const releaseTemplates = getReleaseTemplates() - // Set shell issues with placeholder title and body - // Need all issue numbers before filling in liquid templates + // Create placeholder issues first because Liquid templates need every issue URL. for (const templateName of Object.keys(releaseTemplates)) { const issue = await createIssue( repo, @@ -161,7 +147,6 @@ async function createReleaseIssue() { releaseTemplates[templateName].issue = issue.data } - // Go back and update title and body with rendered liquid templates const releaseTemplateContext = getReleaseTemplateContext( releaseNumber, releaseInfo, @@ -201,7 +186,6 @@ async function createIssue( throw error } if (issue.status === 201) { - // Write the values to disk for use in the workflow. console.log( `Issue #${issue.data.number} for the ${releaseNumber} ${releaseType} was opened: ${issue.data.html_url}`, ) @@ -305,15 +289,12 @@ function getReleaseTemplateContext( 'release-code-freeze-date': releaseInfo.code_freeze || '', 'release-rc-target-date': releaseInfo.release_candidate || '', } - // Add a context variable for each issue url for (const [templateName, template] of Object.entries(releaseTemplates)) { if (template.issue) { context[`${templateName}-url`] = template.issue.html_url } } - // Create a context variable for each of the - // 7 days before release-rc-target-date if (releaseInfo.release_candidate) { const rcTargetDate = new Date(releaseInfo.release_candidate).getTime() for (let i = 1; i <= 7; i++) { @@ -356,10 +337,6 @@ function getNextReleaseNumber(releaseDates: ReleaseDates): string { return Object.keys(releaseDates)[indexOfNext] } -// examples: -// searchQuery: 'author:docs-bot is:open' -// labels: ['enterprise deprecation', 'ghes 3.0'] -// titleMatch: 'GHES 3.0' async function isExistingIssue( repo: string, opts: IssueSearchOpts = { labels: undefined, searchQuery: undefined, titleMatch: undefined }, diff --git a/src/ghes-releases/scripts/deprecate/archive-version.ts b/src/ghes-releases/scripts/deprecate/archive-version.ts index 9c1f807ba498..46b0a8183f18 100755 --- a/src/ghes-releases/scripts/deprecate/archive-version.ts +++ b/src/ghes-releases/scripts/deprecate/archive-version.ts @@ -1,10 +1,5 @@ -// [start-readme] -// -// Run this script during the Enterprise deprecation process to download -// static copies of all pages for the oldest supported Enterprise version. -// See the Enterprise deprecation issue template for instructions. -// -// [end-readme] +// Run during Enterprise deprecation to download static pages for the oldest supported version. +// The Enterprise deprecation issue template owns the operational checklist. import path from 'path' import fs from 'fs' @@ -90,7 +85,6 @@ async function main() { } } - // remove temp directory await fs.promises.rm(tmpArchivalDirectory, { recursive: true, force: true }) const app = createApp() @@ -103,8 +97,7 @@ async function main() { await scrape({ urls, urlFilter: (url: string) => { - // Do not download assets from other hosts like S3 or octodex.github.com - // (this will keep them as remote references in the downloaded pages) + // Leave assets on other hosts as remote references in downloaded pages. return url.startsWith(`http://localhost:${port}/`) }, directory: tmpArchivalDirectory, @@ -126,7 +119,7 @@ async function main() { console.log(`\n\ndone scraping! added files to ${tmpArchivalDirectory}\n`) if (!singlePage) { - // create redirect html files to preserve frontmatter redirects + // Redirect files preserve frontmatter redirects after static scraping. await createRedirectsFile(pageList, path.join(tmpArchivalDirectory, version)) console.log(`next step: deprecate ${version} in lib/enterprise-server-releases.ts`) } else { @@ -148,9 +141,9 @@ async function createRedirectsFile(pageList: PageList, outputDirectory: string) const redirectEntries: Array<[string, string]> = Object.entries(redirects) for (let [oldPath, newPath] of redirectEntries) { - // remove any liquid variables that sneak in + // Redirect paths can include Liquid version variables. oldPath = oldPath.replace('/{{ page.version }}', '').replace('/{{ currentVersion }}', '') - // ignore any old paths that are not in this version + // Keep only redirects for the archived Enterprise version. if ( !( oldPath.includes(`/enterprise-server@${version}`) || diff --git a/src/ghes-releases/scripts/deprecate/collapse-blank-lines.ts b/src/ghes-releases/scripts/deprecate/collapse-blank-lines.ts index 52815b0b91d8..29290bf4a2b4 100644 --- a/src/ghes-releases/scripts/deprecate/collapse-blank-lines.ts +++ b/src/ghes-releases/scripts/deprecate/collapse-blank-lines.ts @@ -1,13 +1,9 @@ import fs from 'fs' import { execSync } from 'child_process' -// Removing deprecated Liquid conditionals leaves behind extra blank lines. -// The content team flags these every deprecation, and the MD012 linter rule -// is off so nothing catches them automatically. This collapses any run of -// two or more consecutive blank lines down to one, but only in the markdown -// files the deprecation actually changed. Single blank lines are left alone: -// removed Liquid can introduce one in a place where it doesn't belong, so a -// human still reviews each removal site one at a time. +// Deprecated Liquid conditionals leave extra blank lines that content reviewers flag. +// MD012 does not catch them in this repo. Collapse runs of two or more blank lines only in +// markdown files changed by deprecation. Single blank lines still need human review. function getChangedMarkdownFiles(): string[] { const commands = [ @@ -21,7 +17,7 @@ function getChangedMarkdownFiles(): string[] { try { output = execSync(command, { encoding: 'utf8' }) } catch { - // origin/main may not be fetched locally; skip that source. + // Skip origin/main when it is not fetched locally. continue } for (const line of output.split('\n')) { @@ -35,7 +31,6 @@ function getChangedMarkdownFiles(): string[] { return [...files].sort() } -// Collapses any run of 2+ blank lines into a single blank line. function collapse(contents: string): string { const lines = contents.split('\n') const result: string[] = [] diff --git a/src/ghes-releases/scripts/deprecate/create-docs-ghes-version-repo.sh b/src/ghes-releases/scripts/deprecate/create-docs-ghes-version-repo.sh index 76f7be8ef6d9..b357b9add60e 100755 --- a/src/ghes-releases/scripts/deprecate/create-docs-ghes-version-repo.sh +++ b/src/ghes-releases/scripts/deprecate/create-docs-ghes-version-repo.sh @@ -1,13 +1,11 @@ -# This script creates a new repository for an archived version of GitHub Enterprise Server documentation. -# Please update the version variable first. -# You may wish to run this script a little bit at a time instead of all at once incase there are any errors. +# Creates a repository for an archived GitHub Enterprise Server documentation version. +# Pass the version as the first argument, and run sections one at a time when inspecting failures. version=$1 cd ~/Documents/gh/github -# Teams are addressed by numeric ID because IDs survive team renames and slugs do not. -# Some APIs (repo creation, CODEOWNERS, custom properties) only accept slugs, so -# resolve the current slug from the ID at runtime rather than hardcoding it. +# Numeric team IDs survive team renames; slugs do not. +# Repo creation, CODEOWNERS, and custom properties require slugs, so resolve current slugs. org_id=9919 docs_team_id=325922 docs_eng_team_id=3935808 diff --git a/src/ghes-releases/scripts/deprecate/rewrite-asset-paths.ts b/src/ghes-releases/scripts/deprecate/rewrite-asset-paths.ts index 0ae4cc3cd8ad..a04414779db9 100644 --- a/src/ghes-releases/scripts/deprecate/rewrite-asset-paths.ts +++ b/src/ghes-releases/scripts/deprecate/rewrite-asset-paths.ts @@ -24,6 +24,7 @@ export class RewriteAssetPathsPlugin { this.replaceUrl = replaceUrl } + // HTML and CSS asset paths must point at the archive site unless local-dev leaves them relative. apply( registerAction: (event: string, callback: (args: ResourceSavedArgs) => Promise) => void, ) { @@ -35,12 +36,8 @@ export class RewriteAssetPathsPlugin { const text = resource.getText() let newBody = text - // Rewrite HTML asset paths. Example: - // ../assets/images/foo/bar.png -> - // https://github.github.com/docs-ghes-3.10/assets/images/foo/bar.png - if (resource.isHtml()) { - // Remove nextjs scripts and manifest.json link + // Next.js runtime files break static archives. newBody = newBody.replace( /<\/script>/g, '', @@ -58,11 +55,6 @@ export class RewriteAssetPathsPlugin { } } - // Rewrite CSS asset paths. Example - // url("../assets/fonts/alliance/alliance-no-1-regular.woff") -> - // url("https://github.github.com/docs-ghes-3.10/assets/fonts/alliance/alliance-no-1-regular.woff") - // url(../../../assets/cb-303/images/octicons/search-24.svg) -> - // url(https://github.github.com/docs-ghes-3.10/assets/cb-303/images/octicons/search-24.svg) if (resource.isCss()) { if (!this.localDev) { newBody = newBody.replace( diff --git a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts index 46c1f1a0ae04..c237d016948c 100755 --- a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts +++ b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts @@ -1,13 +1,6 @@ -// [start-readme] -// -// This script adds and removes placeholder data files in the -// automation pipelines data directories and -// data/release-notes/enterprise-server directories. This script -// uses the supported and deprecated versions to determine what -// directories should exist. This script also modifies the `api-versions` -// key if it exists in a pipeline's lib/config.json file. -// -// [end-readme] +// Adds and removes placeholder data for automation pipelines and GHES release notes +// from the supported and deprecated GHES versions. +// Updates api-versions in each pipeline lib/config.json when that key exists. import { existsSync, rmSync } from 'fs' import { mkdir, readFile, readdir, writeFile, cp } from 'fs/promises' @@ -20,8 +13,8 @@ const pipelines = JSON.parse(await readFile('src/automated-pipelines/lib/config. 'automation-pipelines' ] -// If the config file for a pipeline includes `api-versions` update that list -// based on the supported and deprecated releases. +// Pipelines with api-versions copy previous calendar date variants to the current release. +// Deprecated variants are dropped. export async function updateAutomatedConfigFiles() { for (const pipeline of pipelines) { const configFilepath = `src/${pipeline}/lib/config.json` @@ -29,12 +22,10 @@ export async function updateAutomatedConfigFiles() { const apiVersions = configData['api-versions'] if (!apiVersions) continue for (const key of Object.keys(apiVersions)) { - // Copy the previous release's calendar date versions to the new release if (key.endsWith(previousReleaseNumber)) { const newKey = key.replace(previousReleaseNumber, currentReleaseNumber) apiVersions[newKey] = apiVersions[key] } - // Remove any deprecated versions for (const deprecatedRelease of deprecated) { if (key.endsWith(deprecatedRelease)) { delete apiVersions[key] @@ -49,15 +40,9 @@ export async function updateAutomatedConfigFiles() { } export async function updateAutomatedPipelines() { - // The allVersions object uses the 'api-versions' data stored in the - // src/rest/lib/config.json file. We want to update 'api-versions' - // before the allVersions object is created so we need to import it - // after calling updateAutomatedConfigFiles. + // Import allVersions after config updates so src/rest/lib/config.json changes take effect. const { allVersions } = await import('@/versions/lib/all-versions') - // Gets all of the base names (e.g., ghes-) in the allVersions object - // Currently, this is only ghes- but if we had more than one type of - // numbered release it would get all of them. const numberedReleaseBaseNames = Array.from( new Set( Object.values(allVersions) @@ -66,12 +51,7 @@ export async function updateAutomatedPipelines() { ), ) - // A list of currently supported versions (calendar date inclusive) - // in the format using the short name rather than full format - // (e.g., enterprise-server@). The list is filtered - // to only include versions that have numbered releases (e.g. ghes-). - // The list is generated from the `apiVersions` key in allVersions. - // This is currently only needed for the rest and github-apps pipelines. + // rest and github-apps read calendar-date versions from allVersions.apiVersions. const versionNamesCalDate = Object.values(allVersions) .filter((version) => version.hasNumberedReleases) .map((version) => @@ -80,16 +60,13 @@ export async function updateAutomatedPipelines() { : version.openApiVersionName, ) .flat() - // A list of currently supported versions in the format using the short name - // rather than the full format (e.g., enterprise-server@). The list is filtered - // to only include versions that have numbered releases (e.g. ghes-). - // Currently, this is used for the graphql and webhooks pipelines. + // graphql and webhooks read numbered versions in ghes-major.minor form. const versionNames = Object.values(allVersions) .filter((version) => version.hasNumberedReleases) .map((version) => version.openApiVersionName) for (const pipeline of pipelines) { - // secret-scanning has a different directory structure than the others + // secret-scanning stores pattern docs outside the shared pipeline data layout. const directoryWithReleases = pipeline === 'secret-scanning' ? 'src/secret-scanning/data/pattern-docs' @@ -101,8 +78,7 @@ export async function updateAutomatedPipelines() { )['api-versions'] const directoryListing = await readdir(directoryWithReleases) - // filter the directory list to only include directories that start with - // basenames with numbered releases (e.g., ghes-). + // Limit pipeline data dirs to numbered release basenames like ghes-. const existingDataDir = directoryListing.filter((directory) => numberedReleaseBaseNames.some((basename) => directory.startsWith(basename)), ) @@ -113,19 +89,15 @@ export async function updateAutomatedPipelines() { const expectedDirectory = isCalendarDateVersioned ? versionNamesCalDate : versionNames - // Get a list of data directories to remove (deprecate) and remove them - // This should only happen if a release is being deprecated. const removeFiles = difference(existingDataDir, expectedDirectory) for (const directory of removeFiles) { console.log(`Removing src/${pipeline}/data/${directory}`) rmSync(`src/${pipeline}/data/${directory}`, { recursive: true, force: true }) } - // Get a list of data directories to create (release) and create them - // This should only happen if a release is being added. const addFiles = difference(expectedDirectory, existingDataDir) - // Verify all new directories belong to the current release + // Reject directories unrelated to the current release before creating them. for (const dir of addFiles) { if (!dir.includes(currentReleaseNumber)) { throw new Error( @@ -136,15 +108,10 @@ export async function updateAutomatedPipelines() { } for (const base of numberedReleaseBaseNames) { - // Find ALL directories to add for this base name (may be multiple - // when a release has more than one calendar-date version). + // Calendar-date releases can add more than one directory for the same base name. const dirsToAdd = addFiles.filter((item) => item.startsWith(base)) for (const dirToAdd of dirsToAdd) { - // Derive the previous release's corresponding directory by replacing - // the current release number with the previous one. This correctly - // maps each calendar-date variant to its predecessor, e.g.: - // ghes-3.20-2022-11-28 -> ghes-3.19-2022-11-28 - // ghes-3.20-2026-03-10 -> ghes-3.19-2026-03-10 + // Keep calendar-date suffixes unchanged when mapping previous dirs to current dirs. const previousDirName = dirToAdd.replace(currentReleaseNumber, previousReleaseNumber) if (!existingDataDir.includes(previousDirName)) { throw new Error( @@ -163,9 +130,7 @@ export async function updateAutomatedPipelines() { } } - // Add and remove the GHES release note data. Once we create an automation - // pipeline for release notes, we can remove this because it will use the - // same directory structure as the other pipeline data directories. + // GHES release notes stay in this path until an automation pipeline owns the same layout. const ghesReleaseNotesDirs = await readdir('data/release-notes/enterprise-server') const supportedHyphenated = supported.map((version) => version.replace('.', '-')) const deprecatedHyphenated = deprecated.map((version) => version.replace('.', '-')) diff --git a/src/ghes-releases/scripts/deprecate/update-content.ts b/src/ghes-releases/scripts/deprecate/update-content.ts index 07422ed63dde..35004217f1a7 100644 --- a/src/ghes-releases/scripts/deprecate/update-content.ts +++ b/src/ghes-releases/scripts/deprecate/update-content.ts @@ -17,9 +17,8 @@ const contentFiles = walkFiles('content', { ignore: ['**/README.md', '**/index.md'], }) -// This module updates the versions frontmatter in content files. -// When a content file contains only deprecated GHES releases, the -// file is deleted and removed from the parent index.md file. +// Updates versions frontmatter during GHES deprecation. +// Deletes GHES-only files with no supported release from their parent index.md. export function updateContentFiles() { for (const file of contentFiles) { const oldContents = fs.readFileSync(file, 'utf8') @@ -35,16 +34,13 @@ export function updateContentFiles() { throw new Error(`Could not load feature versions from ${featureFilePath}`) } - // skip files with no Enterprise Server versions frontmatter if (!data.versions.ghes && !featureData?.versions?.ghes) continue - // skip files with all ghes releases defined if (data.versions.ghes === '*') continue const deprecatedRelease = deprecated[0] const oldestRelease = supported[supported.length - 1] - // If the frontmatter versions.ghes property is now - // applicable to all GHES releases, update the value to '*'. + // Feature-backed content becomes all versions when it applies to FPT, GHEC, and every GHES. const featureAppliesToAllVersions = featureData && featureData.versions.ghec && @@ -55,9 +51,7 @@ export function updateContentFiles() { if (isInAllGhes(data.versions.ghes)) { console.log('Updating GHES version in: ', file) data.versions.ghes = '*' - // To preserve newlines when stringifying, - // you can set the lineWidth option to -1 - // This prevents updates to the file that aren't actual changes. + // lineWidth -1 preserves existing newlines, so only frontmatter changes are written. fs.writeFileSync( file, frontmatter.stringify(content!, data, { lineWidth: -1 } as unknown as Parameters< @@ -73,9 +67,7 @@ export function updateContentFiles() { ghec: '*', ghes: '*', } - // To preserve newlines when stringifying, - // you can set the lineWidth option to -1 - // This prevents updates to the file that aren't actual changes. + // lineWidth -1 preserves existing newlines, so only frontmatter changes are written. fs.writeFileSync( file, frontmatter.stringify(content!, data, { lineWidth: -1 } as unknown as Parameters< @@ -87,9 +79,7 @@ export function updateContentFiles() { const deprecatedRegex = new RegExp(`(<|<=)\\s?${deprecatedRelease}`, 'g') const oldestRegex = new RegExp(`<\\s?${oldestRelease}`, 'g') - // If the frontmatter versions.ghes property is now - // deprecated, remove it. If the content file is only - // versioned for GHES, remove the file and update index.md. + // Remove GHES frontmatter or delete GHES-only files when no supported GHES applies. const featureGhes = featureData?.versions?.ghes || '' const appliesToNoSupportedGhesReleases = deprecatedRegex.test(data.versions.ghes) || @@ -101,7 +91,6 @@ export function updateContentFiles() { if (Object.keys(data.versions).length === 1) { removeFileUpdateParent(file) } else { - // Remove the ghes property from versions Fm and return delete data.versions.ghes console.log('Removing GHES version from: ', file) fs.writeFileSync( @@ -130,15 +119,14 @@ function removeFileUpdateParent(filePath: string) { data: { children: string[] } | undefined } if (!data) return - // Children paths are relative to the index.md file's directory + // Children paths are relative to the index.md file's directory. const childPath = filePath.endsWith('index.md') ? `/${path.basename(path.dirname(filePath))}` : `/${path.basename(filePath, '.md')}` - // Remove the childPath from the parent index.md file's children frontmatter data.children = data.children.filter((child) => child !== childPath) - // If removing the childPath leaves the parent index.md file empty, remove it + // Empty parent indexes must disappear with their last child. if (data.children.length === 0) { removeFileUpdateParent(parentFilePath) } else { @@ -152,15 +140,10 @@ function removeFileUpdateParent(filePath: string) { } } -// Gets the next parent file path. -// If the filePath is an article (e.g., doesn't end with index.md), -// then the parent file is the index.md file in the same directory. -// If the filePath is a category or subcategory (e.g., ends with index.md), -// the parent is the index.md file in the next directory up. +// Articles use the index.md in their directory; index.md files use the parent directory's index.md. +// content/index.md has no parent. function getParentFilePath(filePath: string) { - // This is the root index.md file, it has no parent if (!filePath || filePath === 'content/index.md') return null - // Handle index.md files with index.md parent in directory above if (filePath.endsWith('index.md')) { const pathParts = filePath.split('/') pathParts.pop() @@ -168,6 +151,5 @@ function getParentFilePath(filePath: string) { pathParts.push('index.md') return pathParts.join('/') } - // Handle articles with a parent index.md file return filePath.replace(path.basename(filePath), 'index.md') } diff --git a/src/ghes-releases/scripts/deprecate/update-data.ts b/src/ghes-releases/scripts/deprecate/update-data.ts index 08e91ce9e9b1..9b9badeff590 100644 --- a/src/ghes-releases/scripts/deprecate/update-data.ts +++ b/src/ghes-releases/scripts/deprecate/update-data.ts @@ -31,8 +31,7 @@ export function updateDataFiles() { updateFeatureData() } -// Removes empty data/reusables files and removes the deleted -// reusable from any content or data/reusables files that reference it. +// Remove empty reusable files and their Liquid references so content does not use deleted data. function updateReusableData() { const deletedDataFiles = [] @@ -44,16 +43,12 @@ function updateReusableData() { deletedDataFiles.push(file) } } - // Map the format: - // data/reusables/actions/actions-runner-controller-unsupported-customization.md - // to the format: - // {% data reusables.code-scanning.beta-org-enable-all %} + // Example: data/reusables/actions/runner.md becomes {% data reusables.actions.runner %}. const reusableNames = deletedDataFiles.map( (file) => `{% data ${file.replace('.md', '').split('/').slice(1).join('.')} %}`, ) const existingDataReusables = difference(dataReusables, deletedDataFiles) - // Remove deleted reusables from content and data resuables files for (const file of [...existingDataReusables, ...contentFiles]) { const originalContent = fs.readFileSync(file, 'utf8') let content = originalContent @@ -70,9 +65,7 @@ function updateReusableData() { } } -// Removes deprecated data/feature files and outputs a list of data/features -// available in all versions. That list is only used for review during a GHES -// deprecation. +// Lists all-version data/features for human review during GHES deprecation. function updateFeatureData() { const allFeatureFiles = new Set() diff --git a/src/ghes-releases/scripts/generate-release-notes.ts b/src/ghes-releases/scripts/generate-release-notes.ts index 1ecdd3ab3b21..9da6ed83aada 100644 --- a/src/ghes-releases/scripts/generate-release-notes.ts +++ b/src/ghes-releases/scripts/generate-release-notes.ts @@ -1,13 +1,8 @@ -/** - * @purpose Writer tool - * @description Generate GHES release notes from github/releases issues using Copilot CLI - * - * Generate GHES release notes by: - * 1. Querying github/releases issues labeled "GHES " (all states by default) - * 2. Finding corresponding changelog PRs in github/blog - * 3. Running each through the ghes-release-notes agent via Copilot CLI - * 4. Stitching the YAML outputs into a release notes file - */ +// @purpose Writer tool +// @description Generate GHES release notes from github/releases issues using Copilot CLI +// +// Queries github/releases issues labeled with the GHES release number, all states by default. +// Matches github/blog changelog PRs, runs ghes-release-notes, and writes release note YAML. import { Command } from 'commander' import { execFileSync, spawn, type ChildProcess } from 'child_process' import fs from 'fs' @@ -80,9 +75,7 @@ function loadFeatureHeadings(): string[] { return _featureHeadingsCache } -/** - * Run `gh` CLI commands with native auth (no GITHUB_TOKEN interference) - */ +// Drop GITHUB_TOKEN so gh uses native auth instead of repo workflow auth. function gh(args: string[]): string { const env = { ...process.env } delete env.GITHUB_TOKEN @@ -105,11 +98,8 @@ interface ChangelogInfo { body: string | null } -/** - * Try to extract a changelog PR URL from a release issue body. - * Looks for patterns like: - * 📄 **Changelog post:** https://github.com/github/blog/pull/1234 - */ +// Release issues can link the changelog PR in a Changelog post field. +// Example field: Changelog post. The value is a github/blog pull request URL. function extractChangelogPrUrl(issueBody: string): string | null { const match = issueBody.match(/https:\/\/github\.com\/github\/blog\/pull\/\d+/) return match ? match[0] : null @@ -125,11 +115,8 @@ function fetchChangelogPrBody(prUrl: string): string | null { } } -/** - * Search github/blog for a merged PR that references a release issue number. - * Caches the fetched PR list to avoid redundant API calls. - * Returns URL + body when found. - */ +// Cache recent github/blog changelog PRs because multiple release issues search the same list. +// Each changelog PR body links back to its release issue. let _blogPrsCache: { number: number; body: string; url: string }[] | null = null function searchChangelogPr(issueNumber: number): ChangelogInfo | null { try { @@ -170,10 +157,6 @@ function searchChangelogPr(issueNumber: number): ChangelogInfo | null { return null } -/** - * Find the changelog PR for a release issue, checking the issue body first, - * then falling back to searching github/blog. Returns URL + body. - */ function findChangelogPr(issue: ReleaseIssue): ChangelogInfo | null { const fromBody = extractChangelogPrUrl(issue.body) if (fromBody) { @@ -183,11 +166,8 @@ function findChangelogPr(issue: ReleaseIssue): ChangelogInfo | null { return searchChangelogPr(issue.number) } -/** - * Resolve the Copilot CLI path. - * Result is cached after first call. - */ let _copilotCliPath: string | null = null +// The VS Code extension fallback covers macOS only; otherwise which must find a global install. function findCopilotCli(): string { if (_copilotCliPath) return _copilotCliPath @@ -199,10 +179,6 @@ function findCopilotCli(): string { } } catch {} - // Fallback: check VS Code extension storage locations. - // These paths are macOS-only. On Linux/Windows the `which` check above should - // find Copilot CLI if it's installed globally. If needed, add platform-specific - // paths here (e.g., ~/.config/Code/User/globalStorage/... for Linux). const homeDir = os.homedir() const vsCodePath = path.join( homeDir, @@ -225,11 +201,6 @@ function findCopilotCli(): string { throw new Error('Copilot CLI not found. Install via: npm install -g @github/copilot@prerelease') } -/** - * Run the ghes-release-notes agent on a release issue (+optional changelog PR) - * via Copilot CLI and return the raw output. - * Uses async spawn so SIGINT (Ctrl+C) is not blocked. - */ interface AgentContext { issueUrl: string issueTitle: string @@ -239,32 +210,27 @@ interface AgentContext { featureHeadings: string[] } -/** - * Extract the title tag (e.g., "GA", "Public Preview") from a release issue title. - */ function parseTitleTag(title: string): string | null { const match = title.match(/\[(GA|Public Preview|Beta|Private Preview|Closing Down|Retired)\]/i) return match ? match[1] : null } +// Use async spawn so Ctrl+C can interrupt the agent process. function runAgent(ctx: AgentContext): Promise { const copilotPath = findCopilotCli() const titleTag = parseTitleTag(ctx.issueTitle) - // Build an optimized prompt that pre-includes all context so the agent - // doesn't need to make tool calls to fetch it. + // Preload context so the agent does not need network tools during note generation. let prompt = `You are running in non-interactive mode. Do NOT ask any follow-up questions. ` prompt += `Generate a release note for ${ctx.issueUrl}. ` prompt += `Follow the ghes-release-notes agent instructions. ` prompt += `Return ONLY a single YAML code block (\`\`\`yaml ... \`\`\`). No conversation, no questions, no explanations outside the code block.\n\n` - // Pre-supply the title tag so the agent doesn't need to re-parse if (titleTag) { prompt += `The issue title tag is [${titleTag}].\n\n` } - // Pre-supply valid headings so the agent doesn't need to read PLACEHOLDER-TEMPLATE.yml prompt += `IMPORTANT: Do NOT read PLACEHOLDER-TEMPLATE.yml or data/variables/product.yml — all necessary context is provided below.\n\n` prompt += `Valid feature headings (use ONLY these for feature notes):\n` for (const h of ctx.featureHeadings) { @@ -272,13 +238,11 @@ function runAgent(ctx: AgentContext): Promise { } prompt += `\nFor non-feature notes, use: Changes, Closing down, or Retired.\n\n` - // Pre-supply the issue body so the agent doesn't need to fetch it prompt += `--- RELEASE ISSUE (${ctx.issueUrl}) ---\n` prompt += `Title: ${ctx.issueTitle}\n` prompt += ctx.issueBody.substring(0, 15000) prompt += `\n--- END RELEASE ISSUE ---\n\n` - // Pre-supply the changelog PR body if available if (ctx.changelogUrl && ctx.changelogBody) { prompt += `--- CHANGELOG PR (${ctx.changelogUrl}) ---\n` prompt += ctx.changelogBody.substring(0, 10000) @@ -379,14 +343,8 @@ interface AgentResult { skipWarning?: string } -/** - * Run the agent with retry logic. Retries up to `maxRetries` times on failure. - * Validates that extracted YAML parses into non-empty entries before accepting. - * If the agent tries to skip (returns `# SKIP: reason` + `[]`), treats it as a - * failed attempt and retries, because issues that matched the GHES label filter should - * always get a release note. If all attempts result in skips, the last skip - * reason is attached as a warning. - */ +// Retry agent failures and reject empty YAML so matching GHES issues produce release notes. +// Skip signals become warnings only after every attempt skips. async function runAgentWithRetry(ctx: AgentContext, maxRetries = 2): Promise { let lastError: Error | null = null let lastRawOutput = '' @@ -397,8 +355,7 @@ async function runAgentWithRetry(ctx: AgentContext, maxRetries = 2): Promise', 'GHES release number (e.g., 3.20, 3.21)') .option('--rc [boolean]', 'Generate release candidate notes (omit for GA)', (val: string) => { - // Support both `--rc` (no value → true) and `--rc true`/`--rc false` (legacy) + // Accept --rc alone, --rc true, and --rc false. if (val === undefined || val === 'true') return true if (val === 'false') return false return true @@ -462,7 +419,7 @@ program '-i, --issue ', 'Process a single issue by number or URL (replaces its entry if it already exists)', (val: string) => { - // Accept a full URL like https://github.com/github/releases/issues/6768 + // Accept full github/releases issue URLs as well as numbers. const urlMatch = val.match(/\/issues\/(\d+)/) if (urlMatch) return parseInt(urlMatch[1], 10) const num = parseInt(val, 10) @@ -496,7 +453,6 @@ program process.exit(1) } - // Prerequisite checks. try { execFileSync('gh', ['--version'], { stdio: 'ignore' }) } catch { @@ -524,7 +480,6 @@ program process.exit(1) } - // Step 1: Fetch release issues. let issues: ReleaseIssue[] if (singleIssue) { @@ -570,12 +525,11 @@ program process.exit(0) } - // GA meta-issues (e.g. "GHES 3.20 GA [GA]") are tracking issues, not features. + // GA meta-issues named GHES X.Y GA or GHES X.Y release are tracking issues. const originalCount = issues.length issues = issues.filter((issue) => { const title = issue.title.trim() const stripped = title.replace(/\s*\[[^\]]*\]/g, '').trim() - // Skip issues whose title is just "GHES X.Y GA" or "GHES X.Y release" if (/^ghes\s+\d+\.\d+\s+ga$/i.test(stripped)) return false if (/^ghes\s+\d+\.\d+\s+release$/i.test(stripped)) return false return true @@ -613,14 +567,14 @@ program const outputDir = path.join(process.cwd(), 'data/release-notes/enterprise-server', dirName) const outputPath = path.join(outputDir, fileName) - // Incremental mode: load existing entries. + // Existing entries make generation incremental by default. const allEntries: NoteEntry[] = [] let existingCoveredUrls = new Set() if (!force && !stdout && fs.existsSync(outputPath)) { const existing = loadExistingEntries(outputPath) if (existing && existing.entries.length > 0) { - // When --issue is specified, remove the old entry for that issue so it gets regenerated + // Regenerate a single issue by dropping its previous source URL entry first. if (singleIssue) { const issueUrl = `https://github.com/github/releases/issues/${singleIssue}` const kept = existing.entries.filter((e) => e.sourceUrl !== issueUrl) @@ -638,7 +592,7 @@ program } } - // Filter out issues already covered by existing file (incremental mode) + // Incremental mode skips release issues already covered by the existing file. if (existingCoveredUrls.size > 0 && !singleIssue) { const beforeCount = issues.length issues = issues.filter((issue) => !existingCoveredUrls.has(issue.url)) @@ -652,7 +606,6 @@ program } } - // Step 2: Find changelog PRs. spinner.start('Finding changelog PRs...') const issueChangelogMap = new Map() let changelogFound = 0 @@ -664,14 +617,12 @@ program } spinner.succeed(`Found changelog PRs for ${changelogFound}/${issues.length} issues`) - // Step 3: Run agent on each issue. const failures: { issue: ReleaseIssue; error: string }[] = [] const existingEntryCount = allEntries.length - // Hoisted for clarity. The underlying load is already cached. const featureHeadings = loadFeatureHeadings() - // Helper to write current entries to file (called after each success and on Ctrl+C) + // Flush after each success and on Ctrl+C so long runs keep completed notes. const writeCurrentOutput = () => { if (stdout || allEntries.length === 0) return try { @@ -712,15 +663,14 @@ program ) } - // entries are pre-validated by runAgentWithRetry, but re-parse for the actual list + // Re-parse validated YAML so the main list uses parseNoteEntries output. const entries = parseNoteEntries(result.yamlStr, issue.url) - // Warn about headings that don't match any known feature heading or special section + // Unknown headings fall back to changes after case-insensitive correction fails. const specialHeadings = ['Changes', 'Closing down', 'Retired'] const validHeadings = new Set([...featureHeadings, ...specialHeadings]) for (const entry of entries) { if (!validHeadings.has(entry.heading)) { - // Check for near-matches (case-insensitive) const lowerHeading = entry.heading.toLowerCase() const closeMatch = featureHeadings.find((h) => h.toLowerCase() === lowerHeading) if (closeMatch) { @@ -734,7 +684,7 @@ program } } - // Dedup: avoid duplicate notes if the same issue was partially loaded from an existing file + // Avoid duplicates when an existing file already contributed the same issue note. for (const entry of entries) { const isDuplicate = allEntries.some( (existing) => @@ -765,7 +715,7 @@ program console.log(` Reason: ${skipReason}`) } else { spinner.fail(`${label} — ${msg.substring(0, 100)}`) - // Show raw agent output only for real errors + // Show raw agent output only for real errors. if (err.rawOutput) { console.log('\n --- Raw agent output (last attempt) ---') const truncated = @@ -779,7 +729,6 @@ program } } - // Step 4: Final summary. flushBeforeExit = null const newCount = allEntries.length - existingEntryCount if (existingEntryCount > 0) { @@ -802,7 +751,7 @@ program process.exit(1) } if (singleIssue) { - // For a single issue + stdout, print just the raw note YAML (not the full template) + // Single-issue stdout prints note entries, not the full release template. const newEntries = allEntries.filter( (e) => e.sourceUrl === `https://github.com/github/releases/issues/${singleIssue}`, ) @@ -828,7 +777,7 @@ program console.log('\nNo entries generated — no file written.') } - // Clean up stdin raw mode so the process can exit gracefully + // Clean up stdin raw mode so the process can exit gracefully. if (process.stdin.isTTY) { process.stdin.setRawMode(false) process.stdin.pause() diff --git a/src/ghes-releases/scripts/notify-release-pms.ts b/src/ghes-releases/scripts/notify-release-pms.ts index 79adb25aad38..fb12bd9791ad 100644 --- a/src/ghes-releases/scripts/notify-release-pms.ts +++ b/src/ghes-releases/scripts/notify-release-pms.ts @@ -1,21 +1,10 @@ -/** - * @purpose Writer tool - * @description Notify PMs to review their GHES release notes on a PR - * - * Notify PMs about generated GHES release notes by posting a review comment - * on each source release issue in github/releases. - * - * For each release issue URL found in the YAML file's `# https://...` comments, - * this script posts a comment asking the PM to review the note in the PR and - * react with 🚀 once satisfied. - * - * Usage: - * # Post comments via GitHub Actions (handles auth automatically): - * gh workflow run notify-release-pms.yml -f release=3.20 -f pr=12345 - * - * # Preview locally (dry run, no token needed): - * npm run notify-release-pms -- --release 3.20 --pr 12345 --dry-run - */ +// @purpose Writer tool +// @description Notify PMs to review their GHES release notes on a PR +// +// Posts docs-bot review comments on github/releases source issues from YAML source comments. +// Product managers (PMs) review the PR and react with 🚀 when satisfied. +// GitHub Actions usage: gh workflow run notify-release-pms.yml -f release= -f pr= +// Local preview: npm run notify-release-pms -- --release --pr --dry-run import { Command } from 'commander' import { execFileSync } from 'child_process' import fs from 'fs' @@ -27,11 +16,7 @@ export interface SourceNote { issueNumber: number } -/** - * Run read-only `gh` CLI commands. - * Uses DOCS_BOT_PAT_BASE when available (CI), otherwise falls back to - * the caller's native `gh` auth (local). - */ +// DOCS_BOT_PAT_BASE authenticates CI reads; caller gh auth handles local reads. function ghRead(args: string[]): string { const env = { ...process.env } if (env.DOCS_BOT_PAT_BASE) { @@ -46,10 +31,7 @@ function ghRead(args: string[]): string { }) } -/** - * Run `gh` CLI commands authenticated as docs-bot (for posting comments). - * Requires the DOCS_BOT_PAT_BASE environment variable to be set. - */ +// Posting comments requires DOCS_BOT_PAT_BASE so they come from docs-bot. function ghWrite(args: string[]): string { const token = process.env.DOCS_BOT_PAT_BASE if (!token) { @@ -62,7 +44,7 @@ function ghWrite(args: string[]): string { process.exit(1) } const env = { ...process.env, GH_TOKEN: token } - // Ensure GH_TOKEN takes precedence over any pre-existing GITHUB_TOKEN + // GH_TOKEN must take precedence over any pre-existing GITHUB_TOKEN. delete (env as Record).GITHUB_TOKEN return execFileSync('gh', args, { encoding: 'utf8', @@ -72,11 +54,7 @@ function ghWrite(args: string[]): string { }) } -/** - * Parse release notes content and extract source issue URLs. - * Each `# https://github.com/github/releases/issues/NNNN` comment - * maps to the note(s) that follow it. - */ +// Source issue URL comments attach each generated note to its github/releases issue. export function parseSourceNotes(content: string): SourceNote[] { const lines = content.split('\n') const notes: SourceNote[] = [] @@ -86,8 +64,7 @@ export function parseSourceNotes(content: string): SourceNote[] { const match = lines[i].match(/^\s*#\s*(https:\/\/github\.com\/github\/releases\/issues\/(\d+))/) if (match) { const issueNumber = parseInt(match[2], 10) - // Some issues appear multiple times (e.g. in features and changes). Keep the - // first occurrence so the link points to the primary note. + // Keep the first occurrence so duplicate issue links point to the primary note. if (!seen.has(issueNumber)) { seen.add(issueNumber) notes.push({ @@ -116,8 +93,7 @@ export function buildCommentBody( const prUrl = `https://github.com/github/docs-internal/pull/${prNumber}` const fileUrl = `${prUrl}/files` - // Use a marker so we can identify our comments later (for duplicate-prevention). - // Include releaseType so RC and GA comments are distinguishable. + // Mark comments for duplicate detection; releaseType keeps RC and GA distinct. const marker = buildMarker(version, releaseType.toLowerCase() as 'rc' | 'ga') const mentions = assignees.length > 0 ? `${assignees.map((a) => `@${a}`).join(' ')} ` : '' @@ -222,7 +198,7 @@ program process.exit(1) } } else { - // Auto-detect: prefer GA if it exists, otherwise RC (consistent with generate-release-notes) + // Auto-detect prefers GA over RC to match generate-release-notes. if (fs.existsSync(gaPath)) { rc = false yamlPath = gaPath @@ -239,7 +215,6 @@ program const relativeFilePath = path.relative(process.cwd(), yamlPath) - // Step 1: Extract source issue URLs. spinner.start('Parsing release notes file...') const sourceNotes = extractSourceNotes(yamlPath) spinner.succeed(`Found ${sourceNotes.length} unique release issue(s) in ${relativeFilePath}`) @@ -249,7 +224,6 @@ program process.exit(0) } - // Step 2: Check for existing comments (avoid duplicates). const releaseType = rc ? 'rc' : 'ga' const marker = buildMarker(release, releaseType) const alreadyCommented = new Set() @@ -268,7 +242,7 @@ program alreadyCommented.add(note.issueNumber) } } catch { - // If we can't read comments, we'll try to post and handle errors then + // Post anyway when comment reads fail; posting reports permission errors. } } if (alreadyCommented.size > 0) { @@ -282,7 +256,6 @@ program spinner.succeed('No existing notifications found') } - // Step 3: Post comments. const toNotify = sourceNotes.filter((n) => !alreadyCommented.has(n.issueNumber)) if (toNotify.length === 0) { @@ -295,18 +268,17 @@ program for (let i = 0; i < toNotify.length; i++) { const note = toNotify[i] - // Fetch assignees (or fall back to issue author) for the release issue + // Mention assignees, or the non-bot issue author when no assignee exists. let assignees: string[] = [] try { const raw = ghRead(['api', `repos/github/releases/issues/${note.issueNumber}`]) const issue = JSON.parse(raw) assignees = (issue.assignees || []).map((a: { login: string }) => a.login) - // Fall back to the issue author unless they're a bot if (assignees.length === 0 && issue.user?.login && issue.user.type !== 'Bot') { assignees = [issue.user.login] } } catch { - // If we can't fetch the issue, post without mentions + // Post without mentions when the issue fetch fails. } const commentBody = buildCommentBody(release, rc, prNumber, assignees) @@ -342,7 +314,6 @@ program } } - // Summary. console.log(`\n${'─'.repeat(40)}`) console.log(`${dryRun ? '🔍 Dry run' : '✅ Done'}`) console.log( @@ -356,7 +327,7 @@ program }, ) -// Only run CLI when executed directly (not when imported in tests) +// Tests import helpers without running the CLI. if (import.meta.url === `file://${process.argv[1]}`) { program.parse(process.argv) } diff --git a/src/ghes-releases/scripts/release-banner.ts b/src/ghes-releases/scripts/release-banner.ts index 7c2136cb64b4..64f15e74c3f1 100644 --- a/src/ghes-releases/scripts/release-banner.ts +++ b/src/ghes-releases/scripts/release-banner.ts @@ -1,12 +1,5 @@ -/** - * @purpose Writer tool - * @description Create or remove a release candidate banner for a GHES version - */ -// [start-readme] -// -// This script creates or removes a release candidate banner for a specified version. -// -// [end-readme] +// @purpose Writer tool +// @description Create or remove a release candidate banner for a GHES version import fs from 'fs/promises' import { program } from 'commander' diff --git a/src/ghes-releases/scripts/update-enterprise-dates.ts b/src/ghes-releases/scripts/update-enterprise-dates.ts index 2eff3bc7c721..6a3a3bb12f1d 100644 --- a/src/ghes-releases/scripts/update-enterprise-dates.ts +++ b/src/ghes-releases/scripts/update-enterprise-dates.ts @@ -1,13 +1,9 @@ -/** - * @purpose Writer tool - * @description Update enterprise release dates from github/enterprise-releases - */ -// [start-readme] +// @purpose Writer tool +// @description Update enterprise release dates from github/enterprise-releases // -// This script fetches data from https://github.com/github/enterprise-releases/blob/master/releases.json -// and updates `src/ghes-releases/lib/enterprise-dates.json`, which the site uses for various functionality. -// -// [end-readme] +// Fetches https://github.com/github/enterprise-releases/blob/master/releases.json +// and updates src/ghes-releases/lib/enterprise-dates.json. +// enterprise-dates.json supplies site release date behavior. import { fileURLToPath } from 'url' import path from 'path' @@ -17,10 +13,11 @@ import { getContents } from '@/workflows/git-utils' interface EnterpriseDates { [releaseNumber: string]: { - releaseDate: string // For backward compatibility - RC date initially, then GA date once available + // Keep releaseDate as the RC date until a GA date exists for backward compatibility. + releaseDate: string deprecationDate: string - releaseCandidateDate?: string // Release Candidate date - generalAvailabilityDate?: string // General Availability date + releaseCandidateDate?: string + generalAvailabilityDate?: string } } @@ -36,7 +33,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)) const enterpriseDatesFile = path.join(__dirname, '../lib/enterprise-dates.json') const enterpriseDatesString = await fs.readFile(enterpriseDatesFile, 'utf8') -// check for required PAT +// getContents requires GITHUB_TOKEN. if (!process.env.GITHUB_TOKEN) { throw new Error('Error! You must have a GITHUB_TOKEN set in an .env file to run this script.') } @@ -59,7 +56,7 @@ async function main(): Promise { const formattedDates: EnterpriseDates = {} for (const [releaseNumber, releaseObject] of Object.entries(rawDates)) { formattedDates[releaseNumber] = { - // For backward compatibility, keep releaseDate as RC date initially, then GA date once available + // Keep releaseDate as the RC date until a GA date exists for backward compatibility. releaseDate: releaseObject.release_candidate || releaseObject.start, deprecationDate: releaseObject.end, releaseCandidateDate: releaseObject.release_candidate, diff --git a/src/ghes-releases/scripts/version-utils.ts b/src/ghes-releases/scripts/version-utils.ts index 9e71b1d7cd63..f6fe47d63cdb 100644 --- a/src/ghes-releases/scripts/version-utils.ts +++ b/src/ghes-releases/scripts/version-utils.ts @@ -4,15 +4,12 @@ import { supported } from '@/versions/lib/enterprise-server-releases' import getDataDirectory from '@/data-directory/lib/data-directory' import { FeatureData, FrontmatterVersions } from '@/types' -// Return true if lowestSupportedVersion > semVerRange export function isGhesReleaseDeprecated(lowestSupportedVersion: string, semVerRange: string) { const lowestSemver = semver.coerce(lowestSupportedVersion) if (!lowestSemver) return false return semver.gtr(lowestSemver.version, semVerRange) } -// Return true if the semver range is greater than the -// lowest supported GHES version export function isInAllGhes(semverRange: string) { if (semverRange === '*') return true const regexGt = /(>|>=){1}\s?(\d+\.\d+)/g @@ -27,11 +24,8 @@ export function isInAllGhes(semverRange: string) { return semver.lte(minVersion, oldestSupported) } -// A feature is deprecated if it only contains -// GHES releases and all releases are deprecated -// or all releases are supported. +// GHES-only features disappear when their GHES range is fully deprecated. export function isFeatureDeprecated(versions: FrontmatterVersions) { - // All GHES releases are deprecated return ( !!versions.ghes && !versions.fpt && @@ -40,8 +34,6 @@ export function isFeatureDeprecated(versions: FrontmatterVersions) { ) } -// Return true when the feature version is in all versions -// and all GHES releases. export function isAllVersions(versions: FrontmatterVersions) { if ( versions && diff --git a/src/ghes-releases/tests/generate-release-notes.ts b/src/ghes-releases/tests/generate-release-notes.ts index a656f6d0125d..02aee8cf6988 100644 --- a/src/ghes-releases/tests/generate-release-notes.ts +++ b/src/ghes-releases/tests/generate-release-notes.ts @@ -270,7 +270,6 @@ describe('buildReleaseNotesYaml', () => { const reposIdx = yaml.indexOf('- heading: Repositories') expect(actionsIdx).toBeGreaterThan(-1) expect(reposIdx).toBeGreaterThan(-1) - // GitHub Actions comes before Repositories in featureHeadings expect(actionsIdx).toBeLessThan(reposIdx) expect(yaml).toContain('Actions note.') @@ -283,7 +282,6 @@ describe('buildReleaseNotesYaml', () => { ] const yaml = buildReleaseNotesYaml(entries, false, featureHeadings) - // Should appear under changes, not features expect(yaml).toContain(' features:\n # TODO: Add feature notes') expect(yaml).toContain(' changes:') expect(yaml).toContain('# https://example.com/1') @@ -305,7 +303,6 @@ describe('buildReleaseNotesYaml', () => { expect(yaml).toContain('Deprecating X.') expect(yaml).toContain(' retired:\n # https://example.com/2') expect(yaml).toContain('Removed Y.') - // Changes should be omitted since Closing down/Retired are excluded and no other entries exist expect(yaml).not.toContain(' changes:') }) @@ -314,8 +311,7 @@ describe('buildReleaseNotesYaml', () => { expect(yaml).toContain('# TODO: Add feature notes') expect(yaml).toContain('# TODO: Add known issues') - // Empty changes, closing_down, and retired are omitted entirely - // to avoid YAML parsing as null (which fails schema validation) + // Omit empty changes, closing_down, and retired so schema validation does not see null sections. expect(yaml).not.toContain(' changes:') expect(yaml).not.toContain(' closing_down:') expect(yaml).not.toContain(' retired:') diff --git a/src/ghes-releases/tests/notify-release-pms.ts b/src/ghes-releases/tests/notify-release-pms.ts index d9e87a09cd8f..33eea75582e6 100644 --- a/src/ghes-releases/tests/notify-release-pms.ts +++ b/src/ghes-releases/tests/notify-release-pms.ts @@ -100,8 +100,7 @@ describe('buildCommentBody', () => { }) describe('duplicate-prevention filtering', () => { - // This tests the core filtering logic used in the CLI action: - // const toNotify = sourceNotes.filter((n) => !alreadyCommented.has(n.issueNumber)) + // These tests cover duplicate filtering without running the CLI action. const sourceNotes: SourceNote[] = [ { issueUrl: 'https://github.com/github/releases/issues/100', issueNumber: 100 }, @@ -133,7 +132,6 @@ describe('duplicate-prevention filtering', () => { const marker = buildMarker('3.21', 'rc') const commentBody = buildCommentBody('3.21', true, 100, ['octocat']) - // Simulates the duplicate-check logic: comments.includes(marker) expect(commentBody.includes(marker)).toBe(true) }) @@ -145,7 +143,6 @@ describe('duplicate-prevention filtering', () => { }) test('new issues added after initial run are not excluded', () => { - // Simulates: ran script once for issues 100+200, then re-run after adding 300 const alreadyCommented = new Set([100, 200]) const updatedSourceNotes: SourceNote[] = [ ...sourceNotes, From dceabdf6796994feab90f7cd31cb54ddfab088cb Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 17:40:22 +0000 Subject: [PATCH 03/18] Tighten code comments in src/archives (#63465) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/archives/lib/is-archived-version.ts | 6 +- src/archives/lib/old-versions-utils.ts | 46 ++-- .../middleware/archived-asset-redirects.ts | 21 +- .../archived-enterprise-versions-assets.ts | 66 +---- .../archived-enterprise-versions.ts | 228 +++++------------- src/archives/scripts/warmup-remotejson.ts | 21 +- .../tests/deprecated-enterprise-versions.ts | 15 +- 7 files changed, 104 insertions(+), 299 deletions(-) diff --git a/src/archives/lib/is-archived-version.ts b/src/archives/lib/is-archived-version.ts index 8b08bacd856c..d2e44173e21b 100644 --- a/src/archives/lib/is-archived-version.ts +++ b/src/archives/lib/is-archived-version.ts @@ -8,14 +8,12 @@ type IsArchivedInfo = { } export function isArchivedVersion(req: ExtendedRequest): IsArchivedInfo { - // if this is an assets path, use the referrer - // if this is a docs path, use the req.path + // Asset requests carry the archive version in the Referrer, not req.path. const pathToCheck = patterns.assetPaths.test(req.path) ? req.get('referrer') : req.path return isArchivedVersionByPath(pathToCheck || '') } export function isArchivedVersionByPath(pathToCheck: string): IsArchivedInfo { - // ignore paths that don't have an enterprise version number if ( !( patterns.getEnterpriseVersionNumber.test(pathToCheck) || @@ -25,12 +23,10 @@ export function isArchivedVersionByPath(pathToCheck: string): IsArchivedInfo { return {} } - // extract enterprise version from path, e.g. 2.16 const requestedVersion = pathToCheck.includes('enterprise-server@') ? pathToCheck.match(patterns.getEnterpriseServerNumber)?.[1] : pathToCheck.match(patterns.getEnterpriseVersionNumber)?.[1] - // bail if the request version is not deprecated if (!requestedVersion || !deprecated.includes(requestedVersion)) { return {} } diff --git a/src/archives/lib/old-versions-utils.ts b/src/archives/lib/old-versions-utils.ts index 6804f8551d3d..880aaeed49d6 100644 --- a/src/archives/lib/old-versions-utils.ts +++ b/src/archives/lib/old-versions-utils.ts @@ -7,67 +7,55 @@ const latestNewVersion = `enterprise-server@${latest}` const oldVersions = ['dotcom'].concat(supported) const newVersions = Object.keys(allVersions) -// Utility functions for converting between old version paths and new version paths. -// See lib/path-utils.ts for utility functions based on new paths. -// Examples: -// OLD /github/category/article to NEW /free-pro-team@latest/github/category/article -// OLD /enterprise/2.21/user/github/category/article to NEW /enterprise-server@2.21/github/category/article -// OLD /enterprise/user/github/category/article to NEW /enterprise-server@/github/category/article +// Converts legacy version paths to versioned paths. +// See lib/path-utils.ts for utilities based on versioned paths. +// /github/category/article becomes /free-pro-team@latest/github/category/article. +// /enterprise/2.21/user/github/category/article becomes +// /enterprise-server@2.21/github/category/article. +// /enterprise/user/github/category/article becomes +// /enterprise-server@/github/category/article. -// Given a new version like enterprise-server@2.21, -// return an old version like 2.21. -// Fall back to latest GHES version if one can't be found, -// for example, if the new version is private-instances@latest. +// Unknown enterprise version names fall back to the latest GHES release. +// Example: private-instances@latest maps to the latest GHES release. export function getOldVersionFromNewVersion(newVersion: string) { return newVersion === nonEnterpriseDefaultVersion ? 'dotcom' : oldVersions.find((oldVersion) => newVersion.includes(oldVersion)) || latest } -// Given an old version like 2.21, -// return a new version like enterprise-server@2.21. -// Fall back to latest GHES version if one can't be found. +// Unknown legacy enterprise versions fall back to the latest versioned GHES path. export function getNewVersionFromOldVersion(oldVersion: string) { return oldVersion === 'dotcom' ? nonEnterpriseDefaultVersion : newVersions.find((newVersion) => newVersion.includes(oldVersion)) || latestNewVersion } -// Given an old path like /enterprise/2.21/user/github/category/article, -// return an old version like 2.21. export function getOldVersionFromOldPath(oldPath: string) { - // We should never be calling this function on a path that starts with a new version, - // so we can assume the path either uses the old /enterprise format or it's dotcom. + // Callers pass legacy enterprise paths or dotcom paths, not enterprise-server@ paths. if (!patterns.enterprise.test(oldPath)) return 'dotcom' const ghesNumber = oldPath.match(patterns.getEnterpriseVersionNumber) return ghesNumber ? ghesNumber[1] : latest } -// Given an old path like /en/enterprise/2.21/user/github/category/article, -// return a new path like /en/enterprise-server@2.21/github/category/article. +// /en/enterprise/2.21/user/github/category/article becomes +// /en/enterprise-server@2.21/github/category/article. +// Paths can already contain a versioned segment after currentVersion renders. +// Example: /en/enterprise/private-instances@latest/admin/category/article keeps +// private-instances@latest. export function getNewVersionedPath(oldPath: string, languageCode = '') { - // It's possible a new version has been injected into an old path - // via syntax like: /en/enterprise/{{ currentVersion }}/admin/category/article - // which could resolve to /en/enterprise/private-instances@latest/admin/category/article, - // in which case the new version is the `private-instances@latest` segment. - // Get the second or third segment depending on whether there is a lang code. const pathParts = oldPath.split('/') const possibleVersion = languageCode ? pathParts[3] : pathParts[2] let newVersion = newVersions.includes(possibleVersion) ? possibleVersion : '' - // If no new version was found, assume path contains an old version, like 2.21 if (!newVersion) { const oldVersion = getOldVersionFromOldPath(oldPath) newVersion = getNewVersionFromOldVersion(oldVersion) } - // Remove /?/enterprise?/?/user? if present. - // This leaves only the part of the string that starts with the product. - // Example: /github/category/article + // patterns.oldEnterprisePath leaves the product path, such as /github/category/article. const restOfString = oldPath.replace(patterns.oldEnterprisePath, '') - // Add the language and new version to the product part of the string return path.posix.join('/', languageCode, newVersion, restOfString) } diff --git a/src/archives/middleware/archived-asset-redirects.ts b/src/archives/middleware/archived-asset-redirects.ts index 5dac6e511bd3..c2579ba1358b 100644 --- a/src/archives/middleware/archived-asset-redirects.ts +++ b/src/archives/middleware/archived-asset-redirects.ts @@ -2,24 +2,15 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest } from '@/types' -// When we archive old versions, we take a snapshot of rendered pages, -// which includes whatever bundles it used at the time. -// Sometimes those archived versions don't include all static assets -// it might refer to. -// This middleware is a chance to redirect to new assets that we can -// use instead. -// Yes, not all legacy assets *can* be redirected to something we have -// today. But for those that we can, this is the middleware to do it. -// And the reason we don't host a copy of these old files is because -// we strive to make the files in the repo only files that we actually -// use and refer to in the non-archived content. +// Archived rendered pages can reference static assets that no longer exist in the repo. +// Redirect legacy assets we can map instead of hosting unused files. -// Note that, we also have `archived-enterprise-versions-assets.ts` -// but that one assumes the whole path refers to a prefix which is -// considered archived. E.g. /en/enterprise-server@2.9/foo/bar.css +// archived-enterprise-versions-assets.ts handles whole archived path prefixes, such as +// /en/enterprise-server@2.9/foo/bar.css. const REDIRECTS: Record = { - // Example: https://docs.github.com/en/enterprise-server@2.22/authentication/connecting-to-github-with-ssh + // One archived source is + // https://docs.github.com/en/enterprise-server@2.22/authentication/connecting-to-github-with-ssh. '/assets/images/octicons/search.svg': '/assets/images/octicons/search-24.svg', } export default function archivedAssetRedirects( diff --git a/src/archives/middleware/archived-enterprise-versions-assets.ts b/src/archives/middleware/archived-enterprise-versions-assets.ts index 5e80dacc376e..3c8c213b6bec 100644 --- a/src/archives/middleware/archived-enterprise-versions-assets.ts +++ b/src/archives/middleware/archived-enterprise-versions-assets.ts @@ -10,28 +10,17 @@ import { createLogger } from '@/observability/logger' const logger = createLogger(import.meta.url) -// This module handles requests for the CSS and JS assets for -// deprecated GitHub Enterprise versions by routing them to static content in -// one of the docs-ghes- repos. -// See also ./archived-enterprise-versions.ts for non-CSS/JS paths +// Proxies archived CSS and JS assets from docs-ghes- repositories. +// archived-enterprise-versions.ts handles non-asset paths. export default async function archivedEnterpriseVersionsAssets( req: ExtendedRequest, res: Response, next: NextFunction, ) { - // Only match asset paths - // This can be true on /enterprise/2.22/_next/static/foo.css - // or /_next/static/foo.css if (!patterns.assetPaths.test(req.path)) return next() - // The URL is either in the format - // /enterprise/2.22/_next/static/foo.css, - // /enterprise-server@, - // or /_next/static/foo.css. - // If the URL is prefixed with the enterprise version and release number - // or if the Referrer contains the enterprise version and release number, - // then we'll fetch it from the docs-ghes- repo. + // Versioned and bare asset paths still require an archived Referrer before proxying. if ( !( patterns.getEnterpriseVersionNumber.test(req.path) || @@ -43,20 +32,10 @@ export default async function archivedEnterpriseVersionsAssets( return next() } - // Now we know the URL is definitely not /_next/static/foo.css - // So it's probably /enterprise/2.22/_next/static/foo.css and we - // should see if we might find this in the proxied backend. - // But `isArchivedVersion()` will only return truthy if the - // Referrer header also indicates that the request for this static - // asset came from a page const { isArchived, requestedVersion } = isArchivedVersion(req) if (!isArchived || !requestedVersion) return next() - // If this looks like a Next.js chunk or build manifest request from an archived page, - // just return 204 No Content instead of trying to proxy it. - // This suppresses noise from hydration requests that don't affect - // content viewing since archived pages render fine server-side. - // Only target specific problematic asset types, not all _next/static assets. + // Send 204 for chunks, _buildManifest.js, and _ssgManifest.js; archive pages render without them. if ( (req.path.includes('/_next/static/chunks/') || req.path.includes('/_buildManifest.js') || @@ -65,18 +44,15 @@ export default async function archivedEnterpriseVersionsAssets( ) { archivedCacheControl(res) setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL) - return res.sendStatus(204) // No Content - silently ignore + return res.sendStatus(204) } - // In all of the `docs-ghes- - // These will thus be requested, with a Referrer header that - // forces us to give it a chance, but it'll find it can't find it - // but we mustn't return a 404 yet, because that - // /_next/static/styles.css will probably still succeed because the 404 - // page is not that of the archived enterprise version. + // Fall through on proxy misses; 404 pages request /_next/static/styles.css from archived pages. return next() } } diff --git a/src/archives/middleware/archived-enterprise-versions.ts b/src/archives/middleware/archived-enterprise-versions.ts index 6a678383e7c8..cae98add1a4f 100644 --- a/src/archives/middleware/archived-enterprise-versions.ts +++ b/src/archives/middleware/archived-enterprise-versions.ts @@ -24,22 +24,19 @@ import { ExtendedRequest } from '@/types' const logger = createLogger(import.meta.url) const OLD_PUBLIC_AZURE_BLOB_URL = 'https://githubdocs.azureedge.net' -// Old Azure Blob Storage `enterprise` container. +// Old Azure Blob Storage enterprise container. const OLD_AZURE_BLOB_ENTERPRISE_DIR = `${OLD_PUBLIC_AZURE_BLOB_URL}/enterprise` -// Old Azure Blob storage `github-images` container with -// the root directory of 'enterprise'. +// Old Azure Blob Storage github-images container rooted at enterprise. const OLD_GITHUB_IMAGES_ENTERPRISE_DIR = `${OLD_PUBLIC_AZURE_BLOB_URL}/github-images/enterprise` const OLD_DEVELOPER_SITE_CONTAINER = `${OLD_PUBLIC_AZURE_BLOB_URL}/developer-site` -// This is the new repo naming convention we use for each archived enterprise -// version. E.g. https://github.github.com/docs-ghes-2.10 +// Archived enterprise repositories use https://github.github.com/docs-ghes-2.10. const ENTERPRISE_GH_PAGES_URL_PREFIX = 'https://github.github.com/docs-ghes-' type ArchivedRedirects = { [url: string]: string | null } -// These files are huge so lazy-load them. But note that the -// `readJsonFileLazily()` function will, at import-time, check that -// the path does exist. +// Lazy-load the large redirect files. +// readCompressedJsonFileFallbackLazily verifies the path at import time. const archivedRedirects = readCompressedJsonFileFallbackLazily( './src/redirects/lib/static/archived-redirects-from-213-to-217.json', ) as () => ArchivedRedirects @@ -51,51 +48,23 @@ const archivedFrontmatterValidURLS = readCompressedJsonFileFallbackLazily( './src/redirects/lib/static/archived-frontmatter-valid-urls.json', ) as () => ArchivedFrontmatterURLs -// Combine all the things you need to make sure the response is -// aggressively cached. const cacheAggressively = (res: Response) => { archivedCacheControl(res) - // This sets a custom Fastly surrogate key so that this response - // won't get updated in every deployment. - // Essentially, this sets a surrogate key such that Fastly - // doesn't do soft-purges on these responses on every - // automated deployment. + // Manual surrogate keys avoid Fastly soft purges on every automated deployment. setFastlySurrogateKey(res, SURROGATE_ENUMS.MANUAL) } -// The way `got` does retries: -// -// sleep = 1000 * Math.pow(2, retry - 1) + Math.random() * 100 -// -// So, it means: -// -// 1. ~1000ms -// 2. ~2000ms -// 3. ~4000ms -// -// ...if the limit we set is 3. -// Our own timeout, in @/frame/middleware/timeout.ts defaults to 10 seconds. -// So there's no point in trying more attempts than 3 because it would -// just timeout on the 10s. (i.e. 1000 + 2000 + 4000 + 8000 > 10,000) +// Got sleeps about 1s, 2s, then 4s for three retries. +// A fourth retry would exceed MAX_REQUEST_TIMEOUT, which defaults to 10 seconds in production. const retryConfiguration = { limit: 3 } -// According to our Datadog metrics, the *average* time for the -// the 'archive_enterprise_proxy' metric is ~70ms (excluding spikes) -// which is much less than 3000ms. -// We have observed errors of timeout, in production, when it was -// set to 500ms and then 1500ms. Let's be more conservative here to -// avoid unnecessary error reporting during occasional slow responses. +// Datadog reports archive_enterprise_proxy averages about 70ms excluding spikes. +// Production timed out at 500ms and 1500ms, so 3000ms avoids noise from slow responses. const timeoutConfiguration = { response: 3000 } -// Monitoring thresholds for logging response times -// Log warnings when responses exceed half the timeout threshold -const WARN_RESPONSE_THRESHOLD = timeoutConfiguration.response / 2 // 1500ms -// Log info for responses that are noticeably slow but not concerning -const SLOW_RESPONSE_THRESHOLD = 500 // ms - -// This module handles requests for deprecated GitHub Enterprise versions -// by routing them to static content in -// one of the docs-ghes- repos. +const WARN_RESPONSE_THRESHOLD = timeoutConfiguration.response / 2 +// Log successful responses slower than 500ms. +const SLOW_RESPONSE_THRESHOLD = 500 export default async function archivedEnterpriseVersions( req: ExtendedRequest, @@ -109,14 +78,14 @@ export default async function archivedEnterpriseVersions( const redirectCode = pathLanguagePrefixed(req.path) ? 301 : 302 - // Redirects for releases 3.0+ if (deprecatedWithFunctionalRedirects.includes(requestedVersion)) { const redirectTo = req.context ? getRedirect(req.path, req.context) : undefined if (redirectTo) { if (redirectCode === 302) { - languageCacheControl(res) // call first to get `vary` + // languageCacheControl sets vary; archivedCacheControl extends the cache duration. + languageCacheControl(res) } - archivedCacheControl(res) // call second to extend duration + archivedCacheControl(res) return res.safeRedirect(redirectCode, redirectTo) } @@ -124,11 +93,7 @@ export default async function archivedEnterpriseVersions( try { redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), { retry: retryConfiguration, - // This is allowed to be different compared to the other requests - // we make because downloading the `redirects.json` once is very - // useful because it caches so well. - // And, as of 2021 that `redirects.json` is 10MB so it's more likely - // to time out. + // Cache misses use a 1-second time-to-first-byte limit; body transfer may take longer. timeout: { response: 1000 }, })) as Record } catch (err) { @@ -143,13 +108,14 @@ export default async function archivedEnterpriseVersions( const newRedirectTo = redirectJson[withoutLanguage] if (newRedirectTo && newRedirectTo !== withoutLanguage) { if (redirectCode === 302) { - languageCacheControl(res) // call first to get `vary` + // languageCacheControl sets vary; archivedCacheControl extends the cache duration. + languageCacheControl(res) } - archivedCacheControl(res) // call second to extend duration + archivedCacheControl(res) return res.safeRedirect(redirectCode, `/${language}${newRedirectTo}`) } } - // For releases 2.13 and lower, redirect language-prefixed URLs like /en/enterprise/2.10 -> /enterprise/2.10 + // Earlier releases redirect /en/enterprise/2.10 to /enterprise/2.10. if ( req.path.startsWith('/en/') && versionSatisfiesRange(requestedVersion, `<${firstVersionDeprecatedOnNewSite}`) @@ -158,30 +124,21 @@ export default async function archivedEnterpriseVersions( return res.safeRedirect(redirectCode, req.baseUrl + req.path.replace(/^\/en/, '')) } - // Redirects for releases 2.13 - 2.17 if ( versionSatisfiesRange(requestedVersion, `>=${firstVersionDeprecatedOnNewSite}`) && versionSatisfiesRange(requestedVersion, `<=${lastVersionWithoutArchivedRedirectsFile}`) ) { const [language, withoutLanguagePath] = splitByLanguage(req.path) - // `archivedRedirects` is a callable because it's a lazy function - // and memoized so calling it is cheap. - + // archivedRedirects is lazy and memoized, so calling it here is cheap. const newPath = withoutLanguagePath && archivedRedirects()[withoutLanguagePath] - // Some entries in the lookup exists purely for the sake of injecting - // language. - // E.g. '/enterprise/2.15/user' - // URLs like this only need to redirect the original `req.path` - // didn't already have a language + // Null entries inject /en when the original request has no language prefix. if (newPath !== undefined && (newPath || !language)) { const redirect = `/${language || 'en'}${newPath || withoutLanguagePath}` cacheAggressively(res) return res.safeRedirect(redirectCode, redirect) } } - // Redirects for 2.18 - 3.0. Starting with 2.18, we updated the archival - // script to create a redirects.json file if ( versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`) && !deprecatedWithFunctionalRedirects.includes(requestedVersion) @@ -190,11 +147,7 @@ export default async function archivedEnterpriseVersions( try { redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), { retry: retryConfiguration, - // This is allowed to be different compared to the other requests - // we make because downloading the `redirects.json` once is very - // useful because it caches so well. - // And, as of 2021 that `redirects.json` is 10MB so it's more likely - // to time out. + // Cache misses use a 1-second time-to-first-byte limit; body transfer may take longer. timeout: { response: 1000 }, })) as Record } catch (err) { @@ -205,15 +158,13 @@ export default async function archivedEnterpriseVersions( throw err } - // make redirects found via redirects.json redirect with a 301 if (redirectJson[req.path]) { res.set('x-robots-tag', 'noindex') cacheAggressively(res) return res.safeRedirect(redirectCode, redirectJson[req.path]) } } - // Short-circuit requests that will never resolve on the upstream - // GitHub Pages repos, avoiding unnecessary network requests. + // Short-circuit impossible archive paths to avoid unnecessary upstream requests. const earlyNotFound = getEarlyNotFoundReason(req.path, requestedVersion) if (earlyNotFound) { statsd.increment('middleware.archived_early_not_found', 1, [ @@ -224,9 +175,7 @@ export default async function archivedEnterpriseVersions( return res.status(404).type('text').send('Page not found') } - // Requests without a language prefix for versions > 2.17 will always - // 404 upstream (the archive repos store pages under /en/, /zh/, etc.). - // Skip the fetch and let downstream middleware handle the redirect. + // Archive repos after 2.17 require language prefixes; redirects handle unlanguaged paths. if ( versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`) && !pathLanguagePrefixed(req.path) @@ -235,7 +184,6 @@ export default async function archivedEnterpriseVersions( return next() } - // Retrieve the page from the archived repo const doGet = () => fetchWithRetry( getProxyPath(req.path, requestedVersion), @@ -265,14 +213,13 @@ export default async function archivedEnterpriseVersions( }) } - // Warn on 404s, which are expected for missing archived pages. - // Everything else is a genuine upstream failure. + // Missing archived pages are expected 404s; other upstream failures need error logs. if (r.status !== 200) { let upstreamBody: string | undefined try { upstreamBody = await readBodyWithTimeout(r, () => r.text(), timeoutConfiguration.response) } catch { - // A body we cannot read should not change how we handle the error. + // Ignore unreadable bodies so the original upstream status controls error handling. } const level = r.status === 404 ? 'warn' : 'error' logger[level]('Failed to fetch archived enterprise content', { @@ -286,7 +233,7 @@ export default async function archivedEnterpriseVersions( }) } - // Log successful responses with timing for monitoring trends + // Log slow successful responses for monitoring trends. if (r.status === 200 && responseTime > SLOW_RESPONSE_THRESHOLD) { logger.info('Archived enterprise content response', { version: requestedVersion, @@ -303,7 +250,7 @@ export default async function archivedEnterpriseVersions( ) res.set('x-robots-tag', 'noindex') - // make stubbed redirect files (which exist in versions <2.13) redirect with a 301 + // Stubbed redirect files in releases before 2.13 return a static redirect target. const staticRedirect = body.match(patterns.staticRedirect) if (staticRedirect) { cacheAggressively(res) @@ -314,15 +261,12 @@ export default async function archivedEnterpriseVersions( cacheAggressively(res) - // Releases 3.2 and higher contain image asset paths with the - // old Azure Blob Storage URL. These need to be rewritten to - // the new archived enterprise repo URL. + // Releases 3.2 through 3.9 contain old Azure Blob image URLs that need archive URLs. if ( versionSatisfiesRange(requestedVersion, `>=${firstReleaseStoredInBlobStorage}`) && versionSatisfiesRange(requestedVersion, `<=3.9`) ) { - // `x-host` is a custom header set by Fastly. - // GLB automatically deletes the `x-forwarded-host` header. + // Fastly sets x-host, and GLB removes x-forwarded-host. const host = req.get('x-host') || req.get('x-forwarded-host') || req.get('host') const modifiedBody = body .replaceAll( @@ -337,11 +281,7 @@ export default async function archivedEnterpriseVersions( return res.send(modifiedBody) } - // Releases 3.1 and lower were previously hosted in the - // help-docs-archived-enterprise-versions repo. Only the images - // were stored in the old Azure Blob Storage `github-images` container. - // The image paths all need to be updated to reference the images in the - // new archived enterprise repo's root assets directory. + // Releases before 3.2 need github-images Azure Blob paths rewritten to archive root assets. if (versionSatisfiesRange(requestedVersion, `<${firstReleaseStoredInBlobStorage}`)) { let modifiedBody = body.replaceAll( `${OLD_GITHUB_IMAGES_ENTERPRISE_DIR}/${requestedVersion}`, @@ -352,12 +292,11 @@ export default async function archivedEnterpriseVersions( `${OLD_DEVELOPER_SITE_CONTAINER}/${requestedVersion}`, `${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/developer`, ) - // Update all hrefs to add /developer to the path modifiedBody = modifiedBody.replaceAll( `="/enterprise/${requestedVersion}`, `="/enterprise/${requestedVersion}/developer`, ) - // The changelog is the only thing remaining on developer.github.com + // The changelog remains on developer.github.com. modifiedBody = modifiedBody.replaceAll( 'href="/changes', 'href="https://developer.github.com/changes', @@ -369,46 +308,35 @@ export default async function archivedEnterpriseVersions( `="${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/assets`, ) - // Fix broken hrefs on the 2.16 landing page + // The 2.16 landing page has hrefs missing the version segment. if (requestedVersion === '2.16' && req.path === '/en/enterprise/2.16') { modifiedBody = modifiedBody.replaceAll('ref="/en/enterprise', 'ref="/en/enterprise/2.16') } - // Remove the search results container from the page + // The empty search results container blocks clicks on page links. modifiedBody = modifiedBody.replaceAll('
', '') return res.send(modifiedBody) } - // In all releases, some assets were incorrectly scraped and contain - // deep relative paths. For example, releases 3.4+ use the webp format - // for images. The URLs for those images were never rewritten to pull - // from the Azure Blob Storage container. This may be due to not - // updating our scraping tool to handle the new image types. There - // are additional images in older versions that also have a relative path. - // We want to update the URLs in the format - // "../../../../../../assets/" to prefix the assets directory with the - // new archived enterprise repo URL. + // Deep relative asset paths like "../../../../../../assets/" need archive repo prefixes. let modifiedBody = body.replaceAll( /="(\.\.\/)*assets/g, `="${ENTERPRISE_GH_PAGES_URL_PREFIX}${requestedVersion}/assets`, ) - // Fix broken hrefs on the 2.16 landing page + // The 2.16 landing page has hrefs missing the version segment. if (requestedVersion === '2.16' && req.path === '/en/enterprise/2.16') { modifiedBody = modifiedBody.replaceAll('ref="/en/enterprise', 'ref="/en/enterprise/2.16') } - // Remove the search results container from the page, which removes a white - // box that prevents clicking on page links + // The empty search results container blocks clicks on page links. modifiedBody = modifiedBody.replaceAll('
', '') return res.send(modifiedBody) } - // In releases 2.13 - 2.17, we lost access to frontmatter redirects - // during the archival process. This workaround finds potentially - // relevant frontmatter redirects in currently supported pages + // Releases 2.13 through 2.17 need supported-page frontmatter redirects after data loss. if ( versionSatisfiesRange(requestedVersion, `>=${firstVersionDeprecatedOnNewSite}`) && versionSatisfiesRange(requestedVersion, `<=${lastVersionWithoutArchivedRedirectsFile}`) @@ -433,62 +361,39 @@ function getProxyPath(reqPath: string, requestedVersion: string) { `/enterprise/${requestedVersion}/developer`, ) - // This was the last release supported on developer.github.com + // Developer pages keep the developer-site path layout from the archived release. if (isDeveloperPage) { const enterprisePath = `/enterprise/${requestedVersion}` const newReqPath = reqPath.replace(enterprisePath, '') return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath } - // Releases 2.18 and higher + // Releases 2.18 and later store redirects.json at the repo root and pages at /index.html. if (versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`)) { const newReqPath = reqPath.includes('redirects.json') ? `/${reqPath}` : `${reqPath}/index.html` return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath } - // Releases 2.13 - 2.17 - // redirect.json files don't exist for these versions + // Releases 2.13 through 2.17 lack redirects.json files. if (versionSatisfiesRange(requestedVersion, `>=2.13`)) { return `${ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + reqPath}/index.html` } - // Releases 2.12 and lower + // Releases 2.12 and earlier omit the /enterprise/ path prefix. const enterprisePath = `/enterprise/${requestedVersion}` const newReqPath = reqPath.replace(enterprisePath, '') return ENTERPRISE_GH_PAGES_URL_PREFIX + requestedVersion + newReqPath } -// Module-level global cache object. -// Gets populated lazily inside getFallbackRedirect(). +// Caches fallback redirect lookups across requests. const fallbackRedirectLookups = new Map() +// archived-frontmatter-valid-urls.json maps valid destinations to acceptable source URLs. +// getFallbackRedirect inverts that structure once, so lookups avoid scanning every destination. +// Example source /enterprise/2.13/other/old/thing redirects to destination +// /enterprise/2.13/foo/bar. +// The JSON omits language prefixes, so lookups strip the request language and add it back. function getFallbackRedirect(req: ExtendedRequest) { - // The file `lib/redirects/static/archived-frontmatter-valid-urls.json` which - // we depend on here, is structured like this: - // - // { - // "/enterprise/2.13/foo/bar": [ - // "/enterprise/2.13/other/old/thing", - // "/enterprise/2.13/more/redirectable/url", - // "/enterprise/2.13/etc/etc" - // ], - // ... - // - // The keys are valid URLs that it can redirect to. I.e. these are - // URLs that we definitely know are valid and will be found - // in one of the docs-ghes- repos. - // The array values are possible URLs we deem acceptable redirect - // sources. - // But to avoid an unnecessary, O(n), loop every time, we turn this - // structure around to become: - // - // { - // "/enterprise/2.13/other/old/thing": "/enterprise/2.13/foo/bar", - // "/enterprise/2.13/more/redirectable/url": "/enterprise/2.13/foo/bar", - // "/enterprise/2.13/etc/etc": "/enterprise/2.13/foo/bar", - // ... - // - // Now potential lookups are fast. if (!fallbackRedirectLookups.size) { for (const [destination, sources] of Object.entries(archivedFrontmatterValidURLS())) { for (const source of sources) { @@ -497,14 +402,6 @@ function getFallbackRedirect(req: ExtendedRequest) { } } - // But before we proceed, remember that the - // file lib/redirects/static/archived-frontmatter-valid-urls.json never - // contains a language prefix. - // E.g. only `/enterprise/2.13/foo/bar` but the requested URL can be - // `/en/enterprise/2.13/foo/bar`, `/pt/enterprise/2.13/foo/bar`, - // or just `/enterprise/2.13/foo/bar`. - // Whatever it is, pop the language prefix, operate, and put it back - // again. In the end, it always has to have a language prefix. const [language, withoutLanguage] = splitPathByLanguage(req.path) const fallback = fallbackRedirectLookups.get(withoutLanguage) if (fallback) { @@ -523,43 +420,34 @@ function splitByLanguage(uri: string) { return [language, withoutLanguage] } -// Regex to extract any language-like prefix from the path, including -// "cn" which was the old Chinese language code used in archives ≤3.2. +// Matches language-like path prefixes, including the old Chinese cn code from archives through 3.2. const archiveLanguagePrefixRegex = new RegExp(`^/(${Object.keys(allLanguages).join('|')}|cn)(/|$)`) -// Detects request paths that will never resolve on the upstream GitHub -// Pages archive repos, so we can 404 immediately without making a -// network request. Returns a short reason string, or null if the -// request looks plausible. +// Identifies request paths that cannot resolve on upstream GitHub Pages archive repos. +// Returning a reason lets callers log and skip the network request. function getEarlyNotFoundReason(reqPath: string, version: string): string | null { - // Double slashes in the path never resolve (e.g. ".../about-2fa//index.html") + // Double slashes never resolve, such as /about-2fa//index.html. if (reqPath.includes('//')) { return 'double-slash' } - // A duplicated "/developer/developer/" segment means a broken crawler URL - // from the old developer.github.com site. + // Duplicated /developer/developer/ segments come from broken developer.github.com crawler URLs. if (reqPath.includes('/developer/developer/')) { return 'developer-developer' } - // Check if the language in the path actually exists in this version's - // archive. Each language has a `firstArchivedVersion` indicating when - // it was first included in the GHES archives. + // firstArchivedVersion records when each archive language became available. const langMatch = reqPath.match(archiveLanguagePrefixRegex) if (langMatch) { const lang = langMatch[1] - // "cn" was the old Chinese language code; those archives are ancient - // and effectively dead traffic. Always 404. + // cn was the old Chinese language code; always 404 it as dead archive traffic. if (lang === 'cn') { return 'language-not-in-version' } - const langDef = allLanguages[lang] if (langDef?.firstArchivedVersion) { - // 404 if the requested version is older than when this language - // was first archived (e.g. /zh/ on v3.0 → 404 because zh started in 3.3) + // 404 languages before firstArchivedVersion, such as /zh/ on 3.0 because zh starts in 3.3. if (!versionSatisfiesRange(version, `>=${langDef.firstArchivedVersion}`)) { return 'language-not-in-version' } diff --git a/src/archives/scripts/warmup-remotejson.ts b/src/archives/scripts/warmup-remotejson.ts index 978bcdd4cdfd..4bfd658e58b4 100755 --- a/src/archives/scripts/warmup-remotejson.ts +++ b/src/archives/scripts/warmup-remotejson.ts @@ -1,20 +1,7 @@ -// [start-readme] -// -// This calls a function directly that is used by our archived enterprise -// middleware. Namely, the `getRemoteJSON` function. That function is -// able to use the disk to cache responses quite aggressively. So when -// it's been run once, with the same disk, next time it can draw from disk -// rather than having to rely on network. -// -// We have this script to avoid excessive network fetches in production -// where, due to production deploys restarting new Node services, we -// can't rely on in-memory caching often enough. -// -// The list of URLs hardcoded in here is based on analyzing the URLs that -// were logged as tags in Datadog for entries that couldn't rely on -// in-memory cache. -// -// [end-readme] +// Warms getRemoteJSON's disk cache for archived redirects.json files. +// Production deploys restart Node services often enough that in-memory cache misses repeat. +// Production reuses these entries only when it starts from the same warmed cache directory. +// URLs come from Datadog tags for redirects.json requests that missed the in-memory cache. import { program } from 'commander' import semver, { SemVer } from 'semver' diff --git a/src/archives/tests/deprecated-enterprise-versions.ts b/src/archives/tests/deprecated-enterprise-versions.ts index 3515fb7bb587..ab9a668521ca 100644 --- a/src/archives/tests/deprecated-enterprise-versions.ts +++ b/src/archives/tests/deprecated-enterprise-versions.ts @@ -79,21 +79,19 @@ describe('enterprise deprecation', () => { const { $: $2, res } = await getDOM(`${guidesPath}/${firstLink}`) expect(res.statusCode).toBe(200) - // this test assumes the Installation guide is the first link on the guides page + // The test follows the first link, which is the Installation guide. expect($2('h2').text()).toBe('Installing and configuring GitHub Enterprise') }) }) -// Starting with the deprecation of 3.0, it's the first time we deprecate -// enterprise versions since redirects is a *function* rather than a -// lookup in a big object. +// Enterprise 3.0 redirects use getRedirect plus redirects.json instead of a static object. describe('recently deprecated redirects', () => { test('basic enterprise 3.0 redirects', async () => { const res = await get('/enterprise/3.0') expect(res.statusCode).toBe(302) expect(res.headers.location).toBe('/en/enterprise-server@3.0') expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') @@ -104,7 +102,7 @@ describe('recently deprecated redirects', () => { const res = await get('/en/enterprise/3.0') expect(res.statusCode).toBe(301) expect(res.headers.location).toBe('/en/enterprise-server@3.0') - // 301 redirects are safe to cache aggressively + // 301 redirects can cache aggressively. expect(res.headers['set-cookie']).toBeUndefined() expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) @@ -116,13 +114,12 @@ describe('recently deprecated redirects', () => { ) expect(res.statusCode).toBe(302) expect(res.headers['set-cookie']).toBeUndefined() - // language specific caching + // Language-specific redirects vary by language headers. expect(res.headers['cache-control']).toContain('public') expect(res.headers['cache-control']).toMatch(/max-age=[1-9]/) expect(res.headers.vary).toContain('accept-language') expect(res.headers.vary).toContain('x-user-language') - // This is based on - // https://github.com/github/docs-ghes-3.0/blob/main/redirects.json + // Matches https://github.com/github/docs-ghes-3.0/blob/main/redirects.json. expect(res.headers.location).toBe( '/en/enterprise-server@3.0/get-started/learning-about-github/githubs-products', ) From 12cad6eedc30315377cc3c1170591c710719744a Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 17:40:27 +0000 Subject: [PATCH 04/18] Tighten code comments in src/data-directory (#63466) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/data-directory/lib/data-directory.ts | 14 +- src/data-directory/lib/data-schemas/ctas.ts | 15 +- .../lib/data-schemas/features.ts | 4 +- .../lib/data-schemas/glossaries-candidates.ts | 3 +- src/data-directory/lib/data-schemas/index.ts | 12 +- .../tables/copilot/auto-model-selection.ts | 2 - .../data-schemas/tables/copilot/matrix-ide.ts | 27 +--- .../tables/copilot/matrix-meta.ts | 6 +- .../tables/copilot/model-comparison.ts | 2 - .../copilot/model-deprecation-history.ts | 2 - .../tables/copilot/model-release-status.ts | 2 - .../tables/copilot/model-supported-clients.ts | 2 - .../tables/copilot/model-supported-plans.ts | 2 - .../tables/copilot/models-and-pricing.ts | 2 - .../data-schemas/tables/repository-roles.ts | 8 +- .../data-schemas/tables/rest-api-versions.ts | 2 - .../tables/supported-code-languages.ts | 12 +- src/data-directory/lib/filename-to-key.ts | 4 +- src/data-directory/lib/get-data.ts | 150 ++++-------------- src/data-directory/middleware/data-tables.ts | 3 +- .../scripts/deleted-features-pr-comment.ts | 17 +- .../scripts/find-orphaned-features/find.ts | 69 ++------ .../scripts/find-orphaned-tables.ts | 48 ++---- src/data-directory/tests/copilot-matrix.ts | 30 +--- src/data-directory/tests/data-schemas.ts | 1 - .../tests/find-orphaned-tables.ts | 4 - src/data-directory/tests/get-data.ts | 27 +--- src/data-directory/tests/index.ts | 4 +- src/data-directory/tests/orphaned-features.ts | 13 -- src/data-directory/tests/ui-yml-structure.ts | 2 +- 30 files changed, 106 insertions(+), 383 deletions(-) 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`) From 978d819e9f8e7345aff37791d9019c254fd3af89 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 17:40:32 +0000 Subject: [PATCH 05/18] Tighten code comments in src/observability, src/codeql-queries, and src/automated-pipelines (#63467) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .../components/AutomatedPageContext.tsx | 5 +- .../ChildBodyParametersRows.module.scss | 8 +- .../parameter-table/ParameterRow.tsx | 41 ++----- .../ParameterTable.module.scss | 14 +-- .../lib/update-markdown.ts | 113 ++++-------------- src/automated-pipelines/tests/rendering.ts | 14 +-- .../tests/update-markdown.ts | 12 +- .../generate-code-quality-query-list.ts | 84 ++++--------- .../generate-code-scanning-query-list.ts | 90 ++++---------- src/observability/lib/failbot.ts | 7 +- .../lib/handle-package-not-found.ts | 16 +-- src/observability/lib/runtime-metrics.ts | 21 +--- src/observability/lib/statsd.ts | 14 +-- src/observability/lib/tracing.browser.ts | 4 +- src/observability/lib/tracing.ts | 24 ++-- src/observability/logger/index.ts | 15 +-- src/observability/logger/lib/log-levels.ts | 13 +- .../logger/lib/logger-context.ts | 9 +- src/observability/logger/lib/to-logfmt.ts | 18 +-- .../get-automatic-request-logger.ts | 2 +- src/observability/middleware/handle-errors.ts | 25 ++-- src/observability/middleware/trigger-error.ts | 10 +- src/observability/tests/failbot.ts | 5 +- .../tests/get-automatic-request-logger.ts | 10 +- src/observability/tests/logger-integration.ts | 17 +-- src/observability/tests/logger.ts | 10 +- src/observability/tests/to-error.ts | 4 +- src/observability/tests/to-logfmt.test.ts | 2 +- 28 files changed, 157 insertions(+), 450 deletions(-) diff --git a/src/automated-pipelines/components/AutomatedPageContext.tsx b/src/automated-pipelines/components/AutomatedPageContext.tsx index e5dd1ef44b31..741b5e7be6c1 100644 --- a/src/automated-pipelines/components/AutomatedPageContext.tsx +++ b/src/automated-pipelines/components/AutomatedPageContext.tsx @@ -31,9 +31,8 @@ export const useAutomatedPageContext = (): AutomatedPageContextT => { return context } -// Non-throwing variant: returns null when there is no provider. For components that render -// both inside and outside an AutomatedPageContext.Provider (e.g. the product sidebar, shared -// across automated REST reference pages and conceptual REST pages). Call it unconditionally. +// Returns null outside a provider, so shared navigation can call the hook on REST +// reference and conceptual pages. export const useAutomatedPageContextOptional = (): AutomatedPageContextT | null => { return useContext(AutomatedPageContext) } diff --git a/src/automated-pipelines/components/parameter-table/ChildBodyParametersRows.module.scss b/src/automated-pipelines/components/parameter-table/ChildBodyParametersRows.module.scss index dd2631c562db..b6b5cd611701 100644 --- a/src/automated-pipelines/components/parameter-table/ChildBodyParametersRows.module.scss +++ b/src/automated-pipelines/components/parameter-table/ChildBodyParametersRows.module.scss @@ -3,15 +3,13 @@ border-top: none; } - // Remove any default markdown article padding for property cells + // Markdown article styles add padding that crowds nested property cells. details tr td { padding-bottom: 0.25rem; } - // Set the left border for in the nested property tables. Also need to override - // a default markdown file style that sets a table's font size based on - // percentage which would cause the table font size to shrink more and more - // as the properties nested more and more. + // The muted border distinguishes nested property tables. + // Inherit prevents markdown percentage font sizing from shrinking at each nesting level. td { table { border-left: 4px solid var(--color-border-muted); diff --git a/src/automated-pipelines/components/parameter-table/ParameterRow.tsx b/src/automated-pipelines/components/parameter-table/ParameterRow.tsx index aaf3c91809b1..4752b084e908 100644 --- a/src/automated-pipelines/components/parameter-table/ParameterRow.tsx +++ b/src/automated-pipelines/components/parameter-table/ParameterRow.tsx @@ -15,17 +15,9 @@ type Props = { clickedBodyParameterName?: string | undefined } -// Webhooks have these same properties in common that we describe separately in its -// own section on the webhooks page: -// -// https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#webhook-payload-object-common-properties -// -// Since there's more details for these particular properties, we chose not -// show their child properties for each webhook and we also don't grab this -// information from the schema. -// -// We use this list of common properties to make sure we don't try and request -// the child properties for these specific properties. +// The webhooks page documents common webhook payload properties in one shared section. +// Skipping their child properties here avoids duplicate schema lookups and repeated docs. +// https://docs.github.com/en/webhooks/webhook-events-and-payloads const NO_CHILD_WEBHOOK_PROPERTIES = [ 'action', 'enterprise', @@ -46,8 +38,6 @@ export function ParameterRow({ }: Props) { const { t } = useTranslation(['parameter_table']) - // This will be true if `rowParams` does not have a key called `default` - // and it will be true if it does and its actual value is `undefined`. const hasDefault = rowParams.default !== undefined return ( <> @@ -58,14 +48,11 @@ export function ParameterRow({ {rowParams.name ? ( <> {rowParams.name} - {/* This whitespace is important otherwise, when the CSS is - ignored, the plain text becomes `foobar` if the HTML - was `foobar`. - */}{' '} + {/* Keeps foobar from rendering as foobar without CSS. */}{' '} {Array.isArray(rowParams.type) ? rowParams.type.join(' or ') : rowParams.type} - {/* Ditto about the important explicit whitespace */}{' '} + {/* Keeps readable text spacing if CSS fails to load. */}{' '} {rowParams.isRequired ? ( {t('required')} ) : null} @@ -75,7 +62,7 @@ export function ParameterRow({ {Array.isArray(rowParams.type) ? rowParams.type.join(' or ') : rowParams.type} - {/* Ditto about the important explicit whitespace */}{' '} + {/* Keeps readable text spacing if CSS fails to load. */}{' '} {rowParams.isRequired ? ( {t('required')} ) : null} @@ -96,10 +83,7 @@ export function ParameterRow({ {t('default')}: {typeof rowParams.default === 'string' - ? // In the schema, the default value for strings can - // potentially be the empty string so we handle this case - // in particular by rendering it as "". Otherwise we would - // display an empty code block which could be confusing. + ? // Empty string defaults need visible quotes. rowParams.default || '""' : JSON.stringify(rowParams.default)} @@ -143,16 +127,7 @@ export function ParameterRow({ /> )} - {/* These conditions tell us: - - 1. the param is an object or array AND: - 2. the param has no child param groups AND: - 3. the param isn't one of the common webhook properties - - If all these are true, then that means we haven't yet loaded the - nested parameters so we show a stub
element that triggers - an API request to get the nested parameter data. - */} + {/* Empty child groups mark unloaded nested params except shared webhook props; details lazy-loads them. */} {rowParams.type && (rowParams.type.includes('object') || rowParams.type.includes('array of')) && rowParams.childParamsGroups && diff --git a/src/automated-pipelines/components/parameter-table/ParameterTable.module.scss b/src/automated-pipelines/components/parameter-table/ParameterTable.module.scss index 2914769e4429..66b19a3bd84c 100644 --- a/src/automated-pipelines/components/parameter-table/ParameterTable.module.scss +++ b/src/automated-pipelines/components/parameter-table/ParameterTable.module.scss @@ -1,18 +1,12 @@ .parameterTable { - // this is for the child parameter table row that contains the top level - // properties details toggle element because we want it to match the - // background color of the top level parameter rows. We need the !important - // because the child parameter rows (the nested expanded properties) otherwise - // have the same background color. + // The top-level child row matches parent row backgrounds, not nested property rows. + // !important keeps the top-level child row from taking the nested child-row background. & > tbody > tr { background: var(--color-canvas-default) !important; } - // also for the top level child parameter table row, we want the details toggle - // to align with the top level parameter rows. Child parameter rows have some - // left padding so they can indent as they nest but we don't want that in - // this case. We need the !important to override general default markdown - // article styling. + // The top-level details toggle aligns with parent rows, while nested rows keep indentation. + // !important overrides default markdown article spacing. & > tbody > tr > td > details { padding-left: 0px !important; margin-bottom: 4px !important; diff --git a/src/automated-pipelines/lib/update-markdown.ts b/src/automated-pipelines/lib/update-markdown.ts index 67ab4bd34183..d5d9002d3e5d 100644 --- a/src/automated-pipelines/lib/update-markdown.ts +++ b/src/automated-pipelines/lib/update-markdown.ts @@ -64,9 +64,6 @@ type ChildrenComparison = { const ROOT_INDEX_FILE = 'content/index.md' export const MARKDOWN_COMMENT = '\n\n' -// Main entrypoint into this module. -// Walks every directory under targetDirectory, adding and removing Markdown -// files and keeping the index.md children and versions frontmatter in sync. export async function updateContentDirectory({ targetDirectory, sourceContent, @@ -79,15 +76,12 @@ export async function updateContentDirectory({ await updateMarkdownFiles(targetDirectory, sourceContent, frontmatter, indexOrder) } -// Remove markdown files that are no longer in the source data async function removeMarkdownFiles( targetDirectory: string, sourceFiles: string[], autogeneratedType: string | undefined, ): Promise { const autogeneratedFiles = await getAutogeneratedFiles(targetDirectory, autogeneratedType) - // If the first array contains items that the second array does not, - // it means that a Markdown page was deleted from the OpenAPI schema const filesToRemove = difference(autogeneratedFiles, sourceFiles) if (filesToRemove.length > 0) { logger.info('Removing stale markdown files', { @@ -100,8 +94,6 @@ async function removeMarkdownFiles( } } -// Gets a list of all files under targetDirectory that have the -// `autogenerated` frontmatter set to `autogeneratedType`. async function getAutogeneratedFiles( targetDirectory: string, autogeneratedType: string | undefined, @@ -124,9 +116,6 @@ async function getAutogeneratedFiles( ).filter(Boolean) as string[] } -// The `sourceContent` object contains the new content and target file -// path for the Markdown files. Ex: -// { : { data: , content: } } async function updateMarkdownFiles( targetDirectory: string, sourceContent: SourceContent, @@ -137,14 +126,12 @@ async function updateMarkdownFiles( await updateMarkdownFile(file, newContent.data, newContent.content) } await updateDirectory(targetDirectory, frontmatter, { indexOrder }) - // The pipelines should not touch directories they do not own, so this - // call updates only the index.md file in the parent directory. + // Update only the parent index because pipelines must not touch sibling directories. await updateDirectory(path.dirname(targetDirectory), frontmatter, { rootDirectoryOnly: true }) } -// If the Markdown file already exists on disk, we update only the content -// and the versions frontmatter, so writers can hand-edit the other fields. -// If it does not exist, we create it. +// Existing autogenerated pages keep writer-edited frontmatter except versions. +// New pages use source frontmatter because no writer edits exist yet. async function updateMarkdownFile( file: string, sourceData: FrontmatterData, @@ -154,7 +141,7 @@ async function updateMarkdownFile( if (existsSync(file)) { const { data, content } = matter(await readFile(file, 'utf-8')) - // Double check that the comment delimiter is only used once + // Multiple delimiters make the writer-owned and generated content split unsafe. const matcher = new RegExp(commentDelimiter, 'g') const matches = content.match(matcher) if (matches && matches.length > 1) { @@ -181,9 +168,7 @@ async function updateMarkdownFile( delimiterMissing: isDelimiterMissing, }) - // Create a new object so that we don't mutate the original data const newData = { ...data } - // Only modify the versions property when a file already exists newData.versions = sourceData.versions const targetContent = manuallyCreatedContent + commentDelimiter + sourceContent const newFileContent = appendVersionComment(matter.stringify(targetContent, newData)) @@ -198,10 +183,7 @@ async function updateMarkdownFile( } } -// Recursively walks through the directory structure and updates the -// index.md files to match the disk. Before calling this function -// ensure that the Markdown files have been updated and any files -// that need to be deleted have been removed. +// Call after Markdown updates and deletions because child index files mirror disk state. async function updateDirectory( directory: string, frontmatter: FrontmatterData, @@ -225,8 +207,7 @@ async function updateDirectory( const indexFile = `${directory}/index.md` const { data, content } = await getIndexFileContents(indexFile, frontmatter, shortTitle) - // We need to re-get the directory contents because a recursive call - // may have removed a directory since the initial directory read. + // Recursive calls may remove child directories, so read the directory again before syncing. const { directoryContents, childDirectories, directoryFiles } = await getDirectoryInfo(directory) const { childrenOnDisk, indexChildren } = getChildrenToCompare( @@ -262,12 +243,8 @@ async function updateDirectory( await writeFile(indexFile, matter.stringify(content, dataUpdatedChildren)) } -// Takes the children properties from the index.md file and the -// files/directories on disk and normalizes them to be comparable -// against each other. -// Children properties include a leading slash except when the -// index.md file is the root index.md file. We also want to -// remove the file extension from the files on disk. +// Root index children omit the leading slash; other index.md children include it. +// Disk entries include extensions, so normalize both sides before comparing them. function getChildrenToCompare( indexFile: string, directoryContents: string[], @@ -291,21 +268,8 @@ function getChildrenToCompare( return { childrenOnDisk, indexChildren } } -// Adds and removes children properties to the index.md file. -// There are three possible scenarios that we want to handle: -// -// 1. If the lib/config.json file for the pipeline defines a sort -// order for the index file we're currently processing, then -// we want to use that sort order. Currently, the config files -// only defined a startsWith parameter. This property defines -// the order of the first items in the index files children -// property. All other items are sorted and appended to list. -// -// 2. If no config is defined and the index file is an -// autogenerated file, we sort all the children alphabetically. -// -// 3. If the index file is not autogenerated, we leave the ordering -// as is and append new children to the end. +// Autogenerated indexes use config startsWith ordering first, then alphabetical entries. +// Manual indexes keep existing order and append new children to the end. function updateIndexChildren( data: FrontmatterData, childUpdates: ChildUpdates, @@ -317,17 +281,13 @@ function updateIndexChildren( const childPrefix = rootIndex ? '' : '/' const children = [...(data.children || [])] - // remove the '/' prefix used in index.md children .map((item) => item.replace(childPrefix, '')) .filter((item) => !itemsToRemove.includes(item)) children.push(...itemsToAdd) const orderedIndexChildren: string[] = [] - // Only used for tests. During testing, the content directory is - // in a temp directory so the paths are not relative to - // the current working directory. This gets the relative path - // from the full path to the index file. + // Tests run content in a temp directory, so config keys need repo-relative index paths. const indexRelativePath = process.env.TEST_OS_ROOT_DIR ? indexFile.replace(`${process.env.TEST_OS_ROOT_DIR}/`, '') : indexFile @@ -340,24 +300,19 @@ function updateIndexChildren( orderedIndexChildren.push(...indexOrderConfig.startsWith, ...sortableChildren) } } else if (isAutogenerated) { - // always sort autogenerated index files that have no override config orderedIndexChildren.push(...children) orderedIndexChildren.sort() } else { - // just leave the children in the order they are in the index file - // so they can be manually sorted + // Manual index files keep writer-defined ordering. orderedIndexChildren.push(...children) } const updatedData = { ...data } - // add the '/' prefix back to the children updatedData.children = orderedIndexChildren.map((item) => `${childPrefix}${item}`) return updatedData } -// Gets the contents of the index.md file from disk if it exists, -// or returns default frontmatter for a new one. async function getIndexFileContents( indexFile: string, frontmatter: FrontmatterData, @@ -379,9 +334,6 @@ async function getIndexFileContents( return existsSync(indexFile) ? matter(await readFile(indexFile, 'utf-8')) : indexFileContent } -// Builds the index.md versions frontmatter by consolidating -// the versions from each Markdown file in the directory + the -// index.md files in any subdirectories of directory. async function getIndexFileVersions( directory: string, files: string[], @@ -414,42 +366,22 @@ async function getIndexFileVersions( return await convertVersionsToFrontmatter(versionArray) } -/* Takes a list of versions in the format: -[ - 'free-pro-team@latest', - 'enterprise-cloud@latest', - 'enterprise-server@3.3', - 'enterprise-server@3.4', - 'enterprise-server@3.5', - 'enterprise-server@3.6', - 'enterprise-server@3.7' -] -and returns the frontmatter equivalent JSON: -{ - fpt: '*', - ghec: '*', - ghes: '*' -} -*/ +// Converts applicable versions to versions frontmatter. +// Example: free-pro-team@latest, enterprise-cloud@latest, and every supported GHES release +// become fpt: *, ghec: *, and ghes: *. export async function convertVersionsToFrontmatter( versions: string[], ): Promise<{ [key: string]: string }> { const frontmatterVersions: { [key: string]: string } = {} const numberedReleases: { [key: string]: { availableReleases: (string | undefined)[] } } = {} - // Currently, only GHES is numbered. Number releases have to be - // handled differently because they use semantic versioning. + // GHES uses semantic version ranges because it has numbered releases. for (const version of versions) { const docsVersion = allVersions[version] if (!docsVersion.hasNumberedReleases) { frontmatterVersions[docsVersion.shortName] = '*' } else { - // Each version that has numbered releases in allVersions - // has a string for the number (currentRelease) and an array - // of all of the available releases (e.g. ['3.3', '3.4', '3.5']) - // This creates an array of the applicable releases in the same - // order as the available releases array. This is used to track when - // a release is no longer supported. + // Track supported GHES releases by position so gaps become explicit ranges later. const i = docsVersion.releases.indexOf(docsVersion.currentRelease) if (!numberedReleases[docsVersion.shortName]) { const availableReleases: (string | undefined)[] = Array(docsVersion.releases.length).fill( @@ -465,15 +397,13 @@ export async function convertVersionsToFrontmatter( } } - // Create semantic versions for numbered releases for (const key of Object.keys(numberedReleases)) { const availableReleases = numberedReleases[key].availableReleases const versionContinuity = checkVersionContinuity(availableReleases) if (availableReleases.every(Boolean)) { frontmatterVersions[key] = '*' } else if (!versionContinuity) { - // If there happens to be version gaps, just enumerate each version - // using syntax like =3.x || =3.x + // Gapped releases must enumerate each supported version, such as =3.3 || =3.5. const semVer = availableReleases .filter(Boolean) .map((release) => `=${release}`) @@ -501,14 +431,11 @@ export async function convertVersionsToFrontmatter( return sortedFrontmatterVersions } -// This is uncommon, but we potentially could have the case where an -// article was versioned for say 3.2, not for 3.3, and then again -// versioned for 3.4. This will result in a custom semantic version range +// A gap between supported versions, such as 3.2 and 3.4 without 3.3, needs a custom range. function checkVersionContinuity(versions: (string | undefined)[]): boolean { const availableVersions = [...versions] - // values at the beginning or end of the array are not gaps but normal - // starts and ends of version ranges + // Missing values at the ends mark normal range boundaries, not gaps. while (!availableVersions[0]) { availableVersions.shift() } diff --git a/src/automated-pipelines/tests/rendering.ts b/src/automated-pipelines/tests/rendering.ts index f8dfc17ca55f..9e729afc102f 100644 --- a/src/automated-pipelines/tests/rendering.ts +++ b/src/automated-pipelines/tests/rendering.ts @@ -24,19 +24,17 @@ describe('autogenerated docs render', () => { const autogeneratedPages = pageList.filter((page: Page) => page.autogenerated) test('all automated pages', async () => { - // Each page should render with 200 OK. Also, check for duplicate - // heading IDs on each page. + // Each page must render with 200 OK and unique heading IDs. const errors = ( await Promise.all( autogeneratedPages.map(async (page: Page) => { const url = page.permalinks[0].href - // Some autogenerated pages can be very slow and might fail. - // So we allow a few retries to avoid false positives. + // Slow autogenerated pages get retries to avoid false-positive failures. const res = await get(url, { retries: 3 }) if (res.statusCode !== 200) { return `${res.statusCode} status error on ${url}` } - // Using `xmlMode: true` is marginally faster + // xmlMode is faster for this duplicate-ID scan. const $ = load(res.body, { xmlMode: true }) const headingIDs = $('body') .find('h2, h3, h4, h5, h6') @@ -64,10 +62,8 @@ describe('autogenerated docs render', () => { const ghappsPath: string = JSON.parse( readFileSync('src/github-apps/lib/config.json', 'utf-8'), ).targetDirectory - // Right now only the rest and codeqlcli pages get their frontmatter updated automatically. - // The apps pages do not get their frontmatter auto-updated since they apply to all versions and they are - // single pages. The apps pages are also nested inside of the rest pages. So we want to filter out only - // rest pages and the codeql cli pages for this test. + // Only REST and CodeQL CLI pages get automated version frontmatter updates. + // GitHub Apps pages apply to all versions and nest under REST, so exclude them. const filesWithAutoUpdatedVersions = autogeneratedPages.filter( (page: Page) => (!page.fullPath.startsWith(ghappsPath) && page.fullPath.startsWith(restPath)) || diff --git a/src/automated-pipelines/tests/update-markdown.ts b/src/automated-pipelines/tests/update-markdown.ts index a617287046ff..827d90d27228 100644 --- a/src/automated-pipelines/tests/update-markdown.ts +++ b/src/automated-pipelines/tests/update-markdown.ts @@ -70,10 +70,8 @@ const indexOrder: IndexOrder = { } describe('automated content directory updates', () => { - // Before all tests, copy the content directory fixture - // to the operating systems temp directory. We'll be modifying - // that temp directory during the tests and comparing the directory - // structure and contents after running updateContentDirectory. + // Tests mutate a temp copy of src/automated-pipelines/tests/fixtures/content, then compare + // the resulting file tree and frontmatter after updateContentDirectory runs. beforeAll(async () => { process.env.TEST_OS_ROOT_DIR = tempDirectory mkdirSync(`${tempContentDirectory}`, { recursive: true }) @@ -81,10 +79,7 @@ describe('automated content directory updates', () => { recursive: true, }) - // The updateContentDirectory uses relative paths to the content directory - // because outside of testing it only runs in the docs-internal repo. - // Because of that, we need to update the content paths to use the - // full file path. + // Temp fixtures need absolute paths because this test runs outside the repo content root. const contentDataFullPath: { [key: string]: ContentItem } = {} for (const key of Object.keys(newContentData)) { contentDataFullPath[path.join(targetDirectory, key)] = newContentData[key] @@ -127,7 +122,6 @@ describe('automated content directory updates', () => { }) test('rest/actions index file is updated as expected', async () => { - // workflows added and artifacts removed const actionsIndex = matter( await readFile(`${tempDirectory}/content/rest/actions/index.md`, 'utf8'), ) diff --git a/src/codeql-queries/scripts/generate-code-quality-query-list.ts b/src/codeql-queries/scripts/generate-code-quality-query-list.ts index a0348201000a..eb422526288c 100644 --- a/src/codeql-queries/scripts/generate-code-quality-query-list.ts +++ b/src/codeql-queries/scripts/generate-code-quality-query-list.ts @@ -1,41 +1,13 @@ -/** - * This script generates a block of Markdown that can be saved as a reusable. - * The reusable lists all the code quality queries for one programming language, with categories, as a Markdown table. - * - * To be able to execute this script, you need to have the CodeQL CLI installed. - * To do that, you need two things: - * - * 1. The directory where the github/codeql repo is cloned - * 2. The path to the executable `codeql` file. - * - * The directory where the github/codeql repo is cloned is needed because - * that's how it looks up files. You can set it up like this: - * - * cd /tmp - * git clone git@github.com:github/codeql.git - * cd codeql - * pwd - * - * To install the codeql executable, use `gh` like this: - * - * gh extension install github/gh-codeql - * gh codeql set-channel nightly - * gh codeql version - * - * Note that when you run the `gh codeql version` command, it will tell you - * where the executable is installed. For example: - * - * /Users/peterbe/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-20231204/codeql - * - * If you've git cloned github/codeql in /tmp/ now you can execute this script. - * For example, to generate the Markdown - * for Python: - * - * npm run generate-code-quality-query-list -- \ - * --codeql-path ~/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-20231204/codeql \ - * --codeql-dir /tmp/codeql python | tee /tmp/python.md - * less /tmp/python.md - */ +// Generates reusable Markdown listing code quality queries for one language, with categories. +// Requires a local github/codeql clone and a CodeQL CLI executable. +// Set up the clone with git clone git@github.com:github/codeql.git /tmp/codeql. +// Install the CLI with gh extension install github/gh-codeql, then gh codeql set-channel nightly. +// Run gh codeql version to find the installed codeql path. +// Example: +// npm run generate-code-quality-query-list -- \ +// --codeql-path ~/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-*/codeql \ +// --codeql-dir /tmp/codeql python | tee /tmp/python.md +// Inspect the generated Markdown with less /tmp/python.md. import fs from 'fs' import { execFileSync } from 'child_process' @@ -122,7 +94,7 @@ async function main(options: Options, language: string) { const categories = getCategories(tags || '') const url = getDocsLink(language, id) - // Only include queries that have categories + // Category-less queries have no code quality docs row. if (categories.length) { queries[id] = { url, name, categories, severity: severity || 'N/A' } } else { @@ -135,8 +107,7 @@ async function main(options: Options, language: string) { } function decorate(query: Query): QueryExtended { - // Determine primary category for sorting - // Prefer 'maintainability' over 'reliability' + // Maintainability outranks reliability for table sorting. const primaryCategory = query.categories.includes('maintainability') ? 'maintainability' : query.categories.includes('reliability') @@ -151,7 +122,7 @@ async function main(options: Options, language: string) { const entries = Object.values(queries).map(decorate) - // Sort by primary category (maintainability first), then alphabetically by name + // Sort by primary category, then alphabetically by name. entries.sort((a, b) => { if (a.primaryCategory === 'maintainability' && b.primaryCategory !== 'maintainability') return -1 @@ -170,25 +141,23 @@ async function main(options: Options, language: string) { function printQueries(options: Options, queries: QueryExtended[]) { const markdown: string[] = [] markdown.push('{% rowheaders %}') - markdown.push('') // blank line + markdown.push('') const header = ['Query name', 'Category', 'Severity'] markdown.push(`| ${header.join(' | ')} |`) markdown.push(`| ${header.map(() => '---').join(' | ')} |`) for (const query of queries) { const markdownLink = `[${query.name}](${query.url})` - // Capitalize first letter of category for display const categoryDisplay = query.categories .map((cat) => cat.charAt(0).toUpperCase() + cat.slice(1)) .join(', ') - // Capitalize first letter of severity for display const severityDisplay = query.severity.charAt(0).toUpperCase() + query.severity.slice(1) const row = [markdownLink, categoryDisplay, severityDisplay] markdown.push(`| ${row.join(' | ')} |`) } - markdown.push('') // blank line + markdown.push('') markdown.push('{% endrowheaders %}') - markdown.push('') // always end with a blank line + markdown.push('') if (options.outputFile === 'stdout') { console.log(markdown.join('\n')) @@ -203,9 +172,7 @@ function getMetadata(options: Options, queryFile: string): QueryMetadata { }) const parsed = JSON.parse(metadataJson) - // Extract severity from various possible locations in the metadata - // CodeQL metadata can have @problem.severity in the query file, which may be - // represented in different ways in the JSON output from `codeql resolve metadata` + // CodeQL emits severity through several metadata shapes, depending on the query source. const severity = parsed.problem?.severity || // Nested: { problem: { severity: "error" } } parsed['@problem']?.severity || // Nested with @: { "@problem": { severity: "error" } } @@ -215,7 +182,7 @@ function getMetadata(options: Options, queryFile: string): QueryMetadata { parsed['@severity'] // With @: { "@severity": "error" } if (options.verbose) { - // On first query only, show all available keys to help debug + // Verbose mode logs metadata keys once to avoid noisy output. if (!getMetadata.shownKeys) { console.log(chalk.yellow('Available metadata keys:'), Object.keys(parsed)) if (parsed.problem) { @@ -240,24 +207,15 @@ function getMetadata(options: Options, queryFile: string): QueryMetadata { } } -// Add a property to track if we've shown keys getMetadata.shownKeys = false -/** - * - * @param language 'cpp' - * @param queryId 'external-entity-expansion' - * @returns https://codeql.github.com/codeql-query-help/cpp/cpp-external-entity-expansion/ - */ +// Example: cpp and external-entity-expansion become +// https://codeql.github.com/codeql-query-help/cpp/cpp-external-entity-expansion/ function getDocsLink(language: string, queryId: string) { return `https://codeql.github.com/codeql-query-help/${language}/${queryId.replaceAll('/', '-')}/` } -/** - * - * @param tags 'maintainability readability reliability external/cwe/cwe-1078 external/cwe/cwe-670 security' - * @returns ['maintainability', 'reliability'] - */ +// Example tags with maintainability and reliability return those categories in source order. function getCategories(tags: string) { const categories: string[] = [] for (const tag of tags.split(/\s+/g)) { diff --git a/src/codeql-queries/scripts/generate-code-scanning-query-list.ts b/src/codeql-queries/scripts/generate-code-scanning-query-list.ts index 8dfa75149b2c..e9bafc2b98dd 100644 --- a/src/codeql-queries/scripts/generate-code-scanning-query-list.ts +++ b/src/codeql-queries/scripts/generate-code-scanning-query-list.ts @@ -1,59 +1,23 @@ -/** - * This script generates a block of Markdown that can be saved as a reusable. - * The reusable lists all the queries for one programming language, with CWEs, as a Markdown table. - * - * To be able to execute this script, you need to have the CodeQL CLI installed. - * To do that, you need two things: - * - * 1. The directory where the github/codeql repo is clone - * 2. The path to the executable `codeql` file. - * - * The directory where the github/codeql repo is cloned is needed because - * that's how it looks up files. You can set it up like this: - * - * cd /tmp - * git clone git@github.com:github/codeql.git - * cd codeql - * pwd - * - * To install the codeql executable, use `gh` like this: - * - * gh extension install github/gh-codeql - * gh codeql set-channel nightly - * gh codeql version - * - * Note that when you run the `gh codeql version` command, it will tell you - * where the executable is installed. For example: - * - * /Users/peterbe/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-20231204/codeql - * - * Finally, you need to install `@github/cocofix`. This is a private package, - * so you first need to get the `DOCS_BOT_PAT_BASE` PAT from the vault and - * store it in the environment variable `DOCS_BOT_PAT_BASE`. - * Then run the following command from the root of this repo: - * - * ```sh - * npm i --no-save '--@github:registry=https://npm.pkg.github.com' '--//npm.pkg.github.com/:_authToken=${DOCS_BOT_PAT_BASE}' @github/cocofix - * ``` - * - * If you've git cloned github/codeql in /tmp/ now you can execute this script. - * For example, to generate the Markdown - * for Python: - * - * npm run generate-code-scanning-query-list -- \ - * --codeql-path ~/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-20231204/codeql \ - * --codeql-dir /tmp/codeql python | tee /tmp/python.md - * less /tmp/python.md - */ +// Generates reusable Markdown listing CodeQL code scanning queries for one language, with CWEs. +// Requires a local github/codeql clone and a CodeQL CLI executable. +// Set up the clone with git clone git@github.com:github/codeql.git /tmp/codeql. +// Install the CLI with gh extension install github/gh-codeql, then gh codeql set-channel nightly. +// Run gh codeql version to find the installed codeql path. +// Also requires @github/cocofix, installed locally with DOCS_BOT_PAT_BASE from the vault: +// npm i --no-save '--@github:registry=https://npm.pkg.github.com' \ +// '--//npm.pkg.github.com/:_authToken=${DOCS_BOT_PAT_BASE}' @github/cocofix +// Example: +// npm run generate-code-scanning-query-list -- \ +// --codeql-path ~/.local/share/gh/extensions/gh-codeql/dist/nightly/codeql-bundle-*/codeql \ +// --codeql-dir /tmp/codeql python | tee /tmp/python.md +// Inspect the generated Markdown with less /tmp/python.md. import fs from 'fs' import { execFileSync } from 'child_process' import chalk from 'chalk' import { program } from 'commander' -// We don't want to introduce a global dependency on @github/cocofix, so we install it by hand -// as described above and suppress the import warning. -// eslint-disable-next-line import/no-unresolved -- @github/cocofix is installed manually +// eslint-disable-next-line import/no-unresolved -- @github/cocofix stays manual to avoid a global dependency import { getSupportedQueries } from '@github/cocofix/dist/querySuites' import type { Language } from 'codeql-ts' @@ -150,8 +114,7 @@ async function main(options: Options, language: string) { const url = getDocsLink(language, id) const autofixSupport = autofixSupportedQueryIds.includes(id) ? 'default' : 'none' - // Only include queries that have CWEs, since the other queries deal with code scanning - // metadata and metrics (e.g. counting lines of code or number of files) and have no docs link + // CWE-less queries cover metadata or metrics and have no docs link. if (cwes.length) { if (!(id in queries)) { queries[id] = { url, name, packs: [], cwes, autofixSupport } @@ -178,8 +141,7 @@ async function main(options: Options, language: string) { const entries = Object.values(queries).map(decorate) - // Spec: "Queries that are both in Default and Extended should come first, - // in alphabetical order. Followed by the queries that are in Extended only." + // Default-and-Extended queries sort before Extended-only queries; each group sorts by name. entries.sort((a, b) => { if (a.inDefault && !b.inDefault) return -1 else if (!a.inDefault && b.inDefault) return 1 @@ -196,7 +158,7 @@ async function main(options: Options, language: string) { function printQueries(options: Options, queries: QueryExtended[]) { const markdown: string[] = [] markdown.push('{% rowheaders %}') - markdown.push('') // blank line + markdown.push('') const header = [ 'Query name', 'Related CWEs', @@ -218,9 +180,9 @@ function printQueries(options: Options, queries: QueryExtended[]) { const row = [markdownLink, query.cwes.join(', '), defaultIcon, extendedIcon, autofixIcon] markdown.push(`| ${row.join(' | ')} |`) } - markdown.push('') // blank line + markdown.push('') markdown.push('{% endrowheaders %}') - markdown.push('') // always end with a blank line + markdown.push('') if (options.outputFile === 'stdout') { console.log(markdown.join('\n')) @@ -237,21 +199,13 @@ function getMetadata(options: Options, queryFile: string): QueryMetadata { return parsed } -/** - * - * @param language 'cpp' - * @param queryId 'external-entity-expansion' - * @returns https://codeql.github.com/codeql-query-help/cpp/cpp-external-entity-expansion/ - */ +// Example: cpp and external-entity-expansion become +// https://codeql.github.com/codeql-query-help/cpp/cpp-external-entity-expansion/ function getDocsLink(language: string, queryId: string) { return `https://codeql.github.com/codeql-query-help/${language}/${queryId.replaceAll('/', '-')}/` } -/** - * - * @param tags 'maintainability readability external/cwe/cwe-1078 external/cwe/cwe-670 security' - * @returns ['1078', '670'] - */ +// Example tags with external/cwe/cwe-1078 and external/cwe/cwe-670 return 1078 and 670. function getCWEs(tags: string) { const cwes: string[] = [] for (const tag of tags.split(/\s+/g)) { diff --git a/src/observability/lib/failbot.ts b/src/observability/lib/failbot.ts index e2b90878c26e..dbd89c203edb 100644 --- a/src/observability/lib/failbot.ts +++ b/src/observability/lib/failbot.ts @@ -26,6 +26,8 @@ async function retryingFetch(input: RequestInfo | URL, init?: RequestInit): Prom return response } +// Failbot additional_data only accepts flat string and number values, so keep requestUuid. +// https://github.com/github/failbotg/blob/main/docs/api.md#additional-data export function report(error: Error, metadata?: Record) { if (!process.env.HAYSTACK_URL) { return @@ -42,9 +44,6 @@ export function report(error: Error, metadata?: Record) { backends, }) - // Metadata can only be a flat object with string & number values, - // so only add the requestUuid. - // https://github.com/github/failbotg/blob/main/docs/api.md#additional-data const loggerContext = getLoggerContext() return failbot.report(error, { @@ -53,7 +52,7 @@ export function report(error: Error, metadata?: Record) { }) } -// Kept so legacy callers can keep doing `FailBot.report(myError)`. +// Preserves FailBot.report(error) for existing callers. export default { report, } diff --git a/src/observability/lib/handle-package-not-found.ts b/src/observability/lib/handle-package-not-found.ts index 8bb738532c08..80c6c122d19b 100644 --- a/src/observability/lib/handle-package-not-found.ts +++ b/src/observability/lib/handle-package-not-found.ts @@ -1,14 +1,8 @@ -/* -This file adds a custom error message if a package is missing -to prompt the contributor to run `npm ci`. -This handler must be separate from handle-exceptions.ts in order to function. -It's imported in package.json in nodemonConfig, -whereas that is imported in start-server.ts. -This file must not import any packages. -We are suggesting `npm ci` to contributors -to avoid unexpected changes to the package-lock.json file. -All other errors should fall through to the error handler in handle-exceptions.ts. -*/ +// Shows a custom npm ci prompt for missing-package errors. +// Recommend npm ci instead of npm install to avoid unexpected package-lock.json changes. +// package.json loads this file through nodemonConfig before start-server.ts loads handle-exceptions.ts. +// Keep it dependency-free so missing packages can reach this handler. +// Other uncaught exceptions fall through to handle-exceptions.ts. type ErrorWithCode = { code: string diff --git a/src/observability/lib/runtime-metrics.ts b/src/observability/lib/runtime-metrics.ts index 42740357f5af..695681cceefa 100644 --- a/src/observability/lib/runtime-metrics.ts +++ b/src/observability/lib/runtime-metrics.ts @@ -1,13 +1,7 @@ -/** - * Periodically emits Node.js runtime metrics to Datadog via StatsD. - * - * Covers three categories that are otherwise invisible: - * 1. V8 heap: used vs limit, so we can spot memory pressure before OOMs. - * 2. GC: pause duration, so we can correlate latency spikes with GC. - * 3. Event-loop delay: p50/p99, so we can see when the loop is blocked. - * - * Only activates when StatsD is sending real metrics (MODA_PROD_SERVICE_ENV). - */ +// Emits runtime metrics that StatsD does not capture elsewhere: +// V8 heap usage and limit for memory pressure, GC pause duration for latency correlation, +// and event-loop p50 and p99 delay for blocked-loop detection. +// Starts only when StatsD sends real metrics through MODA_PROD_SERVICE_ENV. import v8 from 'node:v8' import { monitorEventLoopDelay, PerformanceObserver } from 'node:perf_hooks' @@ -21,9 +15,7 @@ function isMetricsEnabled(): boolean { return process.env.MODA_PROD_SERVICE_ENV === 'true' && process.env.NODE_ENV !== 'test' } -/** - * Call once at server start. Safe to call multiple times (no-op after first). - */ +// Safe to call from multiple server-start paths; calls after the first are no-ops. export function startRuntimeMetrics(): void { if (started) return started = true @@ -43,8 +35,7 @@ export function startRuntimeMetrics(): void { const gcObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { const kind = (entry as unknown as { detail?: { kind?: number } }).detail?.kind - // kind: 1 = Scavenge (minor), 2 = Mark-Sweep-Compact (major), - // 4 = Incremental marking, 8 = Process weak callbacks, 15 = All + // perf_hooks GC kinds: 1 minor, 4 major, 8 incremental, 16 weak callbacks. const tag = kind === 1 ? 'minor' : kind === 2 ? 'major' : 'other' statsd.histogram('node.gc.pause', entry.duration, [`gc_type:${tag}`]) } diff --git a/src/observability/lib/statsd.ts b/src/observability/lib/statsd.ts index ff8254d18ffe..5690a54237df 100644 --- a/src/observability/lib/statsd.ts +++ b/src/observability/lib/statsd.ts @@ -11,17 +11,15 @@ const { const mock = NODE_ENV === 'test' || MODA_PROD_SERVICE_ENV !== 'true' -// MODA_APP_NAME gets set when the deploy target is Moda +// Moda deploys set MODA_APP_NAME for tagging. const modaApp = MODA_APP_NAME ? `moda_app_name:${MODA_APP_NAME}` : false const tagCandidates = ['app:docs', modaApp] export const tags: string[] = tagCandidates.filter((tag): tag is string => Boolean(tag)) const statsd = new StatsD({ - // hot-shots falls back to DD_AGENT_HOST and DD_DOGSTATSD_PORT, - // then to localhost:8125. - // Moda defines DD_DOGSTATSD_PORT but not DD_AGENT_HOST, - // and needs the host set to the Kubernetes node name from KUBE_NODE_HOSTNAME. + // hot-shots falls back to localhost:8125 when neither host variable is set. + // Moda sets only DD_DOGSTATSD_PORT, so use KUBE_NODE_HOSTNAME as the DogStatsD host. host: DD_AGENT_HOST || KUBE_NODE_HOSTNAME, port: DD_DOGSTATSD_PORT ? parseInt(DD_DOGSTATSD_PORT, 10) : undefined, prefix: 'docs.', @@ -31,10 +29,8 @@ const statsd = new StatsD({ export default statsd -// hot-shots v14 changed asyncTimer/timer to inject a TimerContext as the -// final argument of the wrapped function. This adapter lets callers keep -// passing functions with their original signatures by appending an ignored -// TimerContext parameter. +// hot-shots asyncTimer and timer append TimerContext to wrapped functions. +// This adapter preserves callers' original signatures by dropping that extra argument. export function adaptForTimer

( fn: (...args: P) => Promise, ): (...args: [...P, TimerContext]) => Promise { diff --git a/src/observability/lib/tracing.browser.ts b/src/observability/lib/tracing.browser.ts index c9413916387a..4eb75bd8ce42 100644 --- a/src/observability/lib/tracing.browser.ts +++ b/src/observability/lib/tracing.browser.ts @@ -1,3 +1 @@ -// Browser stub for tracing.ts: OTel is server-only. -// This file is aliased in by Next.js webpack and Turbopack for client bundles. -// It's a no-op: tracing.ts is a side-effect-only module with no exports. +// Next.js aliases tracing.ts to this empty client-bundle stub because tracing is server-only. diff --git a/src/observability/lib/tracing.ts b/src/observability/lib/tracing.ts index 91f4e9ecb874..524a0b77db32 100644 --- a/src/observability/lib/tracing.ts +++ b/src/observability/lib/tracing.ts @@ -1,18 +1,10 @@ -// OpenTelemetry distributed tracing setup for docs-internal, -// following the same pattern as github/alloy and github/github-ui. -// -// The instrumentation list (HTTP, Express, Undici/fetch) is explicit -// instead of `getNodeAutoInstrumentations()`. -// The "auto" helper enables ~30 instrumentations, -// including ones that patch Node core modules (`fs`, `net`, `dns`) on every server. -// Several of these are known to cause performance and listener-leak issues, -// and OTel itself recommends disabling `instrumentation-fs` in production. -// We only have HTTP traffic and outbound fetch in this app, -// so we wire those up explicitly. -// -// References: -// - https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/ -// - https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/github-telemetry-js-user-guide/ +// Follows the github/alloy and github/github-ui tracing pattern. +// Uses explicit HTTP, Express, and Undici instrumentation instead of getNodeAutoInstrumentations. +// The auto helper enables about 30 instrumentations, including fs, net, and dns patches. +// These can hurt performance or leak listeners; OTel recommends disabling instrumentation-fs in production. +// This app only needs inbound HTTP and outbound fetch tracing. +// See https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/ +// See https://thehub.github.com/epd/engineering/dev-practicals/observability/distributed-tracing/github-telemetry-js-user-guide/ import { CompositePropagator, @@ -51,7 +43,7 @@ if (process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT) { }) } - // Uses `once` to prevent duplicate shutdown if SIGTERM is delivered multiple times. + // once prevents duplicate shutdown when SIGTERM arrives more than once. process.once('SIGTERM', async () => { try { await sdk.shutdown() diff --git a/src/observability/logger/index.ts b/src/observability/logger/index.ts index 3495a799c2d0..d7cf8ff87288 100644 --- a/src/observability/logger/index.ts +++ b/src/observability/logger/index.ts @@ -45,7 +45,7 @@ function formatContext(ctx: Record): string { return parts.length > 0 ? ` ${parts.join(' ')}` : '' } -// Handles file:// URLs (from import.meta.url) and plain string labels. +// Accepts file URLs from import.meta.url and plain string labels. function resolveFilePath(filePath: string): string { try { const parsed = new URL(filePath) @@ -76,13 +76,8 @@ interface LoggerMethod { (message: string, ...args: (string | number | boolean | Error | IncludeContext | object)[]): void } -/* -Call this function with `import.meta.url` as the argument to create a logger for a specific file. - -e.g. `const logger = createLogger(import.meta.url)` - -Logs will be output to the console in development, and in `logfmt` format to stdout in production. -*/ +// Pass import.meta.url so logs identify the caller file. +// Development logs go to console; production logs use logfmt on stdout. export function createLogger(filePath: string) { if (!filePath) { throw new Error('createLogger must be called with the import.meta.url argument') @@ -151,7 +146,7 @@ export function createLogger(filePath: string) { } const currentLogLevel = getLogLevelNumber() if (LOG_LEVELS[level] > currentLogLevel) { - return // Do not log if the requested level is lower priority + return // Higher numbers are more verbose. } const loggerContext = getLoggerContext() @@ -171,7 +166,7 @@ export function createLogger(filePath: string) { const includedContextWithFormattedError = {} as IncludeContext for (const [key, value] of Object.entries(includeContext)) { if (typeof value === 'object' && value instanceof Error) { - // Errors don't serialize well to JSON, so just log the message + stack trace + // Errors need explicit fields because JSON serialization drops message and stack. includedContextWithFormattedError[key] = value.message includedContextWithFormattedError[`${key}_code`] = (value as NodeJS.ErrnoException).code includedContextWithFormattedError[`${key}_name`] = value.name diff --git a/src/observability/logger/lib/log-levels.ts b/src/observability/logger/lib/log-levels.ts index fcf3fd40a419..5e63c3b14e7c 100644 --- a/src/observability/logger/lib/log-levels.ts +++ b/src/observability/logger/lib/log-levels.ts @@ -1,8 +1,5 @@ -/* -The log level is controlled by the `LOG_LEVEL` environment variable, where lower -log levels = more verbose. If log level is 'info', only 'info', 'warn', and -'error' logs are output. -*/ +// LOG_LEVEL controls verbosity. Lower numbers are higher priority. +// For LOG_LEVEL=info, info, warn, and error logs are emitted. export const LOG_LEVELS = { error: 0, warn: 1, @@ -17,10 +14,8 @@ function isValidLogLevel(level: string): level is LogLevel { return level in LOG_LEVELS } -// Defaults when LOG_LEVEL isn't set: -// - 'info' in development -// - 'debug' in production -// - 'debug' in test, because `vitest` turns off logs unless --silent=false is passed +// Default LOG_LEVEL is info in development and debug in production. +// Tests default to debug because vitest suppresses logs unless --silent=false is passed. export function getLogLevelNumber(): LogLevelValue { let defaultLogLevel: LogLevel = 'info' if ( diff --git a/src/observability/logger/lib/logger-context.ts b/src/observability/logger/lib/logger-context.ts index baff1072e685..07b07308b334 100644 --- a/src/observability/logger/lib/logger-context.ts +++ b/src/observability/logger/lib/logger-context.ts @@ -1,9 +1,7 @@ import { AsyncLocalStorage } from 'async_hooks' import type { NextFunction, Request, Response } from 'express' -// Think of this like a Redux store, but for the backend. -// An early middleware calls asyncLocalStorage.run(store, ...), -// which lets all downstream middleware read the store via `getLoggerContext`. +// AsyncLocalStorage carries request fields from early middleware to downstream log calls. export const asyncLocalStorage = new AsyncLocalStorage() export type LoggerContext = { @@ -43,19 +41,14 @@ export function updateLoggerContext(newContext: Partial): void { } const INCLUDE_HEADERS = [ - // Device / UA 'user-agent', 'sec-ch-ua', 'sec-ch-ua-platform', - // Language 'x-user-language', 'accept-language', - // Version 'x-user-version', - // Host 'host', 'x-host', - // Cache control 'cache-control', ] diff --git a/src/observability/logger/lib/to-logfmt.ts b/src/observability/logger/lib/to-logfmt.ts index c1b5f1648243..fd0f06574818 100644 --- a/src/observability/logger/lib/to-logfmt.ts +++ b/src/observability/logger/lib/to-logfmt.ts @@ -1,18 +1,5 @@ -/* - Flattens a JSON object and converts it to a logfmt string - Nested objects are flattened with a dot separator, e.g. requestContext.path=/en - This is because Splunk doesn't support nested JSON objects. - - Example - { - "a": 1, - "b": { - "c": 2 - } - } - becomes - a=1 b.c=2 -*/ +// Flattens nested context for Splunk, which cannot query nested JSON objects. +// Example: { requestContext: { path: "/en" } } becomes requestContext.path=/en. // Matches the original node-logfmt library's quoting and escaping behavior. function stringify(data: Record): string { @@ -46,7 +33,6 @@ function stringify(data: Record): string { line += `${key}=${stringValue} ` } - // trim trailing space return line.substring(0, line.length - 1) } diff --git a/src/observability/logger/middleware/get-automatic-request-logger.ts b/src/observability/logger/middleware/get-automatic-request-logger.ts index 946695ace75e..5cef1caecd88 100644 --- a/src/observability/logger/middleware/get-automatic-request-logger.ts +++ b/src/observability/logger/middleware/get-automatic-request-logger.ts @@ -44,7 +44,7 @@ export function getAutomaticRequestLogger() { } else if (shouldEnableAutomaticDevLogging()) { const logLevelNum = getLogLevelNumber() - // Don't log `/_next/` requests unless LOG_LEVEL is `debug` or higher + // Suppress /_next/ requests unless LOG_LEVEL is debug or more verbose. if (url?.startsWith('/_next/') && logLevelNum < 3) { return originalEnd.apply(this, args as Parameters) } diff --git a/src/observability/middleware/handle-errors.ts b/src/observability/middleware/handle-errors.ts index f43857cffdf1..e0b100026de2 100644 --- a/src/observability/middleware/handle-errors.ts +++ b/src/observability/middleware/handle-errors.ts @@ -44,6 +44,8 @@ async function logException(error: ErrorWithCode, req: ExtendedRequest) { } } +// For asset 404s, handleError sets short cache headers because Fastly caches 404 responses. +// https://docs.fastly.com/en/guides/how-caching-and-cdns-work#http-status-codes-cached-by-default async function handleError( error: ErrorWithCode | number, req: ExtendedRequest, @@ -54,14 +56,8 @@ async function handleError( if (req.path.startsWith('/assets') || req.path.startsWith('/_next/static')) { if (!responseDone) { - // Fastly caches 404s by default, so cache 404'ing assets conservatively: - // a short Cache-Control, plus the default surrogate key - // in case the 404 was a mistake. - // https://docs.fastly.com/en/guides/how-caching-and-cdns-work#http-status-codes-cached-by-default errorCacheControl(res) - // Unsets the manual surrogate key assumed earlier in the middleware chain. - // Falls back to `no-language` when `req.language` isn't set yet, - // e.g. errors before language detection. + // Clear any earlier manual surrogate key; pre-language errors fall back to no-language. setFastlySurrogateKey(res, makeLanguageSurrogateKey(req.language), true) } } else if (DEBUG_MIDDLEWARE_TESTS) { @@ -74,7 +70,7 @@ async function handleError( await logException(error, req) } - // We MUST delegate to the default Express error handler + // Delegate once headers are sent or the request aborted, so Express closes the connection. next(error) return } @@ -83,7 +79,7 @@ async function handleError( req.context = {} } - // Special handling for when a middleware calls `next(404)` + // Middleware may signal a normal 404 by calling next(404). if (error === 404) { errorCacheControl(res) setFastlySurrogateKey(res, makeLanguageSurrogateKey(req.language), true) @@ -94,29 +90,26 @@ async function handleError( throw new Error("Don't use next(xxx) where xxx is any other number than 404") } - // display error on the page in development and staging, but not in production + // Development and staging pages show the error; production pages do not. if (!process.env.MODA_PROD_SERVICE_ENV) { req.context.error = error } - // Errors with a status code usually come from a middleware like `express.json()`. + // Status-code errors usually come from middleware such as express.json. if (error.statusCode) { res.sendStatus(error.statusCode) return } res.statusCode = 500 - // Local dev doesn't need the pretty HTML rendering of 500.tsx. - // Also, as of Jan 2024, calling nextApp.renderError hangs forever - // when `NODE_ENV` is 'development'. We can't fully explain it, - // and it's moot because in local dev the full stack trace is more useful. + // In development, nextApp.renderError hangs forever and the stack trace is more useful. if (process.env.NODE_ENV === 'development') { next(error) return } else { nextApp.renderError(error, req, res, req.path) - // Report to Failbot AFTER responding to the user + // Report to Failbot after responding to the user. await logException(error, req) } } catch (handlingError) { diff --git a/src/observability/middleware/trigger-error.ts b/src/observability/middleware/trigger-error.ts index bcd1fee9157c..7009748fe058 100644 --- a/src/observability/middleware/trigger-error.ts +++ b/src/observability/middleware/trigger-error.ts @@ -2,19 +2,15 @@ import type { Response, NextFunction } from 'express' import type { ExtendedRequest } from '@/types' -// This module is for testing our handling of uncaught async rejections on incoming requests +// Tests use this route to exercise uncaught async rejections on incoming requests. -// IMPORTANT: Leave this function as `async` even though it doesn't need to be! +// Keep triggerError async and unwrapped so it rejects like async middleware. export default async function triggerError( req: ExtendedRequest, res: Response, next: NextFunction, ) { - // IMPORTANT: - // Do NOT wrap this method's contents in the usual `try-catch+next(error)` - // pattern used on async middleware! This is an intentional omission! - - // prevent this from being used in production + // Block intentional errors in production. if (process.env.NODE_ENV === 'production' && process.env.MODA_PROD_SERVICE_ENV === 'true') return next() diff --git a/src/observability/tests/failbot.ts b/src/observability/tests/failbot.ts index 4aef8b831345..2084a27abab7 100644 --- a/src/observability/tests/failbot.ts +++ b/src/observability/tests/failbot.ts @@ -34,14 +34,11 @@ describe('FailBot', () => { process.env.HAYSTACK_URL = 'https://haystack.example.com' const err = new Error('Kaboom') const backendPromises = FailBot.report(err, { foo: 'bar' }) - // Production code doesn't need to await what `FailBot.report()` returns. - // In vitest we await now, - // so we can assert the POST requests happened. + // Tests await backend promises so assertions observe the POST request. if (backendPromises) { await Promise.all(await backendPromises) } - // What `.report()` returns doesn't matter, only that it POSTed. expect(requestBodiesSent.length).toBe(1) expect(requestBodiesSent[0]).toMatchObject({ diff --git a/src/observability/tests/get-automatic-request-logger.ts b/src/observability/tests/get-automatic-request-logger.ts index 0151d35321d2..1204552d4591 100644 --- a/src/observability/tests/get-automatic-request-logger.ts +++ b/src/observability/tests/get-automatic-request-logger.ts @@ -176,7 +176,7 @@ describe('getAutomaticRequestLogger', () => { await new Promise((resolve) => setTimeout(resolve, 20)) - expect(consoleLogs).toHaveLength(0) // Should be filtered out + expect(consoleLogs).toHaveLength(0) }) it('should log _next requests when debug level is set', async () => { @@ -244,7 +244,6 @@ describe('getAutomaticRequestLogger', () => { expect(consoleLogs).toHaveLength(1) const logOutput = consoleLogs[0] - // Should include context fields (even if empty due to mocking) expect(logOutput).toContain('requestUuid=') expect(logOutput).toContain('path=') }) @@ -259,7 +258,6 @@ describe('getAutomaticRequestLogger', () => { }) it('should not log in test environment by default', async () => { - // Explicit environment settings for CI stability. vi.stubEnv('NODE_ENV', 'test') vi.stubEnv('ENABLE_DEV_LOGGING', '') vi.stubEnv('LOG_LIKE_PRODUCTION', '') @@ -314,7 +312,7 @@ describe('getAutomaticRequestLogger', () => { await new Promise((resolve) => setTimeout(resolve, 20)) expect(consoleLogs).toHaveLength(1) - expect(consoleLogs[0]).toContain('-') // Should show '-' for missing content length + expect(consoleLogs[0]).toContain('-') }) it('should handle missing status code', async () => { @@ -327,7 +325,7 @@ describe('getAutomaticRequestLogger', () => { await new Promise((resolve) => setTimeout(resolve, 20)) expect(consoleLogs).toHaveLength(1) - expect(consoleLogs[0]).toContain('200') // Should default to 200 + expect(consoleLogs[0]).toContain('200') }) it('should prefer originalUrl over url', async () => { @@ -351,7 +349,6 @@ describe('getAutomaticRequestLogger', () => { const startTime = Date.now() middleware(mockReq as Request, mockRes as Response, mockNext) - // Simulate some processing time await new Promise((resolve) => setTimeout(resolve, 50)) ;(mockRes as MockResponseWithEnd).end() await new Promise((resolve) => setTimeout(resolve, 20)) @@ -367,7 +364,6 @@ describe('getAutomaticRequestLogger', () => { if (responseTimeMatch) { const loggedTime = parseInt(responseTimeMatch[1], 10) - // Should be reasonably close to actual duration (within 20ms tolerance) expect(loggedTime).toBeGreaterThanOrEqual(40) expect(loggedTime).toBeLessThanOrEqual(actualDuration + 20) } diff --git a/src/observability/tests/logger-integration.ts b/src/observability/tests/logger-integration.ts index c602e7243d15..9071ee9f0d74 100644 --- a/src/observability/tests/logger-integration.ts +++ b/src/observability/tests/logger-integration.ts @@ -16,7 +16,6 @@ function expectDevLog(logs: string[], level: string, message: string): void { expect(match, `Expected a log containing "${level}" and "${message}"`).toBeDefined() } -// Integration tests that use real dependencies without mocks describe('logger integration tests', () => { let originalConsoleLog: typeof console.log let originalConsoleError: typeof console.error @@ -50,7 +49,6 @@ describe('logger integration tests', () => { describe('logger context integration', () => { it('should use empty context when no async local storage is set', () => { - // Set production mode to see the context in the output vi.stubEnv('LOG_LIKE_PRODUCTION', 'true') vi.stubEnv('NODE_ENV', 'development') @@ -60,7 +58,6 @@ describe('logger integration tests', () => { expect(consoleLogs).toHaveLength(1) const logOutput = consoleLogs[0] - // Real getLoggerContext returns empty strings for fields when no context is set expect(logOutput).toContain('level=info') expect(logOutput).toContain('message="Test without context"') expect(logOutput).toContain('timestamp=') @@ -68,7 +65,6 @@ describe('logger integration tests', () => { }) it('should use context from async local storage when available', async () => { - // Set production mode to see the context in the output vi.stubEnv('LOG_LIKE_PRODUCTION', 'true') vi.stubEnv('NODE_ENV', 'development') @@ -88,12 +84,9 @@ describe('logger integration tests', () => { const mockRes = {} as unknown as Response - // Use a Promise to handle the async local storage execution const result = await new Promise((resolve, reject) => { - // Create a next function that will execute our test logic within the async context const mockNext = () => { try { - // Update the context with additional values (simulating subsequent middleware) updateLoggerContext({ language: 'es', userLanguage: 'es', @@ -144,7 +137,7 @@ describe('logger integration tests', () => { logger.warn('Warn message') logger.error('Error message') - // With 'info' level, debug should be filtered out (debug=3, info=2, so debug > info) + // LOG_LEVEL numbers increase with verbosity, so debug 3 is filtered by info 2. const allClean = consoleLogs.map(stripAnsi).join('\n') expect(allClean).not.toContain('Debug message') expectDevLog(consoleLogs, 'INFO', 'Info message') @@ -167,7 +160,7 @@ describe('logger integration tests', () => { logger.warn('Warn message') logger.error('Error message') - // With 'error' level (0), only error should be logged + // error 0 filters every higher-verbosity level. const allClean = consoleLogs.map(stripAnsi).join('\n') expect(allClean).not.toContain('Debug message') expect(allClean).not.toContain('Info message') @@ -197,10 +190,10 @@ describe('logger integration tests', () => { consoleLogs.length = 0 consoleErrors.length = 0 - // Test NODE_ENV=production (but not in CI) + // CI disables production logging unless LOG_LIKE_PRODUCTION is true. vi.stubEnv('NODE_ENV', 'production') - vi.stubEnv('CI', '') // Ensure CI is not set - vi.stubEnv('LOG_LIKE_PRODUCTION', '') // Clear this to test production detection + vi.stubEnv('CI', '') + vi.stubEnv('LOG_LIKE_PRODUCTION', '') const logger = createLogger('file:///path/to/test.js') logger.info('Real production logging test') diff --git a/src/observability/tests/logger.ts b/src/observability/tests/logger.ts index 3b446c459575..69052a424bc6 100644 --- a/src/observability/tests/logger.ts +++ b/src/observability/tests/logger.ts @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { createLogger } from '@/observability/logger' -// Mock only the logger-context for most tests, but we'll test integration without mocks +// Most tests mock logger context; integration coverage lives in logger-integration.ts. vi.mock('@/observability/logger/lib/logger-context') function stripAnsi(s: string): string { @@ -212,11 +212,11 @@ describe('createLogger', () => { logger.error('Multiple errors', error1, error2) - // In development mode, each error triggers a separate console.log + console.error + // Development logging prints one console.log and one console.error per Error. expect(consoleLogs).toHaveLength(2) expect(consoleErrors).toHaveLength(2) - // Both log entries should have the same message + // Both Error entries share the combined message. expectDevLog(consoleLogs, 'ERROR', 'Multiple errors: First error, Second error') expect(consoleErrors[0]).toBe(error1) expect(consoleErrors[1]).toBe(error2) @@ -430,7 +430,7 @@ describe('createLogger', () => { vi.stubEnv('KUBE_NODE_HOSTNAME', 'ghe-k8s-node-42') vi.stubEnv('LOG_LIKE_PRODUCTION', 'true') - // Reset modules so pod-identity is re-evaluated with the new env vars + // Reset modules so pod-identity reads the stubbed environment. vi.resetModules() const { createLogger: freshCreateLogger } = await import('@/observability/logger') @@ -530,7 +530,7 @@ describe('createLogger', () => { const logOutput = consoleLogs[0] expect(logOutput).toContain('included.error="Cannot read property"') expect(logOutput).toContain('included.error_name=TypeError') - // When .code is undefined, error_code is present but empty + // Undefined error.code serializes as an empty error_code field. expect(logOutput).toMatch(/included\.error_code= /) expect(logOutput).toContain('included.error_stack=') }) diff --git a/src/observability/tests/to-error.ts b/src/observability/tests/to-error.ts index 819621bd6428..71e88aa7dc84 100644 --- a/src/observability/tests/to-error.ts +++ b/src/observability/tests/to-error.ts @@ -38,8 +38,7 @@ describe('toError', () => { it('should convert undefined to an Error via JSON.stringify', () => { const result = toError(undefined) expect(result).toBeInstanceOf(Error) - // JSON.stringify(undefined) returns undefined (not a string), - // so new Error(undefined) has an empty message + // JSON.stringify(undefined) makes new Error(undefined) use an empty message. expect(result.message).toBe('') }) @@ -54,7 +53,6 @@ describe('toError', () => { circular.self = circular const result = toError(circular) expect(result).toBeInstanceOf(Error) - // String() on an object returns '[object Object]' expect(result.message).toBe('[object Object]') }) }) diff --git a/src/observability/tests/to-logfmt.test.ts b/src/observability/tests/to-logfmt.test.ts index c75ff6f08df2..0f39a3cfc200 100644 --- a/src/observability/tests/to-logfmt.test.ts +++ b/src/observability/tests/to-logfmt.test.ts @@ -208,7 +208,7 @@ describe('toLogfmt', () => { const result = toLogfmt(obj) expect(result).toContain('name=test') - expect(result).toContain('self=[Circular]') // Our implementation marks circular refs + expect(result).toContain('self=[Circular]') }) it('should handle Date objects', () => { From 6dbe7a3fb5c6721d73c2c93e70fbf889f46ff0b4 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 17:40:37 +0000 Subject: [PATCH 06/18] Tighten code comments in src/early-access, color-schemes, codeql-cli, and secret-scanning (#63468) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- .../scripts/convert-markdown-for-docs.ts | 44 +++++-------------- src/codeql-cli/scripts/sync.ts | 5 +-- .../components/BrandThemeProvider.tsx | 7 ++- src/color-schemes/components/useTheme.ts | 10 ++--- src/color-schemes/lib/color-mode-script.ts | 24 +++++----- src/color-schemes/lib/get-brand-color-mode.ts | 4 +- src/color-schemes/tests/color-mode-script.ts | 26 +++++------ .../tests/get-brand-color-mode.ts | 2 +- .../middleware/early-access-links.ts | 8 ++-- src/early-access/scripts/clone-locally | 4 -- src/early-access/scripts/create-branch | 6 --- .../scripts/merge-early-access.sh | 6 +-- .../scripts/migrate-early-access-product.ts | 33 ++++---------- .../scripts/symlink-from-local-repo.ts | 8 +--- .../scripts/update-data-and-image-paths.ts | 8 ++-- .../scripts/what-docs-early-access-branch.ts | 6 +-- src/early-access/tests/early-access-unit.ts | 2 +- .../components/SecretScanningTable.tsx | 13 +++--- src/secret-scanning/pages/api/patterns.ts | 4 +- .../supported-secret-scanning-patterns.tsx | 2 +- src/secret-scanning/scripts/sync.ts | 12 ++--- .../tests/liquid-evaluation.ts | 4 +- src/secret-scanning/tests/rendering.ts | 2 +- 23 files changed, 79 insertions(+), 161 deletions(-) diff --git a/src/codeql-cli/scripts/convert-markdown-for-docs.ts b/src/codeql-cli/scripts/convert-markdown-for-docs.ts index 2462ba5d927e..7bdf8bcd34bb 100644 --- a/src/codeql-cli/scripts/convert-markdown-for-docs.ts +++ b/src/codeql-cli/scripts/convert-markdown-for-docs.ts @@ -68,7 +68,6 @@ export async function convertContentToDocs( visit(ast, 'heading', (rawNode) => { const node = rawNode as unknown as MdNode - // A level 1 heading is the article title. if (node.depth === 1) { frontmatter.title = node.children[0].value } @@ -78,15 +77,12 @@ export async function convertContentToDocs( node.children[0].value = node.children[0].value.split('{#')[0].trim() } - // Works around secondary options sitting at the wrong heading level - // in the source rst files. - // Everything after the "Synopsis", "Description", and "Options" - // headings moves up one level, so h4 becomes h3. + // Headings after Primary options sit one level too deep in source rst, so shift them up. if (secondaryOptions) { node.depth = Math.max(1, Math.min(6, node.depth - 1)) } - // This needs to be assigned after node.depth is modified above + // Capture depth after secondary options shift changes node.depth. depth = node.depth if (node.children[0].value === LAST_PRIMARY_HEADING && node.children[0].type === 'text') { secondaryOptions = true @@ -98,8 +94,7 @@ export async function convertContentToDocs( const node = rawNode as unknown as MdNode if (node.type !== 'heading' && node.type !== 'paragraph') return false - // The first paragraph after the "Description" heading - // becomes the intro frontmatter. + // The first paragraph after Description becomes intro frontmatter. if (node.children[0]?.value === 'Description' && node.children[0]?.type === 'text') { currentNodeIsDescription = true } @@ -125,10 +120,7 @@ export async function convertContentToDocs( node.meta = 'copy' } - // The start of a secondary options section, for example - // "Output format options." - // The rst file gives these no heading level, so nest them one level - // under `depth`, the last heading level seen by the walk above. + // Secondary labels "Output format options." lack depth; depth+1 nests under last heading. if (node.type === 'text' && node.value && node.value.includes(HEADING_BEGIN)) { node.value = node.value.replace(HEADING_BEGIN, '') // Ancestors run root first, so the last one is the parent. @@ -136,8 +128,7 @@ export async function convertContentToDocs( ancestors[ancestors.length - 1].depth = Math.max(1, Math.min(6, depth + 1)) } - // Keywords like [Plumbing] come from the source code comments - // and should not render in the docs. + // Source code keywords like [Plumbing] do not belong in docs output. if (node.type === 'text' && node.value) { for (const keyword of removeKeywords) { if (node.value.includes(keyword)) { @@ -146,8 +137,7 @@ export async function convertContentToDocs( } } - // Subsections under the level 2 headings are commands - // starting with `-` or `<`, so render them as inline code. + // Level 2 command headings start with - or <, so render them as inline code. if ( node.type === 'text' && ancestors[ancestors.length - 1].type === 'heading' && @@ -161,13 +151,7 @@ export async function convertContentToDocs( node.value = node.value.replace(END_SECTION, '') } - // Links to other CodeQL CLI docs, which need to become Markdown links. - // Pandoc converts the rst links to this shape: - // `codeql test run`{.interpreted-text role="doc"} - // giving a link title of `codeql test run` and a relative path of - // `test-run`. The rest can be dropped. - // The inline code tag is one node and the {.interpreted-text} string - // is another. + // Pandoc emits "codeql test run" as inline code plus role marker; convert to link. if (node.type === 'text' && node.value.includes('{.interpreted-text')) { const paragraph = ancestors[ancestors.length - 1].children const docRoleTagChild = paragraph.findIndex( @@ -189,7 +173,7 @@ export async function convertContentToDocs( node.value = node.value.replace(/\n/g, ' ').replace('{.interpreted-text role="doc"}', '') - // A link to the file being converted would be circular. + // Links to the file being converted would be circular. const currentFileBaseName = currentFileName.replace('.md', '') if (currentFileBaseName && linkPath === currentFileBaseName) { link.type = 'text' @@ -202,15 +186,12 @@ export async function convertContentToDocs( } } - // Collect aka.ms links to resolve after the tree walk. + // Resolve aka.ms redirects after the tree walk, because visit callbacks cannot await. if (node.type === 'link' && node.url.includes('aka.ms')) { akaMsLinkMatches.push(node) } - // Example links like https://containers.GHEHOSTNAME should not be - // checked by the link checker, so render them as inline code. - // The Java program that generates the rst files should do this instead. - // See https://github.com/syntax-tree/mdast#inlinecode + // Render https://containers.GHEHOSTNAME example links as inline code so the link checker skips them. if (node.type === 'link' && node.url.startsWith('https://containers')) { // Strip the double quotes from the nodes either side. const nodeBefore = ancestors[ancestors.length - 1].children[0] @@ -237,12 +218,11 @@ export async function convertContentToDocs( }, ) - // Convert all aka.ms links to the docs.github.com relative path + // aka.ms redirects supply the docs.github.com relative path. await Promise.all( akaMsLinkMatches.map(async (node: MdNode) => { const url = await getRedirect(node.url) - // These are already Markdown links in the ast, - // so only the url and the link text need updating. + // Existing Markdown links only need AUTOTITLE text and the resolved URL. if (node.children[0]) { node.children[0].value = 'AUTOTITLE' } diff --git a/src/codeql-cli/scripts/sync.ts b/src/codeql-cli/scripts/sync.ts index 479bd83de914..ec4390385eba 100755 --- a/src/codeql-cli/scripts/sync.ts +++ b/src/codeql-cli/scripts/sync.ts @@ -30,10 +30,7 @@ async function main() { for (const file of markdownFiles) { const sourceContent = await readFile(file, 'utf8') - // The source content is missing a "Primary Options" heading directly - // under "Options". - // Adding a node to the AST is fiddly when it is not a child of the - // previous heading, so append the heading to the raw Markdown instead. + // Source Markdown lacks a Primary Options heading under Options; raw text avoids AST insertion. const matchHeading = '## Options\n' const primaryHeadingSourceContent = sourceContent.replace( matchHeading, diff --git a/src/color-schemes/components/BrandThemeProvider.tsx b/src/color-schemes/components/BrandThemeProvider.tsx index 4c1387cfdb35..78a1192538f6 100644 --- a/src/color-schemes/components/BrandThemeProvider.tsx +++ b/src/color-schemes/components/BrandThemeProvider.tsx @@ -3,7 +3,7 @@ import { ThemeProvider } from '@primer/react-brand' import { getBrandColorMode, type BrandColorMode } from '@/color-schemes/lib/get-brand-color-mode' -// Brand reads `colorMode="auto"` as "snapshot the OS on mount" rather than +// Brand reads colorMode="auto" as "snapshot the OS on mount" rather than // "inherit", so this only ever passes a concrete mode. export const BrandThemeProvider = ({ children }: PropsWithChildren) => { // Seeded to match SSR; reading the DOM here would break hydration. @@ -11,7 +11,7 @@ export const BrandThemeProvider = ({ children }: PropsWithChildren) => { useEffect(() => { setColorMode(getBrandColorMode()) - // colorModeScript re-stamps when the OS flips under `auto`. + // colorModeScript re-stamps when the OS flips under auto. const observer = new MutationObserver(() => setColorMode(getBrandColorMode())) observer.observe(document.documentElement, { attributes: true, @@ -20,8 +20,7 @@ export const BrandThemeProvider = ({ children }: PropsWithChildren) => { return () => observer.disconnect() }, []) - // Brand spreads rest props after its own attribute, so `data-color-mode={undefined}` - // drops it from the wrapper div; the prop still feeds brand's context. + // Brand spreads rest props last, so undefined removes the wrapper attribute but keeps context. return ( {children} diff --git a/src/color-schemes/components/useTheme.ts b/src/color-schemes/components/useTheme.ts index b51c1daff79d..c6078196be94 100644 --- a/src/color-schemes/components/useTheme.ts +++ b/src/color-schemes/components/useTheme.ts @@ -62,8 +62,8 @@ function filterMode(mode = ''): CssColorMode | undefined { } } -// `?? {}` rather than a default parameter: a default only covers `undefined`, and -// the cookie can carry an explicit `null` (`{"light_theme":null}`). +// Use ?? {} because a default parameter covers undefined, but the cookie can carry +// explicit null, for example {"light_theme":null}. function filterTheme( theme?: { name?: string; color_mode?: string } | null, ): SupportedTheme | undefined { @@ -102,6 +102,8 @@ export function getComponentTheme(cookieValue = ''): ComponentColorTheme { } } +// setTimeout(0) defers cookie reads until after Primer React's effect, which otherwise +// overrides the cookie color mode and reverts the page to auto. export function useTheme() { const [theme, setTheme] = useState({ css: defaultCSSTheme, @@ -109,10 +111,6 @@ export function useTheme() { }) useEffect(() => { - // setTimeout(0) defers this past Primer React's own useEffect, - // which otherwise overrides the cookie's color mode and reverts the page to auto. - // Primer's migration to CSS variables should remove the need for this. - // https://github.com/primer/react/issues/2229 setTimeout(() => { const cookieValue = Cookies.get(COLOR_MODE_COOKIE_NAME) const css = getCssTheme(cookieValue) diff --git a/src/color-schemes/lib/color-mode-script.ts b/src/color-schemes/lib/color-mode-script.ts index bf5833843821..b9a4097cc4c6 100644 --- a/src/color-schemes/lib/color-mode-script.ts +++ b/src/color-schemes/lib/color-mode-script.ts @@ -1,21 +1,19 @@ import { COLOR_MODE_COOKIE_NAME } from '@/frame/lib/constants' import { CssColorMode, SupportedTheme, defaultCSSTheme } from '@/color-schemes/components/useTheme' -// A tiny script that runs synchronously in the document , before the -// browser's first paint. It reads the `color_mode` cookie (set by github.com, -// not HttpOnly) and writes the matching `data-color-mode`, `data-light-theme`, -// and `data-dark-theme` attributes onto the element. Without this, the -// page first paints with the SSR default theme and only switches to the user's -// real theme after the React bundle hydrates, causing a visible flash. +// This script runs synchronously in the document head before first paint. It reads the +// color_mode cookie from github.com, which is not HttpOnly, and writes data-color-mode, +// data-light-theme, and data-dark-theme attributes on html. Without it, the page paints +// with the SSR default theme before React hydrates and switches to the user's theme. // -// `data-color-mode` is always concrete, never `auto` — @primer/react-brand has -// no `auto` palette — and follows the effective theme, because a `light` mode -// can carry a dark day theme. See src/color-schemes/README.md. +// data-color-mode stays concrete, never auto, and follows the effective theme because +// @primer/react-brand lacks an auto palette and light mode can carry a dark day theme. +// See src/color-schemes/README.md. // -// The output is identical for every request, so the HTML stays shared-cacheable -// in our CDN. The validation allowlists and defaults are derived from the same -// enums used by `useTheme`, so they can't drift, and `helmet.ts` hashes this -// exact string for the CSP `script-src` allowance (no nonce, no unsafe-inline). +// The generated output is identical across requests, so CDN caches can share the HTML. +// useTheme supplies the validation allowlists and defaults so they cannot drift. +// helmet.ts hashes this exact string for the CSP script-src allowance, with no nonce +// and no unsafe-inline. const modes = JSON.stringify(Object.values(CssColorMode)) const themes = JSON.stringify(Object.values(SupportedTheme)) const defaults = JSON.stringify(defaultCSSTheme) diff --git a/src/color-schemes/lib/get-brand-color-mode.ts b/src/color-schemes/lib/get-brand-color-mode.ts index cd317a6e9766..9bacbe6864c5 100644 --- a/src/color-schemes/lib/get-brand-color-mode.ts +++ b/src/color-schemes/lib/get-brand-color-mode.ts @@ -1,7 +1,7 @@ export type BrandColorMode = 'light' | 'dark' -// Brand's palette follows 's `data-color-mode`, resolved to a concrete mode -// before first paint — not PRC's `resolvedColorScheme`, which is the THEME. +// Brand's palette follows html data-color-mode, resolved to a concrete mode before first +// paint, not Primer React's resolvedColorScheme, which tracks the theme. export function getBrandColorMode(): BrandColorMode { if (typeof document === 'undefined') return 'light' // SSR fallback return document.documentElement.getAttribute('data-color-mode') === 'dark' ? 'dark' : 'light' diff --git a/src/color-schemes/tests/color-mode-script.ts b/src/color-schemes/tests/color-mode-script.ts index ad6ce2fcb158..7ac8cb9c1f53 100644 --- a/src/color-schemes/tests/color-mode-script.ts +++ b/src/color-schemes/tests/color-mode-script.ts @@ -3,17 +3,15 @@ import { describe, expect, test } from 'vitest' import { colorModeScript } from '../lib/color-mode-script' import { getCssTheme, SupportedTheme } from '../components/useTheme' -// The inline script runs before any bundle loads, so it reimplements -// `useTheme`'s validation instead of importing it. These tests assert the two -// stay in sync. +// The inline script runs before any bundle loads, so it reimplements useTheme validation +// instead of importing it. These tests assert the two stay in sync. function runScript( rawCookie: string, { prefersDark = false, matchMedia = true, legacyListener = false } = {}, ) { const attrs: Record = {} const listeners: Array<(event: { matches: boolean }) => void> = [] - // `matches` reads this through a getter, so `flipSystemPreference` changes - // what an already-registered handler sees. + // matches reads os through a getter, so flipSystemPreference changes what handlers see. const os = { prefersDark } const subscribe = (handler: (event: { matches: boolean }) => void) => { listeners.push(handler) @@ -61,8 +59,7 @@ function cookieFor(value: object) { function expectMatchesGetCssTheme(rawCookie: string, cookieValue: string, prefersDark = false) { const css = getCssTheme(cookieValue) - // Primitives select on the (mode, theme) pair, so the effective theme has to - // land on the attribute for the resolved mode. + // Primer primitives use mode and theme together, so resolved mode gets the effective theme. const mode = css.colorMode === 'auto' ? (prefersDark ? 'dark' : 'light') : css.colorMode const theme = mode === 'dark' ? css.darkTheme : css.lightTheme const resolved = theme.startsWith('dark') ? 'dark' : 'light' @@ -111,7 +108,7 @@ describe('colorModeScript', () => { }) test('survives an explicitly null theme without discarding the mode', () => { - // A default parameter covers `undefined`, not `null`. + // A default parameter covers undefined, not null. const value = { color_mode: 'dark', light_theme: null } expectMatchesGetCssTheme(cookieFor(value), JSON.stringify(value)) expect(runScript(cookieFor(value)).attrs['data-color-mode']).toBe('dark') @@ -159,7 +156,7 @@ describe('colorModeScript', () => { }) test('falls back to the deprecated addListener when addEventListener is absent', () => { - // Pre-14 Safari exposes only `addListener`, so this branch is live. + // Older Safari exposes only addListener, so this branch is live. const run = runScript(cookieFor({ color_mode: 'auto' }), { legacyListener: true }) expect(run.attrs['data-color-mode']).toBe('light') expect(run.listeners).toHaveLength(1) @@ -172,8 +169,7 @@ describe('colorModeScript', () => { }) test('still writes the attributes when matchMedia is unavailable', () => { - // The script's DOM block sits in a try/catch, so an unguarded matchMedia - // call would leave with no attributes at all. + // Guard matchMedia so the DOM try/catch still writes html attributes when it is unavailable. const { attrs } = runScript(cookieFor({ color_mode: 'auto' }), { matchMedia: false }) expect(attrs['data-color-mode']).toBe('light') expect(attrs['data-color-mode-preference']).toBe('auto') @@ -211,8 +207,7 @@ describe('colorModeScript', () => { dark_theme: { name: 'dark_high_contrast', color_mode: 'dark' }, }), ) - // Resolved dark by the DAY theme, so data-dark-theme carries that, not the - // separately configured night theme. + // The resolved day theme supplies data-dark-theme, not the separately configured night theme. expect(attrs['data-color-mode']).toBe('dark') expect(attrs['data-dark-theme']).toBe('dark_dimmed') }) @@ -237,10 +232,9 @@ describe('colorModeScript', () => { expect(runScript(value, { prefersDark: true }).attrs['data-color-mode']).toBe('dark') }) + // html classifies dark themes with startsWith("dark"); Primer React checks includes("dark"). + // A theme name like high_contrast_dark would split those classifications. test('every supported theme classifies the same under both operators', () => { - // must classify a theme's lightness the same way @primer/react does - // for its own wrapper: `startsWith('dark')` here, `includes('dark')` there. - // A name like `high_contrast_dark` would split them. for (const name of Object.values(SupportedTheme)) { expect(`${name} startsWith:${name.startsWith('dark')}`).toBe( `${name} startsWith:${name.includes('dark')}`, diff --git a/src/color-schemes/tests/get-brand-color-mode.ts b/src/color-schemes/tests/get-brand-color-mode.ts index 1653ffcacc9c..13a57486370d 100644 --- a/src/color-schemes/tests/get-brand-color-mode.ts +++ b/src/color-schemes/tests/get-brand-color-mode.ts @@ -21,7 +21,7 @@ describe('getBrandColorMode', () => { }) test.each([ - // Brand has no `auto` mode, so anything not `dark` has to render light. + // Brand has no auto mode, so anything not dark renders light. ['auto', 'light'], ['nonsense', 'light'], [null, 'light'], diff --git a/src/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/secret-scanning/components/SecretScanningTable.tsx b/src/secret-scanning/components/SecretScanningTable.tsx index 6173e89f4445..b1d0fbf0295d 100644 --- a/src/secret-scanning/components/SecretScanningTable.tsx +++ b/src/secret-scanning/components/SecretScanningTable.tsx @@ -14,9 +14,8 @@ const PAGE_SIZE = 25 // Identifies this table in the docs.v0.TableInteractionEvent analytics. const TABLE_INTERACTION_NAME = 'secret-scanning-patterns' -// Maps DataTable column ids to the canonical analytics field name so that a -// filter and a sort on the same column report the same -// table_interaction_field_name. Filter keys already use these canonical names. +// Canonical analytics field names keep filter and sort events for the same column +// grouped together. Filter keys already use these names. const COLUMN_FIELD_NAMES: Record = { provider: 'provider', supportedSecret: 'secret', @@ -59,7 +58,7 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) { const [sortColumn, setSortColumn] = useState(undefined) const [sortDirection, setSortDirection] = useState<'ASC' | 'DESC'>('ASC') - // Emit a TableInteractionEvent for analytics (github/docs-engineering#6593). + // TableInteractionEvent feeds search, filter, sort, and pagination analytics. const trackInteraction = useCallback( (interactionType: TableInteractionType, fieldName?: string, fieldValue?: string) => { sendEvent({ @@ -77,8 +76,7 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) { const debouncedTrackSearchRef = useRef | null>(null) useEffect(() => { debouncedTrackSearchRef.current = debounce((query: string) => { - // Sanitize before logging: users may paste a real secret into this - // table's search to check support, and the query is sent to analytics. + // Sanitize before analytics because users can paste real secrets into this support search. trackInteraction('search', 'search', sanitizeSearchQuery(query)) }, 500) return () => { @@ -263,8 +261,7 @@ export function SecretScanningTable({ data }: { data: SecretScanningData[] }) { field: 'supportedSecret', width: '280px', renderCell: (row) => { - // The middleware appends HTML for duplicates; strip it. - // Also handle
and
separators in the raw secretType. + // Remove duplicate token-versions link; convert raw
separators to commas. const cleanSecretType = row.secretType .replace(/ Token versions<\/a>/, '') .replace(/<\/?br\s*\/?>/gi, ', ') diff --git a/src/secret-scanning/pages/api/patterns.ts b/src/secret-scanning/pages/api/patterns.ts index d8fb0c71369c..38615b48f155 100644 --- a/src/secret-scanning/pages/api/patterns.ts +++ b/src/secret-scanning/pages/api/patterns.ts @@ -2,7 +2,7 @@ import type { NextApiRequest, NextApiResponse } from 'next' import { getSecretScanningData } from '@/secret-scanning/lib/get-secret-scanning-data' import path from 'path' -// Returns the cached pattern data as JSON. No HTML rendering here. +// The API serves cached pattern data as JSON without HTML rendering. export default async function handler(req: NextApiRequest, res: NextApiResponse) { const version = (req.query.version as string) || 'fpt' const filepath = path.join( @@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) try { const data = await getSecretScanningData(filepath) - // The data only changes on deploy, so cache hard. + // The data changes only on deploy, so cache hard. res.setHeader('Cache-Control', 'public, max-age=3600, s-maxage=86400') res.json(data) } catch { diff --git a/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx b/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx index 7307840715cc..594e413ab6d5 100644 --- a/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx +++ b/src/secret-scanning/pages/supported-secret-scanning-patterns.tsx @@ -43,7 +43,7 @@ export const getServerSideProps: GetServerSideProps = async (context) => addUINamespaces(req, mainContext.data.ui, ['secret_scanning']) const automatedPageContext = getAutomatedPageContextFromRequest(req) - // The middleware already loads secretScanningData into req.context + // secretScanning middleware loads secretScanningData into req.context. const patterns = req.context?.secretScanningData ?? [] return { props: { diff --git a/src/secret-scanning/scripts/sync.ts b/src/secret-scanning/scripts/sync.ts index 547767aaa2c6..3f1976d014d4 100755 --- a/src/secret-scanning/scripts/sync.ts +++ b/src/secret-scanning/scripts/sync.ts @@ -1,12 +1,6 @@ -/** - * Required env variables: - * - * GITHUB_TOKEN - * - * Syncs the - * https://github.com/github/token-scanning-service/blob/main/docs/public-docs - * directory to src/secret-scanning/data/pattern-docs - */ +// Required env variable: GITHUB_TOKEN. +// Syncs https://github.com/github/token-scanning-service/blob/main/docs/public-docs into +// src/secret-scanning/data/pattern-docs. import { writeFile, mkdir } from 'fs/promises' import { load, dump } from 'js-yaml' import path from 'path' diff --git a/src/secret-scanning/tests/liquid-evaluation.ts b/src/secret-scanning/tests/liquid-evaluation.ts index 63669f985fd3..55a98e8d1fe0 100644 --- a/src/secret-scanning/tests/liquid-evaluation.ts +++ b/src/secret-scanning/tests/liquid-evaluation.ts @@ -15,8 +15,8 @@ const { targetFilename } = JSON.parse( readFileSync('src/secret-scanning/lib/config.json', 'utf8'), ) as { targetFilename: string } -// Both hasValidityCheck and hasExtendedMetadata can be emitted by token-scanning-service -// as a Liquid conditional that resolves to false on GHES and true elsewhere. +// token-scanning-service can emit hasValidityCheck and hasExtendedMetadata as Liquid +// conditionals that resolve false on GHES and true elsewhere. const ghesConditional = '{% ifversion ghes %}false{% else %}true{% endif %}' const makeEntry = (): SecretScanningData => diff --git a/src/secret-scanning/tests/rendering.ts b/src/secret-scanning/tests/rendering.ts index e169df44e0d8..2332b67337ce 100644 --- a/src/secret-scanning/tests/rendering.ts +++ b/src/secret-scanning/tests/rendering.ts @@ -20,7 +20,7 @@ describe('secret-scanning pipeline', () => { const url = '/en/enterprise-server@3.11/enterprise-cloud@latest/code-security/secret-scanning/introduction/supported-secret-scanning-patterns' const res = await get(url) - // It should probably be a 404 because the URL is invalid, but definitely not a 500 + // Invalid double-version URLs can return 404, but they must not return 500. expect(res.statusCode).not.toBe(500) }) }) From 294637dd9d902ff80d02819fa4e36be455669324 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 17:40:42 +0000 Subject: [PATCH 07/18] Tighten code comments in src/frame/middleware/context and middleware/index.ts (#63469) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/frame/middleware/context/breadcrumbs.ts | 36 +----- src/frame/middleware/context/context.ts | 30 ++--- .../context/current-product-tree.ts | 46 ++----- src/frame/middleware/context/generic-toc.ts | 29 +---- src/frame/middleware/context/glossaries.ts | 33 +---- src/frame/middleware/context/layout.ts | 2 +- .../middleware/context/product-groups.ts | 20 +-- .../middleware/context/render-product-name.ts | 5 +- src/frame/middleware/index.ts | 121 ++++++------------ 9 files changed, 87 insertions(+), 235 deletions(-) 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/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) } From 381d3506bf2b4c719ef84e8a327de4a0bd087d64 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Mon, 28 Sep 2026 17:40:46 +0000 Subject: [PATCH 08/18] Tighten code comments in app, content-pipelines, deployments, eslint-rules, journeys, pages, products, types, and dev-toc (#63470) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7c52b57f-2c29-4aa1-8233-99d0d6e13571 --- src/app/layout.tsx | 6 ++-- src/content-pipelines/config.yml | 36 +++++++------------ src/content-pipelines/scripts/update.ts | 27 +++++--------- src/content-pipelines/state/.gitignore | 4 +-- .../build-scripts/clone-or-use-cached-repo.sh | 8 ++--- .../production/build-scripts/fetch-repos.sh | 15 ++++---- .../build-scripts/merge-early-access.sh | 3 +- src/dev-toc/generate.ts | 18 ++++------ src/dev-toc/layout.html | 1 - .../no-dangerously-set-inner-html.js | 11 ++---- .../tests/no-dangerously-set-inner-html.ts | 4 +-- .../tests/use-custom-logger.ts | 4 +-- .../use-custom-logger/use-custom-logger.js | 22 ++---------- src/journeys/components/JourneyTrackNav.tsx | 3 +- src/journeys/lib/journey-path-resolver.ts | 24 +++++-------- src/journeys/middleware/journey-track.ts | 5 ++- src/journeys/tests/journey-path-resolver.ts | 11 +++--- src/pages/_document.tsx | 11 ++---- src/pages/_error.tsx | 24 +++++-------- src/products/lib/all-products.ts | 4 +-- src/products/lib/get-product-groups.ts | 28 +++++---------- src/products/tests/get-product-groups.ts | 18 +++++----- src/types/eslint-plugins.d.ts | 2 +- src/types/index.ts | 2 +- src/types/primer__octicons.d.ts | 2 +- src/types/types.ts | 17 +++++---- 26 files changed, 108 insertions(+), 202 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9d823c65d72a..c4df4449bac7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,6 @@ -// Stub layout kept so Next.js enables App Router mode, which relaxes -// the "global CSS only in _app" restriction needed by transpilePackages. -// All routing is handled by the Pages Router (src/pages/). +// Stub layout enables App Router mode, relaxing the "global CSS only in _app" +// restriction for transpilePackages. +// The Pages Router still handles all routing through src/pages. import type { ReactNode } from 'react' export default function RootLayout({ children }: { children: ReactNode }) { diff --git a/src/content-pipelines/config.yml b/src/content-pipelines/config.yml index e2b9719e25a3..7a364ca70798 100644 --- a/src/content-pipelines/config.yml +++ b/src/content-pipelines/config.yml @@ -1,19 +1,17 @@ -# Content pipelines configuration +# Content pipelines sync source docs into allowed target articles with the +# content-pipeline-update agent. # -# Each entry defines a content pipeline that syncs docs from an external -# repository and uses the content-pipeline-update agent to update content articles. +# Run a pipeline manually with: +# npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli # -# The update.ts script reads this file so you can run: -# $ npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli -# -# The workflow matrix in .github/workflows/content-pipelines.yml only needs `id`; -# everything else is read from this file. -# -# `exclusions` lists source topics the agent should skip during gap analysis. -# Use an empty list ([]) when nothing should be excluded. Example: -# exclusions: -# - Internal debugging commands -# - Experimental telemetry flags +# The workflow matrix entry needs only id. +# Later workflow steps and update.ts read the other fields from this file. +# exclusions lists source topics the agent skips during gap analysis. +# Use [] when none. +# Example: +# exclusions: +# - Internal debugging commands +# - Experimental telemetry flags # copilot-cli: name: Copilot CLI @@ -55,13 +53,3 @@ gh-stack: The source uses "sh" code fences and title-case headings; use "shell" and sentence case instead. Write "pull request" rather than "PR". Do not remove the public preview reusable near the top of the article; it has no counterpart in the source docs. - -# TODO -# mcp-server: -# name: GitHub MCP Server -# source-repo: github/github-mcp-server -# source-path: docs -# # TBD — update this list as articles are created -# target-articles: [] -# exclusions: [] -# content-mapping: "" diff --git a/src/content-pipelines/scripts/update.ts b/src/content-pipelines/scripts/update.ts index f8b0f67ccc71..3fbb0fddd777 100644 --- a/src/content-pipelines/scripts/update.ts +++ b/src/content-pipelines/scripts/update.ts @@ -1,20 +1,15 @@ -// [start-readme] +// Clones an external source repository, detects changed docs, and runs the +// content-pipeline-update Copilot agent to update reference articles. // -// This script clones an external source repository, detects whether its docs -// have changed since the last processed commit, and if so runs the -// content-pipeline-update Copilot agent to update our reference articles. -// -// The workflow (.github/workflows/content-pipelines.yml) calls this script in CI. -// You can also run it locally for testing and iteration: +// .github/workflows/content-pipelines.yml calls this script in CI. +// Run it locally with: // // npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli // npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli --dry-run // npx tsx src/content-pipelines/scripts/update.ts --id copilot-cli --full-scan // -// Defaults (source-repo, source-path, target-articles) are read from -// src/content-pipelines/config.yml. You can override any value via CLI flags. -// -// [end-readme] +// src/content-pipelines/config.yml supplies source-repo, source-path, and +// target-articles defaults. CLI flags override them. import { execSync, execFileSync } from 'child_process' import fs from 'fs' @@ -149,8 +144,7 @@ async function main(): Promise { const repoUrl = `https://github.com/${SOURCE_REPO}.git` try { - // execFileSync passes the token as an argument instead of embedding it in the URL, - // where it would leak into error messages and logs. + // Use http.extraHeader for token, not clone URL; Git includes clone URLs in errors and logs. const args = ['clone'] if (token) { args.push( @@ -200,9 +194,7 @@ async function main(): Promise { diff = '(diff unavailable)' } - // Empty means no doc files changed. - // A leading "(" means the diff itself failed, - // so fall through and run the agent anyway. + // Empty output means no doc files changed; a leading "(" means diff failed, so run the agent. if (!nameStatus.startsWith('(') && !nameStatus.trim()) { console.log( `No changes in ${SOURCE_PATH} between ${storedSha.slice(0, 7)} and ${currentSha.slice(0, 7)}. Skipping agent run.`, @@ -222,8 +214,7 @@ async function main(): Promise { diff, ].join('\n') } else { - // Initial run or full scan, so list every source doc. - // Incremental runs get this inventory from git diff --name-status instead. + // Initial and full scans list all docs; incremental scans use git diff --name-status. const sourceDocs = path.join(sourceDir, SOURCE_PATH) let fileList: string try { diff --git a/src/content-pipelines/state/.gitignore b/src/content-pipelines/state/.gitignore index 425c0aa18d88..2e5cc2dc37a4 100644 --- a/src/content-pipelines/state/.gitignore +++ b/src/content-pipelines/state/.gitignore @@ -1,4 +1,4 @@ # This directory stores the last-processed commit SHA for each content pipeline. -# SHA files are created and updated by the content-pipelines workflow. -# Diff files (.diff) are ephemeral and should not be committed. +# The content-pipelines workflow creates and updates SHA files. +# Diff files are ephemeral and must not be committed. *.diff diff --git a/src/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/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/journeys/components/JourneyTrackNav.tsx b/src/journeys/components/JourneyTrackNav.tsx index 0a8923691aa8..f940fbc01b72 100644 --- a/src/journeys/components/JourneyTrackNav.tsx +++ b/src/journeys/components/JourneyTrackNav.tsx @@ -16,8 +16,7 @@ export function JourneyTrackNav({ context }: Props) { const upNext = nextGuide ?? nextTrackFirstGuide if (!upNext) return null - // In-track, show the next article's title. - // Crossing tracks, show the track name so the reader knows they're moving on. + // Crossing tracks uses the track name so readers know they are moving to another track. const label = nextGuide ? nextGuide.title : nextTrackFirstGuide!.trackTitle const progress = t('up_next_progress') diff --git a/src/journeys/lib/journey-path-resolver.ts b/src/journeys/lib/journey-path-resolver.ts index 566199827fbd..ecb1640357a3 100644 --- a/src/journeys/lib/journey-path-resolver.ts +++ b/src/journeys/lib/journey-path-resolver.ts @@ -59,9 +59,8 @@ type JourneyPage = { }> } -// All computed once, on first use. -// Guide hrefs containing Liquid can't be resolved ahead of time, -// so they're absent from cachedGuidePaths and set hasDynamicGuides instead. +// Static guide paths cache on first use. +// Rendered hrefs stay out of cachedGuidePaths and set hasDynamicGuides. let cachedJourneyPages: JourneyPage[] | null = null let cachedGuidePaths: Set | null = null let hasDynamicGuides = false @@ -134,9 +133,7 @@ async function fetchGuideData( return null } -/** - * Returns null if the article isn't a guide in any journey track. - */ +// Returns null when no journey track applies to the article and current version. export async function resolveJourneyContext( articlePath: string, pages: Record, @@ -159,8 +156,7 @@ export async function resolveJourneyContext( for (const journeyPage of journeyPages) { if (!journeyPage.journeyTracks) continue - // Track articles inherit the landing page's versions, - // so a journey that doesn't apply to the current version has no navigation to show. + // Track articles inherit landing page versions, so unmatched versions show no navigation. if (journeyPage.versions) { const journeyVersions = getApplicableVersions(journeyPage.versions) if (!journeyVersions.includes(context.currentVersion || '')) { @@ -187,8 +183,7 @@ export async function resolveJourneyContext( () => guidePath, ) } catch { - // executeWithFallback rethrows errors it can't fall back from, - // such as any error in English. + // executeWithFallback rethrows non-fallbackable errors and all English content errors. renderedGuidePath = guidePath } } @@ -205,7 +200,7 @@ export async function resolveJourneyContext( const alternativeNextStep = track.guides[guideIndex].alternativeNextStep || '' let renderedAlternativeNextStep = alternativeNextStep - // Rendered with links intact, unlike the hrefs above which use textOnly. + // Render this with links intact, unlike the hrefs above that use textOnly. if (needsRendering(alternativeNextStep)) { try { renderedAlternativeNextStep = await executeWithFallback( @@ -218,8 +213,7 @@ export async function resolveJourneyContext( } } - // fetchGuideData returns null for guides missing in the current version. - // Dropping them keeps the counts and prev/next links correct. + // Drop guides that fail lookup so counts and prev/next links use resolvable guides. const availableGuides = ( await Promise.all( track.guides.map(async (guide, i) => { @@ -285,9 +279,7 @@ export async function resolveJourneyContext( return result } -/** - * Reads journey tracks from frontmatter, rendering any Liquid they contain. - */ +// Journey track frontmatter may contain Liquid, so render it before components use it. export async function resolveJourneyTracks( journeyTracks: JourneyPage['journeyTracks'], context: Context, diff --git a/src/journeys/middleware/journey-track.ts b/src/journeys/middleware/journey-track.ts index dd06002faa59..5e63d0f5ab14 100644 --- a/src/journeys/middleware/journey-track.ts +++ b/src/journeys/middleware/journey-track.ts @@ -38,12 +38,11 @@ export default async function journeyTrack( if (page.journeyTracks) { const resolvedTracks = await resolveJourneyTracks(page.journeyTracks, req.context) - // Read later by getServerSideProps. + // getServerSideProps reads resolvedJourneyTracks from the page object. page.resolvedJourneyTracks = resolvedTracks } - // Unconditional, because guide articles need this - // even though they carry no journeyTracks of their own. + // Resolve every article because guide pages do not carry their own journeyTracks. const journeyContext = await resolveJourneyContext( req.pagePath || '', req.context.pages || {}, diff --git a/src/journeys/tests/journey-path-resolver.ts b/src/journeys/tests/journey-path-resolver.ts index e037a54309b0..e7afb804e4ad 100644 --- a/src/journeys/tests/journey-path-resolver.ts +++ b/src/journeys/tests/journey-path-resolver.ts @@ -4,8 +4,7 @@ import { resolveJourneyContext, resolveJourneyTracks } from '../lib/journey-path import getLinkData from '@/journeys/lib/get-link-data' import type { Page } from '@/types' -// Mock modules since we just want to test journey functions, not their dependencies or -// against real content files +// Mock dependencies so journey functions run without real content files. vi.mock('@/journeys/lib/get-link-data', () => ({ default: vi.fn(async (rawLinks: string | string[] | undefined) => { const path = Array.isArray(rawLinks) ? rawLinks[0] : rawLinks @@ -191,7 +190,6 @@ describe('journey-path-resolver', () => { mockContext, ) - // This should find the same track as the version with leading slash expect(result?.trackId).toBe('getting_started') expect(result?.currentGuideIndex).toBe(1) }) @@ -235,7 +233,7 @@ describe('journey-path-resolver', () => { test('renders liquid templates in titles and descriptions', async () => { const result = await resolveJourneyTracks(mockJourneyTracks, mockContext) - // Should return the content as-is since our mock renderContent is a passthrough + // The mock renderContent returns input unchanged. expect(result[0].title).toBe( 'Getting started with {% data variables.product.company_short %}', ) @@ -252,15 +250,14 @@ describe('journey-path-resolver', () => { expect(result[0].timeCommitment).toBe('{% data variables.product.company_short %} 2-4 hours') expect(result[1].timeCommitment).toBe('4-6 hours') - // The Liquid-bearing timeCommitment should be rendered with { textOnly: true }, - // matching how title/description are rendered. + // Liquid timeCommitment renders with textOnly, matching titles and descriptions. const timeCommitmentCall = mockRenderContent.mock.calls.find( ([content]) => content === '{% data variables.product.company_short %} 2-4 hours', ) expect(timeCommitmentCall).toBeDefined() expect(timeCommitmentCall?.[2]).toEqual({ textOnly: true }) - // Plain (non-Liquid) timeCommitment should not be sent through renderContent + // Plain timeCommitment skips renderContent. const plainCall = mockRenderContent.mock.calls.find(([content]) => content === '4-6 hours') expect(plainCall).toBeUndefined() }) diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 311fc39c040b..92d962b3d572 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -3,23 +3,18 @@ import Document, { Html, Head, Main, NextScript } from 'next/document' import { defaultCSSTheme } from '@/color-schemes/components/useTheme' import { colorModeScript } from '@/color-schemes/lib/color-mode-script' +// MyDocument leaves SSR theme attributes at defaultCSSTheme so the HTML stays shared-cacheable. +// colorModeScript updates them from the color_mode cookie before the browser's first paint. +// colorModeScript injects executable JS, not content HTML, so RenderedHTML and hast do not apply. export default class MyDocument extends Document { render() { return ( - {/* Inline color-mode script must run before paint to avoid a flash; it - injects executable JS (not content HTML), so RenderedHTML/hast do - not apply here. */} {/* eslint-disable-next-line custom-rules/no-dangerously-set-inner-html */}