From 96e8b98451c97b1f51e7986550eb978e4654cb7d Mon Sep 17 00:00:00 2001 From: Evan Bonsignori Date: Wed, 21 Jan 2026 08:23:45 -0800 Subject: [PATCH 01/12] Attempt at supporting non-child directories via `children` in index pages, or non-child articles via `articles` (#59183) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contributing/content-linter-rules.md | 1 + .../lib/linting-rules/frontmatter-children.ts | 100 +++++++++ src/content-linter/lib/linting-rules/index.ts | 2 + src/content-linter/style/github-docs.ts | 6 + src/content-linter/tests/category-pages.ts | 9 + .../frontmatter-children/invalid-paths.md | 8 + .../frontmatter-children/no-children.md | 5 + .../valid-content-prefix.md | 8 + .../tests/unit/frontmatter-children.ts | 53 +++++ .../fixtures/content/get-started/index.md | 1 + .../articles-only/index.md | 10 + .../children-only/index.md | 10 + .../get-started/non-child-resolution/index.md | 17 ++ .../local-category/index.md | 12 ++ .../local-category/local-article-one.md | 8 + .../local-category/local-article-two.md | 8 + .../standalone-article.md | 8 + .../versioned-cross-product/index.md | 15 ++ .../tests/playwright-rendering.spec.ts | 77 +++++++ src/frame/lib/create-tree.ts | 35 +++- src/frame/lib/page-data.ts | 6 + .../context/current-product-tree.ts | 5 +- .../tests/non-child-pages-resolution.test.ts | 194 ++++++++++++++++++ src/types/types.ts | 3 + 24 files changed, 595 insertions(+), 6 deletions(-) create mode 100644 src/content-linter/lib/linting-rules/frontmatter-children.ts create mode 100644 src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md create mode 100644 src/content-linter/tests/fixtures/frontmatter-children/no-children.md create mode 100644 src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md create mode 100644 src/content-linter/tests/unit/frontmatter-children.ts create mode 100644 src/fixtures/fixtures/content/get-started/non-child-resolution/articles-only/index.md create mode 100644 src/fixtures/fixtures/content/get-started/non-child-resolution/children-only/index.md create mode 100644 src/fixtures/fixtures/content/get-started/non-child-resolution/index.md create mode 100644 src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/index.md create mode 100644 src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-one.md create mode 100644 src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-two.md create mode 100644 src/fixtures/fixtures/content/get-started/non-child-resolution/standalone-article.md create mode 100644 src/fixtures/fixtures/content/get-started/non-child-resolution/versioned-cross-product/index.md create mode 100644 src/frame/tests/non-child-pages-resolution.test.ts diff --git a/data/reusables/contributing/content-linter-rules.md b/data/reusables/contributing/content-linter-rules.md index 11010f9ce8e8..1e7c8e0515f0 100644 --- a/data/reusables/contributing/content-linter-rules.md +++ b/data/reusables/contributing/content-linter-rules.md @@ -64,6 +64,7 @@ | GHD060 | journey-tracks-unique-ids | Journey track IDs must be unique within a page | error | frontmatter, journey-tracks, unique-ids | | GHD061 | frontmatter-hero-image | Hero image paths must be absolute, extensionless, and point to valid images in /assets/images/banner-images/ | error | frontmatter, images | | GHD062 | frontmatter-intro-links | introLinks keys must be valid keys defined in data/ui.yml under product_landing | error | frontmatter, single-source | +| GHD063 | frontmatter-children | Children frontmatter paths must exist. Supports relative paths and absolute /content/ paths for cross-product inclusion. | error | frontmatter, children | | [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: octicon- | The octicon liquid syntax used is deprecated. Use this format instead `octicon "" aria-label=""` | error | | | [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | deprecated liquid syntax: site.data | Catch occurrences of deprecated liquid data syntax. | error | | | [search-replace](https://github.com/OnkarRuikar/markdownlint-rule-search-replace) | developer-domain | Catch occurrences of developer.github.com domain. | error | | diff --git a/src/content-linter/lib/linting-rules/frontmatter-children.ts b/src/content-linter/lib/linting-rules/frontmatter-children.ts new file mode 100644 index 000000000000..1c4d0a5b1ce2 --- /dev/null +++ b/src/content-linter/lib/linting-rules/frontmatter-children.ts @@ -0,0 +1,100 @@ +import fs from 'fs' +import path from 'path' +import { addError } from 'markdownlint-rule-helpers' + +import { getFrontmatter } from '../helpers/utils' +import type { RuleParams, RuleErrorCallback } from '@/content-linter/types' + +interface Frontmatter { + children?: string[] + [key: string]: unknown +} + +/** + * Check if a child path is valid. + * Supports both: + * - Relative paths (e.g., /local-child) resolved from current directory + * - Absolute /content/ paths (e.g., /content/actions/workflows) resolved from content root + */ +function isValidChildPath(childPath: string, currentFilePath: string): boolean { + const ROOT = process.env.ROOT || '.' + const contentDir = path.resolve(ROOT, 'content') + + let resolvedPath: string + + if (childPath.startsWith('/content/')) { + // Absolute path from content root - strip /content/ prefix + const absoluteChildPath = childPath.slice('/content/'.length) + resolvedPath = path.resolve(contentDir, absoluteChildPath) + } else { + // Relative path from current file's directory + const currentDir: string = path.dirname(currentFilePath) + const normalizedPath = childPath.startsWith('/') ? childPath.substring(1) : childPath + resolvedPath = path.resolve(currentDir, normalizedPath) + } + + // Security check: ensure resolved path stays within content directory + // This prevents path traversal attacks using sequences like '../' + if (!resolvedPath.startsWith(contentDir + path.sep) && resolvedPath !== contentDir) { + return false + } + + // Check for direct .md file + const mdPath = `${resolvedPath}.md` + if (fs.existsSync(mdPath) && fs.statSync(mdPath).isFile()) { + return true + } + + // Check for index.md file in directory + const indexPath = path.join(resolvedPath, 'index.md') + if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) { + return true + } + + // Check if the path exists as a directory (may have children) + if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isDirectory()) { + return true + } + + return false +} + +export const frontmatterChildren = { + names: ['GHD063', 'frontmatter-children'], + description: + 'Children frontmatter paths must exist. Supports relative paths and absolute /content/ paths for cross-product inclusion.', + tags: ['frontmatter', 'children'], + function: (params: RuleParams, onError: RuleErrorCallback) => { + const fm = getFrontmatter(params.lines) as Frontmatter | null + if (!fm || !fm.children) return + + const childrenLine: string | undefined = params.lines.find((line) => + line.startsWith('children:'), + ) + + if (!childrenLine) return + + const lineNumber: number = params.lines.indexOf(childrenLine) + 1 + + if (Array.isArray(fm.children)) { + const invalidPaths: string[] = [] + + for (const child of fm.children) { + if (!isValidChildPath(child, params.name)) { + invalidPaths.push(child) + } + } + + if (invalidPaths.length > 0) { + addError( + onError, + lineNumber, + `Found invalid children paths: ${invalidPaths.join(', ')}. For cross-product paths, use /content/ prefix (e.g., /content/actions/workflows).`, + childrenLine, + [1, childrenLine.length], + null, + ) + } + } + }, +} diff --git a/src/content-linter/lib/linting-rules/index.ts b/src/content-linter/lib/linting-rules/index.ts index 3afc9fa151bd..e9afc32987f8 100644 --- a/src/content-linter/lib/linting-rules/index.ts +++ b/src/content-linter/lib/linting-rules/index.ts @@ -53,6 +53,7 @@ import { journeyTracksGuidePathExists } from './journey-tracks-guide-path-exists import { journeyTracksUniqueIds } from './journey-tracks-unique-ids' import { frontmatterHeroImage } from './frontmatter-hero-image' import { frontmatterIntroLinks } from './frontmatter-intro-links' +import { frontmatterChildren } from './frontmatter-children' // Using any type because @github/markdownlint-github doesn't provide TypeScript declarations // The elements in the array have a 'names' property that contains rule identifiers @@ -117,6 +118,7 @@ export const gitHubDocsMarkdownlint = { journeyTracksUniqueIds, // GHD060 frontmatterHeroImage, // GHD061 frontmatterIntroLinks, // GHD062 + frontmatterChildren, // GHD063 // Search-replace rules searchReplace, // Open-source plugin diff --git a/src/content-linter/style/github-docs.ts b/src/content-linter/style/github-docs.ts index e70be6fbf19b..f1f1f9cf7461 100644 --- a/src/content-linter/style/github-docs.ts +++ b/src/content-linter/style/github-docs.ts @@ -284,6 +284,12 @@ export const githubDocsFrontmatterConfig = { 'partial-markdown-files': false, 'yml-files': false, }, + 'frontmatter-children': { + // GHD063 + severity: 'error', + 'partial-markdown-files': false, + 'yml-files': false, + }, } // Configures rules from the `github/markdownlint-github` repo diff --git a/src/content-linter/tests/category-pages.ts b/src/content-linter/tests/category-pages.ts index 5519287ee36f..2c31c6ef04ca 100644 --- a/src/content-linter/tests/category-pages.ts +++ b/src/content-linter/tests/category-pages.ts @@ -239,6 +239,15 @@ describe.skip('category pages', () => { }) function getPath(productDir: string, link: string, filename: string) { + // Handle absolute /content/ paths for cross-product children + // The link parameter contains the child path from frontmatter + if (link.startsWith('/content/')) { + const absolutePath = link.slice('/content/'.length) + if (filename === 'index') { + return path.join(contentDir, absolutePath, 'index.md') + } + return path.join(contentDir, absolutePath, `${filename}.md`) + } return path.join(productDir, link, `${filename}.md`) } diff --git a/src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md b/src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md new file mode 100644 index 000000000000..e2bd12cbd3cb --- /dev/null +++ b/src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md @@ -0,0 +1,8 @@ +--- +title: Invalid children paths +children: + - /content/nonexistent/product + - /another/invalid/path +--- + +This page has invalid children paths. diff --git a/src/content-linter/tests/fixtures/frontmatter-children/no-children.md b/src/content-linter/tests/fixtures/frontmatter-children/no-children.md new file mode 100644 index 000000000000..256cd22d03d7 --- /dev/null +++ b/src/content-linter/tests/fixtures/frontmatter-children/no-children.md @@ -0,0 +1,5 @@ +--- +title: No children +--- + +This page has no children frontmatter. diff --git a/src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md b/src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md new file mode 100644 index 000000000000..85fe35cc0ec1 --- /dev/null +++ b/src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md @@ -0,0 +1,8 @@ +--- +title: Valid children with content prefix +children: + - /content/get-started/foo + - /content/get-started/learning-about-github +--- + +This page has valid /content/ prefixed children paths. diff --git a/src/content-linter/tests/unit/frontmatter-children.ts b/src/content-linter/tests/unit/frontmatter-children.ts new file mode 100644 index 000000000000..388550747f31 --- /dev/null +++ b/src/content-linter/tests/unit/frontmatter-children.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, beforeAll, afterAll } from 'vitest' + +import { runRule } from '@/content-linter/lib/init-test' +import { frontmatterChildren } from '@/content-linter/lib/linting-rules/frontmatter-children' + +const VALID_CONTENT_PREFIX = + 'src/content-linter/tests/fixtures/frontmatter-children/valid-content-prefix.md' +const INVALID_PATHS = 'src/content-linter/tests/fixtures/frontmatter-children/invalid-paths.md' +const NO_CHILDREN = 'src/content-linter/tests/fixtures/frontmatter-children/no-children.md' + +const ruleName = frontmatterChildren.names[1] + +// Configure the test fixture to not split frontmatter and content +const fmOptions = { markdownlintOptions: { frontMatter: null } } + +describe(ruleName, () => { + const envVarValueBefore = process.env.ROOT + + beforeAll(() => { + process.env.ROOT = 'src/fixtures/fixtures' + }) + + afterAll(() => { + process.env.ROOT = envVarValueBefore + }) + + test('page with valid /content/ prefixed children paths passes', async () => { + const result = await runRule(frontmatterChildren, { + files: [VALID_CONTENT_PREFIX], + ...fmOptions, + }) + expect(result[VALID_CONTENT_PREFIX]).toEqual([]) + }) + + test('page without children property passes', async () => { + const result = await runRule(frontmatterChildren, { + files: [NO_CHILDREN], + ...fmOptions, + }) + expect(result[NO_CHILDREN]).toEqual([]) + }) + + test('page with invalid children paths fails', async () => { + const result = await runRule(frontmatterChildren, { + files: [INVALID_PATHS], + ...fmOptions, + }) + expect(result[INVALID_PATHS]).toHaveLength(1) + expect(result[INVALID_PATHS][0].errorDetail).toContain('Found invalid children paths:') + expect(result[INVALID_PATHS][0].errorDetail).toContain('/content/nonexistent/product') + expect(result[INVALID_PATHS][0].errorDetail).toContain('/another/invalid/path') + }) +}) diff --git a/src/fixtures/fixtures/content/get-started/index.md b/src/fixtures/fixtures/content/get-started/index.md index 45693f0c7003..742039a9df50 100644 --- a/src/fixtures/fixtures/content/get-started/index.md +++ b/src/fixtures/fixtures/content/get-started/index.md @@ -45,6 +45,7 @@ children: - /carousel - /article-grid-discovery - /article-grid-bespoke + - /non-child-resolution communityRedirect: name: Provide HubGit Feedback href: 'https://hubgit.com/orgs/community/discussions/categories/get-started' diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/articles-only/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/articles-only/index.md new file mode 100644 index 000000000000..bc99bb47d3be --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/articles-only/index.md @@ -0,0 +1,10 @@ +--- +title: Cross-product children test +intro: Testing cross-product children resolution using /content/ prefix +versions: + fpt: '*' +children: + - /content/actions/using-workflows/storing-workflow-data-as-artifacts +--- + +This category uses /content/ prefix for cross-product article inclusion. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/children-only/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/children-only/index.md new file mode 100644 index 000000000000..30d82dcb912e --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/children-only/index.md @@ -0,0 +1,10 @@ +--- +title: Children only test +intro: Testing children-only page resolution +versions: + fpt: '*' +children: + - /content/actions/using-workflows/storing-workflow-data-as-artifacts +--- + +This category uses traditional children-only approach but with a cross-product path. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/index.md new file mode 100644 index 000000000000..1ddd29f7ffa6 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/index.md @@ -0,0 +1,17 @@ +--- +title: Non-child resolution test +intro: Testing non-child page resolution from frontmatter +versions: + fpt: '*' + ghec: '*' + ghes: '*' +children: + - /children-only + - /articles-only + - /local-category + - /standalone-article + - /versioned-cross-product + - /content/actions/using-workflows/storing-workflow-data-as-artifacts +--- + +This is the non-child resolution test page. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/index.md new file mode 100644 index 000000000000..1f36ea49b409 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/index.md @@ -0,0 +1,12 @@ +--- +title: Local category test +intro: Testing local children combined with cross-product children +versions: + fpt: '*' +children: + - /local-article-one + - /local-article-two + - /content/actions/using-workflows/storing-workflow-data-as-artifacts +--- + +This category has both local children and cross-product children. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-one.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-one.md new file mode 100644 index 000000000000..18ee8fca82cd --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-one.md @@ -0,0 +1,8 @@ +--- +title: Local article one +intro: A local article in the local category +versions: + fpt: '*' +--- + +This is local article one. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-two.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-two.md new file mode 100644 index 000000000000..14cf59975403 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/local-category/local-article-two.md @@ -0,0 +1,8 @@ +--- +title: Local article two +intro: Another local article in the local category +versions: + fpt: '*' +--- + +This is local article two. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/standalone-article.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/standalone-article.md new file mode 100644 index 000000000000..07d23dc68547 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/standalone-article.md @@ -0,0 +1,8 @@ +--- +title: Standalone article +intro: A standalone article for testing +versions: + fpt: '*' +--- + +This is a standalone article. diff --git a/src/fixtures/fixtures/content/get-started/non-child-resolution/versioned-cross-product/index.md b/src/fixtures/fixtures/content/get-started/non-child-resolution/versioned-cross-product/index.md new file mode 100644 index 000000000000..ed09f293cac6 --- /dev/null +++ b/src/fixtures/fixtures/content/get-started/non-child-resolution/versioned-cross-product/index.md @@ -0,0 +1,15 @@ +--- +title: Versioned cross-product test +intro: Testing cross-product children with version constraints +versions: + fpt: '*' + ghec: '*' + ghes: '*' +children: + - /content/get-started/versioning/only-fpt + - /content/get-started/versioning/only-ghec + - /content/get-started/versioning/only-ghec-and-ghes +--- + +This category includes cross-product children from articles with different version constraints. +The children should only appear in versions where the referenced article is available. diff --git a/src/fixtures/tests/playwright-rendering.spec.ts b/src/fixtures/tests/playwright-rendering.spec.ts index d1055d12a3f7..1b1ab529427e 100644 --- a/src/fixtures/tests/playwright-rendering.spec.ts +++ b/src/fixtures/tests/playwright-rendering.spec.ts @@ -1345,3 +1345,80 @@ test.describe('LandingArticleGridWithFilter component', () => { await expect(articleGrid).toBeVisible() }) }) + +test.describe('Non-child page resolution', () => { + test('category page with local children renders properly', async ({ page }) => { + // The local-category has local children (local-article-one, local-article-two) + // and an external article reference via children frontmatter + await page.goto('/get-started/non-child-resolution/local-category') + + // Should have a title + await expect(page).toHaveTitle(/Local category test/) + + // The page should load without errors and have main content + await expect(page.locator('main')).toBeVisible() + }) + + test('cross-product children page loads correctly', async ({ page }) => { + // The articles-only fixture now uses /content/ prefix in children for cross-product paths + await page.goto('/get-started/non-child-resolution/articles-only') + + await expect(page).toHaveTitle(/Cross-product children test/) + await expect(page.locator('main')).toBeVisible() + }) + + test('children-only page with /content/ path loads correctly', async ({ page }) => { + // The children-only fixture uses /content/ prefix for cross-product paths + await page.goto('/get-started/non-child-resolution/children-only') + + await expect(page).toHaveTitle(/Children only test/) + await expect(page.locator('main')).toBeVisible() + }) + + test('standalone article is accessible', async ({ page }) => { + await page.goto('/get-started/non-child-resolution/standalone-article') + + await expect(page).toHaveTitle(/Standalone article/) + await expect(page.locator('main')).toBeVisible() + }) + + test('versioned cross-product children - fpt shows only fpt article', async ({ page }) => { + // In fpt version, only the only-fpt article should be available + await page.goto('/get-started/non-child-resolution/versioned-cross-product') + + await expect(page).toHaveTitle(/Versioned cross-product test/) + await expect(page.locator('main')).toBeVisible() + + // Check TOC has the fpt-only article + const tocLinks = page.locator('[data-testid="table-of-contents"] a') + await expect(tocLinks).toHaveCount(1) + await expect(tocLinks.first()).toHaveAttribute('href', /only-fpt/) + }) + + test('versioned cross-product children - ghec shows ghec articles', async ({ page }) => { + // In ghec version, only-ghec and only-ghec-and-ghes should be available + await page.goto( + '/enterprise-cloud@latest/get-started/non-child-resolution/versioned-cross-product', + ) + + await expect(page).toHaveTitle(/Versioned cross-product test/) + await expect(page.locator('main')).toBeVisible() + + // Check TOC has ghec articles (only-ghec and only-ghec-and-ghes) + const tocLinks = page.locator('[data-testid="table-of-contents"] a') + await expect(tocLinks).toHaveCount(2) + }) + + test('cross-product children excluded from sidebar in Japanese translation', async ({ page }) => { + // The Japanese translation should work with cross-product children + await page.goto('/ja/get-started/non-child-resolution') + + // Verify page loads correctly with Japanese site context + // Note: The title may not be fully translated in test fixtures, but the page should render + await expect(page).toHaveTitle(/GitHub Docs/) + await expect(page.locator('main')).toBeVisible() + + // Verify page loads correctly - the cross-product children don't prevent the page from working + // The detailed sidebar filtering is tested by the survey test which verifies no duplicate entries + }) +}) diff --git a/src/frame/lib/create-tree.ts b/src/frame/lib/create-tree.ts index 7b112a65b93b..1cb18aa80d7d 100644 --- a/src/frame/lib/create-tree.ts +++ b/src/frame/lib/create-tree.ts @@ -119,11 +119,36 @@ export default async function createTree( childPreviousTree = previousTree.childPages[i] } } - const subTree = await createTree( - path.posix.join(originalPath, child), - basePath, - childPreviousTree, - ) + + // Handle absolute /content/ paths - allows cross-product directory inclusion + // e.g., /content/actions/workflows will include the entire actions/workflows tree + let childPath: string + if (child.startsWith('/content/')) { + // Absolute content path - resolve from the content root + // Strip '/content/' prefix and join with the base content directory + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join(basePath, absoluteChildPath) + + // Security check: ensure the resolved path stays within the content directory + // This prevents path traversal attacks using sequences like '../' + const resolvedPath = path.resolve(childPath) + const resolvedBasePath = path.resolve(basePath) + if (!resolvedPath.startsWith(resolvedBasePath + path.sep)) { + throw new Error( + `Invalid child path "${child}" in ${originalPath}/index.md - path traversal detected. ` + + `Resolved path "${resolvedPath}" escapes content directory "${resolvedBasePath}".`, + ) + } + } else { + // Traditional relative path + childPath = path.posix.join(originalPath, child) + } + + const subTree = await createTree(childPath, basePath, childPreviousTree) + if (subTree && child.startsWith('/content/')) { + // Mark this subtree as a cross-product child so it can be excluded from the sidebar + subTree.crossProductChild = true + } if (!subTree) { // Remove that children. // For example, the 'early-access' might have been in the diff --git a/src/frame/lib/page-data.ts b/src/frame/lib/page-data.ts index e301bd223e31..5424d7f362d2 100644 --- a/src/frame/lib/page-data.ts +++ b/src/frame/lib/page-data.ts @@ -259,6 +259,12 @@ async function translateTree( translatedData, ) as any, ) as any + + // Preserve the crossProductChild flag from the English tree + if (enTree.crossProductChild) { + ;(item as UnversionedTree).crossProductChild = true + } + if ( ((item as UnversionedTree).page as any).children && ((item as UnversionedTree).page as any).children.length > 0 diff --git a/src/frame/middleware/context/current-product-tree.ts b/src/frame/middleware/context/current-product-tree.ts index 130e8504e380..80b6fa2ab816 100644 --- a/src/frame/middleware/context/current-product-tree.ts +++ b/src/frame/middleware/context/current-product-tree.ts @@ -127,6 +127,7 @@ async function getCurrentProductTreeTitles(input: Tree, context: Context): Promi if (page.hidden) node.hidden = true if (page.sidebarLink) node.sidebarLink = page.sidebarLink if (page.layout && typeof page.layout === 'string') node.layout = page.layout + if (input.crossProductChild) node.crossProductChild = true return node } @@ -146,7 +147,9 @@ function excludeHidden(tree: TitlesTree) { function sidebarTree(tree: TitlesTree) { const { href, title, shortTitle, childPages, sidebarLink } = tree - const childChildPages = childPages.map(sidebarTree) + // Filter out cross-product children from the sidebar + const filteredChildPages = childPages.filter((child) => !child.crossProductChild) + const childChildPages = filteredChildPages.map(sidebarTree) const newTree: TitlesTree = { href, title: shortTitle || title, diff --git a/src/frame/tests/non-child-pages-resolution.test.ts b/src/frame/tests/non-child-pages-resolution.test.ts new file mode 100644 index 000000000000..39ae9f4a46b9 --- /dev/null +++ b/src/frame/tests/non-child-pages-resolution.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from 'vitest' +import path from 'path' +import fs from 'fs' + +// ROOT is the project root directory +// From src/frame/tests/ -> ../../.. gets to project root +const ROOT = path.resolve(__dirname, '../../..') + +/** + * Tests for non-child page resolution: + * `/content/` prefix in children frontmatter resolves to absolute content paths, + * allowing cross-product directory and article inclusion. + */ + +describe('Non-child page resolution', () => { + describe('/content/ prefix in children frontmatter', () => { + test('detects /content/ prefix in children', () => { + const child = '/content/actions/workflows' + expect(child.startsWith('/content/')).toBe(true) + }) + + test('strips /content/ prefix correctly', () => { + const child = '/content/actions/workflows' + const strippedPath = child.slice('/content/'.length) + expect(strippedPath).toBe('actions/workflows') + }) + + test('relative children do not have /content/ prefix', () => { + const relativeChild = '/local-child' + expect(relativeChild.startsWith('/content/')).toBe(false) + }) + + test('/content/ prefix handling resolves absolute paths for directories', () => { + const basePath = '/Users/test/docs-internal/content' + const child = '/content/actions/workflows' + + // Simulate the logic from create-tree.ts + let childPath: string + if (child.startsWith('/content/')) { + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join(basePath, absoluteChildPath) + } else { + childPath = path.posix.join('/Users/test/docs-internal/content/get-started', child) + } + + expect(childPath).toBe('/Users/test/docs-internal/content/actions/workflows') + }) + + test('/content/ prefix handling resolves absolute paths for articles', () => { + const basePath = '/Users/test/docs-internal/content' + const child = '/content/get-started/foo/bar' + + // Simulate the logic from create-tree.ts + let childPath: string + if (child.startsWith('/content/')) { + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join(basePath, absoluteChildPath) + } else { + childPath = path.posix.join('/Users/test/docs-internal/content/get-started', child) + } + + expect(childPath).toBe('/Users/test/docs-internal/content/get-started/foo/bar') + }) + + test('relative children resolve relative to current directory', () => { + const originalPath = '/Users/test/docs-internal/content/get-started' + const child = '/local-child' + + // Simulate the logic from create-tree.ts + let childPath: string + if (child.startsWith('/content/')) { + const absoluteChildPath = child.slice('/content/'.length) + childPath = path.posix.join('/Users/test/docs-internal/content', absoluteChildPath) + } else { + childPath = path.posix.join(originalPath, child) + } + + expect(childPath).toBe('/Users/test/docs-internal/content/get-started/local-child') + }) + }) + + describe('children path formats', () => { + test('children array can contain mixed path formats', () => { + const children = [ + '/local-category', // Local directory + '/standalone-article', // Local article + '/content/actions/workflows', // Cross-product directory + '/content/get-started/foo/bar', // Cross-product article + ] + expect(Array.isArray(children)).toBe(true) + expect(children.every((c) => typeof c === 'string')).toBe(true) + }) + + test('/content/ paths and relative paths are distinguishable', () => { + const children = ['/local-child', '/content/other-product/article'] + + const crossProductPaths = children.filter((c) => c.startsWith('/content/')) + const localPaths = children.filter((c) => !c.startsWith('/content/')) + + expect(crossProductPaths).toEqual(['/content/other-product/article']) + expect(localPaths).toEqual(['/local-child']) + }) + }) + + describe('test fixtures validation', () => { + const fixturesRoot = ROOT + const nonChildResolutionPath = path.join( + fixturesRoot, + 'src/fixtures/fixtures/content/get-started/non-child-resolution', + ) + + test('non-child-resolution fixture directory exists', () => { + expect(fs.existsSync(nonChildResolutionPath)).toBe(true) + }) + + test('non-child-resolution index.md exists', () => { + const indexPath = path.join(nonChildResolutionPath, 'index.md') + expect(fs.existsSync(indexPath)).toBe(true) + }) + + test('children-only fixture exists', () => { + const childrenOnlyPath = path.join(nonChildResolutionPath, 'children-only/index.md') + expect(fs.existsSync(childrenOnlyPath)).toBe(true) + }) + + test('cross-product children fixture exists (formerly articles-only)', () => { + const articlesOnlyPath = path.join(nonChildResolutionPath, 'articles-only/index.md') + expect(fs.existsSync(articlesOnlyPath)).toBe(true) + }) + + test('local-category fixture exists with local articles', () => { + const localCategoryPath = path.join(nonChildResolutionPath, 'local-category') + expect(fs.existsSync(path.join(localCategoryPath, 'index.md'))).toBe(true) + expect(fs.existsSync(path.join(localCategoryPath, 'local-article-one.md'))).toBe(true) + expect(fs.existsSync(path.join(localCategoryPath, 'local-article-two.md'))).toBe(true) + }) + + test('versioned-cross-product fixture exists', () => { + const versionedPath = path.join(nonChildResolutionPath, 'versioned-cross-product/index.md') + expect(fs.existsSync(versionedPath)).toBe(true) + }) + }) + + describe('translation behavior', () => { + test('cross-product children paths are language-agnostic', () => { + // The /content/ prefix paths should work regardless of the current language + // The actual translation is handled by the page loading system + const child = '/content/actions/using-workflows/storing-workflow-data-as-artifacts' + + // Path should not include language prefix + expect(child.startsWith('/content/')).toBe(true) + expect(child).not.toMatch(/\/content\/(en|ja|es|pt|zh|ru|ko|fr|de)\//) + }) + + test('resolved paths use content directory, not translations', () => { + // Cross-product children are resolved from the main content directory + // Translations are handled separately by the page rendering system + const basePath = '/Users/test/docs-internal/content' + const child = '/content/actions/workflows' + + const resolvedPath = path.posix.join(basePath, child.slice('/content/'.length)) + expect(resolvedPath).toBe('/Users/test/docs-internal/content/actions/workflows') + expect(resolvedPath).not.toContain('translations') + }) + }) + + describe('crossProductChild flag', () => { + test('flag is set for /content/ prefix paths', () => { + // Simulate the logic from create-tree.ts + const child = '/content/actions/workflows' + const isCrossProduct = child.startsWith('/content/') + expect(isCrossProduct).toBe(true) + }) + + test('flag is not set for relative paths', () => { + const child = '/local-child' + const isCrossProduct = child.startsWith('/content/') + expect(isCrossProduct).toBe(false) + }) + + test('crossProductChild flag excludes items from sidebar', () => { + // Simulate the sidebarTree filtering logic + const childPages = [ + { href: '/en/get-started/foo', title: 'Foo', crossProductChild: false }, + { href: '/en/actions/workflows', title: 'Workflows', crossProductChild: true }, + { href: '/en/get-started/bar', title: 'Bar', crossProductChild: false }, + ] + + const sidebarChildPages = childPages.filter((c) => !c.crossProductChild) + expect(sidebarChildPages).toHaveLength(2) + expect(sidebarChildPages.map((c) => c.title)).toEqual(['Foo', 'Bar']) + }) + }) +}) diff --git a/src/types/types.ts b/src/types/types.ts index 74e2819256f6..e38defed7c5a 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -411,6 +411,7 @@ export type TitlesTree = { hidden?: boolean sidebarLink?: SidebarLink layout?: string + crossProductChild?: boolean } export type Tree = { @@ -418,6 +419,7 @@ export type Tree = { children: string[] | undefined href: string childPages: Tree[] + crossProductChild?: boolean } export type VersionedTree = { [version: string]: Tree @@ -431,6 +433,7 @@ export type UnversionedTree = { page: Page children: string[] childPages: UnversionedTree[] + crossProductChild?: boolean } export type UnversionLanguageTree = { From a12c135dbe37f542fde463f73fef39e7f67975a7 Mon Sep 17 00:00:00 2001 From: Sarah Schneider Date: Wed, 21 Jan 2026 11:32:19 -0500 Subject: [PATCH 02/12] Support new secret-scanning directory structure in GHES script (#59227) --- .../deprecate/update-automated-pipelines.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts index 984f5b17c63f..3e3fb3489181 100755 --- a/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts +++ b/src/ghes-releases/scripts/deprecate/update-automated-pipelines.ts @@ -91,17 +91,28 @@ export async function updateAutomatedPipelines() { .map((version) => version.openApiVersionName) for (const pipeline of pipelines) { - if (!existsSync(`src/${pipeline}/data`)) continue + // secret-scanning has a different directory structure than the others + const directoryWithReleases = + pipeline === 'secret-scanning' + ? 'src/secret-scanning/data/pattern-docs' + : `src/${pipeline}/data` + if (!existsSync(directoryWithReleases)) continue + const isCalendarDateVersioned = JSON.parse( await readFile(`src/${pipeline}/lib/config.json`, 'utf-8'), )['api-versions'] - const directoryListing = await readdir(`src/${pipeline}/data`) + const directoryListing = await readdir(directoryWithReleases) // filter the directory list to only include directories that start with // basenames with numbered releases (e.g., ghes-). const existingDataDir = directoryListing.filter((directory) => numberedReleaseBaseNames.some((basename) => directory.startsWith(basename)), ) + + if (!existingDataDir.length) { + throw new Error(`Cannot find ghes- release directories in ${directoryWithReleases}.`) + } + const expectedDirectory = isCalendarDateVersioned ? versionNamesCalDate : versionNames // Get a list of data directories to remove (deprecate) and remove them From 0a25221ef27780da38ac92a41df56b7eb83e564b Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 21 Jan 2026 08:42:00 -0800 Subject: [PATCH 03/12] Fix moda-linter secrets schema validation errors (#59230) --- config/moda/secrets/production/secrets.yml | 2 ++ config/moda/secrets/staging/secrets.yml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/config/moda/secrets/production/secrets.yml b/config/moda/secrets/production/secrets.yml index ce9ebe2c6975..ccdb3af5732d 100644 --- a/config/moda/secrets/production/secrets.yml +++ b/config/moda/secrets/production/secrets.yml @@ -4,3 +4,5 @@ secrets: kind: latest_at_deployment_start key: COOKIE_SECRET type: salt + owner: '@github/docs-engineering' + externally_usable: true diff --git a/config/moda/secrets/staging/secrets.yml b/config/moda/secrets/staging/secrets.yml index ce9ebe2c6975..ccdb3af5732d 100644 --- a/config/moda/secrets/staging/secrets.yml +++ b/config/moda/secrets/staging/secrets.yml @@ -4,3 +4,5 @@ secrets: kind: latest_at_deployment_start key: COOKIE_SECRET type: salt + owner: '@github/docs-engineering' + externally_usable: true From afbba6c2e1c1ddffe452c606ef8f975e2f49bcfa Mon Sep 17 00:00:00 2001 From: docs-bot <77750099+docs-bot@users.noreply.github.com> Date: Wed, 21 Jan 2026 08:43:54 -0800 Subject: [PATCH 04/12] GraphQL schema update (#59233) Co-authored-by: heiskr <1221423+heiskr@users.noreply.github.com> --- src/graphql/data/fpt/changelog.json | 13 +++++++++++++ src/graphql/data/fpt/schema.docs.graphql | 5 +++++ src/graphql/data/fpt/schema.json | 10 ++++++++++ src/graphql/data/ghec/schema.docs.graphql | 5 +++++ src/graphql/data/ghec/schema.json | 10 ++++++++++ 5 files changed, 43 insertions(+) diff --git a/src/graphql/data/fpt/changelog.json b/src/graphql/data/fpt/changelog.json index 7eed7172053e..25c3d9568543 100644 --- a/src/graphql/data/fpt/changelog.json +++ b/src/graphql/data/fpt/changelog.json @@ -1,4 +1,17 @@ [ + { + "schemaChanges": [ + { + "title": "The GraphQL schema includes these changes:", + "changes": [ + "

Argument query: String added to field PullRequest.suggestedReviewerActors

" + ] + } + ], + "previewChanges": [], + "upcomingChanges": [], + "date": "2026-01-21" + }, { "schemaChanges": [ { diff --git a/src/graphql/data/fpt/schema.docs.graphql b/src/graphql/data/fpt/schema.docs.graphql index 7d6b56f005d7..14fffd984755 100644 --- a/src/graphql/data/fpt/schema.docs.graphql +++ b/src/graphql/data/fpt/schema.docs.graphql @@ -41363,6 +41363,11 @@ type PullRequest implements Assignable & Closable & Comment & Labelable & Lockab Returns the last _n_ elements from the list. """ last: Int + + """ + Search actors with query on user name and login. + """ + query: String ): SuggestedReviewerActorConnection! """ diff --git a/src/graphql/data/fpt/schema.json b/src/graphql/data/fpt/schema.json index 064eca54bcac..b7feb1ed8b77 100644 --- a/src/graphql/data/fpt/schema.json +++ b/src/graphql/data/fpt/schema.json @@ -55242,6 +55242,16 @@ "kind": "scalars", "href": "/graphql/reference/scalars#int" } + }, + { + "name": "query", + "description": "

Search actors with query on user name and login.

", + "type": { + "name": "String", + "id": "string", + "kind": "scalars", + "href": "/graphql/reference/scalars#string" + } } ] }, diff --git a/src/graphql/data/ghec/schema.docs.graphql b/src/graphql/data/ghec/schema.docs.graphql index 7d6b56f005d7..14fffd984755 100644 --- a/src/graphql/data/ghec/schema.docs.graphql +++ b/src/graphql/data/ghec/schema.docs.graphql @@ -41363,6 +41363,11 @@ type PullRequest implements Assignable & Closable & Comment & Labelable & Lockab Returns the last _n_ elements from the list. """ last: Int + + """ + Search actors with query on user name and login. + """ + query: String ): SuggestedReviewerActorConnection! """ diff --git a/src/graphql/data/ghec/schema.json b/src/graphql/data/ghec/schema.json index 064eca54bcac..b7feb1ed8b77 100644 --- a/src/graphql/data/ghec/schema.json +++ b/src/graphql/data/ghec/schema.json @@ -55242,6 +55242,16 @@ "kind": "scalars", "href": "/graphql/reference/scalars#int" } + }, + { + "name": "query", + "description": "

Search actors with query on user name and login.

", + "type": { + "name": "String", + "id": "string", + "kind": "scalars", + "href": "/graphql/reference/scalars#string" + } } ] }, From 650d266f711645ccaab2b086e8dbfca13694baa4 Mon Sep 17 00:00:00 2001 From: docs-bot <77750099+docs-bot@users.noreply.github.com> Date: Wed, 21 Jan 2026 08:59:55 -0800 Subject: [PATCH 05/12] Sync secret scanning data (#59232) Co-authored-by: mc <42146119+mchammer01@users.noreply.github.com> --- .../data/pattern-docs/fpt/public-docs.yml | 16 ++++++++-------- .../data/pattern-docs/ghec/public-docs.yml | 16 ++++++++-------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml b/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml index cbe6b33dbd58..13d6eec9b726 100644 --- a/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml +++ b/src/secret-scanning/data/pattern-docs/fpt/public-docs.yml @@ -2648,7 +2648,7 @@ secretType: mapbox_secret_access_token isPublic: false isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: true base64Supported: false isduplicate: false @@ -3648,7 +3648,7 @@ isPublic: true isPrivateWithGhas: true hasPushProtection: true - hasValidityCheck: false + hasValidityCheck: true base64Supported: false isduplicate: false - provider: Sentry @@ -3827,7 +3827,7 @@ secretType: snowflake_postgres_connection_string isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -3836,7 +3836,7 @@ secretType: snowflake_postgres_host,
snowflake_postgres_password isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4178,7 +4178,7 @@ secretType: vercel_api_key isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4205,7 +4205,7 @@ secretType: vercel_integration_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4214,7 +4214,7 @@ secretType: vercel_personal_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4223,7 +4223,7 @@ secretType: vercel_support_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false diff --git a/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml b/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml index cbe6b33dbd58..13d6eec9b726 100644 --- a/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml +++ b/src/secret-scanning/data/pattern-docs/ghec/public-docs.yml @@ -2648,7 +2648,7 @@ secretType: mapbox_secret_access_token isPublic: false isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: true base64Supported: false isduplicate: false @@ -3648,7 +3648,7 @@ isPublic: true isPrivateWithGhas: true hasPushProtection: true - hasValidityCheck: false + hasValidityCheck: true base64Supported: false isduplicate: false - provider: Sentry @@ -3827,7 +3827,7 @@ secretType: snowflake_postgres_connection_string isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -3836,7 +3836,7 @@ secretType: snowflake_postgres_host,
snowflake_postgres_password isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4178,7 +4178,7 @@ secretType: vercel_api_key isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4205,7 +4205,7 @@ secretType: vercel_integration_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4214,7 +4214,7 @@ secretType: vercel_personal_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false @@ -4223,7 +4223,7 @@ secretType: vercel_support_access_token isPublic: true isPrivateWithGhas: true - hasPushProtection: false + hasPushProtection: true hasValidityCheck: false base64Supported: false isduplicate: false From fa68b9e18f58ba44f350adb4b3a0a6ab8102a996 Mon Sep 17 00:00:00 2001 From: Sarah Schneider Date: Wed, 21 Jan 2026 13:26:12 -0500 Subject: [PATCH 06/12] Include deprecation banner on all versions that share the oldest deprecation date (#59218) --- src/app/lib/main-context-adapter.ts | 1 + src/frame/components/context/MainContext.tsx | 8 +++++++- src/versions/components/DeprecationBanner.tsx | 3 ++- .../lib/enterprise-server-releases.d.ts | 1 + src/versions/lib/enterprise-server-releases.ts | 7 +++++++ src/versions/tests/enterprise-versions.ts | 17 +++++++++++++++-- 6 files changed, 33 insertions(+), 4 deletions(-) diff --git a/src/app/lib/main-context-adapter.ts b/src/app/lib/main-context-adapter.ts index ee79b2b62693..ccca0c98a17a 100644 --- a/src/app/lib/main-context-adapter.ts +++ b/src/app/lib/main-context-adapter.ts @@ -40,6 +40,7 @@ export function adaptAppRouterContextToMainContext( oldestSupported: '', nextDeprecationDate: '', supported: [], + releasesWithOldestDeprecationDate: [], }, enterpriseServerVersions: [], error: '', diff --git a/src/frame/components/context/MainContext.tsx b/src/frame/components/context/MainContext.tsx index 10fcb7a42b58..560aff136184 100644 --- a/src/frame/components/context/MainContext.tsx +++ b/src/frame/components/context/MainContext.tsx @@ -86,6 +86,7 @@ type EnterpriseServerReleases = { oldestSupported: string nextDeprecationDate: string supported: Array + releasesWithOldestDeprecationDate: Array } export type MainContextT = { @@ -193,7 +194,11 @@ export const getMainContext = async (req: any, res: any): Promise // To know whether we need this key, we need to match this // with the business logic in `DeprecationBanner.tsx` which is as follows: - if (req.context.currentVersion.includes(req.context.enterpriseServerReleases.oldestSupported)) { + if ( + req.context.enterpriseServerReleases.releasesWithOldestDeprecationDate.includes( + req.context.currentRelease, + ) + ) { reusables.enterprise_deprecation = { version_was_deprecated: req.context.getDottedData( 'reusables.enterprise_deprecation.version_was_deprecated', @@ -264,6 +269,7 @@ export const getMainContext = async (req: any, res: any): Promise 'oldestSupported', 'nextDeprecationDate', 'supported', + 'releasesWithOldestDeprecationDate', ]), enterpriseServerVersions: req.context.enterpriseServerVersions, error: req.context.error ? req.context.error.toString() : '', diff --git a/src/versions/components/DeprecationBanner.tsx b/src/versions/components/DeprecationBanner.tsx index ed989fb15344..66b72cc5230a 100644 --- a/src/versions/components/DeprecationBanner.tsx +++ b/src/versions/components/DeprecationBanner.tsx @@ -9,8 +9,9 @@ import styles from './DeprecationBanner.module.scss' export const DeprecationBanner = () => { const { data, enterpriseServerReleases } = useMainContext() const { currentVersion } = useVersion() + const currentRelease = currentVersion.replace('enterprise-server@', '') - if (!currentVersion.includes(enterpriseServerReleases.oldestSupported)) { + if (!enterpriseServerReleases.releasesWithOldestDeprecationDate.includes(currentRelease)) { return null } diff --git a/src/versions/lib/enterprise-server-releases.d.ts b/src/versions/lib/enterprise-server-releases.d.ts index e094961c96cb..cab010afad08 100644 --- a/src/versions/lib/enterprise-server-releases.d.ts +++ b/src/versions/lib/enterprise-server-releases.d.ts @@ -42,6 +42,7 @@ export const oldestSupported: string export const dates: Dates export const nextDeprecationDate: string export const isOldestReleaseDeprecated: boolean +export const releasesWithOldestDeprecationDate: string[] export const deprecatedOnNewSite: string[] export const deprecatedReleasesWithLegacyFormat: string[] export const deprecatedReleasesWithNewFormat: string[] diff --git a/src/versions/lib/enterprise-server-releases.ts b/src/versions/lib/enterprise-server-releases.ts index 560fd836d7d3..7a4772b7b417 100644 --- a/src/versions/lib/enterprise-server-releases.ts +++ b/src/versions/lib/enterprise-server-releases.ts @@ -127,6 +127,12 @@ export const isOldestReleaseDeprecated = nextDeprecationDate ? new Date() > new Date(nextDeprecationDate) : false +// Find any other releases that may share the oldest deprecation date +// We'll want to display the deprecation banner on all of these releases (not just oldest) +export const releasesWithOldestDeprecationDate = Object.entries(dates) + .filter(([, versionData]) => versionData.deprecationDate === nextDeprecationDate) + .map(([version]) => version) + // Filtered version arrays for different use cases export const deprecatedOnNewSite = deprecated.filter((version) => versionSatisfiesRange(version, '>=2.13'), @@ -210,6 +216,7 @@ export default { oldestSupported, nextDeprecationDate, isOldestReleaseDeprecated, + releasesWithOldestDeprecationDate, deprecatedOnNewSite, dates, firstVersionDeprecatedOnNewSite, diff --git a/src/versions/tests/enterprise-versions.ts b/src/versions/tests/enterprise-versions.ts index 9a3e6cbc3a2b..c4eae998974e 100644 --- a/src/versions/tests/enterprise-versions.ts +++ b/src/versions/tests/enterprise-versions.ts @@ -2,8 +2,15 @@ import { describe, expect, test } from 'vitest' import patterns from '@/frame/lib/patterns' import EnterpriseServerReleases from '@/versions/lib/enterprise-server-releases' -const { supported, deprecated, all, latest, oldestSupported, nextDeprecationDate } = - EnterpriseServerReleases +const { + supported, + deprecated, + all, + latest, + oldestSupported, + nextDeprecationDate, + releasesWithOldestDeprecationDate, +} = EnterpriseServerReleases describe('enterpriseServerReleases module', () => { test('includes an array of `supported` versions', async () => { @@ -37,4 +44,10 @@ describe('enterpriseServerReleases module', () => { test('has a `nextDeprecationDate` property', async () => { expect(nextDeprecationDate).toMatch(patterns.ymd) }) + + test('has a `releasesWithOldestDeprecationDate` property', async () => { + expect(Array.isArray(releasesWithOldestDeprecationDate)).toBe(true) + expect(releasesWithOldestDeprecationDate.length).toBeGreaterThan(0) + expect(releasesWithOldestDeprecationDate).toContain(oldestSupported) + }) }) From deadfce464ddb94553fe11c2964944a1d971bf39 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 21 Jan 2026 10:50:17 -0800 Subject: [PATCH 07/12] Fix TypeError when childPages is undefined in generic-toc middleware (#59169) --- src/frame/middleware/context/generic-toc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/frame/middleware/context/generic-toc.ts b/src/frame/middleware/context/generic-toc.ts index 7d5bf55da1f5..7a22c9053944 100644 --- a/src/frame/middleware/context/generic-toc.ts +++ b/src/frame/middleware/context/generic-toc.ts @@ -131,7 +131,7 @@ async function getTocItems(node: Tree, context: Context, opts: Options): Promise } return await Promise.all( - node.childPages.filter(filterHidden).map(async (child) => { + (node.childPages || []).filter(filterHidden).map(async (child) => { const { page } = child const title = await page.renderProp('rawTitle', context, { textOnly: true }) const octicon = page.octicon ?? null From f30653572220fa3a47f5452abf9704b70c664f18 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 21 Jan 2026 10:58:55 -0800 Subject: [PATCH 08/12] Run article-api tests serially (#59193) --- .github/workflows/test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5368a43186a8..4c223305e759 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -166,4 +166,5 @@ jobs: # Enable debug logging when "Re-run jobs with debug logging" is used in GitHub Actions UI # This will output additional timing and path information to help diagnose timeout issues RUNNER_DEBUG: ${{ runner.debug }} - run: npm test -- src/${{ matrix.name }}/tests/ + VITEST_FLAGS: ${{ matrix.name == 'article-api' && '--no-file-parallelism --maxWorkers=1' || '' }} + run: npm test -- $VITEST_FLAGS src/${{ matrix.name }}/tests/ From 3674ab80bd7443092f27be49aebc3e1d4bb9e4c6 Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 21 Jan 2026 10:59:24 -0800 Subject: [PATCH 09/12] Delete unused environment secret configs (#59173) --- config/moda/secrets/docs-internal-staging-boxwood/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-cedar/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-cypress/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-fir/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-hemlock/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-holly/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-juniper/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-laurel/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-pine/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-redwood/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-sequoia/secrets.yml | 2 -- config/moda/secrets/docs-internal-staging-spruce/secrets.yml | 2 -- config/moda/secrets/review-os/secrets.yml | 2 -- config/moda/secrets/review/secrets.yml | 2 -- 14 files changed, 28 deletions(-) delete mode 100644 config/moda/secrets/docs-internal-staging-boxwood/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-cedar/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-cypress/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-fir/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-hemlock/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-holly/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-juniper/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-laurel/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-pine/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-redwood/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-sequoia/secrets.yml delete mode 100644 config/moda/secrets/docs-internal-staging-spruce/secrets.yml delete mode 100644 config/moda/secrets/review-os/secrets.yml delete mode 100644 config/moda/secrets/review/secrets.yml diff --git a/config/moda/secrets/docs-internal-staging-boxwood/secrets.yml b/config/moda/secrets/docs-internal-staging-boxwood/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-boxwood/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-cedar/secrets.yml b/config/moda/secrets/docs-internal-staging-cedar/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-cedar/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-cypress/secrets.yml b/config/moda/secrets/docs-internal-staging-cypress/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-cypress/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-fir/secrets.yml b/config/moda/secrets/docs-internal-staging-fir/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-fir/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-hemlock/secrets.yml b/config/moda/secrets/docs-internal-staging-hemlock/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-hemlock/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-holly/secrets.yml b/config/moda/secrets/docs-internal-staging-holly/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-holly/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-juniper/secrets.yml b/config/moda/secrets/docs-internal-staging-juniper/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-juniper/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-laurel/secrets.yml b/config/moda/secrets/docs-internal-staging-laurel/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-laurel/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-pine/secrets.yml b/config/moda/secrets/docs-internal-staging-pine/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-pine/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-redwood/secrets.yml b/config/moda/secrets/docs-internal-staging-redwood/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-redwood/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-sequoia/secrets.yml b/config/moda/secrets/docs-internal-staging-sequoia/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-sequoia/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/docs-internal-staging-spruce/secrets.yml b/config/moda/secrets/docs-internal-staging-spruce/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/docs-internal-staging-spruce/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/review-os/secrets.yml b/config/moda/secrets/review-os/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/review-os/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} diff --git a/config/moda/secrets/review/secrets.yml b/config/moda/secrets/review/secrets.yml deleted file mode 100644 index 1cc2ba803f6c..000000000000 --- a/config/moda/secrets/review/secrets.yml +++ /dev/null @@ -1,2 +0,0 @@ ---- -secrets: {} From c7287205b6f102088d08b2728ad14b69ece6015f Mon Sep 17 00:00:00 2001 From: Kevin Heis Date: Wed, 21 Jan 2026 10:59:41 -0800 Subject: [PATCH 10/12] Refine transformers section in README.md (#59174) --- src/article-api/README.md | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/article-api/README.md b/src/article-api/README.md index acc061b020de..98667b88e554 100644 --- a/src/article-api/README.md +++ b/src/article-api/README.md @@ -23,12 +23,9 @@ The `/api/article` endpoints return information about a page by `pathname`. ### Autogenerated Content Transformers -For autogenerated pages (REST, GraphQL, webhooks, landing pages, audit logs, etc), the Article API uses specialized transformers to convert the rendered content into markdown format. These transformers are located in `src/article-api/transformers/` and use an extensible architecture: +For autogenerated pages (REST, GraphQL, webhooks, landing pages, audit logs, etc), the Article API uses specialized transformers to convert the rendered content into markdown format. These transformers are located in `src/article-api/transformers/` and use an extensible architecture. -#### Current Transformers - -- **REST Transformer** (`rest-transformer.ts`) - Converts REST API operations into markdown, including endpoints, parameters, status codes, and code examples -- **GraphQL Transformer** (`graphql-transformer.ts`) - Converts GraphQL schema documentation into markdown, including queries, mutations, objects, interfaces, enums, unions, input objects, scalars, changelog, and breaking changes +#### Transformers To add a new transformer for other autogenerated content types: 1. Create a new transformer file implementing the `PageTransformer` interface @@ -195,15 +192,7 @@ npm run test -- src/article-api/tests - Team: Docs Engineering -## Transformers - -Currently implemented transformers: -- **REST API transformer** (`rest-transformer.ts`) - Converts REST API autogenerated content -- **GraphQL transformer** (`graphql-transformer.ts`) - Converts GraphQL API autogenerated content -- **Audit logs transformer** (`audit-logs-transformer.ts`) - Converts audit log tables to markdown - ### Known limitations -- Some autogenerated content types don't have transformers yet - Cache invalidation is manual -- No built-in rate limiting +- No built-in rate limiting (uses Fastly instead) - Limited API versioning From 89e5b4634d4482c79c370a3727fa89210123f816 Mon Sep 17 00:00:00 2001 From: Sarah Schneider Date: Wed, 21 Jan 2026 14:48:41 -0500 Subject: [PATCH 11/12] Ensure index files have children (#59194) --- package-lock.json | 17 +++++++ src/fixtures/fixtures/index.md | 6 +++ src/fixtures/fixtures/sample-toc-index.md | 4 +- src/frame/lib/get-toc-items.ts | 57 ----------------------- src/frame/lib/page.ts | 12 +++-- src/frame/tests/page.ts | 12 +++++ 6 files changed, 45 insertions(+), 63 deletions(-) create mode 100644 src/fixtures/fixtures/index.md delete mode 100644 src/frame/lib/get-toc-items.ts diff --git a/package-lock.json b/package-lock.json index 8a52ea324048..abf264333371 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3018,6 +3018,7 @@ "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3057,6 +3058,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3077,6 +3079,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3097,6 +3100,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3117,6 +3121,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3137,6 +3142,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3157,6 +3163,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3177,6 +3184,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3197,6 +3205,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3217,6 +3226,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3237,6 +3247,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3257,6 +3268,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3277,6 +3289,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3297,6 +3310,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3314,6 +3328,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "dev": true, "license": "Apache-2.0", "optional": true, "bin": { @@ -8633,6 +8648,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "optional": true, "os": [ @@ -12589,6 +12605,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, "license": "MIT", "optional": true }, diff --git a/src/fixtures/fixtures/index.md b/src/fixtures/fixtures/index.md new file mode 100644 index 000000000000..5a6fbc3cbb75 --- /dev/null +++ b/src/fixtures/fixtures/index.md @@ -0,0 +1,6 @@ +--- +title: This is an index page +intro: 'I am missing a children frontmatter property' +versions: + fpt: '*' +--- \ No newline at end of file diff --git a/src/fixtures/fixtures/sample-toc-index.md b/src/fixtures/fixtures/sample-toc-index.md index b9ec21688f23..e73e44e96834 100644 --- a/src/fixtures/fixtures/sample-toc-index.md +++ b/src/fixtures/fixtures/sample-toc-index.md @@ -2,6 +2,6 @@ title: A sample TOC versions: free-pro-team: '*' +children: + - /article-one --- - -{% link_in_list /sample-article %} diff --git a/src/frame/lib/get-toc-items.ts b/src/frame/lib/get-toc-items.ts deleted file mode 100644 index 4cd40eb0ba8f..000000000000 --- a/src/frame/lib/get-toc-items.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { productMap } from '@/products/lib/all-products' - -interface TocItem { - type: 'category' | 'subcategory' | 'article' - href: string -} - -interface Page { - relativePath: string - markdown: string -} - -const productTOCs = Object.values(productMap) - .filter((product) => !product.external) - .map((product) => product.toc.replace('content/', '')) - -const linkString = /{% [^}]*?link.*? \/(.*?) ?%}/m -const linksArray = new RegExp(linkString.source, 'gm') - -// return an array of objects like { type: 'category|subcategory|article', href: 'path' } -export default function getTocItems(page: Page): TocItem[] | undefined { - // only process product and category tocs - if (!page.relativePath.endsWith('index.md')) return - if (page.relativePath === 'index.md') return - - // ignore content above Table of Contents heading - const pageContent = page.markdown.replace(/[\s\S]*?# Table of contents\n/im, '') - - // find array of TOC link strings - const rawItems = pageContent.match(linksArray) - - // return an empty array if this is a localized page - if (!rawItems) { - return [] - } - - return rawItems - .map((item: string) => { - const match = item.match(linkString) - if (!match) return null - - const tocItem: TocItem = {} as TocItem - - // a product's toc items are always categories - // whereas a category's toc items can be either subcategories or articles - tocItem.type = productTOCs.includes(page.relativePath) - ? 'category' - : page.relativePath.includes('/index.md') - ? 'subcategory' - : 'article' - - tocItem.href = match[1] - - return tocItem - }) - .filter((item): item is TocItem => item !== null) -} diff --git a/src/frame/lib/page.ts b/src/frame/lib/page.ts index b90a82345f57..05de3fef941e 100644 --- a/src/frame/lib/page.ts +++ b/src/frame/lib/page.ts @@ -6,7 +6,6 @@ import getApplicableVersions from '@/versions/lib/get-applicable-versions' import generateRedirectsForPermalinks from '@/redirects/lib/permalinks' import getEnglishHeadings from '@/languages/lib/get-english-headings' import { getAlertTitles } from '@/languages/lib/get-alert-titles' -import getTocItems from './get-toc-items' import Permalink from './permalink' import { renderContent } from '@/content-render/index' import processLearningTracks from '@/learning-track/lib/process-learning-tracks' @@ -100,6 +99,8 @@ class Page { public rawRecommended?: string[] public autogenerated?: string public featuredLinks?: FeaturedLinksExpanded + public children?: string[] + public layout?: string // Derived properties public languageCode!: string @@ -111,7 +112,6 @@ class Page { public documentType: string public applicableVersions: string[] public permalinks: Permalink[] - public tocItems?: any[] public communityRedirect?: CommunityRedirect public detectedPlatforms: string[] = [] public includesPlatformSpecificContent: boolean = false @@ -259,9 +259,13 @@ class Page { this.applicableVersions, ) + // Ensure 'children' frontmatter exists if this is a standard index page if (this.relativePath.endsWith('index.md')) { - // get an array of linked items in product and category TOCs - this.tocItems = getTocItems(this) + if (!this.children && !/(search|early-access)\/.*index.md/.test(this.relativePath)) { + if (this.layout !== 'journey-landing') { + throw new Error(`${this.fullPath} must contain 'children' frontmatter.`) + } + } } // if this is an article and it doesn't have showMiniToc = false, set mini TOC to true diff --git a/src/frame/tests/page.ts b/src/frame/tests/page.ts index 03f8821b0798..266babf20100 100644 --- a/src/frame/tests/page.ts +++ b/src/frame/tests/page.ts @@ -439,6 +439,18 @@ describe('catches errors thrown in Page class', () => { await expect(getPage).rejects.toThrowError('versions') }) + test('missing children frontmatter in index file', async () => { + async function getPage() { + return await Page.init({ + relativePath: 'index.md', + basePath: path.join(__dirname, '../../../src/fixtures/fixtures'), + languageCode: 'en', + }) + } + + await expect(getPage).rejects.toThrowError(/must contain 'children' frontmatter/) + }) + test('English page with a version in frontmatter that its parent product is not available in', async () => { async function getPage() { return await Page.init({ From 9d548805570fc7082e0a51d8bc4caf1d5b476d72 Mon Sep 17 00:00:00 2001 From: Stevie <92107431+Stevie-10P@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:57:23 -0800 Subject: [PATCH 12/12] secret scanning: add 'Manual_submission` to secret scanning partner program token sources (#59223) --- .../code-security/tutorials/secret-scanning-partner-program.md | 1 + 1 file changed, 1 insertion(+) diff --git a/content/code-security/tutorials/secret-scanning-partner-program.md b/content/code-security/tutorials/secret-scanning-partner-program.md index e742e073bd1b..027c96d3f2a1 100644 --- a/content/code-security/tutorials/secret-scanning-partner-program.md +++ b/content/code-security/tutorials/secret-scanning-partner-program.md @@ -109,6 +109,7 @@ The list of valid values for `source` are: * Wiki_content * Wiki_commit * Npm +* Manual_submission * Unknown ### Implement signature verification in your secret alert service