` cannot be a descendant of `
`), which
+ // the browser silently repairs for dangerouslySetInnerHTML but causes a React
+ // hydration mismatch when the content is rendered as real elements. So only
+ // add the wrapper for the no-
(tight list) case it was designed for.
+ if ((parent as Element).tagName === 'p') return
+
+ const shallowClone: Element = Object.assign({}, node)
+ shallowClone.tagName = 'div'
+ shallowClone.properties = { class: 'procedural-image-wrapper' }
+ shallowClone.children = [node]
+ parent.children = parent.children.map((child) => {
+ if (child.type === 'element' && (child as Element).tagName === 'img') {
+ return shallowClone
}
- }
+ return child
+ })
}
export default function wrapProceduralImages() {
diff --git a/src/fixtures/tests/images.ts b/src/fixtures/tests/images.ts
index 229d9c188d24..a1eaaccfec5d 100644
--- a/src/fixtures/tests/images.ts
+++ b/src/fixtures/tests/images.ts
@@ -1,10 +1,19 @@
import { describe, expect, test } from 'vitest'
import sharp from 'sharp'
-import type { CheerioAPI } from 'cheerio'
+import type { Cheerio, CheerioAPI } from 'cheerio'
+import type { Element } from 'domhandler'
import { get, head, getDOM } from '@/tests/helpers/e2etest'
import { MAX_WIDTH } from '@/content-render/unified/rewrite-asset-img-tags'
+// `getDOM` parses with `xmlMode: true`, which is case-sensitive on attribute
+// names. The legacy string render path emits a lowercase `srcset`, but the
+// React render path (hast -> JSX) emits React 19's camelCase `srcSet`. Both are
+// valid HTML (attribute names are case-insensitive in browsers), so read either.
+function srcsetOf(el: Cheerio): string | undefined {
+ return el.attr('srcset') ?? el.attr('srcSet')
+}
+
describe('render Markdown image tags', () => {
test('page with a single image', async () => {
const $: CheerioAPI = await getDOM('/get-started/images/single-image')
@@ -14,7 +23,7 @@ describe('render Markdown image tags', () => {
const sources = $('source', pictures)
expect(sources.length).toBe(1)
- const srcset = sources.attr('srcset')
+ const srcset = srcsetOf(sources)
expect(srcset).toMatch(
new RegExp(`^/assets/cb-\\w+/mw-${MAX_WIDTH}/images/_fixtures/screenshot\\.webp 2x$`),
)
@@ -54,9 +63,9 @@ describe('render Markdown image tags', () => {
const sources = $('source', pictures)
expect(sources.length).toBe(3)
- expect(sources.eq(0).attr('srcset')).toContain('1x') // 0
- expect(sources.eq(1).attr('srcset')).toContain('2x') // 1
- expect(sources.eq(2).attr('srcset')).toContain('2x') // 2
+ expect(srcsetOf(sources.eq(0))).toContain('1x') // 0
+ expect(srcsetOf(sources.eq(1))).toContain('2x') // 1
+ expect(srcsetOf(sources.eq(2))).toContain('2x') // 2
})
test('image inside a list keeps its span', async () => {
diff --git a/src/frame/components/CodeTabsGroup.tsx b/src/frame/components/CodeTabsGroup.tsx
new file mode 100644
index 000000000000..2d326d26eb03
--- /dev/null
+++ b/src/frame/components/CodeTabsGroup.tsx
@@ -0,0 +1,164 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useEffect,
+ useId,
+ useMemo,
+ useState,
+ isValidElement,
+ Children,
+ cloneElement,
+ type ReactElement,
+ type ReactNode,
+ type MouseEvent as ReactMouseEvent,
+ type KeyboardEvent as ReactKeyboardEvent,
+} from 'react'
+import { useRouter } from 'next/router'
+import { UnderlineNav } from '@primer/react'
+import cx from 'classnames'
+
+import Cookies from '@/frame/components/lib/cookies'
+import { CODE_SAMPLE_LANGUAGE_COOKIE_NAME } from '@/frame/lib/constants'
+import { sendEvent } from '@/events/components/events'
+import { EventType } from '@/events/types'
+import { useTranslation } from '@/languages/components/useTranslation'
+
+// React-native replacement for the imperative CodeTabs enhancer (#6619). The old
+// component scanned `#article-contents` for `.ghd-codetabs`, inserted a foreign
+// `.ghd-codetabs-nav` mountPoint as the container's first child, portaled a nav
+// into it, and toggled panel attributes — destructive surgery on React-owned
+// nodes that breaks on client-side navigation teardown. Instead, the article body
+// hast maps each `.ghd-codetabs` container to , which reads its
+// `.ghd-codetab` panel children straight from props and renders the nav + panels
+// itself. No DOM scanning, no portal, no foreign nodes.
+//
+// The selected language lives in CodeLanguageContext so multiple code-tab groups
+// on one page stay in sync and share the language cookie, matching the previous
+// single-component behavior.
+
+type CodeLanguageContextT = {
+ language: string
+ setLanguage: (value: string) => void
+}
+
+const CodeLanguageContext = createContext({
+ language: '',
+ setLanguage: () => {},
+})
+
+export function CodeTabsProvider({ children }: { children: ReactNode }) {
+ // Start empty so server + first client render select each group's first tab
+ // (deterministic, hydration-safe). The cookie preference is applied after
+ // hydration, the same moment the old imperative enhancer used to run.
+ const [language, setLanguageState] = useState('')
+
+ useEffect(() => {
+ const cookieValue = Cookies.get(CODE_SAMPLE_LANGUAGE_COOKIE_NAME)
+ if (cookieValue) setLanguageState(cookieValue)
+ }, [])
+
+ const setLanguage = useCallback((value: string) => {
+ setLanguageState(value)
+ Cookies.set(CODE_SAMPLE_LANGUAGE_COOKIE_NAME, value)
+ sendEvent({
+ type: EventType.preference,
+ preference_name: 'code_language',
+ preference_value: value,
+ })
+ }, [])
+
+ const value = useMemo(
+ () => ({ language, setLanguage }),
+ [language, setLanguage],
+ )
+
+ return {children}
+}
+
+type PanelTab = {
+ key: string
+ label: string
+ panel: ReactElement<{ className?: string }>
+}
+
+function hasClass(className: unknown, target: string): boolean {
+ return String(className || '')
+ .split(/\s+/)
+ .includes(target)
+}
+
+function getActiveKey(tabs: PanelTab[], selectedLanguage: string): string {
+ return tabs.some((tab) => tab.key === selectedLanguage) ? selectedLanguage : (tabs[0]?.key ?? '')
+}
+
+type CodeTabsGroupProps = {
+ className?: string
+ children?: ReactNode
+ [key: string]: unknown
+}
+
+export function CodeTabsGroup({ className, children, ...rest }: CodeTabsGroupProps) {
+ const router = useRouter()
+ const { t } = useTranslation('code_tabs')
+ const { language, setLanguage } = useContext(CodeLanguageContext)
+ const baseId = useId()
+
+ // Pull the `.ghd-codetab` panel children straight from the converted hast. Fail
+ // open (render the original markup) if the expected metadata isn't present.
+ const tabs: PanelTab[] = Children.toArray(children)
+ .filter((child): child is ReactElement<{ className?: string }> => isValidElement(child))
+ .filter((child) => hasClass(child.props.className, 'ghd-codetab'))
+ .map((panel) => {
+ const props = panel.props as { 'data-lang'?: string; 'data-label'?: string }
+ const key = props['data-lang']
+ const label = props['data-label']
+ if (!key || !label) return null
+ return { key, label, panel }
+ })
+ .filter((tab): tab is PanelTab => tab !== null)
+
+ if (!tabs.length) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ const activeKey = getActiveKey(tabs, language)
+
+ return (
+
+
+ {/* key on asPath works around a Primer UnderlineNav re-render bug. */}
+
+ {tabs.map((tab) => (
+ {
+ event.preventDefault()
+ setLanguage(tab.key)
+ }}
+ >
+ {tab.label}
+
+ ))}
+
+
+ {tabs.map((tab, index) => {
+ const isActive = tab.key === activeKey
+ return cloneElement(tab.panel, {
+ key: tab.key,
+ id: `${baseId}-panel-${index}`,
+ role: 'tabpanel',
+ tabIndex: 0,
+ hidden: !isActive,
+ className: cx(tab.panel.props.className, { 'ghd-codetab-hidden': !isActive }),
+ } as Record
)
+ })}
+
+ )
+}
diff --git a/src/frame/components/article/ArticlePage.tsx b/src/frame/components/article/ArticlePage.tsx
index 212909ccd7da..68a6a826597e 100644
--- a/src/frame/components/article/ArticlePage.tsx
+++ b/src/frame/components/article/ArticlePage.tsx
@@ -21,6 +21,8 @@ import { CodeTabs } from '@/frame/components/CodeTabs'
import { JourneyTrackCard, JourneyTrackNav } from '@/journeys/components'
import { CopyMarkdownMenu } from './ViewMarkdownButton'
import { ExperimentContentSwap } from '@/events/components/experiments/ExperimentContentSwap'
+import { SelectionProvider } from '@/tools/components/SelectionContext'
+import { CodeTabsProvider } from '@/frame/components/CodeTabsGroup'
const ClientSideRefresh = dynamic(() => import('@/frame/components/ClientSideRefresh'), {
ssr: false,
@@ -34,6 +36,7 @@ export const ArticlePage = () => {
intro,
effectiveDate,
renderedPage,
+ renderedPageHast,
permissions,
includesPlatformSpecificContent,
includesToolSpecificContent,
@@ -77,7 +80,11 @@ export const ArticlePage = () => {
const articleContents = (
-
{renderedPage}
+ {renderedPageHast ? (
+
+ ) : (
+
{renderedPage}
+ )}
{effectiveDate && (
@@ -92,56 +99,62 @@ export const ArticlePage = () => {
return (
-
-
-
- {isDev && }
- {router.pathname.includes('/rest/') && }
- {currentLayout === 'inline' ? (
- <>
- {title}}
- intro={introProp}
- introCallOuts={introCalloutsProp}
- toc={toc}
- breadcrumbs={}
- >
- {articleContents}
-
- {isJourneyTrack ? (
-
-
-
- ) : null}
- >
- ) : (
-
-
-
-
+
+
+
+
+ {/* The imperative CodeTabs enhancer only runs for the string fallback
+ path; on the hast path, CodeTabsGroup renders tabs React-natively. */}
+ {!renderedPageHast && }
+ {isDev && }
+ {router.pathname.includes('/rest/') && }
+ {currentLayout === 'inline' ? (
+ <>
+ {title}}
+ intro={introProp}
+ introCallOuts={introCalloutsProp}
+ toc={toc}
+ breadcrumbs={}
+ >
+ {articleContents}
+
+ {isJourneyTrack ? (
+
+
+
+ ) : null}
+ >
+ ) : (
+
+
+
+
-
{title}}
- intro={
- <>
- {introProp}
- {introCalloutsProp}
- >
- }
- toc={toc}
- >
- {articleContents}
-
+
{title}}
+ intro={
+ <>
+ {introProp}
+ {introCalloutsProp}
+ >
+ }
+ toc={toc}
+ >
+ {articleContents}
+
- {isJourneyTrack ? (
-
-
+ {isJourneyTrack ? (
+
+
+
+ ) : null}
- ) : null}
-
- )}
+ )}
+
+
)
}
diff --git a/src/frame/components/context/ArticleContext.tsx b/src/frame/components/context/ArticleContext.tsx
index c91ab9253234..43dc59e2d3f2 100644
--- a/src/frame/components/context/ArticleContext.tsx
+++ b/src/frame/components/context/ArticleContext.tsx
@@ -17,6 +17,7 @@ export type ArticleContextT = {
intro: string
effectiveDate: string
renderedPage: string | JSX.Element[]
+ renderedPageHast?: import('hast').Root
miniTocItems: Array
permissions?: string
includesPlatformSpecificContent: boolean
@@ -60,6 +61,7 @@ interface ContextRequest {
context: {
page: Record & { fullPath: string; title: string; intro: string }
renderedPage?: string
+ renderedPageHast?: import('hast').Root
miniTocItems?: MiniTocItem[]
currentJourneyTrack?: JourneyContext
currentLayoutName?: string
@@ -97,6 +99,7 @@ export const getArticleContextFromRequest = (req: ContextRequest): ArticleContex
intro: page.intro,
effectiveDate,
renderedPage: (req.context.renderedPage as string) || '',
+ renderedPageHast: req.context.renderedPageHast,
miniTocItems: req.context.miniTocItems || [],
permissions: (page.permissions as string) || '',
includesPlatformSpecificContent: (page.includesPlatformSpecificContent as boolean) || false,
diff --git a/src/frame/components/context/MainContext.tsx b/src/frame/components/context/MainContext.tsx
index 9a48ac250781..31e2afc7e877 100644
--- a/src/frame/components/context/MainContext.tsx
+++ b/src/frame/components/context/MainContext.tsx
@@ -191,6 +191,11 @@ export const getMainContext = async (
if (context.currentJourneyTrack?.trackId) {
addUINamespaces(req, ui, ['journey_track_nav'])
}
+ // CodeTabs (rendered React-natively from the article body hast) needs its i18n
+ // strings shipped to the page; only articles can contain code tabs.
+ if (documentType === 'article') {
+ addUINamespaces(req, ui, ['code_tabs'])
+ }
// Product index pages (depth-2 index.md, e.g. actions/index.md) need the
// full product tree for landing rendering.
diff --git a/src/frame/components/ui/MarkdownContent/MarkdownContent.tsx b/src/frame/components/ui/MarkdownContent/MarkdownContent.tsx
index 123058a88eff..ef2102c7aaef 100644
--- a/src/frame/components/ui/MarkdownContent/MarkdownContent.tsx
+++ b/src/frame/components/ui/MarkdownContent/MarkdownContent.tsx
@@ -6,6 +6,7 @@ import type { Root as HastRoot } from 'hast'
import cx from 'classnames'
import { CopyButton } from '@/frame/components/CopyButton'
+import { CodeTabsGroup } from '@/frame/components/CodeTabsGroup'
import { ToggleableContent } from '@/tools/components/ToggleableContent'
import { isToggleClass } from '@/tools/components/SelectionContext'
import styles from './MarkdownContent.module.scss'
@@ -35,6 +36,10 @@ const markdownComponents = {
// and runs first, so only the handful of toggleable elements become context
// consumers; every other div/span renders as a plain element with no hook.
div(props: ComponentProps<'div'>) {
+ const classes = String(props.className || '').split(/\s+/)
+ if (classes.includes('ghd-codetabs')) {
+ return
+ }
if (isToggleClass(props.className)) {
return
}
diff --git a/src/frame/components/ui/MiniTocs/MiniTocs.tsx b/src/frame/components/ui/MiniTocs/MiniTocs.tsx
index 46ee2f5e5a81..99920b1ae9e1 100644
--- a/src/frame/components/ui/MiniTocs/MiniTocs.tsx
+++ b/src/frame/components/ui/MiniTocs/MiniTocs.tsx
@@ -4,6 +4,11 @@ import cx from 'classnames'
import type { MiniTocItem } from '@/frame/components/context/ArticleContext'
import { useTranslation } from '@/languages/components/useTranslation'
+import {
+ classifyToggleClass,
+ isContentVisible,
+ useSelection,
+} from '@/tools/components/SelectionContext'
import styles from './Minitocs.module.scss'
@@ -13,6 +18,7 @@ export type MiniTocsPropsT = {
function RenderTocItem(item: MiniTocItem) {
const [currentAnchor, setCurrentAnchor] = useState('')
+ const { platform, tool } = useSelection()
useEffect(() => {
const onHashChanged = () => {
@@ -26,6 +32,14 @@ function RenderTocItem(item: MiniTocItem) {
}
}, [])
+ // `item.platform` holds the class string of the heading's `.ghd-tool` ancestor
+ // (platform OR tool value). Hide the TOC entry when its platform/tool isn't the
+ // selected one, replacing the old imperative parent- `style.display` hack.
+ const classification = classifyToggleClass(item.platform)
+ if (classification && !isContentVisible(classification, { platform, tool })) {
+ return null
+ }
+
return (
<>
{
return (await pageRenderTimed(context)) as string
}
+/**
+ * Spike for #6619 (remove dangerouslySetInnerHTML): produce the article body as
+ * a serializable hast (HTML AST) tree alongside the legacy HTML string.
+ *
+ * Must run AFTER buildRenderedPage, which calls page.render and populates the
+ * context fields the pipeline reads (englishHeadings, alertTitles). We render
+ * the same raw `page.markdown`, but with a context clone that omits
+ * `collectMiniToc` so the mini-TOC isn't collected a second time.
+ *
+ * Wrapped so a hast failure can never break the page: the React layer falls
+ * back to the string path when this is undefined. NOTE: this currently renders
+ * the body pipeline twice; the production design (see #6619 plan) should produce
+ * hast once and derive the string from it.
+ */
+async function buildRenderedPageHast(req: ExtendedRequest) {
+ const { context } = req
+ if (!context) throw new Error('request not contextualized')
+ const { page } = context
+ if (!page || !page.markdown) return undefined
+
+ try {
+ const hastContext = { ...context, collectMiniToc: undefined }
+ const { hast } = await renderContentToHast(page.markdown, hastContext)
+ return hast || undefined
+ } catch (error) {
+ logger.error(
+ 'buildRenderedPageHast failed; falling back to string path',
+ error instanceof Error ? error : new Error(String(error)),
+ { path: req.pagePath || req.path },
+ )
+ return undefined
+ }
+}
+
function buildMiniTocItems(req: ExtendedRequest) {
const { context } = req
if (!context) throw new Error('request not contextualized')
@@ -119,6 +154,7 @@ export default async function renderPage(req: ExtendedRequest, res: Response) {
)
} else {
req.context.renderedPage = await buildRenderedPage(req)
+ req.context.renderedPageHast = await buildRenderedPageHast(req)
req.context.miniTocItems = buildMiniTocItems(req)
}
diff --git a/src/frame/pages/app.tsx b/src/frame/pages/app.tsx
index 357532d2cec4..979be3598c20 100644
--- a/src/frame/pages/app.tsx
+++ b/src/frame/pages/app.tsx
@@ -86,37 +86,6 @@ const MyApp = ({ Component, pageProps, languagesContext, stagingName }: MyAppPro
}
}, [router, router.query, pageProps.mainContext])
- useEffect(() => {
- // The CSS from primer looks something like this:
- //
- // @media (prefers-color-scheme: dark) [data-color-mode=auto][data-dark-theme=dark] {
- // --color-canvas-default: black;
- // }
- // html {
- // background-color: var(--color-canvas-default);
- // }
- //
- // So if that `[data-color-mode][data-dark-theme=dark]` isn't present
- // on the html, but on a top-level wrapping `` then the ``
- // doesn't get the right CSS.
- // Normally, with Primer you make sure you set these things in the
- // `` tag and you can use `_document.tsx` for that but that's
- // only something you can do in server-side rendering. So,
- // we use a hook to assure that the `` tag has the correct
- // dataset attribute values.
- const html = document.querySelector('html')
- if (html) {
- // Note, this is the same as setting ``
- // But you can't do `html.dataset['color-mode']` so you use the
- // camelCase variant and you get the same effect.
- // Appears Next.js can't modify after server rendering:
- // https://stackoverflow.com/a/54774431
- html.dataset.colorMode = theme.css.colorMode
- html.dataset.darkTheme = theme.css.darkTheme
- html.dataset.lightTheme = theme.css.lightTheme
- }
- }, [theme])
-
return (
<>
diff --git a/src/ghes-releases/lib/release-issues.ts b/src/ghes-releases/lib/release-issues.ts
new file mode 100644
index 000000000000..dfcb373786b6
--- /dev/null
+++ b/src/ghes-releases/lib/release-issues.ts
@@ -0,0 +1,52 @@
+export type IssueState = 'open' | 'closed' | 'all'
+
+const VALID_ISSUE_STATES: IssueState[] = ['open', 'closed', 'all']
+const EXCLUDED_RELEASE_LABELS = new Set(['public roadmap', 'not planned'])
+
+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'
+
+ const normalized = value.toLowerCase()
+ if (VALID_ISSUE_STATES.includes(normalized as IssueState)) {
+ return normalized as IssueState
+ }
+
+ throw new Error(
+ `Invalid issue state "${value}". Expected one of: ${VALID_ISSUE_STATES.join(', ')}`,
+ )
+}
+
+/**
+ * Build gh CLI args for listing release issues.
+ */
+export function buildReleaseIssueListArgs(version: string, issueState: IssueState): string[] {
+ const label = `GHES ${version}`
+ return [
+ 'issue',
+ 'list',
+ '--repo',
+ 'github/releases',
+ '--label',
+ label,
+ '--state',
+ issueState,
+ '--limit',
+ '200',
+ '--json',
+ 'number,title,url,body,labels',
+ ]
+}
+
+/**
+ * Excludes release issues that should not 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/generate-release-notes.ts b/src/ghes-releases/scripts/generate-release-notes.ts
index ca0fb075554e..aae793be2c2e 100644
--- a/src/ghes-releases/scripts/generate-release-notes.ts
+++ b/src/ghes-releases/scripts/generate-release-notes.ts
@@ -3,7 +3,7 @@
* @description Generate GHES release notes from github/releases issues using Copilot CLI
*
* Generate GHES release notes by:
- * 1. Querying github/releases for open issues labeled "GHES "
+ * 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
@@ -25,6 +25,12 @@ import {
buildReleaseNotesYaml,
appendNoteLines,
} from '@/ghes-releases/lib/parse-release-notes'
+import {
+ type IssueState,
+ buildReleaseIssueListArgs,
+ isExcludedReleaseIssue,
+ parseIssueState,
+} from '@/ghes-releases/lib/release-issues'
// ─── Ctrl+C handling ─────────────────────────────────────────────────────────
// Copilot CLI puts the terminal in raw mode, so we catch Ctrl+C (0x03) manually.
@@ -94,25 +100,12 @@ function gh(args: string[]): string {
}
/**
- * Fetch open release issues labeled "GHES "
+ * Fetch release issues labeled "GHES " using the selected issue state.
*/
-function fetchReleaseIssues(version: string): ReleaseIssue[] {
- const label = `GHES ${version}`
- const output = gh([
- 'issue',
- 'list',
- '--repo',
- 'github/releases',
- '--label',
- label,
- '--state',
- 'open',
- '--limit',
- '200',
- '--json',
- 'number,title,url,body,labels',
- ])
- return JSON.parse(output) as ReleaseIssue[]
+function fetchReleaseIssues(version: string, issueState: IssueState): ReleaseIssue[] {
+ const output = gh(buildReleaseIssueListArgs(version, issueState))
+ const issues = JSON.parse(output) as ReleaseIssue[]
+ return issues.filter((issue) => !isExcludedReleaseIssue(issue))
}
interface ChangelogInfo {
@@ -484,6 +477,11 @@ program
return true
})
.option('--stdout', 'Print output to console instead of writing to file')
+ .option(
+ '--issue-state ',
+ 'Issue state filter for github/releases issues (open, closed, all). Defaults to all.',
+ 'all',
+ )
.option(
'-i, --issue ',
'Process a single issue by number or URL (replaces its entry if it already exists)',
@@ -507,12 +505,20 @@ program
release: string
rc: boolean
stdout?: boolean
+ issueState?: string
issue?: number
force?: boolean
}) => {
const { release, stdout, issue: singleIssue, force } = options
const rc = options.rc ?? false
const spinner = ora()
+ let issueState: IssueState
+ try {
+ issueState = parseIssueState(options.issueState)
+ } catch (error) {
+ console.error(`Error: ${(error as Error).message}`)
+ process.exit(1)
+ }
// ── Prerequisite checks ──
try {
@@ -560,6 +566,12 @@ program
'number,title,url,body,labels',
])
const issue = JSON.parse(output) as ReleaseIssue
+ if (isExcludedReleaseIssue(issue)) {
+ spinner.fail(
+ `Issue #${singleIssue} is excluded by label (public roadmap or not planned).`,
+ )
+ process.exit(0)
+ }
issues = [issue]
spinner.succeed(`Fetched issue #${singleIssue}: ${issue.title}`)
} catch (error) {
@@ -567,10 +579,12 @@ program
process.exit(1)
}
} else {
- spinner.start(`Fetching open issues labeled "GHES ${release}"...`)
+ spinner.start(`Fetching issues labeled "GHES ${release}" (state: ${issueState})...`)
try {
- issues = fetchReleaseIssues(release)
- spinner.succeed(`Found ${issues.length} open issues labeled "GHES ${release}"`)
+ issues = fetchReleaseIssues(release, issueState)
+ spinner.succeed(
+ `Found ${issues.length} issues labeled "GHES ${release}" (state: ${issueState})`,
+ )
} catch (error) {
spinner.fail(`Failed to fetch issues: ${(error as Error).message}`)
process.exit(1)
diff --git a/src/ghes-releases/tests/release-issues.ts b/src/ghes-releases/tests/release-issues.ts
new file mode 100644
index 000000000000..10355bf04a34
--- /dev/null
+++ b/src/ghes-releases/tests/release-issues.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from 'vitest'
+
+import {
+ buildReleaseIssueListArgs,
+ isExcludedReleaseIssue,
+ parseIssueState,
+ type IssueState,
+} from '@/ghes-releases/lib/release-issues'
+
+describe('parseIssueState', () => {
+ test('defaults to all when not provided', () => {
+ expect(parseIssueState()).toBe('all')
+ })
+
+ test('accepts valid values case-insensitively', () => {
+ expect(parseIssueState('OPEN')).toBe('open')
+ expect(parseIssueState('closed')).toBe('closed')
+ expect(parseIssueState('All')).toBe('all')
+ })
+
+ test('throws on invalid values', () => {
+ expect(() => parseIssueState('anything-else')).toThrow('Invalid issue state')
+ })
+})
+
+describe('buildReleaseIssueListArgs', () => {
+ test('builds gh issue list args with release label and state', () => {
+ const args = buildReleaseIssueListArgs('3.20', 'closed' satisfies IssueState)
+
+ expect(args).toEqual([
+ 'issue',
+ 'list',
+ '--repo',
+ 'github/releases',
+ '--label',
+ 'GHES 3.20',
+ '--state',
+ 'closed',
+ '--limit',
+ '200',
+ '--json',
+ 'number,title,url,body,labels',
+ ])
+ })
+})
+
+describe('isExcludedReleaseIssue', () => {
+ test('returns true for public roadmap label', () => {
+ expect(isExcludedReleaseIssue({ labels: [{ name: 'public roadmap' }] })).toBe(true)
+ })
+
+ test('returns true for not planned label (case-insensitive)', () => {
+ expect(isExcludedReleaseIssue({ labels: [{ name: 'Not Planned' }] })).toBe(true)
+ })
+
+ test('returns false when no excluded labels are present', () => {
+ expect(isExcludedReleaseIssue({ labels: [{ name: 'GHES 3.20' }, { name: 'bug' }] })).toBe(false)
+ })
+})
diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx
index 168d735433dd..b1fbb30b3088 100644
--- a/src/pages/_document.tsx
+++ b/src/pages/_document.tsx
@@ -1,19 +1,24 @@
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'
export default class MyDocument extends Document {
render() {
return (
-
+
+
+
diff --git a/src/tools/components/PlatformPicker.tsx b/src/tools/components/PlatformPicker.tsx
index 016b96d4d638..4c95b7681711 100644
--- a/src/tools/components/PlatformPicker.tsx
+++ b/src/tools/components/PlatformPicker.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { useArticleContext } from '@/frame/components/context/ArticleContext'
import { parseUserAgent } from '@/events/components/user-agent'
import { InArticlePicker } from './InArticlePicker'
+import { useSelection } from './SelectionContext'
import { OS_PREFERRED_COOKIE_NAME } from '@/frame/lib/constants'
const platformQueryKey = 'platform'
@@ -47,7 +48,8 @@ function showPlatformSpecificContent(platform: string) {
}
export const PlatformPicker = () => {
- const { defaultPlatform, detectedPlatforms } = useArticleContext()
+ const { defaultPlatform, detectedPlatforms, renderedPageHast } = useArticleContext()
+ const { setPlatform } = useSelection()
const [defaultUA, setDefaultUA] = useState('')
useEffect(() => {
@@ -75,7 +77,13 @@ export const PlatformPicker = () => {
}
cookieKey={OS_PREFERRED_COOKIE_NAME}
queryStringKey={platformQueryKey}
- onValue={showPlatformSpecificContent}
+ onValue={(value: string) => {
+ // Drive visibility through React state on the hast path (#6619). Only the
+ // string fallback (renderedPageHast undefined) still needs the imperative
+ // DOM mutation, since that markup isn't React-owned.
+ setPlatform(value)
+ if (!renderedPageHast) showPlatformSpecificContent(value)
+ }}
preferenceName="os"
ariaLabel="Platform"
options={options}
diff --git a/src/tools/components/ToolPicker.tsx b/src/tools/components/ToolPicker.tsx
index 083e08b0c87a..d9e465733e00 100644
--- a/src/tools/components/ToolPicker.tsx
+++ b/src/tools/components/ToolPicker.tsx
@@ -2,6 +2,7 @@ import { preserveAnchorNodePosition } from 'scroll-anchoring'
import { useArticleContext } from '@/frame/components/context/ArticleContext'
import { InArticlePicker } from './InArticlePicker'
+import { useSelection } from './SelectionContext'
import { TOOL_PREFERRED_COOKIE_NAME } from '@/frame/lib/constants'
// example: http://localhost:4000/en/codespaces/developing-in-codespaces/creating-a-codespace
@@ -58,7 +59,8 @@ function getDefaultTool(defaultTool: string | undefined, detectedTools: Array {
// allTools comes from the ArticleContext which contains the list of tools available
- const { defaultTool, detectedTools, allTools } = useArticleContext()
+ const { defaultTool, detectedTools, allTools, renderedPageHast } = useArticleContext()
+ const { setTool } = useSelection()
if (!detectedTools.length) return null
@@ -72,9 +74,14 @@ export const ToolPicker = () => {
cookieKey={TOOL_PREFERRED_COOKIE_NAME}
queryStringKey={toolQueryKey}
onValue={(value: string) => {
- preserveAnchorNodePosition(document, () => {
- showToolSpecificContent(value, Object.keys(allTools))
- })
+ // Drive visibility through React state on the hast path (#6619). Only the
+ // string fallback still needs the imperative DOM mutation.
+ setTool(value)
+ if (!renderedPageHast) {
+ preserveAnchorNodePosition(document, () => {
+ showToolSpecificContent(value, Object.keys(allTools))
+ })
+ }
}}
preferenceName="application"
ariaLabel="Tool"
diff --git a/src/types/types.ts b/src/types/types.ts
index 78345dc6023f..57d65f37a03d 100644
--- a/src/types/types.ts
+++ b/src/types/types.ts
@@ -178,6 +178,7 @@ export type Context = {
productGroups?: ProductGroup[]
featuredLinks?: FeaturedLinksExpanded
renderedPage?: string
+ renderedPageHast?: import('hast').Root
miniTocItems?: MiniTocItem[]
markdownRequested?: boolean
markdownViaUrl?: boolean