Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/cache-nextjs/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ runs:
using: 'composite'
steps:
- name: Cache .next/cache
uses: actions/cache@v4
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.next/cache
# Packages and source files both invalidate the cache.
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/get-docs-early-access/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ runs:
run: npm run what-docs-early-access-branch

- name: Clone
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: github/docs-early-access
token: ${{ inputs.token }}
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/node-npm-setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ runs:
using: 'composite'
steps:
- name: Cache node_modules
uses: actions/cache@v4
uses: actions/cache@v6
id: cache-node_modules
env:
# Cache restore failures are acceptable, so keep the segment timeout short.
Expand All @@ -17,7 +17,7 @@ runs:

# The npm download cache only helps npm ci, so skip its 153 MB restore when node_modules hits.
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version-file: 'package.json'
package-manager-cache: false
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/slack-alert/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ runs:
} >> "$GITHUB_OUTPUT"
- name: Send Slack notification if workflow fails
uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
with:
method: chat.postMessage
token: ${{ inputs.slack_token }}
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/warmup-remotejson-cache/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ runs:
steps:
# Restore a stable key, add to the cache, then save by SHA so the cache can grow without bound.
- name: Cache .remotejson-cache (restore)
uses: actions/cache/restore@v4
uses: actions/cache/restore@v6
with:
path: .remotejson-cache
key: remotejson-cache-
Expand All @@ -27,7 +27,7 @@ runs:

- name: Cache .remotejson-cache (save)
if: ${{ inputs.restore-only == '' }}
uses: actions/cache/save@v4
uses: actions/cache/save@v6
with:
path: .remotejson-cache
key: remotejson-cache-${{ github.sha }}
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

# Update the base image digest from the gh-base-noble package page:
# https://github.com/github/gh-base-image/pkgs/container/gh-base-image%2Fgh-base-noble
FROM ghcr.io/github/gh-base-image/gh-base-noble:20261016-181622-g7dfd3798b@sha256:d41c754579434b9b6479a3d3786a6a84ad7be50ce64626bbc07579ab4c36302b AS base
FROM ghcr.io/github/gh-base-image/gh-base-noble:20261020-225427-g5ec2175c5@sha256:931ce7d43f6f3dbe28113cb12f5fac2206a0b08dad395172f6d73ce4f95f77f3 AS base
# Install curl for NodeSource setup.
# Install git for early-access and translation clones.
# Ubuntu's nodejs package lags the Node LTS release line.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 10 additions & 6 deletions src/audit-logs/components/GroupedEvents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,16 @@ export default function GroupedEvents({ auditLogEvents, category, categoryNote }
.filter((link) => link && link !== 'N/A')
.map(normalizeReferenceLink)

// Titles can contain ', ', so a single link keeps its whole title.
const titles = !event.docs_reference_titles
? links
: links.length === 1
? [event.docs_reference_titles]
: event.docs_reference_titles.split(', ')
// Older generated data joins titles with ', ', and titles can contain ', ',
// so a single link keeps its whole title.
const storedTitles = event.docs_reference_titles
const titles = Array.isArray(storedTitles)
? storedTitles
: !storedTitles
? links
: links.length === 1
? [storedTitles]
: storedTitles.split(', ')

return links.map((link, index) => (
<span key={link}>
Expand Down
6 changes: 3 additions & 3 deletions src/audit-logs/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,9 +194,9 @@ async function computeReferenceLinksToMarkdown(
async function resolveReferenceLinksToTitles(
docsReferenceLinks: string,
context: TitleResolutionContext,
): Promise<string> {
): Promise<string[]> {
if (!docsReferenceLinks || docsReferenceLinks === 'N/A') {
return ''
return []
}

const links = docsReferenceLinks
Expand Down Expand Up @@ -233,7 +233,7 @@ async function resolveReferenceLinksToTitles(
}
}

return titles.join(', ')
return titles
}

export function getAuditLogEvents(page: string, version: string): AuditLogEventT[] {
Expand Down
4 changes: 3 additions & 1 deletion src/audit-logs/tests/unit/deduplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,9 @@ describe('audit log deduplication', () => {
expect(reconstructed[i].action).toBe(original[i].action)
expect(reconstructed[i].description).toBe(original[i].description)
expect(reconstructed[i].docs_reference_links).toBe(original[i].docs_reference_links)
expect(reconstructed[i].docs_reference_titles).toBe(original[i].docs_reference_titles)
expect(reconstructed[i].docs_reference_titles).toEqual(
original[i].docs_reference_titles,
)
if (original[i].fields) {
expect(reconstructed[i].fields).toEqual(original[i].fields)
}
Expand Down
36 changes: 35 additions & 1 deletion src/audit-logs/tests/unit/filter-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ describe('audit log event filtering', () => {
pipelineConfig: { sha: '', appendedDescriptions: {} },
titleContext,
})
expect(filteredEvents[0].docs_reference_titles).toBe('Bar page title, /foo/missing-page')
expect(filteredEvents[0].docs_reference_titles).toEqual(['Bar page title', '/foo/missing-page'])
})

test('resolves Markdown reference links without a language prefix', async () => {
Expand Down Expand Up @@ -318,4 +318,38 @@ describe('audit log event filtering', () => {
'repo.create',
)
})

test('keeps reference titles that contain commas intact', async () => {
const mockPage = (title: string) => ({ renderProp: async () => title })
const titleContext = {
pages: {
'/en/sponsors/about-sponsorships-fees-and-taxes': mockPage(
'About sponsorships, fees, and taxes',
),
'/en/billing/upgrading-a-sponsorship': mockPage('Upgrading a sponsorship'),
},
redirects: {},
} as unknown as TitleResolutionContext
const eventsToProcess: RawAuditLogEventT[] = [
{
action: 'sponsors.sponsor_sponsorship_create',
_allowlists: ['user'],
description: 'A sponsorship was created.',
docs_reference_links:
'/sponsors/about-sponsorships-fees-and-taxes, /billing/upgrading-a-sponsorship',
ghes: {},
},
]

const filteredEvents = await filterByAllowlistValues({
eventsToCheck: eventsToProcess,
allowListValues: 'user',
pipelineConfig: { sha: '', appendedDescriptions: {} },
titleContext,
})
expect(filteredEvents[0].docs_reference_titles).toEqual([
'About sponsorships, fees, and taxes',
'Upgrading a sponsorship',
])
})
})
4 changes: 2 additions & 2 deletions src/audit-logs/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export type AuditLogEventT = {
description: string
fields?: string[]
docs_reference_links?: string
docs_reference_titles?: string
docs_reference_titles?: string | string[]
}

export type RawAuditLogEventT = {
Expand All @@ -33,7 +33,7 @@ export type DeduplicatedAuditLogEntry = {
action: string
description: string
docs_reference_links?: string
docs_reference_titles?: string
docs_reference_titles?: string | string[]
fieldsIndex?: number
}

Expand Down
26 changes: 26 additions & 0 deletions src/fixtures/tests/playwright-rendering.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3092,6 +3092,32 @@ test.describe('LandingArticleGridWithFilter component', () => {
await expect(page.getByTestId('article-grid').getByTestId('article-card')).toHaveCount(4)
})

test('typing in the header search keeps the selected category', async ({ page }) => {
// The category must not be in the URL at load, so the header's query state never sees it.
await page.goto('/get-started/article-grid-discovery')
await turnOffExperimentsInPage(page)
const trigger = page.getByTestId('filter-header').getByRole('button')
const cards = page.getByTestId('article-grid').getByTestId('article-card')
await trigger.click()
const menu = page.getByRole('menu', { name: 'Category', exact: true })
await menu.getByRole('menuitemradio', { name: 'Testing', exact: true }).click()
await expect(page).toHaveURL(/articles-category=Testing/)
await expect(cards).toHaveCount(1)

await page.getByTestId('toggle-search').click()
const input = page.getByTestId('overlay-search-input')
await input.pressSequentially('a')
await expect(page).toHaveURL(/search-overlay-input=a/)
await expect(page).toHaveURL(/articles-category=Testing/)
await expect(cards).toHaveCount(1)

await input.press('Escape')
await expect(input).toHaveCount(0)
await expect(page).toHaveURL(/articles-category=Testing/)
await expect(trigger).toContainText('Testing')
await expect(cards).toHaveCount(1)
})

test('category menu aligns with its inline label on desktop', async ({ page }) => {
await page.setViewportSize({ width: 1400, height: 900 })
await page.goto('/get-started/article-grid-discovery?articles-category=Testing')
Expand Down
4 changes: 2 additions & 2 deletions src/frame/components/context/ArticleContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ export const useArticleContext = (): ArticleContextT => {
return context
}

const PagePathToVaFlowMapping: Record<string, string> = {
'content/account-and-profile/setting-up-and-managing-your-github-profile/managing-contribution-settings-on-your-profile/why-are-my-contributions-not-showing-up-on-my-profile.md':
export const PagePathToVaFlowMapping: Record<string, string> = {
'content/account-and-profile/how-tos/contribution-settings/troubleshooting-missing-contributions.md':
'contribution_troubleshooting',
'content/authentication/securing-your-account-with-two-factor-authentication-2fa/recovering-your-account-if-you-lose-your-2fa-credentials.md':
'2fa',
Expand Down
19 changes: 13 additions & 6 deletions src/frame/lib/find-page.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { getLanguageCode } from '@/frame/lib/patterns'
import getRedirect from '@/redirects/lib/get-redirect'
import getRedirect, { splitPathByLanguage } from '@/redirects/lib/get-redirect'
import type { Context, Page } from '@/types'

export default function findPage(
Expand All @@ -19,11 +19,18 @@ export default function findPage(
const page = pages[href] || (redirectedHref ? pages[redirectedHref] : undefined)
if (page) return page

// Missing translated pages and hrefs without a language prefix fall back to English.
const [, withoutLanguage] = splitPathByLanguage(href)
const englishHrefs = new Set([withoutLanguage === '/' ? '/en' : `/en${withoutLanguage}`])
// Also strip any two-letter prefix, such as a language not enabled here.
const languageMatch = href.match(getLanguageCode)
const currentLang = getLanguageCode.test(href) && languageMatch ? languageMatch[1] : 'en'
if (languageMatch) englishHrefs.add(`/en${href.slice(languageMatch[0].length)}`)

// Missing translated pages fall back to English.
const englishHref = href.replace(`/${currentLang}/`, '/en/')
const redirectedEnglishHref = getRedirect(englishHref, redirectsContext)
return pages[englishHref] || (redirectedEnglishHref ? pages[redirectedEnglishHref] : undefined)
for (const englishHref of englishHrefs) {
const redirectedEnglishHref = getRedirect(englishHref, redirectsContext)
const englishPage =
pages[englishHref] || (redirectedEnglishHref ? pages[redirectedEnglishHref] : undefined)
if (englishPage) return englishPage
}
return undefined
}
33 changes: 33 additions & 0 deletions src/frame/tests/find-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,37 @@ describe('find page', () => {
expect(redirectedPage).toBeDefined()
expect(typeof redirectedPage?.title).toBe('string')
})

test('finds a page by an href without a language prefix', async () => {
const page = await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
})
const permalink = page?.permalinks[0]
if (!page || !permalink) {
throw new Error('Page or permalink not found')
}
const pageMap = { [permalink.href]: page } as unknown as Record<string, PageType>

// The first two letters of these paths are not a language code.
expect(findPage(`${permalink.hrefWithoutLanguage}#a-fragment`, pageMap, {})).toBe(page)
expect(findPage(permalink.hrefWithoutLanguage, pageMap, {})).toBe(page)
})

test('falls back to English for a language prefix that is not enabled', async () => {
const page = await Page.init({
relativePath: 'page-with-redirects.md',
basePath: path.join(__dirname, '../../../src/fixtures/fixtures'),
languageCode: 'en',
})
const permalink = page?.permalinks[0]
if (!page || !permalink) {
throw new Error('Page or permalink not found')
}
const pageMap = { [permalink.href]: page } as unknown as Record<string, PageType>

// Unit tests run English-only, so `ja` is not an enabled language here.
expect(findPage(`/ja${permalink.hrefWithoutLanguage}`, pageMap, {})).toBe(page)
})
})
12 changes: 12 additions & 0 deletions src/frame/tests/va-flow-mapping.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import fs from 'fs'

import { describe, expect, test } from 'vitest'

import { PagePathToVaFlowMapping } from '@/frame/components/context/ArticleContext'

describe('PagePathToVaFlowMapping', () => {
// Keys are matched against page.fullPath, so a moved article silently loses its VA iframe.
test.each(Object.keys(PagePathToVaFlowMapping))('%s exists', (pagePath) => {
expect(fs.existsSync(pagePath)).toBe(true)
})
})
9 changes: 9 additions & 0 deletions src/languages/lib/correct-translation-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1980,6 +1980,15 @@ export function correctTranslatedContentStrings(

content = content.replace(/\{ +%([^%]+?)% *\}/g, '{%$1%}')

content = content.replace(
// eslint-disable-next-line no-control-regex
/\{%(-?)\s*([^\x00-\x7F][^\x00-\x7F\s]*(?: [^\x00-\x7F]+){0,3})(?:\.\s*|\s+)((?:variables|reusables)\.)?([a-z][\w-]*\.[\w-]+(?:\.[\w-]+)*)\s*(-?)%\}/g,
(_m, open: string, label: string, kind: string | undefined, path: string, close: string) => {
const ns = kind ?? (/变量|変数|변수|переменн/.test(label) ? 'variables.' : 'reusables.')
return `{%${open} data ${ns}${path} ${close}%}`
},
)

// eslint-disable-next-line no-control-regex -- Runs after per-language fixes; non-ASCII after {% is invalid.
content = content.replace(/\{% (?=[^\x00-\x7F])/g, '')

Expand Down
17 changes: 17 additions & 0 deletions src/languages/tests/correct-translation-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3797,4 +3797,21 @@ Para más información, consulta "[AUTOTITLE](/path)".
expect(fix('{% warning %}\nx\n{% endwarning %}', 'ru')).toBe('x')
})
})

describe('translated data label before an untranslated dotted path', () => {
test.each([
['zh', '{% 数据重用.profile.access_org %}', '{% data reusables.profile.access_org %}'],
['zh', '{%- 数据重用.profile.access_org -%}', '{%- data reusables.profile.access_org -%}'],
['ja', '{% データ 使い回し.user.dev-settings %}', '{% data reusables.user.dev-settings %}'],
['ko', '{% 데이터 재사용가능한.a.b-c %}', '{% data reusables.a.b-c %}'],
['ru', '{% переменных данных.product.name %}', '{% data variables.product.name %}'],
])('fixes %s: %s', (code, before, after) => {
expect(fix(`a ${before} b`, code)).toBe(`a ${after} b`)
})

test('leaves already-correct tags unchanged', () => {
const ok = '{% data reusables.profile.access_org %} {% data variables.product.name %}'
for (const code of ['zh', 'ja', 'ko', 'ru']) expect(fix(ok, code)).toBe(ok)
})
})
})
5 changes: 4 additions & 1 deletion src/search/components/helpers/execute-search-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ const OVERLAY_PARAMS = [
'query',
]

// Landing grid params describe the source page's grid, so a destination landing must not reuse them.
const PAGE_LOCAL_PARAMS = ['articles-category', 'articles-filter', 'articles-page']

// Carries tool pickers, platform switches, and feature flags to the destination URL.
// Not built from `router.query`, which also holds dynamic route params like `restPage`.
// A `restPage` search param outranks the real path, so navigation would stay on this article.
// Reads `window`, so call it only from event handlers, never during render.
export function inheritableSearchParams(): URLSearchParams {
const params = new URLSearchParams(window.location.search)
for (const key of OVERLAY_PARAMS) {
for (const key of [...OVERLAY_PARAMS, ...PAGE_LOCAL_PARAMS]) {
params.delete(key)
}
return params
Expand Down
Loading
Loading